Fix refresh-token rotation race that logged users out

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
This commit is contained in:
Заид Омар Медхат | Zaid Omar Medhat 2026-07-11 01:53:04 +05:00
parent 7ab6b72866
commit 71e39c7e88
3 changed files with 45 additions and 6 deletions

View file

@ -17,6 +17,8 @@ export interface RefreshTokensRepository {
findByHash(tokenHash: string): Promise<RefreshTokenRow | undefined>; findByHash(tokenHash: string): Promise<RefreshTokenRow | undefined>;
revokeById(id: string): Promise<void>; revokeById(id: string): Promise<void>;
revokeFamily(familyId: string): Promise<void>; revokeFamily(familyId: string): Promise<void>;
/** True when the family still has a live (unrevoked, unexpired) token. */
hasActiveInFamily(familyId: string): Promise<boolean>;
revokeAllForUser(userId: string): Promise<void>; revokeAllForUser(userId: string): Promise<void>;
listActiveForUser(userId: string): Promise<RefreshTokenRow[]>; listActiveForUser(userId: string): Promise<RefreshTokenRow[]>;
} }
@ -50,6 +52,18 @@ export const createRefreshTokensRepository = (
.execute(); .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) => { revokeFamily: async (familyId) => {
await db await db
.updateTable('refresh_tokens') .updateTable('refresh_tokens')

View file

@ -135,8 +135,18 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
throw new HttpError(401, 'invalid_token', 'Invalid refresh token'); throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
} }
if (row.revoked_at !== null) { if (row.revoked_at !== null) {
await tokens.revokeFamily(row.family_id); // Grace window: the same cookie replayed moments after rotation is a
throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected'); // 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()) { if (row.expires_at.getTime() <= Date.now()) {
throw new HttpError(401, 'invalid_token', 'Refresh token expired'); throw new HttpError(401, 'invalid_token', 'Refresh token expired');
@ -145,7 +155,9 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
if (user === undefined) { if (user === undefined) {
throw new HttpError(401, 'invalid_token', 'Invalid refresh token'); 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); return issue(user, ctx, row.family_id);
}, },

View file

@ -22,9 +22,22 @@ export const register = async (config: ApiClientConfig, body: RegisterBody): Pro
export const login = async (config: ApiClientConfig, body: LoginBody): Promise<AuthResult> => export const login = async (config: ApiClientConfig, body: LoginBody): Promise<AuthResult> =>
parse(authResultV, await requestJson(config, 'POST', '/auth/login', body)); parse(authResultV, await requestJson(config, 'POST', '/auth/login', body));
// Uses the httpOnly refresh cookie — no body. // Uses the httpOnly refresh cookie — no body. Single-flighted: refresh rotates
export const refresh = async (config: ApiClientConfig): Promise<AuthResult> => // the cookie, so two concurrent calls (double-mounted bootstrap effect, several
parse(authResultV, await requestJson(config, 'POST', '/auth/refresh')); // 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<AuthResult> | null = null;
export const refresh = (config: ApiClientConfig): Promise<AuthResult> => {
inflightRefresh ??= (async () => {
try {
return parse(authResultV, await requestJson(config, 'POST', '/auth/refresh'));
} finally {
inflightRefresh = null;
}
})();
return inflightRefresh;
};
export const logout = async (config: ApiClientConfig): Promise<void> => { export const logout = async (config: ApiClientConfig): Promise<void> => {
await requestJson(config, 'POST', '/auth/logout'); await requestJson(config, 'POST', '/auth/logout');