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:
parent
7ab6b72866
commit
71e39c7e88
3 changed files with 45 additions and 6 deletions
|
|
@ -17,6 +17,8 @@ export interface RefreshTokensRepository {
|
|||
findByHash(tokenHash: string): Promise<RefreshTokenRow | undefined>;
|
||||
revokeById(id: 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>;
|
||||
listActiveForUser(userId: string): Promise<RefreshTokenRow[]>;
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -135,9 +135,19 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
|||
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
|
||||
}
|
||||
if (row.revoked_at !== null) {
|
||||
// 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');
|
||||
}
|
||||
if (row.revoked_at === null) {
|
||||
await tokens.revokeById(row.id);
|
||||
}
|
||||
return issue(user, ctx, row.family_id);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,22 @@ export const register = async (config: ApiClientConfig, body: RegisterBody): Pro
|
|||
export const login = async (config: ApiClientConfig, body: LoginBody): Promise<AuthResult> =>
|
||||
parse(authResultV, await requestJson(config, 'POST', '/auth/login', body));
|
||||
|
||||
// Uses the httpOnly refresh cookie — no body.
|
||||
export const refresh = async (config: ApiClientConfig): Promise<AuthResult> =>
|
||||
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<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> => {
|
||||
await requestJson(config, 'POST', '/auth/logout');
|
||||
|
|
|
|||
Loading…
Reference in a new issue