Skip to content

Commit c5c5882

Browse files
committed
feat: split browser and serverside pkce generators
1 parent 35964f3 commit c5c5882

File tree

12 files changed

+173
-102
lines changed

12 files changed

+173
-102
lines changed

apps/web/src/app/(public)/sign-in/_components/wizard/context.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
'use client'
22

33
import type { Dispatch, PropsWithChildren, SetStateAction } from 'react'
4-
import { createContext, useCallback, useContext, useRef, useState } from 'react'
4+
import { createContext, useContext, useRef, useState } from 'react'
55
import { useInterval } from 'usehooks-ts'
66

77
import {
8-
createPkceChallenge,
9-
createPkceVerifier,
10-
} from '~/server/modules/auth/auth.pkce'
8+
browserCreatePkceChallenge,
9+
browserCreatePkceVerifier,
10+
} from '~/lib/pkce/browser-pkce'
1111

1212
interface SignInState {
1313
timer: number
1414
resetTimer: () => void
1515
vfnStepData: VfnStepData | undefined
1616
setVfnStepData: Dispatch<SetStateAction<VfnStepData | undefined>>
1717
getVerifier: (challenge: string) => string | undefined
18-
newChallenge: () => string
18+
newChallenge: () => Promise<string>
1919
clearVerifierMap: () => void
2020
}
2121

@@ -57,18 +57,18 @@ export const SignInWizardProvider = ({
5757
const [timer, setTimer] = useState(delayForResendSeconds)
5858

5959
const challengeToVerifierMap = useRef(new Map<string, string>())
60-
const newChallenge = useCallback(() => {
61-
const verifier = createPkceVerifier()
62-
const challenge = createPkceChallenge(verifier)
60+
const newChallenge = async () => {
61+
const verifier = browserCreatePkceVerifier()
62+
const challenge = await browserCreatePkceChallenge(verifier)
6363
challengeToVerifierMap.current.set(challenge, verifier)
6464
return challenge
65-
}, []) // stable reference no deps
66-
const getVerifier = useCallback((challenge: string) => {
65+
}
66+
const getVerifier = (challenge: string) => {
6767
return challengeToVerifierMap.current.get(challenge)
68-
}, []) // stable reference no deps
69-
const clearVerifierMap = useCallback(() => {
68+
}
69+
const clearVerifierMap = () => {
7070
challengeToVerifierMap.current.clear()
71-
}, []) // stable reference no deps
71+
}
7272

7373
const resetTimer = () => setTimer(delayForResendSeconds)
7474

apps/web/src/app/(public)/sign-in/_components/wizard/email/email-step.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect } from 'react'
1+
import { useEffect, useState } from 'react'
22
import { zodResolver } from '@hookform/resolvers/zod'
33
import { Button } from '@opengovsg/oui/button'
44
import { useMutation } from '@tanstack/react-query'
@@ -15,8 +15,11 @@ import { useSignInWizard } from '../context'
1515
interface EmailStepProps {
1616
onNext: ({ email, otpPrefix, codeChallenge }: VfnStepData) => void
1717
}
18+
1819
export const EmailStep = ({ onNext }: EmailStepProps) => {
1920
const { newChallenge } = useSignInWizard()
21+
const [newChallengePending, setNewChallengePending] = useState(false)
22+
2023
const { handleSubmit, setError, control } = useForm({
2124
resolver: zodResolver(emailSignInSchema.omit({ codeChallenge: true })),
2225
defaultValues: {
@@ -47,12 +50,23 @@ export const EmailStep = ({ onNext }: EmailStepProps) => {
4750
}),
4851
)
4952

53+
const isPending = loginMutation.isPending || newChallengePending
54+
5055
return (
5156
<form
5257
noValidate
53-
onSubmit={handleSubmit(({ email }) =>
54-
loginMutation.mutate({ email, codeChallenge: newChallenge() }),
55-
)}
58+
onSubmit={handleSubmit(({ email }) => {
59+
if (isPending) return
60+
setNewChallengePending(true)
61+
newChallenge()
62+
.then((codeChallenge) => {
63+
loginMutation.mutate({ email, codeChallenge })
64+
})
65+
.catch(console.error)
66+
.finally(() => {
67+
setNewChallengePending(false)
68+
})
69+
})}
5670
className="flex flex-1 flex-col gap-4"
5771
>
5872
<Controller
@@ -72,7 +86,7 @@ export const EmailStep = ({ onNext }: EmailStepProps) => {
7286
/>
7387
)}
7488
/>
75-
<Button size="sm" isPending={loginMutation.isPending} type="submit">
89+
<Button size="sm" isPending={isPending} type="submit">
7690
Get OTP
7791
</Button>
7892
</form>

apps/web/src/app/(public)/sign-in/_components/wizard/email/verification-step.tsx

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const VerificationStep = () => {
2929
getVerifier,
3030
clearVerifierMap,
3131
} = useSignInWizard()
32+
const [newChallengePending, setNewChallengePending] = useState(false)
3233
const codeVerifier = getVerifier(vfnStepData?.codeChallenge ?? '') ?? ''
3334

3435
useInterval(
@@ -65,28 +66,34 @@ export const VerificationStep = () => {
6566

6667
const resendOtpMutation = useMutation(
6768
trpc.auth.email.login.mutationOptions({
69+
onSuccess: (res, req) => {
70+
setVfnStepData({
71+
email: res.email,
72+
otpPrefix: res.otpPrefix,
73+
codeChallenge: req.codeChallenge,
74+
})
75+
resetField('token')
76+
setFocus('token')
77+
// On success, restart the timer before this can be called again.
78+
resetTimer()
79+
},
6880
onError: (error) => setError('token', { message: error.message }),
6981
}),
7082
)
7183

84+
const isResendPending = resendOtpMutation.isPending || newChallengePending
7285
const handleResendOtp = () => {
7386
if (timer > 0 || !vfnStepData?.email) return
74-
return resendOtpMutation.mutate(
75-
{ email: vfnStepData.email, codeChallenge: newChallenge() },
76-
{
77-
onSuccess: (res, req) => {
78-
setVfnStepData({
79-
email: res.email,
80-
otpPrefix: res.otpPrefix,
81-
codeChallenge: req.codeChallenge,
82-
})
83-
resetField('token')
84-
setFocus('token')
85-
// On success, restart the timer before this can be called again.
86-
resetTimer()
87-
},
88-
},
89-
)
87+
if (isResendPending) return
88+
setNewChallengePending(true)
89+
newChallenge()
90+
.then((codeChallenge) => {
91+
resendOtpMutation.mutate({ email: vfnStepData.email, codeChallenge })
92+
})
93+
.catch(console.error)
94+
.finally(() => {
95+
setNewChallengePending(false)
96+
})
9097
}
9198

9299
if (!vfnStepData) return null
@@ -147,7 +154,7 @@ export const VerificationStep = () => {
147154
)}
148155
size="xs"
149156
onPress={handleResendOtp}
150-
isPending={resendOtpMutation.isPending}
157+
isPending={isResendPending}
151158
// isPending
152159
isDisabled={timer > 0}
153160
spinner={
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { pkceVerifierGenerator } from '~/lib/pkce/pkce'
2+
3+
// This file hosts functions pertaining to PKCE (Proof Key for Code Exchange) as per RFC 7636
4+
// If you need to understand PKCE, read https://datatracker.ietf.org/doc/html/rfc7636
5+
// Do not use this for actual OAuth flows, please use a tested library instead.
6+
7+
// We need to split up server and browser implementations due to using crypto APIs
8+
9+
export function browserCreatePkceVerifier(): string {
10+
return pkceVerifierGenerator()
11+
}
12+
13+
export async function browserCreatePkceChallenge(
14+
codeVerifier: string,
15+
): Promise<string> {
16+
const encoder = new TextEncoder()
17+
const data = encoder.encode(codeVerifier)
18+
const hashBuffer = await window.crypto.subtle.digest('SHA-256', data)
19+
20+
const hashArray = Array.from(new Uint8Array(hashBuffer))
21+
const base64 = btoa(String.fromCharCode(...hashArray))
22+
23+
return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
24+
}

apps/web/src/lib/pkce/pkce.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { customAlphabet } from 'nanoid'
2+
3+
import { PKCE_LENGTH, PKCE_VERIFIER_ALPHABET } from '~/validators/auth'
4+
5+
export const pkceVerifierGenerator = customAlphabet(
6+
PKCE_VERIFIER_ALPHABET,
7+
PKCE_LENGTH,
8+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createHash } from 'crypto'
2+
3+
import { pkceVerifierGenerator } from '~/lib/pkce/pkce'
4+
5+
// This file hosts functions pertaining to PKCE (Proof Key for Code Exchange) as per RFC 7636
6+
// If you need to understand PKCE, read https://datatracker.ietf.org/doc/html/rfc7636
7+
// Do not use this for actual OAuth flows, please use a tested library instead.
8+
9+
// We need to split up server and browser implementations due to using crypto APIs
10+
11+
export function ssCreatePkceVerifier(): string {
12+
return pkceVerifierGenerator()
13+
}
14+
15+
export function ssCreatePkceChallenge(codeVerifier: string): string {
16+
return createHash('sha256').update(codeVerifier).digest('base64url')
17+
}

apps/web/src/server/modules/auth/__tests__/auth.service.spec.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import { mock } from 'vitest-mock-extended'
66

77
import { db } from '@acme/db'
88

9+
import {
10+
ssCreatePkceChallenge,
11+
ssCreatePkceVerifier,
12+
} from '~/lib/pkce/server-pkce'
913
import * as mailService from '../../mail/mail.service'
10-
import { createPkceChallenge, createPkceVerifier } from '../auth.pkce'
1114
import { emailLogin, emailVerifyOtp } from '../auth.service'
1215
import { createAuthToken, createVfnIdentifier } from '../auth.utils'
1316

@@ -88,8 +91,8 @@ describe('auth.service', () => {
8891
describe('emailVerifyOtp', () => {
8992
it('should successfully verify a valid OTP', async () => {
9093
const email = '[email protected]'
91-
const codeVerifier = createPkceVerifier()
92-
const codeChallenge = createPkceChallenge(codeVerifier)
94+
const codeVerifier = ssCreatePkceVerifier()
95+
const codeChallenge = ssCreatePkceChallenge(codeVerifier)
9396

9497
// Create a verification token
9598
const { token } = await emailLogin({ email, codeChallenge })
@@ -109,9 +112,9 @@ describe('auth.service', () => {
109112

110113
it('should reject a correct OTP with wrong codeVerifier', async () => {
111114
const email = '[email protected]'
112-
const correctVerifier = createPkceVerifier()
113-
const wrongVerifier = createPkceVerifier()
114-
const codeChallenge = createPkceChallenge(correctVerifier)
115+
const correctVerifier = ssCreatePkceVerifier()
116+
const wrongVerifier = ssCreatePkceVerifier()
117+
const codeChallenge = ssCreatePkceChallenge(correctVerifier)
115118

116119
// Create a verification token
117120
const { token } = await emailLogin({ email, codeChallenge })
@@ -123,7 +126,7 @@ describe('auth.service', () => {
123126
})
124127
it('should throw error for non-existent codeChallenge', async () => {
125128
const email = '[email protected]'
126-
const codeVerifier = createPkceVerifier()
129+
const codeVerifier = ssCreatePkceVerifier()
127130
const token = '123456'
128131

129132
await expect(
@@ -133,10 +136,10 @@ describe('auth.service', () => {
133136

134137
it('should reject a wrong OTP with wrong codeVerifier', async () => {
135138
const email = '[email protected]'
136-
const correctVerifier = createPkceVerifier()
137-
const correctCodeChallenge = createPkceChallenge(correctVerifier)
139+
const correctVerifier = ssCreatePkceVerifier()
140+
const correctCodeChallenge = ssCreatePkceChallenge(correctVerifier)
138141

139-
const wrongVerifier = createPkceVerifier()
142+
const wrongVerifier = ssCreatePkceVerifier()
140143

141144
// Create a verification token
142145
await emailLogin({ email, codeChallenge: correctCodeChallenge })
@@ -155,8 +158,8 @@ describe('auth.service', () => {
155158

156159
it('should reject a wrong OTP with correct codeVerifier', async () => {
157160
const email = '[email protected]'
158-
const codeVerifier = createPkceVerifier()
159-
const codeChallenge = createPkceChallenge(codeVerifier)
161+
const codeVerifier = ssCreatePkceVerifier()
162+
const codeChallenge = ssCreatePkceChallenge(codeVerifier)
160163
const wrongToken = 'WRONG6'
161164

162165
await emailLogin({ email, codeChallenge: codeChallenge })
@@ -167,8 +170,8 @@ describe('auth.service', () => {
167170

168171
it('should reject an expired OTP with correct codeVerifier', async () => {
169172
const email = '[email protected]'
170-
const codeVerifier = createPkceVerifier()
171-
const codeChallenge = createPkceChallenge(codeVerifier)
173+
const codeVerifier = ssCreatePkceVerifier()
174+
const codeChallenge = ssCreatePkceChallenge(codeVerifier)
172175

173176
const { token, hashedToken } = createAuthToken({
174177
email,
@@ -196,8 +199,8 @@ describe('auth.service', () => {
196199

197200
it('should increment attempts on each verification try', async () => {
198201
const email = '[email protected]'
199-
const codeVerifier = createPkceVerifier()
200-
const codeChallenge = createPkceChallenge(codeVerifier)
202+
const codeVerifier = ssCreatePkceVerifier()
203+
const codeChallenge = ssCreatePkceChallenge(codeVerifier)
201204
const wrongToken = 'WRONG6'
202205

203206
await emailLogin({ email, codeChallenge: codeChallenge })
@@ -216,8 +219,8 @@ describe('auth.service', () => {
216219

217220
it('should reject after too many failed attempts (>5)', async () => {
218221
const email = '[email protected]'
219-
const codeVerifier = createPkceVerifier()
220-
const codeChallenge = createPkceChallenge(codeVerifier)
222+
const codeVerifier = ssCreatePkceVerifier()
223+
const codeChallenge = ssCreatePkceChallenge(codeVerifier)
221224
const token = 'WRONG6'
222225

223226
await emailLogin({ email, codeChallenge: codeChallenge })
@@ -237,8 +240,8 @@ describe('auth.service', () => {
237240

238241
it('should delete verification token after successful verification', async () => {
239242
const email = '[email protected]'
240-
const codeVerifier = createPkceVerifier()
241-
const codeChallenge = createPkceChallenge(codeVerifier)
243+
const codeVerifier = ssCreatePkceVerifier()
244+
const codeChallenge = ssCreatePkceChallenge(codeVerifier)
242245
const { token } = await emailLogin({ email, codeChallenge })
243246

244247
await emailVerifyOtp({ email, token, codeVerifier })
@@ -256,8 +259,8 @@ describe('auth.service', () => {
256259

257260
it('should prevent token reuse after successful verification', async () => {
258261
const email = '[email protected]'
259-
const codeVerifier = createPkceVerifier()
260-
const codeChallenge = createPkceChallenge(codeVerifier)
262+
const codeVerifier = ssCreatePkceVerifier()
263+
const codeChallenge = ssCreatePkceChallenge(codeVerifier)
261264
const { token } = await emailLogin({ email, codeChallenge })
262265

263266
// First verification succeeds

apps/web/src/server/modules/auth/__tests__/auth.utils.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ describe('auth.utils', () => {
8282
const testCodeChallenge = 'test-codeChallenge-123'
8383

8484
it('should generate tokens of sufficient entropy', () => {
85-
const N = 10000
85+
const N = 100
8686
const tokens = Array.from({ length: N }).map(
8787
() =>
8888
createAuthToken({

apps/web/src/server/modules/auth/auth.pkce.ts

Lines changed: 0 additions & 29 deletions
This file was deleted.

0 commit comments

Comments
 (0)