From 71e39c7e883251875c788856a1e9070b06976c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82=20=7C=20Zaid=20Omar=20Medh?= =?UTF-8?q?at?= Date: Sat, 11 Jul 2026 01:53:04 +0500 Subject: [PATCH] Fix refresh-token rotation race that logged users out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two concurrent refresh calls (double-mounted bootstrap effect, second tab) replayed the same cookie; the loser tripped reuse detection and revoked the whole token family, forcing re-login. - Client: single-flight /auth/refresh — concurrent callers share one request. - Server: 30s grace window for a just-rotated token, but only while the family still has a live successor, so theft detection still kills a genuinely compromised family and logout stays final. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD --- .../src/modules/auth/auth.repository.ts | 14 ++++++++++++++ .../backend/src/modules/auth/auth.service.ts | 18 +++++++++++++++--- packages/core/src/api/auth.ts | 19 ++++++++++++++++--- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/modules/auth/auth.repository.ts b/packages/backend/src/modules/auth/auth.repository.ts index 0262cf6..9c747bb 100644 --- a/packages/backend/src/modules/auth/auth.repository.ts +++ b/packages/backend/src/modules/auth/auth.repository.ts @@ -17,6 +17,8 @@ export interface RefreshTokensRepository { findByHash(tokenHash: string): Promise; revokeById(id: string): Promise; revokeFamily(familyId: string): Promise; + /** True when the family still has a live (unrevoked, unexpired) token. */ + hasActiveInFamily(familyId: string): Promise; revokeAllForUser(userId: string): Promise; listActiveForUser(userId: string): Promise; } @@ -50,6 +52,18 @@ export const createRefreshTokensRepository = ( .execute(); }, + hasActiveInFamily: async (familyId) => { + const row = await db + .selectFrom('refresh_tokens') + .select('id') + .where('family_id', '=', familyId) + .where('revoked_at', 'is', null) + .where('expires_at', '>', new Date()) + .limit(1) + .executeTakeFirst(); + return row !== undefined; + }, + revokeFamily: async (familyId) => { await db .updateTable('refresh_tokens') diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts index e2cf146..04a618d 100644 --- a/packages/backend/src/modules/auth/auth.service.ts +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -135,8 +135,18 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => { throw new HttpError(401, 'invalid_token', 'Invalid refresh token'); } if (row.revoked_at !== null) { - await tokens.revokeFamily(row.family_id); - throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected'); + // Grace window: the same cookie replayed moments after rotation is a + // parallel client (second tab, double-mounted bootstrap), not theft. + // Re-issue within the family instead of nuking it. The grace only + // applies while the family still has a live successor — a family + // killed by reuse-detection or logout stays dead, so genuine theft + // can't ride the window back in. + const graceMs = 30_000; + const withinGrace = Date.now() - row.revoked_at.getTime() <= graceMs; + if (!withinGrace || !(await tokens.hasActiveInFamily(row.family_id))) { + await tokens.revokeFamily(row.family_id); + throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected'); + } } if (row.expires_at.getTime() <= Date.now()) { throw new HttpError(401, 'invalid_token', 'Refresh token expired'); @@ -145,7 +155,9 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => { if (user === undefined) { throw new HttpError(401, 'invalid_token', 'Invalid refresh token'); } - await tokens.revokeById(row.id); + if (row.revoked_at === null) { + await tokens.revokeById(row.id); + } return issue(user, ctx, row.family_id); }, diff --git a/packages/core/src/api/auth.ts b/packages/core/src/api/auth.ts index 9666325..18baaff 100644 --- a/packages/core/src/api/auth.ts +++ b/packages/core/src/api/auth.ts @@ -22,9 +22,22 @@ export const register = async (config: ApiClientConfig, body: RegisterBody): Pro export const login = async (config: ApiClientConfig, body: LoginBody): Promise => parse(authResultV, await requestJson(config, 'POST', '/auth/login', body)); -// Uses the httpOnly refresh cookie — no body. -export const refresh = async (config: ApiClientConfig): Promise => - parse(authResultV, await requestJson(config, 'POST', '/auth/refresh')); +// Uses the httpOnly refresh cookie — no body. Single-flighted: refresh rotates +// the cookie, so two concurrent calls (double-mounted bootstrap effect, several +// features racing on a 401) would replay the same token and trip the server's +// reuse detection. All concurrent callers share one in-flight request. +let inflightRefresh: Promise | null = null; + +export const refresh = (config: ApiClientConfig): Promise => { + inflightRefresh ??= (async () => { + try { + return parse(authResultV, await requestJson(config, 'POST', '/auth/refresh')); + } finally { + inflightRefresh = null; + } + })(); + return inflightRefresh; +}; export const logout = async (config: ApiClientConfig): Promise => { await requestJson(config, 'POST', '/auth/logout');