Skip to content

Commit 7225059

Browse files
authored
feat: add nonce to ensure generation and verify OTP is via same session (#518)
* feat: use nonce to ascertain that same device is verifying email * test: update tests for new nonce implementation * feat: add and use vfn identifier fn so we know which email * feat: simplify and vaguelify error message when nonce doesn't exist * feat: return false if timingSafeEqual throws
1 parent b43d14d commit 7225059

File tree

8 files changed

+433
-100
lines changed

8 files changed

+433
-100
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export const EmailStep = ({ onNext }: EmailStepProps) => {
3636
trpc.auth.email.login.mutationOptions({
3737
onSuccess: onNext,
3838
onError: (error) => setError('email', { message: error.message }),
39+
trpc: {
40+
context: {
41+
// Need to set session data for nonce
42+
skipStreaming: true,
43+
},
44+
},
3945
}),
4046
)
4147

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ export const VerificationStep = () => {
5656
const resendOtpMutation = useMutation(
5757
trpc.auth.email.login.mutationOptions({
5858
onError: (error) => setError('token', { message: error.message }),
59+
trpc: {
60+
context: {
61+
// Need to set session data for nonce
62+
skipStreaming: true,
63+
},
64+
},
5965
}),
6066
)
6167

apps/web/src/server/api/routers/auth/auth.email.router.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { randomUUID } from 'node:crypto'
2+
import { TRPCError } from '@trpc/server'
13
import z from 'zod'
24

35
import { emailLogin, emailVerifyOtp } from '~/server/modules/auth/auth.service'
@@ -19,19 +21,36 @@ export const emailAuthRouter = createTRPCRouter({
1921
otpPrefix: z.string().length(OTP_PREFIX_LENGTH),
2022
}),
2123
)
22-
.mutation(async ({ input }) => {
23-
const { email, otpPrefix } = await emailLogin(input.email)
24+
.mutation(async ({ input, ctx }) => {
25+
const nonce = randomUUID()
26+
const { email, otpPrefix } = await emailLogin({
27+
email: input.email,
28+
nonce,
29+
})
30+
31+
ctx.session.nonce = nonce
32+
await ctx.session.save()
2433
return {
2534
email,
2635
otpPrefix,
2736
}
2837
}),
2938
verifyOtp: publicProcedure
3039
.input(emailVerifyOtpSchema)
31-
.mutation(async ({ input, ctx }) => {
32-
await emailVerifyOtp(input)
33-
const user = await upsertUserAndAccountByEmail(input.email)
40+
.mutation(async ({ input: { email, token }, ctx }) => {
41+
const nonce = ctx.session.nonce
42+
// Ensure nonce exists in session
43+
if (!nonce) {
44+
throw new TRPCError({
45+
code: 'BAD_REQUEST',
46+
message:
47+
'Something went wrong. Please request for a new OTP before retrying.',
48+
})
49+
}
3450

51+
await emailVerifyOtp({ email, token, nonce })
52+
const user = await upsertUserAndAccountByEmail(email)
53+
ctx.session.nonce = undefined
3554
ctx.session.userId = user.id
3655
await ctx.session.save()
3756
return user

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

Lines changed: 125 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { db } from '@acme/db'
88

99
import * as mailService from '../../mail/mail.service'
1010
import { emailLogin, emailVerifyOtp } from '../auth.service'
11-
import { createAuthToken } from '../auth.utils'
11+
import { createAuthToken, createVfnIdentifier } from '../auth.utils'
1212

1313
const mockedMailService = mock(mailService)
1414

@@ -20,18 +20,20 @@ describe('auth.service', () => {
2020
describe('emailLogin', () => {
2121
it('should create a verification token and send OTP email', async () => {
2222
const email = '[email protected]'
23+
const nonce = 'test-nonce-123'
2324

24-
const result = await emailLogin(email)
25+
const result = await emailLogin({ email, nonce })
2526

2627
expect(result).toEqual({
2728
email,
2829
token: expect.any(String),
2930
otpPrefix: expect.any(String),
3031
})
3132

32-
// Verify token was created in database
33+
// Verify token was created in database with vfnIdentifier
34+
const vfnIdentifier = createVfnIdentifier({ email, nonce })
3335
const token = await db.verificationToken.findUnique({
34-
where: { identifier: email },
36+
where: { identifier: vfnIdentifier },
3537
})
3638
expect(token).toBeDefined()
3739
expect(mockedMailService.sendMail).toHaveBeenCalledWith({
@@ -41,165 +43,242 @@ describe('auth.service', () => {
4143
})
4244
})
4345

44-
it('should reset attempts when sending new OTP for existing user', async () => {
46+
it('should reset attempts when sending new OTP for existing nonce', async () => {
4547
const email = '[email protected]'
48+
const nonce = 'test-nonce-123'
4649

4750
// First login
48-
await emailLogin(email)
51+
await emailLogin({ email, nonce })
4952

53+
const vfnIdentifier = createVfnIdentifier({ email, nonce })
5054
// Simulate failed attempts
5155
await db.verificationToken.update({
52-
where: { identifier: email },
56+
where: { identifier: vfnIdentifier },
5357
data: { attempts: 3 },
5458
})
5559

5660
// Second login should reset attempts
57-
await emailLogin(email)
61+
await emailLogin({ email, nonce })
5862

5963
const token = await db.verificationToken.findUnique({
60-
where: { identifier: email },
64+
where: { identifier: vfnIdentifier },
6165
})
6266
expect(token?.attempts).toBe(0)
6367
})
6468

65-
it('should update existing token instead of creating duplicate', async () => {
69+
it('should update existing token instead of creating duplicate for same nonce', async () => {
6670
const email = '[email protected]'
71+
const nonce = 'test-nonce-123'
6772

68-
await emailLogin(email)
69-
await emailLogin(email)
73+
await emailLogin({ email, nonce })
74+
await emailLogin({ email, nonce })
7075

76+
const vfnIdentifier = createVfnIdentifier({ email, nonce })
7177
// Should only have one record
7278
const tokens = await db.verificationToken.findMany({
73-
where: { identifier: email },
79+
where: { identifier: vfnIdentifier },
7480
})
7581
expect(tokens).toHaveLength(1)
7682
})
83+
84+
it('should allow different nonces for same email', async () => {
85+
const email = '[email protected]'
86+
const nonce1 = 'test-nonce-1'
87+
const nonce2 = 'test-nonce-2'
88+
89+
await emailLogin({ email, nonce: nonce1 })
90+
await emailLogin({ email, nonce: nonce2 })
91+
92+
// Should have two records with different nonces
93+
const vfnIdentifier1 = createVfnIdentifier({ email, nonce: nonce1 })
94+
const vfnIdentifier2 = createVfnIdentifier({ email, nonce: nonce2 })
95+
const token1 = await db.verificationToken.findUnique({
96+
where: { identifier: vfnIdentifier1 },
97+
})
98+
const token2 = await db.verificationToken.findUnique({
99+
where: { identifier: vfnIdentifier2 },
100+
})
101+
102+
expect(token1).toBeDefined()
103+
expect(token2).toBeDefined()
104+
expect(token1?.token).not.toBe(token2?.token)
105+
})
77106
})
78107

79108
describe('emailVerifyOtp', () => {
80109
it('should successfully verify a valid OTP', async () => {
81110
const email = '[email protected]'
111+
const nonce = 'test-nonce-123'
82112

83113
// Create a verification token
84-
const { token } = await emailLogin(email)
114+
const { token } = await emailLogin({ email, nonce })
85115

86116
// Should not throw
87-
await expect(emailVerifyOtp({ email, token })).resolves.not.toThrow()
117+
await expect(
118+
emailVerifyOtp({ email, token, nonce }),
119+
).resolves.not.toThrow()
88120

89121
// Token should be deleted after successful verification
122+
const vfnIdentifier = createVfnIdentifier({ email, nonce })
90123
const verificationToken = await db.verificationToken.findUnique({
91-
where: { identifier: email },
124+
where: { identifier: vfnIdentifier },
92125
})
93126
expect(verificationToken).toBeNull()
94127
})
95128

96129
it('should reject an invalid OTP', async () => {
97130
const email = '[email protected]'
131+
const nonce = 'test-nonce-123'
98132
const token = 'WRONG6'
99133

100-
await emailLogin(email)
101-
await expect(emailVerifyOtp({ email, token })).rejects.toThrow(
134+
await emailLogin({ email, nonce })
135+
await expect(emailVerifyOtp({ email, token, nonce })).rejects.toThrow(
102136
'Token is invalid or has expired',
103137
)
104138
})
105139

106140
it('should reject an expired OTP', async () => {
107141
const email = '[email protected]'
142+
const nonce = 'test-nonce-123'
108143

109-
const { token, hashedToken } = createAuthToken(email)
144+
const { token, hashedToken } = createAuthToken({ email, nonce })
110145

146+
const vfnIdentifier = createVfnIdentifier({ email, nonce })
111147
// Create a verification token with an old issuedAt date
112148
const oldDate = add(new Date(), { seconds: -700 }) // 700 seconds ago (beyond 600s expiry)
113149
await db.verificationToken.create({
114150
data: {
115-
identifier: email,
151+
identifier: vfnIdentifier,
116152
token: hashedToken,
117153
issuedAt: oldDate,
118154
},
119155
})
120156

121-
await expect(emailVerifyOtp({ email, token })).rejects.toThrow(
157+
await expect(emailVerifyOtp({ email, token, nonce })).rejects.toThrow(
122158
'Token is invalid or has expired',
123159
)
124160
})
125161

126162
it('should increment attempts on each verification try', async () => {
127163
const email = '[email protected]'
164+
const nonce = 'test-nonce-123'
128165
const token = 'WRONG6'
129166

130-
await emailLogin(email)
167+
await emailLogin({ email, nonce })
131168

169+
const vfnIdentifier = createVfnIdentifier({ email, nonce })
132170
// First attempt
133-
await expect(emailVerifyOtp({ email, token })).rejects.toThrow()
171+
await expect(emailVerifyOtp({ email, token, nonce })).rejects.toThrow()
134172
let verificationToken = await db.verificationToken.findUnique({
135-
where: { identifier: email },
173+
where: { identifier: vfnIdentifier },
136174
})
137175
expect(verificationToken?.attempts).toBe(1)
138176

139177
// Second attempt
140-
await expect(emailVerifyOtp({ email, token })).rejects.toThrow()
178+
await expect(emailVerifyOtp({ email, token, nonce })).rejects.toThrow()
141179
verificationToken = await db.verificationToken.findUnique({
142-
where: { identifier: email },
180+
where: { identifier: vfnIdentifier },
143181
})
144182
expect(verificationToken?.attempts).toBe(2)
145183
})
146184

147185
it('should reject after too many failed attempts (>5)', async () => {
148186
const email = '[email protected]'
187+
const nonce = 'test-nonce-123'
149188
const token = 'WRONG6'
150189

151-
await emailLogin(email)
190+
await emailLogin({ email, nonce })
152191

153192
// Make 5 failed attempts
154193
for (let i = 0; i < 5; i++) {
155-
await expect(emailVerifyOtp({ email, token })).rejects.toThrow()
194+
await expect(emailVerifyOtp({ email, token, nonce })).rejects.toThrow()
156195
}
157196

158197
// 6th attempt should give TOO_MANY_REQUESTS
159-
await expect(emailVerifyOtp({ email, token })).rejects.toThrow(
198+
await expect(emailVerifyOtp({ email, token, nonce })).rejects.toThrow(
160199
'Wrong OTP was entered too many times',
161200
)
162201
})
163202

164-
it('should throw error for non-existent email', async () => {
165-
const email = '[email protected]'
203+
it('should throw error for non-existent nonce', async () => {
204+
const email = '[email protected]'
205+
const nonce = 'nonexistent-nonce'
166206
const token = '123456'
167207

168-
await expect(emailVerifyOtp({ email, token })).rejects.toThrow(
169-
'Invalid login email',
208+
await expect(emailVerifyOtp({ email, token, nonce })).rejects.toThrow(
209+
'Invalid login email or missing nonce',
170210
)
171211
})
172212

173213
it('should delete verification token after successful verification', async () => {
174-
// Arrange
175214
const email = '[email protected]'
176-
const { token } = await emailLogin(email)
215+
const nonce = 'test-nonce-123'
216+
const { token } = await emailLogin({ email, nonce })
177217

178-
// Act
179-
await emailVerifyOtp({ email, token })
218+
await emailVerifyOtp({ email, token, nonce })
180219

181-
// Assert
182220
// Token should be deleted
221+
const vfnIdentifier = createVfnIdentifier({ email, nonce })
183222
const verificationToken = await db.verificationToken.findUnique({
184-
where: { identifier: email },
223+
where: { identifier: vfnIdentifier },
185224
})
186225
expect(verificationToken).toBeNull()
187226
})
188227

189228
it('should prevent token reuse after successful verification', async () => {
190-
// Arrange
191229
const email = '[email protected]'
192-
const { token } = await emailLogin(email)
230+
const nonce = 'test-nonce-123'
231+
const { token } = await emailLogin({ email, nonce })
193232

194-
// Act
195233
// First verification succeeds
196-
await expect(emailVerifyOtp({ email, token })).resolves.toBeDefined()
234+
await expect(
235+
emailVerifyOtp({ email, token, nonce }),
236+
).resolves.toBeDefined()
197237

198-
// Assert
199238
// Second verification with same token should fail
200-
await expect(emailVerifyOtp({ email, token })).rejects.toThrow(
201-
'Invalid login email',
239+
await expect(emailVerifyOtp({ email, token, nonce })).rejects.toThrow(
240+
'Invalid login email or missing nonce',
202241
)
203242
})
243+
244+
it('should not allow using token with wrong nonce', async () => {
245+
const email = '[email protected]'
246+
const nonce1 = 'test-nonce-1'
247+
const nonce2 = 'test-nonce-2'
248+
249+
const { token } = await emailLogin({ email, nonce: nonce1 })
250+
251+
// Try to verify with wrong nonce
252+
await expect(
253+
emailVerifyOtp({ email, token, nonce: nonce2 }),
254+
).rejects.toThrow('Invalid login email or missing nonce')
255+
256+
// Original token should still exist
257+
const vfnIdentifier1 = createVfnIdentifier({ email, nonce: nonce1 })
258+
const verificationToken = await db.verificationToken.findUnique({
259+
where: { identifier: vfnIdentifier1 },
260+
})
261+
expect(verificationToken).toBeDefined()
262+
})
263+
264+
it('should ensure OTP is tied to specific session via nonce', async () => {
265+
const email = '[email protected]'
266+
const nonce1 = 'session-1-nonce'
267+
const nonce2 = 'session-2-nonce'
268+
269+
// Two different sessions for same email
270+
const { token: token1 } = await emailLogin({ email, nonce: nonce1 })
271+
const { token: token2 } = await emailLogin({ email, nonce: nonce2 })
272+
273+
// Each token should only work with its own nonce
274+
await expect(
275+
emailVerifyOtp({ email, token: token1, nonce: nonce1 }),
276+
).resolves.toBeDefined()
277+
278+
// token2 with nonce2 should still work (not affected by token1 verification)
279+
await expect(
280+
emailVerifyOtp({ email, token: token2, nonce: nonce2 }),
281+
).resolves.toBeDefined()
282+
})
204283
})
205284
})

0 commit comments

Comments
 (0)