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');