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 + 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 (
<>
-