From 026aea03dc74374b07f2b915062215277de44f34 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 25 Feb 2026 00:22:31 +0800 Subject: [PATCH] feat: add overlap grace period for refresh tokens to handle concurrent requests --- src/config/limits.ts | 3 +++ src/handlers/identity.ts | 8 ++++++-- src/services/storage.ts | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/config/limits.ts b/src/config/limits.ts index 4a1481a..ffcd2d4 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -6,6 +6,9 @@ // Refresh token lifetime in milliseconds. // 刷新令牌有效期(毫秒)。 refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, + // Grace window for previous refresh token after rotation (ms). + // 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 + refreshTokenOverlapGraceMs: 60 * 1000, // Refresh token random byte length. // 刷新令牌随机字节长度。 refreshTokenRandomBytes: 32, diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 937d872..482d306 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -244,8 +244,12 @@ export async function handleToken(request: Request, env: Env): Promise return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); } - // Revoke old refresh token (prevent reuse) - await storage.deleteRefreshToken(refreshToken); + // Keep a short overlap window for old refresh token to absorb + // concurrent refresh requests from multiple client contexts. + await storage.constrainRefreshTokenExpiry( + refreshToken, + Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs + ); const { accessToken, user } = result; const newRefreshToken = await auth.generateRefreshToken(user.id); diff --git a/src/services/storage.ts b/src/services/storage.ts index 828c2ec..4a689a6 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -643,6 +643,26 @@ export class StorageService { await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run(); } + // Keep a short overlap window for rotated refresh token to reduce + // multi-context refresh races (e.g. browser extension popup/background). + // Expiry is only tightened, never extended. + async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise { + const tokenKey = await this.refreshTokenKey(token); + + await this.db.prepare( + 'UPDATE refresh_tokens ' + + 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + + 'WHERE token = ?' + ).bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey).run(); + + // Best-effort legacy plaintext support for older rows. + await this.db.prepare( + 'UPDATE refresh_tokens ' + + 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + + 'WHERE token = ?' + ).bind(maxExpiresAtMs, maxExpiresAtMs, token).run(); + } + private async trustedTwoFactorTokenKey(token: string): Promise { const digest = await this.sha256Hex(token); return `sha256:${digest}`;