From f538dadc295e6291287813899a7799e517b2b0ee Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 13 Aug 2024 22:49:07 +0800 Subject: [PATCH] feat: spotify pkce auth --- .env.sample | 2 + .gitignore | 3 + package-lock.json | 131 +++++++++++++++++++++++++-- package.json | 5 +- src/App.tsx | 24 +---- src/api/auth/hooks/useUserQuery.ts | 0 src/api/auth/service/auth.service.ts | 83 +++++++++++++++++ src/axios/createBaseAxiosInstance.ts | 11 +++ src/axios/index.ts | 10 ++ src/components/Auth.tsx | 72 +++++++++++++++ src/data-objects/interface/index.ts | 13 +++ src/pages/Callback.tsx | 9 ++ src/pages/Dashboard.tsx | 26 ++++++ src/routes/index.tsx | 14 +++ 14 files changed, 375 insertions(+), 28 deletions(-) create mode 100644 .env.sample create mode 100644 src/api/auth/hooks/useUserQuery.ts create mode 100644 src/api/auth/service/auth.service.ts create mode 100644 src/axios/createBaseAxiosInstance.ts create mode 100644 src/axios/index.ts create mode 100644 src/components/Auth.tsx create mode 100644 src/data-objects/interface/index.ts create mode 100644 src/pages/Callback.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/routes/index.tsx diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..166a5cc --- /dev/null +++ b/.env.sample @@ -0,0 +1,2 @@ +VITE_CLIENT_ID= +VITE_CLIENT_SECRET= \ No newline at end of file diff --git a/.gitignore b/.gitignore index a547bf3..d2f0760 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +coverage/ +.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d0e36f4..8e3b107 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,11 @@ "name": "react-typescript-spotify", "version": "0.0.0", "dependencies": { + "axios": "^1.7.3", + "query-string": "^9.1.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" }, "devDependencies": { "@commitlint/cli": "^19.4.0", @@ -1425,6 +1428,14 @@ "node": ">=14" } }, + "node_modules/@remix-run/router": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.0.tgz", + "integrity": "sha512-zDICCLKEwbVYTS6TjYaWtHXxkdoUvD/QXvyVZjGCsWz5vyH7aFeONlPffPdW+Y/t6KT0MgXb2Mfjun9YpWN1dA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.20.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.20.0.tgz", @@ -2481,8 +2492,17 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz", + "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } }, "node_modules/balanced-match": { "version": "1.0.2", @@ -2781,7 +2801,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3047,6 +3066,14 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, + "node_modules/decode-uri-component": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", + "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", + "engines": { + "node": ">=14.16" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3066,7 +3093,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -3599,6 +3625,17 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", + "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3634,6 +3671,25 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -3654,7 +3710,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4614,7 +4669,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -4623,7 +4677,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -5212,6 +5265,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -5227,6 +5285,22 @@ "node": ">=6" } }, + "node_modules/query-string": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.1.0.tgz", + "integrity": "sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==", + "dependencies": { + "decode-uri-component": "^0.4.1", + "filter-obj": "^5.1.0", + "split-on-first": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -5292,6 +5366,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.0.tgz", + "integrity": "sha512-wVQq0/iFYd3iZ9H2l3N3k4PL8EEHcb0XlU2Na8nEwmiXgIUElEH6gaJDtUQxJ+JFzmIXaQjfdpcGWaM6IoQGxg==", + "dependencies": { + "@remix-run/router": "1.19.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.0.tgz", + "integrity": "sha512-RRGUIiDtLrkX3uYcFiCIxKFWMcWQGMojpYZfcstc63A1+sSnVgILGIm9gNUA6na3Fm1QuPGSBQH2EMbAZOnMsQ==", + "dependencies": { + "@remix-run/router": "1.19.0", + "react-router": "6.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5548,6 +5652,17 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", + "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", diff --git a/package.json b/package.json index af4a8b7..e1074b0 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,11 @@ "commitlint": "commitlint --edit" }, "dependencies": { + "axios": "^1.7.3", + "query-string": "^9.1.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" }, "devDependencies": { "@commitlint/cli": "^19.4.0", diff --git a/src/App.tsx b/src/App.tsx index 0d3a3ab..3941604 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,22 +1,8 @@ - -import './App.css' - +import "./App.css"; +import { RouterProvider } from "react-router-dom"; +import { router } from "./routes"; function App() { - - return ( -
-
-
-

Hello there

-

- Provident cupiditate voluptatem et in. Quaerat fugiat ut assumenda excepturi exercitationem - quasi. In deleniti eaque aut repudiandae et a id nisi. -

- -
-
-
- ) + return ; } -export default App +export default App; diff --git a/src/api/auth/hooks/useUserQuery.ts b/src/api/auth/hooks/useUserQuery.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/api/auth/service/auth.service.ts b/src/api/auth/service/auth.service.ts new file mode 100644 index 0000000..4b08e00 --- /dev/null +++ b/src/api/auth/service/auth.service.ts @@ -0,0 +1,83 @@ +import { TokenScopeResponse } from "../../../data-objects/interface"; + +const authorizationEndpoint = "https://accounts.spotify.com/authorize"; +const clientId = import.meta.env.VITE_CLIENT_ID; +const scope = "user-read-private user-read-email"; +const redirectUrl = "http://localhost:5173/"; +const tokenEndpoint = "https://accounts.spotify.com/api/token"; + +export async function redirectToSpotifyAuthorize(): Promise { + const possible: string = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const randomValues: Uint8Array = crypto.getRandomValues(new Uint8Array(64)); + const randomString: string = randomValues.reduce( + (acc, x) => acc + possible[x % possible.length], + "", + ); + + const code_verifier: string = randomString; + const data: Uint8Array = new TextEncoder().encode(code_verifier); + const hashed: ArrayBuffer = await crypto.subtle.digest("SHA-256", data); + + const code_challenge_base64: string = btoa( + String.fromCharCode(...new Uint8Array(hashed)), + ) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + + window.localStorage.setItem("code_verifier", code_verifier); + + const authUrl: URL = new URL(authorizationEndpoint); + const params: Record = { + response_type: "code", + client_id: clientId, + scope: scope, + code_challenge_method: "S256", + code_challenge: code_challenge_base64, + redirect_uri: redirectUrl, + }; + + authUrl.search = new URLSearchParams(params).toString(); + window.location.href = authUrl.toString(); // Redirect the user to the authorization server for login +} + +export async function getToken(code: string): Promise { + const code_verifier = localStorage.getItem("code_verifier"); + + if (!code_verifier) { + throw new Error("Code verifier not found in localStorage"); + } + + const payload = { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + grant_type: "authorization_code", + code, + redirect_uri: redirectUrl, + code_verifier: code_verifier, + }), + }; + + const body = await fetch(tokenEndpoint, payload); + if (!body.ok) { + throw new Error("Failed to fetch token"); + } + + const response = await body.json(); + + return response; +} + +export async function getUserData(accessToken: string) { + const response = await fetch("https://api.spotify.com/v1/me", { + method: "GET", + headers: { Authorization: "Bearer " + accessToken }, + }); + + return await response.json(); +} diff --git a/src/axios/createBaseAxiosInstance.ts b/src/axios/createBaseAxiosInstance.ts new file mode 100644 index 0000000..9725206 --- /dev/null +++ b/src/axios/createBaseAxiosInstance.ts @@ -0,0 +1,11 @@ +import axios, { CreateAxiosDefaults } from "axios"; + +export const createBaseAxiosInstance = ( + configOverrides: CreateAxiosDefaults = {}, +) => { + const baseURL = import.meta.env.VITE_TMDB_API_URL; + return axios.create({ + baseURL, + ...configOverrides, + }); +}; diff --git a/src/axios/index.ts b/src/axios/index.ts new file mode 100644 index 0000000..969a1e6 --- /dev/null +++ b/src/axios/index.ts @@ -0,0 +1,10 @@ +import { createBaseAxiosInstance } from './createBaseAxiosInstance'; +import Qs from 'query-string'; + +export const axios = createBaseAxiosInstance({ + // Add params serializer to convert objects to query string + // This is useful when you want to pass an object as a query parameter + paramsSerializer: function (params) { + return Qs.stringify(params); + }, +}); \ No newline at end of file diff --git a/src/components/Auth.tsx b/src/components/Auth.tsx new file mode 100644 index 0000000..fe5805e --- /dev/null +++ b/src/components/Auth.tsx @@ -0,0 +1,72 @@ +import { + getToken, + redirectToSpotifyAuthorize, +} from "../api/auth/service/auth.service.ts"; +import { useEffect } from "react"; +import { TokenResponse } from "../data-objects/interface"; + +const Auth = () => { + // Data structure that manages the current active token, caching it in localStorage + const currentToken = { + get access_token(): string | null { + return localStorage.getItem("access_token"); + }, + get refresh_token(): string | null { + return localStorage.getItem("refresh_token"); + }, + get expires_in(): string | null { + return localStorage.getItem("expires_in"); + }, + get expires(): string | null { + return localStorage.getItem("expires"); + }, + + save: function (response: TokenResponse): void { + const { access_token, refresh_token, expires_in } = response; + localStorage.setItem("access_token", access_token); + localStorage.setItem("refresh_token", refresh_token); + localStorage.setItem("expires_in", expires_in.toString()); + + const now = new Date(); + const expiry = new Date(now.getTime() + expires_in * 1000); + localStorage.setItem("expires", expiry.toISOString()); + }, + }; + + // On page load, try to fetch auth code from current browser search URL + const args = new URLSearchParams(window.location.search); + const code = args.get("code"); + + // If we find a code, we're in a callback, do a token exchange + useEffect(() => { + const fetchToken = async () => { + if (code) { + const token = await getToken(code); + + console.log("token", token); + + currentToken.save(token); + // Remove code from URL so we can refresh correctly. + const url = new URL(window.location.href); + url.searchParams.delete("code"); + + const updatedUrl = url.search ? url.href : url.href.replace("?", ""); + window.history.replaceState({}, document.title, updatedUrl); + } + }; + + fetchToken(); + }, [code]); + + const onLogin = async () => { + await redirectToSpotifyAuthorize(); + }; + + return ( + + ); +}; + +export default Auth; diff --git a/src/data-objects/interface/index.ts b/src/data-objects/interface/index.ts new file mode 100644 index 0000000..9ff976e --- /dev/null +++ b/src/data-objects/interface/index.ts @@ -0,0 +1,13 @@ +export interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; +} + +export interface TokenScopeResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +} diff --git a/src/pages/Callback.tsx b/src/pages/Callback.tsx new file mode 100644 index 0000000..b64f5b0 --- /dev/null +++ b/src/pages/Callback.tsx @@ -0,0 +1,9 @@ +const Callback = () => { + return ( +
+

Callback

+
+ ); +}; + +export default Callback; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..5b2bc63 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,26 @@ +import Auth from "../components/Auth.tsx"; +import { getUserData } from "../api/auth/service/auth.service.ts"; + +const Dashboard = () => { + const accessToken = localStorage.getItem("access_token"); + console.log("Dashboard accessToken", accessToken); + const userData = getUserData(accessToken ?? ""); + console.log("Dashboard userData", userData); + return ( +
+
+
+

Hello there

+

+ Provident cupiditate voluptatem et in. Quaerat fugiat ut assumenda + excepturi exercitationem quasi. In deleniti eaque aut repudiandae et + a id nisi. +

+ +
+
+
+ ); +}; + +export default Dashboard; diff --git a/src/routes/index.tsx b/src/routes/index.tsx new file mode 100644 index 0000000..b883401 --- /dev/null +++ b/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { createBrowserRouter } from "react-router-dom"; +import Dashboard from "../pages/Dashboard.tsx"; +import Callback from "../pages/Callback.tsx"; + +export const router = createBrowserRouter([ + { + path: "/", + element: , + }, + { + path: "/callback", + element: , + }, +]);