From 2de93bc087ed68dec7e48f82b3bcdeca485bd9a6 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 02:30:32 +0200 Subject: [PATCH] fix(auth): atomic refresh rotation and a 7-day absolute session limit - Rotation claims the old token with a conditional update; of two concurrent refreshes only one gets a successor, so a stolen token cannot fork into two live sessions. - A token rotated out less than 10 s ago (parallel tab) is refused without revoking every session; later reuse still revokes them all. - Sessions end at most SESSION_MAX_AGE (default 7 days) after login: the login time is inherited by every rotated token and caps its expiry. - The refresh cookie is cleared with the same attributes it was set with. Co-Authored-By: Claude Opus 5.5 (1M context) --- ApiCore-Backend/.env.example | 2 + .../migration.sql | 3 + ApiCore-Backend/prisma/schema.prisma | 3 + ApiCore-Backend/src/auth/auth.service.spec.ts | 65 +++++++++++++++++-- ApiCore-Backend/src/auth/auth.service.ts | 49 +++++++++++--- .../src/auth/util/refresh-token-cookie.ts | 22 +++++-- 6 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261001110000_refresh_session_start/migration.sql diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index bc49470..015013e 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -9,6 +9,8 @@ JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long" JWT_REFRESH_SECRET="change-me-refresh-secret" JWT_ACCESS_EXPIRES_IN="15m" JWT_REFRESH_EXPIRES_IN="7d" +# Hard limit from login, however often the session is refreshed. +SESSION_MAX_AGE="7d" PORT=3001 FRONTEND_ORIGIN="http://localhost:3000" diff --git a/ApiCore-Backend/prisma/migrations/20261001110000_refresh_session_start/migration.sql b/ApiCore-Backend/prisma/migrations/20261001110000_refresh_session_start/migration.sql new file mode 100644 index 0000000..f5efa95 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261001110000_refresh_session_start/migration.sql @@ -0,0 +1,3 @@ +-- Absolute session lifetime: every rotated refresh token keeps the time +-- the session was first opened. Existing sessions count from now. +ALTER TABLE "RefreshToken" ADD COLUMN "sessionStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index fb5e788..0d544a7 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -161,6 +161,9 @@ model RefreshToken { expiresAt DateTime createdAt DateTime @default(now()) revokedAt DateTime? + // Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt: + // spaetestens SESSION_MAX_AGE danach ist neu anzumelden. + sessionStartedAt DateTime @default(now()) @@index([userId]) } diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts index 2d90ce3..8391f5c 100644 --- a/ApiCore-Backend/src/auth/auth.service.spec.ts +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -323,7 +323,8 @@ describe('AuthService', () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, - revokedAt: new Date(), + // Rotated out a minute ago - well past the parallel-tab window. + revokedAt: new Date(Date.now() - 60_000), expiresAt: new Date(Date.now() + 1000), }); prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 }); @@ -353,22 +354,78 @@ describe('AuthService', () => { ); }); + it('does not revoke everything when a parallel tab just rotated the token', async () => { + prisma.refreshToken.findUnique.mockResolvedValue({ + id: 'rt-1', + userId: activeUser.id, + revokedAt: new Date(Date.now() - 1000), + expiresAt: new Date(Date.now() + 1000), + }); + + await expect(authService.refresh('just-rotated')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(prisma.refreshToken.updateMany).not.toHaveBeenCalled(); + }); + + it('gives only one of two concurrent refreshes a successor', async () => { + prisma.refreshToken.findUnique.mockResolvedValue({ + id: 'rt-1', + userId: activeUser.id, + revokedAt: null, + expiresAt: new Date(Date.now() + 1000), + sessionStartedAt: new Date(), + }); + usersService.findById.mockResolvedValue(activeUser); + prisma.refreshToken.updateMany.mockResolvedValue({ count: 0 }); + + await expect(authService.refresh('raced-token')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(prisma.refreshToken.create).not.toHaveBeenCalled(); + }); + + it('never extends a session past seven days from login', async () => { + const startedSixDaysAgo = new Date(Date.now() - 6 * 24 * 3600 * 1000); + prisma.refreshToken.findUnique.mockResolvedValue({ + id: 'rt-1', + userId: activeUser.id, + revokedAt: null, + expiresAt: new Date(Date.now() + 1000), + sessionStartedAt: startedSixDaysAgo, + }); + usersService.findById.mockResolvedValue(activeUser); + prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 }); + prisma.refreshToken.create.mockResolvedValue({}); + + await authService.refresh('valid-token'); + + const [[createArg]] = prisma.refreshToken.create.mock.calls as [ + [{ data: { expiresAt: Date; sessionStartedAt: Date } }], + ]; + expect(createArg.data.sessionStartedAt).toEqual(startedSixDaysAgo); + expect(createArg.data.expiresAt.getTime()).toBe( + startedSixDaysAgo.getTime() + 7 * 24 * 3600 * 1000, + ); + }); + it('rotates the refresh token on success', async () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, revokedAt: null, expiresAt: new Date(Date.now() + 1000), + sessionStartedAt: new Date(), }); usersService.findById.mockResolvedValue(activeUser); - prisma.refreshToken.update.mockResolvedValue({}); + prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 }); prisma.refreshToken.create.mockResolvedValue({}); const result = await authService.refresh('valid-token'); expect(result.accessToken).toBe('signed.jwt.token'); - expect(prisma.refreshToken.update).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'rt-1' } }), + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'rt-1', revokedAt: null } }), ); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index 8fb20d3..98521f4 100644 --- a/ApiCore-Backend/src/auth/auth.service.ts +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -33,6 +33,12 @@ export interface CurrentUserProfile { const REFRESH_TOKEN_BYTES = 40; +// Hard cap from the moment of login, however often the session is refreshed. +const DEFAULT_SESSION_MAX_AGE = '7d'; +// Two tabs refreshing with the same token at the same moment is normal; +// only a reuse after this window is treated as theft. +const ROTATION_GRACE_MS = 10_000; + // Same answer for unknown email, wrong password, deactivated or locked // account - nothing in the response tells which one it was. const INVALID_CREDENTIALS = @@ -92,14 +98,24 @@ export class AuthService { ); } - private async issueRefreshToken(userId: string): Promise { + private async issueRefreshToken( + userId: string, + sessionStartedAt: Date = new Date(), + ): Promise { const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex'); - const expiresAt = new Date( + const slidingExpiry = Date.now() + - parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'), - ); + parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'); + const absoluteExpiry = + sessionStartedAt.getTime() + + parseDurationToMs(process.env.SESSION_MAX_AGE ?? DEFAULT_SESSION_MAX_AGE); await this.prisma.refreshToken.create({ - data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt }, + data: { + userId, + tokenHash: this.hashRefreshToken(token), + expiresAt: new Date(Math.min(slidingExpiry, absoluteExpiry)), + sessionStartedAt, + }, }); return token; } @@ -238,6 +254,14 @@ export class AuthService { if (!stored) { throw new UnauthorizedException('Invalid refresh token'); } + if ( + stored.revokedAt && + Date.now() - stored.revokedAt.getTime() < ROTATION_GRACE_MS + ) { + // Lost a race with a parallel refresh (second tab): no new session, + // but no alarm either - that tab simply uses the other's cookie. + throw new UnauthorizedException('Invalid refresh token'); + } if (stored.revokedAt) { // Reuse of an already-rotated-out refresh token is a strong signal // it was stolen (a legitimate client only ever presents the newest @@ -260,16 +284,25 @@ export class AuthService { if (!user.isActive) { throw new UnauthorizedException('Invalid refresh token'); } - await this.prisma.refreshToken.update({ - where: { id: stored.id }, + // Atomic: of two requests presenting the same token, exactly one wins + // and gets a successor - one stolen token can never fork into two live + // sessions. + const claimed = await this.prisma.refreshToken.updateMany({ + where: { id: stored.id, revokedAt: null }, data: { revokedAt: new Date() }, }); + if (claimed.count !== 1) { + throw new UnauthorizedException('Invalid refresh token'); + } const authenticatedUser = { id: user.id, email: user.email, }; const accessToken = this.signAccessToken(authenticatedUser); - const newRefreshToken = await this.issueRefreshToken(user.id); + const newRefreshToken = await this.issueRefreshToken( + user.id, + stored.sessionStartedAt, + ); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, diff --git a/ApiCore-Backend/src/auth/util/refresh-token-cookie.ts b/ApiCore-Backend/src/auth/util/refresh-token-cookie.ts index 90907c4..cd23fab 100644 --- a/ApiCore-Backend/src/auth/util/refresh-token-cookie.ts +++ b/ApiCore-Backend/src/auth/util/refresh-token-cookie.ts @@ -5,24 +5,32 @@ import { parseDurationToMs } from './duration'; export const REFRESH_TOKEN_COOKIE = 'refreshToken'; export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth'; -export function setRefreshTokenCookie( - res: Response, - refreshToken: string, -): void { - res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { +function cookieOptions() { + return { httpOnly: true, // Explicit opt-out for local HTTP dev only; every real deployment must // set COOKIE_SECURE=true so the refresh-token cookie is never sent // over plaintext HTTP. secure: process.env.COOKIE_SECURE !== 'false', - sameSite: 'lax', + sameSite: 'lax' as const, path: REFRESH_TOKEN_COOKIE_PATH, + }; +} + +export function setRefreshTokenCookie( + res: Response, + refreshToken: string, +): void { + res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { + ...cookieOptions(), maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'), }); } +// Same attributes as when set - some browsers keep a cookie that is +// cleared with different ones. export function clearRefreshTokenCookie(res: Response): void { - res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH }); + res.clearCookie(REFRESH_TOKEN_COOKIE, cookieOptions()); } export function extractRefreshToken(req: Request): string {