diff --git a/.env.test.local b/.env.test.local new file mode 100644 index 0000000..74f2f83 --- /dev/null +++ b/.env.test.local @@ -0,0 +1,3 @@ +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 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/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 @@ -
- 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}
} ++ {error} ++
+ {document.cookie} ++ > + ) +} + +export default LoginButton diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..74ef65d --- /dev/null +++ b/src/config.ts @@ -0,0 +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 STATE_COOKIE_PREFIX = "app.txs." + +export default { + BASE_URL, + LOGIN_URL, + TOKEN_URL, + + STATE_COOKIE_PREFIX +} 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 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; +} diff --git a/src/lib/__tests__/cookie.test.js b/src/lib/__tests__/cookie.test.js deleted file mode 100644 index 0f6bb74..0000000 --- a/src/lib/__tests__/cookie.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, test, expect, beforeEach } from "vitest" -import { saveCookie, getCookie, deleteCookie } from "../cookie" - -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=") - }) - - 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") - }) -}) - -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") - }) - - test("deletes none", () => { - saveCookie("one", "12345") - saveCookie("two", "54321") - deleteCookie("e") - expect(document.cookie).toMatch("one=12345") - expect(document.cookie).toMatch("two=54321") - }) -}) diff --git a/src/lib/cookie.ts b/src/lib/cookie.ts deleted file mode 100644 index 47aed71..0000000 --- a/src/lib/cookie.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const saveCookie = (name: string, value: string, mins: number = 60) => { - if (!name) - throw new Error('Cookie name is required') - - const date = new Date() - date.setTime(date.getTime() + mins * 60 * 1000) - document.cookie = `${name}=${value};Expires=${date.toUTCString()}; path=/; Secure; SameSite=None` -} - -export const getCookie = (name: string) => { - const value = `; ${document.cookie}` - const parts = value.split(`; ${name}=`) - if (parts.length === 2) return parts.pop()?.split(';').shift() -} - -export const deleteCookie = (name: string) => { - document.cookie = `${name}=; Max-Age=0; path=/; Secure; SameSite=None` -} diff --git a/src/lib/stateCookie.ts b/src/lib/stateCookie.ts deleted file mode 100644 index e6cd8c0..0000000 --- a/src/lib/stateCookie.ts +++ /dev/null @@ -1,15 +0,0 @@ - -import { saveCookie, getCookie } from './cookie' - -export const createStateCookie = (value: string) => { - if (!value) throw new Error('state value is required') - - const state = crypto.randomUUID() - saveCookie(`app.txs.${state}`, value, 30) - - return state -} - -export const getStateCookie = (state: string) => { - return getCookie(`app.txs.${state}`) -} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..5ad2e54 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,5 @@ +export type PKCEState = { + 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/lib/__tests__/stateCookie.test.ts b/src/utils/__tests__/stateCookie.test.ts similarity index 70% rename from src/lib/__tests__/stateCookie.test.ts rename to src/utils/__tests__/stateCookie.test.ts index a15bc6d..cf009da 100644 --- a/src/lib/__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") diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..5a71219 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,45 @@ +import { + getStateCookie, + createStateCookie +} from "@/utils/stateCookie" +import { + createPKCECodeChallenge, + createRandomString +} from "@lib/pkce" +import { PKCEState } from "@/types" +import config from "@/config" + +export const createPKCECodes = async () => { + const codeVerifier = createRandomString() + const codeChallenge = await createPKCECodeChallenge(codeVerifier) + const state = createStateCookie(codeVerifier) + + return { codeVerifier, codeChallenge, state } +} + +export const redirectToLogin = 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 getPKCEStatus = (state?: string): PKCEState => { + // guard: pkce is not initialized yet + if (!state) { + return { isDone: false } + } + + const codeVerifier = getStateCookie(state) + if (!codeVerifier) return { isDone: false } + + return { + isDone: true, + codeVerifier, + } +} diff --git a/src/utils/stateCookie.ts b/src/utils/stateCookie.ts new file mode 100644 index 0000000..844b8ed --- /dev/null +++ b/src/utils/stateCookie.ts @@ -0,0 +1,20 @@ + +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(`${config.STATE_COOKIE_PREFIX}${state}`, value, 30) + + return state +} + +export const getStateCookie = (state: string) => { + return getCookie(`${config.STATE_COOKIE_PREFIX}${state}`) +} + +export const deleteStateCookie = (state: string) => { + deleteCookie(`${config.STATE_COOKIE_PREFIX}${state}`) +} diff --git a/tsconfig.json b/tsconfig.json index a7fc6fb..87e99c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,10 @@ { "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@lib/*": ["./lib/*"] + }, "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], @@ -20,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 2b37192..ba76fb0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,22 @@ 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') }, + { 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 + // } } })