Skip to content

Commit 5c7a2ba

Browse files
committed
fix(otp): keep sub-second code lifetimes and drop unused deleteOTP export
TTL rounds a key with under a second left to 0, so the reservation script rewrote it without an expiry and the code never expired. Read PTTL and rewrite with PX, deleting a code with no remaining lifetime. deleteOTP is now only used inside the module.
1 parent 9615cb6 commit 5c7a2ba

2 files changed

Lines changed: 15 additions & 17 deletions

File tree

‎apps/sim/app/api/chat/[identifier]/otp/route.integration.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,11 @@ describe.each(['database', 'redis'] as const)('%s chat OTP verification', (backe
134134
it('keeps the code expiry when a failed attempt is recorded', async () => {
135135
if (backend === 'redis') {
136136
const key = `otp:${email}:${chatId}`
137-
await redisClient.current?.expire(key, 60)
137+
await redisClient.current?.pexpire(key, 900)
138138
expect(await verify('999999')).toMatchObject({ status: 400, error: INVALID })
139-
const ttl = await redisClient.current?.ttl(key)
140-
expect(ttl).toBeGreaterThan(55)
141-
expect(ttl).toBeLessThanOrEqual(60)
139+
const ttl = await redisClient.current?.pttl(key)
140+
expect(ttl).toBeGreaterThan(0)
141+
expect(ttl).toBeLessThanOrEqual(900)
142142
return
143143
}
144144
const readExpiry = async () =>

‎apps/sim/lib/core/security/otp.ts‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ export async function storeOTP(
118118

119119
/**
120120
* Lua script that reserves one verification attempt in Redis. Returns the
121-
* code with the reserved attempt number, or nil when no code exists.
121+
* code with the reserved attempt number, or nil when no code exists. Rewrites
122+
* keep the remaining lifetime to the millisecond; a code without one is
123+
* treated as expired so it can never become permanent.
122124
*/
123125
const RESERVE_ATTEMPT_SCRIPT = `
124126
local val = redis.call('GET', KEYS[1])
@@ -136,14 +138,14 @@ attempts = attempts + 1
136138
local newVal = otp .. ':' .. attempts
137139
if attempts >= tonumber(ARGV[1]) then
138140
redis.call('DEL', KEYS[1])
139-
else
140-
local ttl = redis.call('TTL', KEYS[1])
141-
if ttl > 0 then
142-
redis.call('SET', KEYS[1], newVal, 'EX', ttl)
143-
else
144-
redis.call('SET', KEYS[1], newVal)
145-
end
141+
return newVal
142+
end
143+
local ttl = redis.call('PTTL', KEYS[1])
144+
if ttl <= 0 then
145+
redis.call('DEL', KEYS[1])
146+
return nil
146147
end
148+
redis.call('SET', KEYS[1], newVal, 'PX', ttl)
147149
return newVal
148150
`
149151

@@ -220,11 +222,7 @@ export async function verifyOTP(
220222
return reserved.attempts >= MAX_OTP_ATTEMPTS ? 'locked' : 'invalid'
221223
}
222224

223-
export async function deleteOTP(
224-
kind: DeploymentKind,
225-
deploymentId: string,
226-
email: string
227-
): Promise<void> {
225+
async function deleteOTP(kind: DeploymentKind, deploymentId: string, email: string): Promise<void> {
228226
const keys = OTP_KEYS[kind]
229227
const storageMethod = getStorageMethod()
230228

0 commit comments

Comments
 (0)