11import { randomInt } from 'crypto'
22import { db } from '@sim/db'
33import { verification } from '@sim/db/schema'
4+ import { safeCompare } from '@sim/security/compare'
45import { generateId } from '@sim/utils/id'
56import { and , eq , gt , lt , sql } from 'drizzle-orm'
67import { getRedisClient } from '@/lib/core/config/redis'
@@ -70,7 +71,7 @@ function encodeOTPValue(otp: string, attempts: number): string {
7071 return `${ otp } :${ attempts } `
7172}
7273
73- export function decodeOTPValue ( value : string ) : { otp : string ; attempts : number } {
74+ function decodeOTPValue ( value : string ) : { otp : string ; attempts : number } {
7475 const lastColon = value . lastIndexOf ( ':' )
7576 if ( lastColon === - 1 ) return { otp : value , attempts : 0 }
7677 const attempts = Number . parseInt ( value . slice ( lastColon + 1 ) , 10 )
@@ -115,40 +116,9 @@ export async function storeOTP(
115116 } )
116117}
117118
118- export async function getOTP (
119- kind : DeploymentKind ,
120- deploymentId : string ,
121- email : string
122- ) : Promise < string | null > {
123- const keys = OTP_KEYS [ kind ]
124- const storageMethod = getStorageMethod ( )
125-
126- if ( storageMethod === 'redis' ) {
127- const redis = getRedisClient ( )
128- if ( ! redis ) throw new Error ( 'Redis configured but client unavailable' )
129- return redis . get ( keys . redisKey ( email , deploymentId ) )
130- }
131-
132- const now = new Date ( )
133- const [ record ] = await db
134- . select ( { value : verification . value } )
135- . from ( verification )
136- . where (
137- and (
138- eq ( verification . identifier , keys . dbIdentifier ( email , deploymentId ) ) ,
139- gt ( verification . expiresAt , now )
140- )
141- )
142- . limit ( 1 )
143-
144- return record ?. value ?? null
145- }
146-
147119/**
148120 * Lua script that reserves one verification attempt in Redis. Returns the
149- * code with the reserved attempt number, or nil when no code exists or every
150- * attempt is already spent. The reservation that spends the last attempt
151- * deletes the key, so later requests find no code.
121+ * code with the reserved attempt number, or nil when no code exists.
152122 */
153123const RESERVE_ATTEMPT_SCRIPT = `
154124local val = redis.call('GET', KEYS[1])
@@ -163,13 +133,8 @@ else
163133 attempts = 0
164134end
165135attempts = attempts + 1
166- local max = tonumber(ARGV[1])
167- if attempts > max then
168- redis.call('DEL', KEYS[1])
169- return nil
170- end
171136local newVal = otp .. ':' .. attempts
172- if attempts == max then
137+ if attempts >= tonumber(ARGV[1]) then
173138 redis.call('DEL', KEYS[1])
174139else
175140 local ttl = redis.call('TTL', KEYS[1])
@@ -184,12 +149,11 @@ return newVal
184149
185150/**
186151 * Atomically spends one of the code's {@link MAX_OTP_ATTEMPTS} attempts and
187- * returns the stored code for the caller to compare, or `null` when no live
188- * code is left. Reserving before comparing bounds the guesses checked against
189- * one code to the attempt limit however many requests run concurrently. The
190- * reservation that spends the last attempt also deletes the code.
152+ * returns the stored code with its reserved attempt count, or `null` when no
153+ * live code is left. The reservation that spends the last attempt also
154+ * deletes the code.
191155 */
192- export async function reserveOTPAttempt (
156+ async function reserveOTPAttempt (
193157 kind : DeploymentKind ,
194158 deploymentId : string ,
195159 email : string
@@ -233,6 +197,29 @@ export async function reserveOTPAttempt(
233197 return decoded
234198}
235199
200+ export type OTPVerification = 'valid' | 'invalid' | 'locked' | 'missing'
201+
202+ /**
203+ * Checks a submitted code and consumes it on a match. An attempt is reserved
204+ * before the comparison, so at most {@link MAX_OTP_ATTEMPTS} guesses are ever
205+ * compared against one code however many requests run concurrently.
206+ * `'locked'` means this guess spent the last attempt and the code is gone.
207+ */
208+ export async function verifyOTP (
209+ kind : DeploymentKind ,
210+ deploymentId : string ,
211+ email : string ,
212+ code : string
213+ ) : Promise < OTPVerification > {
214+ const reserved = await reserveOTPAttempt ( kind , deploymentId , email )
215+ if ( ! reserved ) return 'missing'
216+ if ( safeCompare ( reserved . otp , code ) ) {
217+ await deleteOTP ( kind , deploymentId , email )
218+ return 'valid'
219+ }
220+ return reserved . attempts >= MAX_OTP_ATTEMPTS ? 'locked' : 'invalid'
221+ }
222+
236223export async function deleteOTP (
237224 kind : DeploymentKind ,
238225 deploymentId : string ,
0 commit comments