diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 5416779..d30dfa6 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -132,6 +132,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, + security_stamp TEXT, + created_at INTEGER, + last_used_at INTEGER, + absolute_expires_at INTEGER, + client_type TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); diff --git a/src/config/limits.ts b/src/config/limits.ts index 52a008b..077b5e7 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -3,12 +3,14 @@ // Access token lifetime in seconds. // 访问令牌有效期(秒)。 accessTokenTtlSeconds: 7200, - // Refresh token lifetime in milliseconds. - // 刷新令牌有效期(毫秒)。 - refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000, - // Grace window for previous refresh token after rotation (ms). - // 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 - refreshTokenOverlapGraceMs: 30 * 60 * 1000, + // Refresh sessions use a reusable opaque token with a sliding idle lifetime. + // 刷新会话使用可复用的随机令牌,并按客户端采用滑动空闲期限。 + refreshTokenWebSlidingTtlMs: 30 * 24 * 60 * 60 * 1000, + refreshTokenDefaultSlidingTtlMs: 30 * 24 * 60 * 60 * 1000, + refreshTokenMobileSlidingTtlMs: 90 * 24 * 60 * 60 * 1000, + // Hard upper bound for one login session, regardless of sliding refreshes. + // 单次登录会话的绝对最长寿命,不因滑动续期突破该上限。 + refreshTokenAbsoluteTtlMs: 365 * 24 * 60 * 60 * 1000, // Refresh token random byte length. // 刷新令牌随机字节长度。 refreshTokenRandomBytes: 32, @@ -62,6 +64,9 @@ // Refresh-token grant budget per IP per minute. // refresh_token 授权每 IP 每分钟请求配额。 refreshTokenRequestsPerMinute: 30, + // Coarser IP budget; the per-session budget above remains the primary guard. + // 更宽松的 IP 总预算;主要保护仍由每个 refresh session 的预算承担。 + refreshTokenRequestsPerIpMinute: 300, // Passwordless/auth-request creation budget per IP/email/device per minute. // 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。 authRequestRequestsPerMinute: 5, @@ -159,3 +164,10 @@ cipherKeyEncryptionFeatureEnabled: true, }, } as const; + +export function getRefreshTokenSlidingTtlMs(clientType?: string | null): number { + const normalized = String(clientType || '').trim().toLowerCase(); + if (normalized === 'web') return LIMITS.auth.refreshTokenWebSlidingTtlMs; + if (normalized === 'mobile') return LIMITS.auth.refreshTokenMobileSlidingTtlMs; + return LIMITS.auth.refreshTokenDefaultSlidingTtlMs; +} diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 5331551..0816182 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -3,7 +3,7 @@ import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; -import { LIMITS } from '../config/limits'; +import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { createRefreshToken } from '../utils/jwt'; import { readAuthRequestDeviceInfo } from '../utils/device'; @@ -42,6 +42,10 @@ const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; +function identityJsonResponse(data: unknown, status: number = 200): Response { + return jsonResponse(data, status, { 'Cache-Control': 'no-store', Pragma: 'no-cache' }); +} + function resolveTotpSecret(userSecret: string | null): string | null { if (userSecret && isTotpEnabled(userSecret)) { return userSecret; @@ -60,6 +64,33 @@ async function resolveDeviceSession( return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; } +function resolveRefreshClientType(request: Request, body: Record): string { + if (shouldUseWebSession(request)) return 'web'; + const clientId = String(body.client_id || '').trim().toLowerCase(); + if (clientId === 'mobile') return 'mobile'; + if (clientId === 'browser' || clientId === 'desktop' || clientId === 'cli') return clientId; + return clientId || 'other'; +} + +async function persistAndResolveDeviceSession( + storage: StorageService, + userId: string, + deviceInfo: ReturnType +): Promise<{ identifier: string; sessionStamp: string } | null> { + const candidate = await resolveDeviceSession(storage, userId, deviceInfo); + if (!candidate) return null; + await storage.upsertDevice( + userId, + candidate.identifier, + deviceInfo.deviceName, + deviceInfo.deviceType, + candidate.sessionStamp + ); + const persisted = await storage.getDevice(userId, candidate.identifier); + if (!persisted?.sessionStamp) throw new Error('Failed to persist device session'); + return { identifier: persisted.deviceIdentifier, sessionStamp: persisted.sessionStamp }; +} + function readDevicePushToken(body: Record): string { return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim(); } @@ -163,7 +194,7 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken headers.append( 'Set-Cookie', refreshToken - ? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000)) + ? buildRefreshCookie(request, refreshToken, Math.floor(getRefreshTokenSlidingTtlMs('web') / 1000)) : buildClearedRefreshCookie(request) ); return new Response(response.body, { @@ -173,30 +204,6 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken }); } -async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise { - const authHeader = request.headers.get('Authorization'); - if (!authHeader) return; - - const auth = new AuthService(env); - const verified = await auth.verifyAccessTokenWithUser(authHeader); - if (!verified) return; - - const deviceIdentifier = String(verified.payload.did || '').trim(); - if (deviceIdentifier) { - const nextSessionStamp = generateUUID(); - await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp); - await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier); - AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier); - return; - } - - verified.user.securityStamp = generateUUID(); - verified.user.updatedAt = new Date().toISOString(); - await storage.saveUser(verified.user); - await storage.deleteRefreshTokensByUserId(verified.user.id); - AuthService.invalidateUserCache(verified.user.id); -} - function buildPreloginResponse( email: string, kdfType: number, @@ -267,7 +274,7 @@ async function twoFactorRequiredResponse( }; // Bitwarden clients rely on these fields to trigger the 2FA UI flow. - return jsonResponse( + return identityJsonResponse( { error: 'invalid_grant', error_description: message, @@ -341,8 +348,20 @@ export async function handleToken(request: Request, env: Env): Promise const grantType = body.grant_type; const clientIdentifier = getClientIdentifier(request); - if (!clientIdentifier) { - return identityErrorResponse('Client IP is required', 'invalid_request', 403); + if (!clientIdentifier && grantType !== 'refresh_token') { + await safeWriteAuditEvent(env, { + action: 'auth.client_ip.missing', + category: 'auth', + level: 'error', + targetType: 'tokenEndpoint', + metadata: { grantType, reason: 'client_ip_missing', ...auditRequestMetadata(request) }, + }); + return identityErrorResponse( + 'Authentication is temporarily unavailable', + 'temporarily_unavailable', + 503, + { 'Retry-After': '5' } + ); } if (grantType === 'password') { @@ -359,7 +378,7 @@ export async function handleToken(request: Request, env: Env): Promise // Bitwarden clients expect OAuth-style error fields. return identityErrorResponse('Email and password are required', 'invalid_request', 400); } - const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email); + const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, email); // Check login lockout before user lookup to reduce user-enumeration signal const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); @@ -550,15 +569,8 @@ export async function handleToken(request: Request, env: Env): Promise } // Persist device only after successful password + (optional) 2FA verification. - const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); + const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo); if (deviceSession) { - await storage.upsertDevice( - user.id, - deviceSession.identifier, - deviceInfo.deviceName, - deviceInfo.deviceType, - deviceSession.sessionStamp - ); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } @@ -569,7 +581,7 @@ export async function handleToken(request: Request, env: Env): Promise } const accessToken = await auth.generateAccessToken(user, deviceSession); - const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body)); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); await safeWriteAuditEvent(env, { @@ -612,14 +624,14 @@ export async function handleToken(request: Request, env: Env): Promise userDecryptionOptions: userDecryptionOptions, }; - const baseResponse = jsonResponse(response); + const baseResponse = identityJsonResponse(response); return shouldUseWebSession(request) ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; } else if (grantType === 'webauthn') { const token = String(body.token || '').trim(); - const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token'); + const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, token || 'missing-token'); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); if (!loginCheck.allowed) { return identityErrorResponse( @@ -673,22 +685,15 @@ export async function handleToken(request: Request, env: Env): Promise } const deviceInfo = readAuthRequestDeviceInfo(body, request); - const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); + const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo); if (deviceSession) { - await storage.upsertDevice( - user.id, - deviceSession.identifier, - deviceInfo.deviceName, - deviceInfo.deviceType, - deviceSession.sessionStamp - ); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } await rateLimit.clearLoginAttempts(loginIdentifier); const accessToken = await auth.generateAccessToken(user, deviceSession); - const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body)); const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair'); const accountKeys = buildAccountKeys(user); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); @@ -734,7 +739,7 @@ export async function handleToken(request: Request, env: Env): Promise userDecryptionOptions: userDecryptionOptions, }; - const baseResponse = jsonResponse(response); + const baseResponse = identityJsonResponse(response); return shouldUseWebSession(request) ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; @@ -751,7 +756,7 @@ export async function handleToken(request: Request, env: Env): Promise return identityErrorResponse('Parameter error', 'invalid_request', 400); } const uid = clientId.slice(5); - const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid); + const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, uid); // Check login lockout before user lookup to reduce user-enumeration signal const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); @@ -805,15 +810,8 @@ export async function handleToken(request: Request, env: Env): Promise } // Persist device only after successful client credential verification. - const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); + const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo); if (deviceSession) { - await storage.upsertDevice( - user.id, - deviceSession.identifier, - deviceInfo.deviceName, - deviceInfo.deviceType, - deviceSession.sessionStamp - ); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } @@ -821,7 +819,7 @@ export async function handleToken(request: Request, env: Env): Promise await rateLimit.clearLoginAttempts(loginIdentifier); const accessToken = await auth.generateAccessToken(user, deviceSession); - const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body)); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); await safeWriteAuditEvent(env, { @@ -863,7 +861,7 @@ export async function handleToken(request: Request, env: Env): Promise userDecryptionOptions: userDecryptionOptions, }; - const baseResponse = jsonResponse(response); + const baseResponse = identityJsonResponse(response); return shouldUseWebSession(request) ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; @@ -880,7 +878,7 @@ export async function handleToken(request: Request, env: Env): Promise const sendId = String(body.send_id || body.sendId || '').trim(); if (!sendId) { - return jsonResponse( + return identityJsonResponse( { error: 'invalid_request', error_description: 'send_id is required', @@ -905,13 +903,13 @@ export async function handleToken(request: Request, env: Env): Promise passwordHashB64, password, rateLimit, - clientIdentifier + clientIdentifier || undefined ); if ('error' in result) { return result.error; } - return jsonResponse({ + return identityJsonResponse({ access_token: result.token, expires_in: LIMITS.auth.sendAccessTokenTtlSeconds, token_type: 'Bearer', @@ -919,19 +917,6 @@ export async function handleToken(request: Request, env: Env): Promise unofficialServer: true, }); } else if (grantType === 'refresh_token') { - const refreshLimit = await rateLimit.consumeBudget( - `${clientIdentifier}:identity-refresh`, - LIMITS.rateLimit.refreshTokenRequestsPerMinute - ); - if (!refreshLimit.allowed) { - return identityErrorResponse( - `Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`, - 'TooManyRequests', - 429 - ); - } - - // Refresh token const refreshToken = String(body.refresh_token || '').trim() || ( shouldUseWebSession(request) ? parseCookieValue(request, WEB_REFRESH_COOKIE) @@ -941,7 +926,72 @@ export async function handleToken(request: Request, env: Env): Promise return identityErrorResponse('Refresh token is required', 'invalid_request', 400); } - const result = await auth.refreshAccessTokenDetailed(refreshToken); + const refreshTokenHash = await sha256Hex(refreshToken); + try { + const sessionLimit = await rateLimit.consumeBudget( + `refresh-session:${refreshTokenHash}`, + LIMITS.rateLimit.refreshTokenRequestsPerMinute + ); + const ipLimit = clientIdentifier + ? await rateLimit.consumeBudget( + `refresh-ip:${clientIdentifier}`, + LIMITS.rateLimit.refreshTokenRequestsPerIpMinute + ) + : null; + const rejected = !sessionLimit.allowed ? sessionLimit : (ipLimit && !ipLimit.allowed ? ipLimit : null); + if (rejected) { + const retryAfter = Math.max(1, rejected.retryAfterSeconds || 1); + return identityErrorResponse( + `Rate limit exceeded. Try again in ${retryAfter} seconds.`, + 'temporarily_unavailable', + 429, + { 'Retry-After': String(retryAfter) } + ); + } + } catch (error) { + await safeWriteAuditEvent(env, { + action: 'auth.refresh.failed.rate_limit_unavailable', + category: 'auth', + level: 'error', + targetType: 'refreshToken', + metadata: { grantType, reason: 'rate_limit_unavailable', error: error instanceof Error ? error.message : String(error), ...auditRequestMetadata(request) }, + }); + return identityErrorResponse( + 'Session refresh is temporarily unavailable', + 'temporarily_unavailable', + 503, + { 'Retry-After': '5' } + ); + } + + if (!clientIdentifier) { + await safeWriteAuditEvent(env, { + action: 'auth.client_ip.missing', + category: 'auth', + level: 'warn', + targetType: 'refreshToken', + metadata: { grantType, reason: 'client_ip_missing', webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) }, + }); + } + + let result: Awaited>; + try { + result = await auth.refreshAccessTokenDetailed(refreshToken); + } catch (error) { + await safeWriteAuditEvent(env, { + action: 'auth.refresh.failed.temporarily_unavailable', + category: 'auth', + level: 'error', + targetType: 'refreshToken', + metadata: { grantType, reason: 'storage_or_worker_error', error: error instanceof Error ? error.message : String(error), webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) }, + }); + return identityErrorResponse( + 'Session refresh is temporarily unavailable', + 'temporarily_unavailable', + 503, + { 'Retry-After': '5' } + ); + } if (!result.ok) { await safeWriteAuditEvent(env, { actorUserId: result.userId ?? null, @@ -963,18 +1013,10 @@ export async function handleToken(request: Request, env: Env): Promise : invalidResponse; } - // 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, device } = result; if (device?.identifier) { await storage.touchDeviceLastSeen(user.id, device.identifier); } - const newRefreshToken = await auth.generateRefreshToken(user.id, device); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); @@ -982,7 +1024,7 @@ export async function handleToken(request: Request, env: Env): Promise access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', - ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), Key: user.key, PrivateKey: user.privateKey, AccountKeys: accountKeys, @@ -1001,9 +1043,9 @@ export async function handleToken(request: Request, env: Env): Promise userDecryptionOptions: userDecryptionOptions, }; - const baseResponse = jsonResponse(response); + const baseResponse = identityJsonResponse(response); return shouldUseWebSession(request) - ? withWebRefreshCookie(request, baseResponse, newRefreshToken) + ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; } @@ -1036,7 +1078,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); - try { - await revokePresentedAccessTokenSession(request, env, storage); - } catch { - // RFC 7009 revocation is best-effort and should not reveal token state. - } - let body: Record; const contentType = request.headers.get('content-type') || ''; try { @@ -1060,7 +1096,7 @@ export async function handleRevocation(request: Request, env: Env): Promise { + async generateRefreshToken( + user: User, + device?: { identifier: string; sessionStamp: string } | null, + clientType: string = 'other' + ): Promise { const token = createRefreshToken(); - await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null); + const now = Date.now(); + await this.storage.saveRefreshToken( + token, + user.id, + now + getRefreshTokenSlidingTtlMs(clientType), + device?.identifier ?? null, + device?.sessionStamp ?? null, + user.securityStamp, + clientType, + now + LIMITS.auth.refreshTokenAbsoluteTtlMs + ); return token; } @@ -251,25 +267,42 @@ export class AuthService { return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier }; } + if (record.securityStamp && record.securityStamp !== user.securityStamp) { + await this.storage.deleteRefreshToken(refreshToken); + return { ok: false, reason: 'security_stamp_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier }; + } + if (!record.securityStamp) { + await this.storage.bindRefreshTokenSecurityStamp(refreshToken, user.securityStamp); + } + let device: { identifier: string; sessionStamp: string } | null = null; - if (!record.deviceIdentifier || !record.deviceSessionStamp) { - await this.storage.deleteRefreshToken(refreshToken); - return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; + if (record.deviceIdentifier) { + const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); + if (!boundDevice) { + await this.storage.deleteRefreshToken(refreshToken); + return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; + } + if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) { + await this.storage.deleteRefreshToken(refreshToken); + return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier }; + } + if (!record.deviceSessionStamp) { + await this.storage.bindRefreshTokenDeviceStamp(refreshToken, boundDevice.sessionStamp); + } + device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp }; } - const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); - if (!boundDevice) { - await this.storage.deleteRefreshToken(refreshToken); - return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; + const now = Date.now(); + const expiresAt = Math.min( + now + getRefreshTokenSlidingTtlMs(record.clientType), + record.absoluteExpiresAt || (now + LIMITS.auth.refreshTokenAbsoluteTtlMs) + ); + const extended = await this.storage.extendRefreshTokenExpiry(refreshToken, expiresAt, now); + if (!extended) { + return { ok: false, reason: 'token_not_found_or_expired', userId: user.id, deviceIdentifier: record.deviceIdentifier }; } - if (boundDevice.sessionStamp !== record.deviceSessionStamp) { - await this.storage.deleteRefreshToken(refreshToken); - return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier }; - } - device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp }; - const accessToken = await this.generateAccessToken(user, device); - return { ok: true, accessToken, user, device }; + return { ok: true, accessToken, user, device, expiresAt }; } async refreshAccessToken( diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts index bdd4f8c..b4b6cd4 100644 --- a/src/services/storage-device-repo.ts +++ b/src/services/storage-device-repo.ts @@ -45,7 +45,8 @@ export async function upsertDevice( await db .prepare( 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + - 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, ' + + 'session_stamp=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + @@ -66,7 +67,8 @@ export async function upsertDevice( existingDevice?.deviceNote ?? null, now, now, - now + now, + '' ) .run(); } diff --git a/src/services/storage-refresh-token-repo.ts b/src/services/storage-refresh-token-repo.ts index d387c43..4a19ba6 100644 --- a/src/services/storage-refresh-token-repo.ts +++ b/src/services/storage-refresh-token-repo.ts @@ -11,16 +11,34 @@ export async function saveRefreshToken( userId: string, expiresAtMs: number, deviceIdentifier?: string | null, - deviceSessionStamp?: string | null + deviceSessionStamp?: string | null, + securityStamp?: string | null, + clientType?: string | null, + absoluteExpiresAtMs?: number | null ): Promise { await maybeCleanupExpiredRefreshTokens(Date.now()); const tokenKey = await refreshTokenKey(token); + const now = Date.now(); await db .prepare( - 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' + - 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp' + 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, ' + + 'device_session_stamp=excluded.device_session_stamp, security_stamp=excluded.security_stamp, last_used_at=excluded.last_used_at, ' + + 'absolute_expires_at=excluded.absolute_expires_at, client_type=excluded.client_type' + ) + .bind( + tokenKey, + userId, + expiresAtMs, + deviceIdentifier ?? null, + deviceSessionStamp ?? null, + securityStamp ?? null, + now, + now, + absoluteExpiresAtMs ?? null, + clientType ?? null ) - .bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null) .run(); } @@ -36,12 +54,25 @@ export async function getRefreshTokenRecord( const tokenKey = await refreshTokenKey(token); const row = await db - .prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') + .prepare( + 'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' + + 'FROM refresh_tokens WHERE token = ?' + ) .bind(tokenKey) - .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); + .first<{ + user_id: string; + expires_at: number; + device_identifier: string | null; + device_session_stamp: string | null; + security_stamp: string | null; + created_at: number | null; + last_used_at: number | null; + absolute_expires_at: number | null; + client_type: string | null; + }>(); if (!row) return null; - if (row.expires_at && row.expires_at < now) { + if ((row.expires_at && row.expires_at < now) || (row.absolute_expires_at && row.absolute_expires_at < now)) { await deleteRefreshTokenRecord(token); return null; } @@ -50,9 +81,62 @@ export async function getRefreshTokenRecord( expiresAt: row.expires_at, deviceIdentifier: row.device_identifier ?? null, deviceSessionStamp: row.device_session_stamp ?? null, + securityStamp: row.security_stamp ?? null, + createdAt: row.created_at ?? null, + lastUsedAt: row.last_used_at ?? null, + absoluteExpiresAt: row.absolute_expires_at ?? null, + clientType: row.client_type ?? null, }; } +export async function extendRefreshTokenExpiry( + db: D1Database, + refreshTokenKey: RefreshTokenKeyFn, + token: string, + requestedExpiresAtMs: number, + nowMs: number +): Promise { + const tokenKey = await refreshTokenKey(token); + const result = await db + .prepare( + 'UPDATE refresh_tokens SET ' + + 'expires_at = CASE ' + + 'WHEN absolute_expires_at IS NOT NULL AND absolute_expires_at < ? THEN absolute_expires_at ' + + 'ELSE ? END, ' + + 'last_used_at = ? ' + + 'WHERE token = ? AND expires_at >= ? AND (absolute_expires_at IS NULL OR absolute_expires_at >= ?)' + ) + .bind(requestedExpiresAtMs, requestedExpiresAtMs, nowMs, tokenKey, nowMs, nowMs) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} + +export async function bindRefreshTokenSecurityStamp( + db: D1Database, + refreshTokenKey: RefreshTokenKeyFn, + token: string, + securityStamp: string +): Promise { + const tokenKey = await refreshTokenKey(token); + await db + .prepare('UPDATE refresh_tokens SET security_stamp = ? WHERE token = ? AND (security_stamp IS NULL OR security_stamp = ?)') + .bind(securityStamp, tokenKey, '') + .run(); +} + +export async function bindRefreshTokenDeviceStamp( + db: D1Database, + refreshTokenKey: RefreshTokenKeyFn, + token: string, + deviceSessionStamp: string +): Promise { + const tokenKey = await refreshTokenKey(token); + await db + .prepare('UPDATE refresh_tokens SET device_session_stamp = ? WHERE token = ? AND (device_session_stamp IS NULL OR device_session_stamp = ?)') + .bind(deviceSessionStamp, tokenKey, '') + .run(); +} + export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise { const tokenKey = await refreshTokenKey(token); await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); @@ -71,30 +155,3 @@ export async function deleteRefreshTokensByDevice(db: D1Database, userId: string .run(); return Number(result.meta.changes ?? 0); } - -export async function constrainRefreshTokenExpiry( - db: D1Database, - refreshTokenKey: RefreshTokenKeyFn, - token: string, - maxExpiresAtMs: number -): Promise { - const tokenKey = await refreshTokenKey(token); - - await db - .prepare( - 'UPDATE refresh_tokens ' + - 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + - 'WHERE token = ?' - ) - .bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey) - .run(); - - await db - .prepare( - 'UPDATE refresh_tokens ' + - 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + - 'WHERE token = ?' - ) - .bind(maxExpiresAtMs, maxExpiresAtMs, token) - .run(); -} diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index b7a8096..e2b1433 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -74,11 +74,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'ALTER TABLE sends ADD COLUMN emails TEXT', 'CREATE TABLE IF NOT EXISTS refresh_tokens (' + - 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' + + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type TEXT, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', 'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT', + 'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT', + 'ALTER TABLE refresh_tokens ADD COLUMN created_at INTEGER', + 'ALTER TABLE refresh_tokens ADD COLUMN last_used_at INTEGER', + 'ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at INTEGER', + 'ALTER TABLE refresh_tokens ADD COLUMN client_type TEXT', + "UPDATE refresh_tokens SET security_stamp = (SELECT users.security_stamp FROM users WHERE users.id = refresh_tokens.user_id) WHERE security_stamp IS NULL OR security_stamp = ''", + "UPDATE refresh_tokens SET created_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE created_at IS NULL", + "UPDATE refresh_tokens SET last_used_at = created_at WHERE last_used_at IS NULL", + 'UPDATE refresh_tokens SET absolute_expires_at = expires_at WHERE absolute_expires_at IS NULL', 'CREATE TABLE IF NOT EXISTS invites (' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + @@ -118,6 +127,8 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)', + "UPDATE refresh_tokens SET device_session_stamp = (SELECT devices.session_stamp FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier) WHERE device_identifier IS NOT NULL AND (device_session_stamp IS NULL OR device_session_stamp = '') AND EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier)", + "UPDATE refresh_tokens SET client_type = CASE WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type IN (0, 1)) THEN 'mobile' WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type = 14) THEN 'web' ELSE 'other' END WHERE client_type IS NULL OR client_type = ''", 'CREATE TABLE IF NOT EXISTS auth_requests (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + diff --git a/src/services/storage.ts b/src/services/storage.ts index ae92062..a4d9647 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -87,10 +87,12 @@ import { saveSend as saveStoredSend, } from './storage-send-repo'; import { - constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry, + bindRefreshTokenDeviceStamp as bindStoredRefreshTokenDeviceStamp, + bindRefreshTokenSecurityStamp as bindStoredRefreshTokenSecurityStamp, deleteRefreshToken as deleteStoredRefreshToken, deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice, deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId, + extendRefreshTokenExpiry as extendStoredRefreshTokenExpiry, getRefreshTokenRecord as findStoredRefreshTokenRecord, saveRefreshToken as saveStoredRefreshToken, } from './storage-refresh-token-repo'; @@ -162,7 +164,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // changes. Existing D1 installs only rerun ensureStorageSchema() when this value // differs from config.schema.version. -const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa'; +const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse'; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; // D1-backed storage. @@ -632,9 +634,13 @@ export class StorageService { userId: string, expiresAtMs?: number, deviceIdentifier?: string | null, - deviceSessionStamp?: string | null + deviceSessionStamp?: string | null, + securityStamp?: string | null, + clientType?: string | null, + absoluteExpiresAtMs?: number | null ): Promise { - const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); + const now = Date.now(); + const expiresAt = expiresAtMs ?? (now + LIMITS.auth.refreshTokenDefaultSlidingTtlMs); await saveStoredRefreshToken( this.db, this.refreshTokenKey.bind(this), @@ -643,7 +649,10 @@ export class StorageService { userId, expiresAt, deviceIdentifier, - deviceSessionStamp + deviceSessionStamp, + securityStamp, + clientType, + absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs) ); } @@ -717,11 +726,16 @@ export class StorageService { return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier); } - // 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 { - await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs); + async extendRefreshTokenExpiry(token: string, requestedExpiresAtMs: number, nowMs: number = Date.now()): Promise { + return extendStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, requestedExpiresAtMs, nowMs); + } + + async bindRefreshTokenSecurityStamp(token: string, securityStamp: string): Promise { + await bindStoredRefreshTokenSecurityStamp(this.db, this.refreshTokenKey.bind(this), token, securityStamp); + } + + async bindRefreshTokenDeviceStamp(token: string, deviceSessionStamp: string): Promise { + await bindStoredRefreshTokenDeviceStamp(this.db, this.refreshTokenKey.bind(this), token, deviceSessionStamp); } private async trustedTwoFactorTokenKey(token: string): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index 7865144..2d94570 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -402,6 +402,11 @@ export interface RefreshTokenRecord { expiresAt: number; deviceIdentifier: string | null; deviceSessionStamp: string | null; + securityStamp: string | null; + createdAt: number | null; + lastUsedAt: number | null; + absoluteExpiresAt: number | null; + clientType: string | null; } export interface TrustedDeviceTokenSummary { diff --git a/src/utils/response.ts b/src/utils/response.ts index 749a811..2f3a008 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -144,7 +144,12 @@ export function unsupportedResponse(message: string = 'This feature is not suppo } // Identity endpoint error response (for /identity/connect/token) -export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response { +export function identityErrorResponse( + message: string, + error: string = 'invalid_grant', + status: number = 400, + headers: Record = {} +): Response { return jsonResponse( { error: error, @@ -154,7 +159,8 @@ export function identityErrorResponse(message: string, error: string = 'invalid_ Object: 'error', }, }, - status + status, + { 'Cache-Control': 'no-store', Pragma: 'no-cache', ...headers } ); } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 0d00f89..c490578 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -253,6 +253,8 @@ export default function App() { const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState(() => readLockTimeoutMinutes()); const [sessionTimeoutAction, setSessionTimeoutActionState] = useState(() => readSessionTimeoutAction()); const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email); + const [lockedSessionRefreshError, setLockedSessionRefreshError] = useState(''); + const [lockedSessionRetryKey, setLockedSessionRetryKey] = useState(0); const [confirm, setConfirm] = useState(null); const [mobileLayout, setMobileLayout] = useState(false); @@ -269,6 +271,7 @@ export default function App() { const [vaultDecryptError, setVaultDecryptError] = useState(''); const [sendsDecryptDone, setSendsDecryptDone] = useState(false); const sessionRef = useRef(initialBootstrap.session); + const lockedSessionRetryAttemptRef = useRef(0); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise>(async () => {}); @@ -503,13 +506,15 @@ export default function App() { if (phase !== 'locked' || !session) return; if (IS_DEMO_MODE) return; let cancelled = false; + let retryTimerId: number | null = null; void (async () => { const result = await hydrateLockedSession(session, profile); if (cancelled) return; - if (!result.session) { + if (result.kind === 'expired') { setSession(null); setProfile(null); setUnlockPreparing(false); + setLockedSessionRefreshError(''); setPhase('login'); if (location !== '/login') navigate('/login'); return; @@ -518,11 +523,43 @@ export default function App() { if (result.profile) { setProfile(stripProfileSecrets(result.profile)); } + if (result.kind === 'transient') { + setUnlockPreparing(false); + setLockedSessionRefreshError(result.message || t('txt_session_refresh_temporarily_unavailable')); + const retrySchedule = [2_000, 5_000, 15_000, 30_000, 60_000]; + const scheduledDelay = retrySchedule[Math.min(lockedSessionRetryAttemptRef.current, retrySchedule.length - 1)]; + lockedSessionRetryAttemptRef.current += 1; + const retryAfterMs = Math.min(60_000, Math.max(scheduledDelay, result.retryAfterMs || 0)); + retryTimerId = window.setTimeout(() => { + setLockedSessionRetryKey((value) => value + 1); + }, retryAfterMs); + return; + } + lockedSessionRetryAttemptRef.current = 0; + setLockedSessionRefreshError(''); })(); return () => { cancelled = true; + if (retryTimerId !== null) window.clearTimeout(retryTimerId); }; - }, [phase, session?.email, location, navigate]); + }, [phase, session?.email, location, navigate, lockedSessionRetryKey]); + + useEffect(() => { + if (!lockedSessionRefreshError || phase !== 'locked') return; + const retryNow = () => { + lockedSessionRetryAttemptRef.current = 0; + setLockedSessionRetryKey((value) => value + 1); + }; + const handleVisibility = () => { + if (document.visibilityState === 'visible') retryNow(); + }; + window.addEventListener('online', retryNow); + document.addEventListener('visibilitychange', handleVisibility); + return () => { + window.removeEventListener('online', retryNow); + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, [lockedSessionRefreshError, phase]); async function finalizeLogin(login: CompletedLogin) { loginScopedBackupRepairAuthRef.current = @@ -536,6 +573,7 @@ export default function App() { setSession(login.session); setProfile(login.profile); setUnlockPreparing(false); + setLockedSessionRefreshError(''); setPendingTotp(null); setPendingTotpMode(null); setPendingPasskeyPassword(null); @@ -884,6 +922,7 @@ export default function App() { setPendingTotpMode(null); setTotpCode(''); setUnlockPreparing(false); + setLockedSessionRefreshError(''); setPhase('locked'); navigate('/lock'); } @@ -2221,6 +2260,7 @@ export default function App() { unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined} unlockReady={!!session?.email} unlockPreparing={unlockPreparing} + sessionRefreshError={lockedSessionRefreshError} loginValues={loginValues} pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null} passkeyPassword={passkeyPassword} @@ -2261,6 +2301,11 @@ export default function App() { onLogout={logoutNow} onTogglePasswordHint={() => void handleTogglePasswordHint()} onShowLockedPasswordHint={handleShowLockedPasswordHint} + onRetrySessionRefresh={() => { + lockedSessionRetryAttemptRef.current = 0; + setLockedSessionRefreshError(''); + setLockedSessionRetryKey((value) => value + 1); + }} /> void; onTogglePasswordHint: () => void; onShowLockedPasswordHint: () => void; + onRetrySessionRefresh: () => void; } function PasswordField(props: { @@ -155,6 +157,19 @@ export default function AuthViews(props: AuthViewsProps) { {props.unlockPreparing ? (

{t('txt_loading')}

) : null} + {props.sessionRefreshError ? ( +
+ +
+ {props.sessionRefreshError} +
+ +
+
+
+ ) : null}