Skip to content

Commit edee9e1

Browse files
committed
feat: use nonce to ascertain that same device is verifying email
1 parent b43d14d commit edee9e1

File tree

6 files changed

+75
-18
lines changed

6 files changed

+75
-18
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/auth.service.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,29 @@ import { getBaseUrl } from '~/utils/get-base-url'
1010
import { sendMail } from '../mail/mail.service'
1111
import { createAuthToken, createVfnPrefix, isValidToken } from './auth.utils'
1212

13-
export const emailLogin = async (email: string) => {
14-
const { token, hashedToken } = createAuthToken(email)
13+
export const emailLogin = async ({
14+
email,
15+
nonce,
16+
}: {
17+
email: string
18+
nonce: string
19+
}) => {
20+
const { token, hashedToken } = createAuthToken({ email, nonce })
1521
const otpPrefix = createVfnPrefix()
1622

1723
const url = new URL(getBaseUrl())
1824

1925
const { issuedAt } = await db.verificationToken.upsert({
2026
where: {
21-
identifier: email,
27+
identifier: nonce,
2228
},
2329
update: {
2430
token: hashedToken,
2531
attempts: 0,
2632
issuedAt: new Date(),
2733
},
2834
create: {
29-
identifier: email,
35+
identifier: nonce,
3036
token: hashedToken,
3137
issuedAt: new Date(),
3238
},
@@ -57,15 +63,17 @@ export const emailLogin = async (email: string) => {
5763
export const emailVerifyOtp = async ({
5864
email,
5965
token,
66+
nonce,
6067
}: {
6168
email: string
6269
token: string
70+
nonce: string
6371
}) => {
6472
try {
6573
// Not in transaction, because we do not want it to rollback
6674
const hashedToken = await db.verificationToken.update({
6775
where: {
68-
identifier: email,
76+
identifier: nonce,
6977
},
7078
data: {
7179
attempts: {
@@ -87,7 +95,7 @@ export const emailVerifyOtp = async ({
8795
add(hashedToken.issuedAt, { seconds: env.OTP_EXPIRY }) < new Date()
8896
if (
8997
hasExpired ||
90-
!isValidToken({ token, email, hash: hashedToken.token })
98+
!isValidToken({ token, email, nonce, hash: hashedToken.token })
9199
) {
92100
throw new TRPCError({
93101
code: 'BAD_REQUEST',
@@ -98,7 +106,7 @@ export const emailVerifyOtp = async ({
98106
// Valid token, delete record to prevent reuse
99107
return db.verificationToken.delete({
100108
where: {
101-
identifier: email,
109+
identifier: nonce,
102110
},
103111
})
104112
} catch (error) {
@@ -109,7 +117,7 @@ export const emailVerifyOtp = async ({
109117
) {
110118
throw new TRPCError({
111119
code: 'BAD_REQUEST',
112-
message: 'Invalid login email',
120+
message: 'Invalid login email or missing nonce',
113121
})
114122
}
115123
throw error

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,44 @@ export const createVfnPrefix = customAlphabet(
1414
OTP_PREFIX_LENGTH,
1515
)
1616

17-
const createTokenHash = (token: string, email: string) => {
18-
return scryptSync(token, email, 64).toString('base64')
17+
const createTokenHash = ({
18+
token,
19+
email,
20+
nonce,
21+
}: {
22+
token: string
23+
email: string
24+
nonce: string
25+
}) => {
26+
return scryptSync(`${nonce}${token}`, email, 64).toString('base64')
1927
}
2028

2129
export const isValidToken = ({
2230
token,
2331
email,
2432
hash,
33+
nonce,
2534
}: {
2635
token: string
2736
email: string
2837
hash: string
38+
nonce: string
2939
}) => {
3040
return timingSafeEqual(
3141
Buffer.from(hash),
32-
Buffer.from(createTokenHash(token, email)),
42+
Buffer.from(createTokenHash({ token, email, nonce })),
3343
)
3444
}
3545

36-
export const createAuthToken = (email: string) => {
46+
export const createAuthToken = ({
47+
email,
48+
nonce,
49+
}: {
50+
email: string
51+
nonce: string
52+
}) => {
3753
const token = createVfnToken()
38-
const hashedToken = createTokenHash(token, email)
54+
const hashedToken = createTokenHash({ token, email, nonce })
3955

4056
return {
4157
token,

apps/web/src/server/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { getIronSession } from 'iron-session'
55
import { env } from '~/env'
66

77
export interface SessionData {
8+
// Used to verify that the same user that started the authentication flow is the one verifying it
9+
nonce?: string
810
userId?: string
911
// Add other session data as needed
1012
}

0 commit comments

Comments
 (0)