From a2e8b05410afa70c00ae1bede66492fed850b0ac Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sat, 8 Jun 2024 23:11:49 +0800 Subject: [PATCH 01/15] use alias --- tsconfig.json | 4 ++++ vite.config.ts | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index a7fc6fb..139580b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,9 @@ { "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], diff --git a/vite.config.ts b/vite.config.ts index 2b37192..080de45 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,14 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' - +import path from 'path' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + alias: [ + { find: '@', replacement: path.resolve(__dirname, 'src') } + ] + }, test: { globals: true, environment: 'happy-dom', From 821362d6a7e63ce35f68155ff042c3541be4aa8b Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 01:01:15 +0800 Subject: [PATCH 02/15] add LoginButton, cookie handling --- src/components/LoginButton.tsx | 33 +++++++++++++++ src/lib/cookie.ts | 14 ++++++- src/lib/stateCookie.ts | 6 ++- src/utils/auth.ts | 76 ++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/components/LoginButton.tsx create mode 100644 src/utils/auth.ts diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx new file mode 100644 index 0000000..1860936 --- /dev/null +++ b/src/components/LoginButton.tsx @@ -0,0 +1,33 @@ +import { useCallback, useState } from 'react' +import { getPKCECodes, redirectAuthRequestE } from '@/utils/auth' + +const LoginButton = () => { + const [error, setError] = useState('') + + const handleLogin = useCallback(async () => { + try { + const codes = await getPKCECodes() + redirectAuthRequestE(codes.state, codes.codeChallenge) + } catch (error) { + if (error instanceof Error) + setError(error.message) + else setError('An unknown error occurred') + } + }, []) + + return ( + <> + +
+        {error}
+      
+
+        {document.cookie}
+      
+ + ) +} + +export default LoginButton diff --git a/src/lib/cookie.ts b/src/lib/cookie.ts index 47aed71..a5cd1db 100644 --- a/src/lib/cookie.ts +++ b/src/lib/cookie.ts @@ -4,7 +4,7 @@ export const saveCookie = (name: string, value: string, mins: number = 60) => { const date = new Date() date.setTime(date.getTime() + mins * 60 * 1000) - document.cookie = `${name}=${value};Expires=${date.toUTCString()}; path=/; Secure; SameSite=None` + document.cookie = `${name}=${value};Expires=${date.toUTCString()}; path=/; Secure; SameSite=Strict` } export const getCookie = (name: string) => { @@ -14,5 +14,15 @@ export const getCookie = (name: string) => { } export const deleteCookie = (name: string) => { - document.cookie = `${name}=; Max-Age=0; path=/; Secure; SameSite=None` + document.cookie = `${name}=; Max-Age=0; path=/; Secure; SameSite=Strict` +} + +export const clearAllCookies = () => { + const cookies = document.cookie.split(";") + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i] + const eqPos = cookie.indexOf("=") + const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT` + } } diff --git a/src/lib/stateCookie.ts b/src/lib/stateCookie.ts index e6cd8c0..5144527 100644 --- a/src/lib/stateCookie.ts +++ b/src/lib/stateCookie.ts @@ -1,5 +1,5 @@ -import { saveCookie, getCookie } from './cookie' +import { saveCookie, getCookie, deleteCookie } from './cookie' export const createStateCookie = (value: string) => { if (!value) throw new Error('state value is required') @@ -13,3 +13,7 @@ export const createStateCookie = (value: string) => { export const getStateCookie = (state: string) => { return getCookie(`app.txs.${state}`) } + +export const deleteStateCookie = (state: string) => { + deleteCookie(`app.txs.${state}`) +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..b1e6364 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,76 @@ +import { + deleteStateCookie, + getStateCookie, + createStateCookie +} from "@/lib/stateCookie" +import { + createPKCECodeChallenge, + createRandomString +} from "@/lib/pkce" +import { PKCEState } from "@/types" +import config from "@/config" + +export const getPKCEState = (state?: string, code?: string): PKCEState => { + // guard: pkce is not initialized yet + if (!state || !code) { + return { isReady: false } + } + + const codeVerifier = getStateCookie(state) + if (!codeVerifier) return { isReady: false } + + return { + isReady: true, + codeVerifier, + code + } +} + +export const redirectAuthRequestE = async ( + state: string, codeChallenge: string +) => { + const query = new URLSearchParams() + query.append('response_type', 'code,id_token') + query.append('redirect_uri', config.BASE_URL) + query.append('state', state) + query.append('code_challenge', codeChallenge) + + window.location.replace(`${config.LOGIN_URL}?${query.toString()}`) +} + +export const postForTokens = async (code: string, codeVerifier: string) => { + const body = JSON.stringify({ + code, + code_verifier: codeVerifier, + grant_type: 'authorization_code' + }) + const request = new Request(config.TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }) + + try { + const response = await fetch(request) + const res = await response.json() + + if (!response.ok) { + throw new Error(res.error) + } + // FIXME for more error handling of different response + return res + + } catch (error) { + if (error instanceof Error) + throw new Error(error?.message) + else throw new Error('An unknown error occurred') + } +} + +export const getPKCECodes = async () => { + const codeVerifier = createRandomString() + const codeChallenge = await createPKCECodeChallenge(codeVerifier) + const state = createStateCookie(codeVerifier) + + return { codeVerifier, codeChallenge, state } +} From d31ef79b22ec217f45d07d00f1b7ab23c9450efe Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 01:01:47 +0800 Subject: [PATCH 03/15] get code verifier on query param --- src/App.test.tsx | 2 +- src/App.tsx | 49 ++++++++++++++++++++++++------------------------ 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index ad60949..39c3d01 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -12,5 +12,5 @@ test('renders text', () => { expect(wrapper).toBeTruthy() const { getByText } = wrapper - expect(getByText('Vite + React')).toBeTruthy() + expect(getByText('Login')).toBeTruthy() }) diff --git a/src/App.tsx b/src/App.tsx index afe48ac..c60289e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,32 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import { useEffect, useState } from 'react' + +import { getPKCEState } from '@/utils/auth' +import LoginButton from '@/components/LoginButton' +import { clearAllCookies } from './lib/cookie' function App() { - const [count, setCount] = useState(0) + const [isAuthReady, setIsAuthReady] = useState(false) + const [status, setStatus] = useState('') + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const state = params.get('state') + const code = params.get('code') + + const { isReady: isAuthReady, codeVerifier } = + getPKCEState(state ?? undefined, code ?? undefined) + + setIsAuthReady(isAuthReady) + if (!isAuthReady) return () => clearAllCookies() + else setStatus(`code: ${code}, codeVerifier: ${codeVerifier}`) + + return () => clearAllCookies() + }, []) return ( <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

+ {isAuthReady &&

Ready for Auth Request

} + {status &&

{status}

} + ) } From d39798e0dc342f10ebc8a7839c9179fd464f50a9 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 01:02:02 +0800 Subject: [PATCH 04/15] fix styles --- src/App.css | 42 ------------------------------------------ src/index.css | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 42 deletions(-) delete mode 100644 src/App.css diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/index.css b/src/index.css index 6119ad9..58643dd 100644 --- a/src/index.css +++ b/src/index.css @@ -66,3 +66,25 @@ button:focus-visible { background-color: #f9f9f9; } } + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; +} + +pre { + max-width: 50vw; + text-align: left; + overflow: hidden; + white-space: wrap; + word-wrap: normal; + font-size: 0.9em; + line-height: 1.5; + border-radius: 8px; + padding: 1em; +} + +.error { + color: red; +} From a76830835809ea260c938ff9f14b68c4b5973c7b Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 01:03:02 +0800 Subject: [PATCH 05/15] add config, url found --- src/config.ts | 9 +++++++++ src/types/index.ts | 5 +++++ 2 files changed, 14 insertions(+) create mode 100644 src/config.ts create mode 100644 src/types/index.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3e7ae2a --- /dev/null +++ b/src/config.ts @@ -0,0 +1,9 @@ +const LOGIN_URL = import.meta.env.VITE_AUTH_URL +const TOKEN_URL = import.meta.env.VITE_TOKEN_URL +const BASE_URL = import.meta.env.VITE_BASE_URL + +export default { + LOGIN_URL, + TOKEN_URL, + BASE_URL +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..f371ab7 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,5 @@ +export type PKCEState = { + isReady: boolean, + codeVerifier?: string, + code?: string +} From 900845162aa13b2748b0d1ef89af64e587cb4208 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 01:03:24 +0800 Subject: [PATCH 06/15] minor change --- README.md | 30 ------------------------------ index.html | 2 +- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/README.md b/README.md index 0d6babe..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,30 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} -``` - -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/index.html b/index.html index e4b78ea..770a8aa 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Refresh Token Rotation Demo
From fb82f7b275bbd1658fa570ab2a873709b2c95f59 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 12:44:48 +0800 Subject: [PATCH 07/15] moved --- {src/lib => lib}/__tests__/cookie.test.js | 0 {src/lib => lib}/__tests__/pkce.test.js | 0 {src/lib => lib}/cookie.ts | 0 {src/lib => lib}/pkce.ts | 0 src/{lib => utils}/__tests__/stateCookie.test.ts | 0 src/{lib => utils}/stateCookie.ts | 9 +++++---- 6 files changed, 5 insertions(+), 4 deletions(-) rename {src/lib => lib}/__tests__/cookie.test.js (100%) rename {src/lib => lib}/__tests__/pkce.test.js (100%) rename {src/lib => lib}/cookie.ts (100%) rename {src/lib => lib}/pkce.ts (100%) rename src/{lib => utils}/__tests__/stateCookie.test.ts (100%) rename src/{lib => utils}/stateCookie.ts (50%) diff --git a/src/lib/__tests__/cookie.test.js b/lib/__tests__/cookie.test.js similarity index 100% rename from src/lib/__tests__/cookie.test.js rename to lib/__tests__/cookie.test.js diff --git a/src/lib/__tests__/pkce.test.js b/lib/__tests__/pkce.test.js similarity index 100% rename from src/lib/__tests__/pkce.test.js rename to lib/__tests__/pkce.test.js diff --git a/src/lib/cookie.ts b/lib/cookie.ts similarity index 100% rename from src/lib/cookie.ts rename to lib/cookie.ts diff --git a/src/lib/pkce.ts b/lib/pkce.ts similarity index 100% rename from src/lib/pkce.ts rename to lib/pkce.ts diff --git a/src/lib/__tests__/stateCookie.test.ts b/src/utils/__tests__/stateCookie.test.ts similarity index 100% rename from src/lib/__tests__/stateCookie.test.ts rename to src/utils/__tests__/stateCookie.test.ts diff --git a/src/lib/stateCookie.ts b/src/utils/stateCookie.ts similarity index 50% rename from src/lib/stateCookie.ts rename to src/utils/stateCookie.ts index 5144527..844b8ed 100644 --- a/src/lib/stateCookie.ts +++ b/src/utils/stateCookie.ts @@ -1,19 +1,20 @@ -import { saveCookie, getCookie, deleteCookie } from './cookie' +import { saveCookie, getCookie, deleteCookie } from '@lib/cookie' +import config from '@/config' export const createStateCookie = (value: string) => { if (!value) throw new Error('state value is required') const state = crypto.randomUUID() - saveCookie(`app.txs.${state}`, value, 30) + saveCookie(`${config.STATE_COOKIE_PREFIX}${state}`, value, 30) return state } export const getStateCookie = (state: string) => { - return getCookie(`app.txs.${state}`) + return getCookie(`${config.STATE_COOKIE_PREFIX}${state}`) } export const deleteStateCookie = (state: string) => { - deleteCookie(`app.txs.${state}`) + deleteCookie(`${config.STATE_COOKIE_PREFIX}${state}`) } From cbbcb5aa85d887208178c2b86b44c696f2c92f8a Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 12:50:31 +0800 Subject: [PATCH 08/15] update to @lib --- tsconfig.json | 5 +++-- vite.config.ts | 13 +++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 139580b..87e99c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@lib/*": ["./lib/*"] }, "target": "ES2020", "useDefineForClassFields": true, @@ -24,6 +25,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], + "include": ["src", "lib"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 080de45..ba76fb0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,16 +6,17 @@ export default defineConfig({ plugins: [react()], resolve: { alias: [ - { find: '@', replacement: path.resolve(__dirname, 'src') } + { find: '@', replacement: path.resolve(__dirname, 'src') }, + { find: '@lib', replacement: path.resolve(__dirname, 'lib') } ] }, test: { globals: true, environment: 'happy-dom', - browser: { - enabled: true, - name: 'chrome', - headless: true - } + // browser: { + // enabled: true, + // name: 'chrome', + // headless: true + // } } }) From f3fdd372f4c81bbaf0592a65fc9b1f09b48a6f42 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 12:52:12 +0800 Subject: [PATCH 09/15] add test and fix --- .env.test.local | 3 ++ .gitignore | 1 + src/components/LoginButton.tsx | 6 +-- src/config.ts | 8 +++- src/types/index.ts | 2 +- src/utils/__tests__/auth.test.ts | 54 ++++++++++++++++++++++++++ src/utils/auth.ts | 66 +++++++++----------------------- 7 files changed, 86 insertions(+), 54 deletions(-) create mode 100644 .env.test.local create mode 100644 src/utils/__tests__/auth.test.ts diff --git a/.env.test.local b/.env.test.local new file mode 100644 index 0000000..61f85d0 --- /dev/null +++ b/.env.test.local @@ -0,0 +1,3 @@ +VITE_AUTH_URL=http://localhost:8080/api/authorize +VITE_TOKEN_URL=http://localhost:8080/api/oauth/token +VITE_BASE_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore index a547bf3..dbeb9a2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +!.env.test.local # Editor directories and files .vscode/* diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx index 1860936..8f1fb67 100644 --- a/src/components/LoginButton.tsx +++ b/src/components/LoginButton.tsx @@ -1,13 +1,13 @@ import { useCallback, useState } from 'react' -import { getPKCECodes, redirectAuthRequestE } from '@/utils/auth' +import { createPKCECodes, redirectToLogin } from '@/utils/auth' const LoginButton = () => { const [error, setError] = useState('') const handleLogin = useCallback(async () => { try { - const codes = await getPKCECodes() - redirectAuthRequestE(codes.state, codes.codeChallenge) + const codes = await createPKCECodes() + redirectToLogin(codes.state, codes.codeChallenge) } catch (error) { if (error instanceof Error) setError(error.message) diff --git a/src/config.ts b/src/config.ts index 3e7ae2a..74ef65d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,13 @@ +const BASE_URL = import.meta.env.VITE_BASE_URL const LOGIN_URL = import.meta.env.VITE_AUTH_URL const TOKEN_URL = import.meta.env.VITE_TOKEN_URL -const BASE_URL = import.meta.env.VITE_BASE_URL + +const STATE_COOKIE_PREFIX = "app.txs." export default { + BASE_URL, LOGIN_URL, TOKEN_URL, - BASE_URL + + STATE_COOKIE_PREFIX } diff --git a/src/types/index.ts b/src/types/index.ts index f371ab7..5ad2e54 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ export type PKCEState = { - isReady: boolean, + isDone: boolean, codeVerifier?: string, code?: string } diff --git a/src/utils/__tests__/auth.test.ts b/src/utils/__tests__/auth.test.ts new file mode 100644 index 0000000..a74d44d --- /dev/null +++ b/src/utils/__tests__/auth.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect, vi, afterAll } from "vitest" +import { getPKCEStatus, redirectToLogin, createPKCECodes } from "../auth" +import config from "@/config" + +describe("getPKCEStatus", () => { + test("returns false on empty state", () => { + expect(getPKCEStatus()).toEqual({ isDone: false }) + expect(getPKCEStatus('state')).toEqual({ isDone: false }) + }) + + test("returns true on state and code", () => { + document.cookie = 'app.txs.state-uuid=code_verifier' + expect(getPKCEStatus('state-uuid')).toEqual({ + isDone: true, + codeVerifier: 'code_verifier' + }) + }) +}) + +describe("createPKCECodes", () => { + test("returns codeVerifier, codeChallenge, state", async () => { + const codes = await createPKCECodes() + expect(codes).toMatchObject({ + codeVerifier: expect.any(String), + codeChallenge: expect.any(String), + state: expect.any(String) + }) + }) +}) + +describe("redirectToLogin", () => { + afterAll(() => { + vi.unstubAllGlobals() + }) + + test("redirects to login url", async () => { + const state = 'state' + const codeChallenge = 'code_challenge' + const redirect = vi.fn() + + const query = new URLSearchParams() + query.append('response_type', 'code,id_token') + query.append('redirect_uri', config.BASE_URL) + query.append('state', state) + query.append('code_challenge', codeChallenge) + + const url = `${config.LOGIN_URL}?${query.toString()}` + vi.stubGlobal('location', { replace: redirect }) + + redirectToLogin(state, codeChallenge) + + expect(redirect).toHaveBeenCalledWith(url) + }) +}) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index b1e6364..a837066 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -2,31 +2,23 @@ import { deleteStateCookie, getStateCookie, createStateCookie -} from "@/lib/stateCookie" +} from "@/utils/stateCookie" import { createPKCECodeChallenge, createRandomString -} from "@/lib/pkce" +} from "@lib/pkce" import { PKCEState } from "@/types" import config from "@/config" -export const getPKCEState = (state?: string, code?: string): PKCEState => { - // guard: pkce is not initialized yet - if (!state || !code) { - return { isReady: false } - } - - const codeVerifier = getStateCookie(state) - if (!codeVerifier) return { isReady: false } +export const createPKCECodes = async () => { + const codeVerifier = createRandomString() + const codeChallenge = await createPKCECodeChallenge(codeVerifier) + const state = createStateCookie(codeVerifier) - return { - isReady: true, - codeVerifier, - code - } + return { codeVerifier, codeChallenge, state } } -export const redirectAuthRequestE = async ( +export const redirectToLogin = async ( state: string, codeChallenge: string ) => { const query = new URLSearchParams() @@ -38,39 +30,17 @@ export const redirectAuthRequestE = async ( window.location.replace(`${config.LOGIN_URL}?${query.toString()}`) } -export const postForTokens = async (code: string, codeVerifier: string) => { - const body = JSON.stringify({ - code, - code_verifier: codeVerifier, - grant_type: 'authorization_code' - }) - const request = new Request(config.TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body - }) - - try { - const response = await fetch(request) - const res = await response.json() - - if (!response.ok) { - throw new Error(res.error) - } - // FIXME for more error handling of different response - return res - - } catch (error) { - if (error instanceof Error) - throw new Error(error?.message) - else throw new Error('An unknown error occurred') +export const getPKCEStatus = (state?: string): PKCEState => { + // guard: pkce is not initialized yet + if (!state) { + return { isDone: false } } -} -export const getPKCECodes = async () => { - const codeVerifier = createRandomString() - const codeChallenge = await createPKCECodeChallenge(codeVerifier) - const state = createStateCookie(codeVerifier) + const codeVerifier = getStateCookie(state) + if (!codeVerifier) return { isDone: false } - return { codeVerifier, codeChallenge, state } + return { + isDone: true, + codeVerifier, + } } From 9c28cbf8c3b6b1955eb31854a9cb79c427931f34 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 13:29:34 +0800 Subject: [PATCH 10/15] mock cookies and fixes --- lib/__tests__/cookie.test.js | 124 ++++++++++++++++-------- lib/cookie.ts | 4 +- src/utils/__tests__/stateCookie.test.ts | 23 ++++- 3 files changed, 108 insertions(+), 43 deletions(-) diff --git a/lib/__tests__/cookie.test.js b/lib/__tests__/cookie.test.js index 0f6bb74..51498fc 100644 --- a/lib/__tests__/cookie.test.js +++ b/lib/__tests__/cookie.test.js @@ -1,56 +1,100 @@ -import { describe, test, expect, beforeEach } from "vitest" -import { saveCookie, getCookie, deleteCookie } from "../cookie" +import { describe, test, expect, beforeAll, afterAll, vi, afterEach } from 'vitest' +import { saveCookie, getCookie, deleteCookie, clearAllCookies } from '../cookie' -describe("saveCookie", () => { - test("throws error on empty name", () => { - expect(() => saveCookie()) - .toThrowError("Cookie name is required") +describe('cookie', () => { + let mockCookie = [] + beforeAll(() => { + vi.stubGlobal('document', { + get cookie() { + return mockCookie.join(';') + }, + set cookie(value) { + mockCookie = mockCookie.filter(c => !c.startsWith(value.split('=')[0])) + mockCookie.push(value) + }, + }) }) - test("saves empty value", () => { - saveCookie("a", "") - expect(document.cookie).toMatch("a=") + afterEach(() => { + mockCookie = [] }) - test("saves cookie", () => { - saveCookie("a", "1234") - expect(document.cookie).toMatch("a=1234") + afterAll(() => { + vi.unstubAllGlobals() }) - test("saves cookie with latest value", () => { - saveCookie("a", "1234") - saveCookie("a", "124") - expect(document.cookie).toMatch("a=124") - expect(document.cookie).not.toMatch("a=1234") - }) -}) + describe('saveCookie', () => { + test('throws error on empty name', () => { + expect(() => saveCookie()).toThrowError('Cookie name is required') + }) + + test('saves empty value', () => { + saveCookie('a', '') + expect(document.cookie).toMatch('a=;') + }) -describe("getCookie", () => { - test("gets none", () => { - saveCookie("b", "54321") - expect(getCookie("c")).toBeUndefined() + test('saves cookie', () => { + saveCookie('a', '1234') + expect(document.cookie).toMatch('a=1234;') + }) + + test('saves cookie with latest value', () => { + saveCookie('a', '1234') + saveCookie('a', '124') + expect(document.cookie).toMatch('a=124;') + expect(document.cookie).not.toMatch('a=1234;') + }) }) - test("gets cookie", () => { - saveCookie("b", "54321") - expect(getCookie("b")).toBe("54321") + describe('getCookie', () => { + test('gets none', () => { + saveCookie('b', '54321') + expect(getCookie('c')).toBeUndefined() + }) + + test('gets cookie', () => { + saveCookie('b', '54321') + expect(getCookie('b')).toBe('54321') + }) }) -}) -describe("deleteCookie", () => { - test("deletes cookie", () => { - saveCookie("c", "12345") - saveCookie("d", "54321") - deleteCookie("c") - expect(document.cookie).not.toMatch("c=12345") - expect(document.cookie).toMatch("d=54321") + describe('deleteCookie', () => { + test('deletes cookie', () => { + saveCookie('a', '12345') + expect(document.cookie).toMatch('a=12345;') + + deleteCookie('a') + expect(document.cookie).toMatch('a=;') + }) + + test('deletes cookie without affecting others', () => { + saveCookie('a', '12345') + expect(document.cookie).toMatch('a=12345;') + + saveCookie('b', '54321') + expect(document.cookie).toMatch('b=54321;') + + deleteCookie('a') + expect(document.cookie).toMatch('a=;') + expect(document.cookie).toMatch('b=54321;') + }) + + test('deletes none', () => { + saveCookie('a', '12345') + saveCookie('b', '54321') + deleteCookie('c') + expect(document.cookie).toMatch('a=12345;') + expect(document.cookie).toMatch('b=54321;') + }) }) - test("deletes none", () => { - saveCookie("one", "12345") - saveCookie("two", "54321") - deleteCookie("e") - expect(document.cookie).toMatch("one=12345") - expect(document.cookie).toMatch("two=54321") + describe('clearAllCookies', () => { + test('clears all cookies', () => { + saveCookie('a', '12345') + saveCookie('b', '54321') + clearAllCookies() + expect(document.cookie).toBe('a=;') + expect(document.cookie).toBe('b=;') + }) }) }) diff --git a/lib/cookie.ts b/lib/cookie.ts index a5cd1db..320cf39 100644 --- a/lib/cookie.ts +++ b/lib/cookie.ts @@ -4,7 +4,7 @@ export const saveCookie = (name: string, value: string, mins: number = 60) => { const date = new Date() date.setTime(date.getTime() + mins * 60 * 1000) - document.cookie = `${name}=${value};Expires=${date.toUTCString()}; path=/; Secure; SameSite=Strict` + document.cookie = `${name}=${value};Expires=${date.toUTCString()};\ path=/; Secure; SameSite=Strict` } export const getCookie = (name: string) => { @@ -14,7 +14,7 @@ export const getCookie = (name: string) => { } export const deleteCookie = (name: string) => { - document.cookie = `${name}=; Max-Age=0; path=/; Secure; SameSite=Strict` + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT` } export const clearAllCookies = () => { diff --git a/src/utils/__tests__/stateCookie.test.ts b/src/utils/__tests__/stateCookie.test.ts index a15bc6d..cf009da 100644 --- a/src/utils/__tests__/stateCookie.test.ts +++ b/src/utils/__tests__/stateCookie.test.ts @@ -1,7 +1,28 @@ -import { describe, test, expect } from "vitest" +import { describe, test, expect, beforeAll, afterAll, vi } from "vitest" import { getStateCookie, createStateCookie } from "../stateCookie" describe("createStateCookie", () => { + let mockCookie = [] + beforeAll(() => { + vi.stubGlobal('document', { + get cookie() { + return mockCookie.join(';') + }, + set cookie(value) { + mockCookie = mockCookie.filter(c => !c.startsWith(value.split('=')[0])) + mockCookie.push(value) + }, + }) + }) + + afterEach(() => { + mockCookie = [] + }) + + afterAll(() => { + vi.unstubAllGlobals() + }) + test("throws errors on empty string", () => { expect(() => createStateCookie("")) .toThrow("state value is required") From 98789bf3cdcd8e9dae94f92c8fb47c19487a9cca Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 13:29:56 +0800 Subject: [PATCH 11/15] clean up --- src/App.tsx | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c60289e..32b0465 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,23 +1,27 @@ import { useEffect, useState } from 'react' -import { getPKCEState } from '@/utils/auth' +import { getPKCEStatus } from '@/utils/auth' import LoginButton from '@/components/LoginButton' -import { clearAllCookies } from './lib/cookie' +import { clearAllCookies } from '@lib/cookie' function App() { + const params = new URLSearchParams(window.location.search) + const state = params.get('state') + const code = params.get('code') + const [isAuthReady, setIsAuthReady] = useState(false) const [status, setStatus] = useState('') - useEffect(() => { - const params = new URLSearchParams(window.location.search) - const state = params.get('state') - const code = params.get('code') - const { isReady: isAuthReady, codeVerifier } = - getPKCEState(state ?? undefined, code ?? undefined) + useEffect(() => { + if (state) { + const { isDone: isAuthReady, codeVerifier } = + getPKCEStatus(state) - setIsAuthReady(isAuthReady) - if (!isAuthReady) return () => clearAllCookies() - else setStatus(`code: ${code}, codeVerifier: ${codeVerifier}`) + if (isAuthReady) { + setIsAuthReady(isAuthReady) + setStatus(`code: ${code}, codeVerifier: ${codeVerifier}`) + } + } return () => clearAllCookies() }, []) From 28bc70301152cdb064a805feba0adcf9c13a4e1a Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Sun, 9 Jun 2024 13:52:00 +0800 Subject: [PATCH 12/15] refactor and add test --- src/components/LoginButton.tsx | 18 ++---------- src/hooks/__tests__/useInitPKCE.test.ts | 39 +++++++++++++++++++++++++ src/hooks/useInitPKCE.ts | 21 +++++++++++++ 3 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 src/hooks/__tests__/useInitPKCE.test.ts create mode 100644 src/hooks/useInitPKCE.ts diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx index 8f1fb67..aa24280 100644 --- a/src/components/LoginButton.tsx +++ b/src/components/LoginButton.tsx @@ -1,23 +1,11 @@ -import { useCallback, useState } from 'react' -import { createPKCECodes, redirectToLogin } from '@/utils/auth' +import useInitPKCE from '@/hooks/useInitPKCE' const LoginButton = () => { - const [error, setError] = useState('') - - const handleLogin = useCallback(async () => { - try { - const codes = await createPKCECodes() - redirectToLogin(codes.state, codes.codeChallenge) - } catch (error) { - if (error instanceof Error) - setError(error.message) - else setError('An unknown error occurred') - } - }, []) + const { error, onLogin } = useInitPKCE() return ( <> -
diff --git a/src/hooks/__tests__/useInitPKCE.test.ts b/src/hooks/__tests__/useInitPKCE.test.ts
new file mode 100644
index 0000000..df2e313
--- /dev/null
+++ b/src/hooks/__tests__/useInitPKCE.test.ts
@@ -0,0 +1,39 @@
+import { beforeAll, expect, describe, test, vi, afterAll } from 'vitest'
+import { renderHook } from '@testing-library/react'
+
+import useInitPKCE from '../useInitPKCE'
+import config from '@/config'
+
+describe('useInitPKCE', () => {
+  const redirect = vi.fn()
+  beforeAll(() => {
+    vi.stubGlobal('location', { replace: redirect })
+  })
+
+  afterAll(() => {
+    vi.unstubAllGlobals()
+  })
+
+  test('returns empty error and onLogin', () => {
+    const { result } = renderHook(() => useInitPKCE())
+    expect(result.current).toMatchObject({
+      error: '',
+      onLogin: expect.any(Function),
+    })
+  })
+
+  test('redirects to login url', async () => {
+    const { result } = renderHook(() => useInitPKCE())
+    await result.current.onLogin()
+
+    expect(redirect).toHaveBeenCalled()
+    const url = redirect.mock.calls[0][0]
+    const query = new URLSearchParams(url.split('?')[1])
+
+    expect(query.get('response_type')).toBe('code,id_token')
+    expect(query.get('redirect_uri')).toBe(config.BASE_URL)
+    expect(query.get('state')).toEqual(expect.any(String))
+    expect(query.get('code_challenge')).toEqual(expect.any(String))
+  })
+})
+
diff --git a/src/hooks/useInitPKCE.ts b/src/hooks/useInitPKCE.ts
new file mode 100644
index 0000000..8b3b542
--- /dev/null
+++ b/src/hooks/useInitPKCE.ts
@@ -0,0 +1,21 @@
+import { useCallback, useState } from 'react'
+import { createPKCECodes, redirectToLogin } from '@/utils/auth'
+
+const useInitPKCE = () => {
+  const [error, setError] = useState('')
+
+  const onLogin = useCallback(async () => {
+    try {
+      const codes = await createPKCECodes()
+      redirectToLogin(codes.state, codes.codeChallenge)
+    } catch (error) {
+      if (error instanceof Error)
+        setError(error.message)
+      else setError('An unknown error occurred')
+    }
+  }, [])
+
+  return { error, onLogin }
+}
+
+export default useInitPKCE

From cb97b0dbfebc0ef19e898b88d81a461fd6a58ad3 Mon Sep 17 00:00:00 2001
From: Alvin Ng <243186+alvinsj@users.noreply.github.com>
Date: Sun, 9 Jun 2024 13:52:08 +0800
Subject: [PATCH 13/15] fix test

---
 lib/__tests__/cookie.test.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/lib/__tests__/cookie.test.js b/lib/__tests__/cookie.test.js
index 51498fc..afd8b6c 100644
--- a/lib/__tests__/cookie.test.js
+++ b/lib/__tests__/cookie.test.js
@@ -93,8 +93,8 @@ describe('cookie', () => {
       saveCookie('a', '12345')
       saveCookie('b', '54321')
       clearAllCookies()
-      expect(document.cookie).toBe('a=;')
-      expect(document.cookie).toBe('b=;')
+      expect(document.cookie).toMatch('a=;')
+      expect(document.cookie).toMatch('b=;')
     })
   })
 })

From 9d69fd51cc7e95d7fb7d0bd98fcdf7c5df9129d4 Mon Sep 17 00:00:00 2001
From: Alvin Ng <243186+alvinsj@users.noreply.github.com>
Date: Sun, 9 Jun 2024 13:52:20 +0800
Subject: [PATCH 14/15] update test env

---
 .env.test.local | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/.env.test.local b/.env.test.local
index 61f85d0..74f2f83 100644
--- a/.env.test.local
+++ b/.env.test.local
@@ -1,3 +1,3 @@
-VITE_AUTH_URL=http://localhost:8080/api/authorize
-VITE_TOKEN_URL=http://localhost:8080/api/oauth/token
-VITE_BASE_URL=http://localhost:3000
+VITE_AUTH_URL=http://test-auth-api/api/authorize
+VITE_TOKEN_URL=http://test-token-api/api/oauth/token
+VITE_BASE_URL=http://test-base-url

From c96770d23d8c3aacf3a53ea676a74a26b06fac2c Mon Sep 17 00:00:00 2001
From: Alvin Ng <243186+alvinsj@users.noreply.github.com>
Date: Sun, 9 Jun 2024 13:55:58 +0800
Subject: [PATCH 15/15] linter fix

---
 lib/cookie.ts     | 4 +++-
 src/App.tsx       | 2 ++
 src/utils/auth.ts | 1 -
 3 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/lib/cookie.ts b/lib/cookie.ts
index 320cf39..091d26f 100644
--- a/lib/cookie.ts
+++ b/lib/cookie.ts
@@ -4,7 +4,9 @@ export const saveCookie = (name: string, value: string, mins: number = 60) => {
 
   const date = new Date()
   date.setTime(date.getTime() + mins * 60 * 1000)
-  document.cookie = `${name}=${value};Expires=${date.toUTCString()};\ path=/; Secure; SameSite=Strict`
+  document.cookie =
+    `${name}=${value};Expires=${date.toUTCString()}; \
+    path=/; Secure; SameSite=Strict`
 }
 
 export const getCookie = (name: string) => {
diff --git a/src/App.tsx b/src/App.tsx
index 32b0465..35845dc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -24,6 +24,8 @@ function App() {
     }
 
     return () => clearAllCookies()
+    // on mount only
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [])
 
   return (
diff --git a/src/utils/auth.ts b/src/utils/auth.ts
index a837066..5a71219 100644
--- a/src/utils/auth.ts
+++ b/src/utils/auth.ts
@@ -1,5 +1,4 @@
 import {
-  deleteStateCookie,
   getStateCookie,
   createStateCookie
 } from "@/utils/stateCookie"