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>;
|
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')
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue