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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:30:32 +02:00
1 parent 5c666fe49a
commit 2de93bc087
6 files changed
+125 -19

No files matched your search

+61 -4
View File
@@ -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({
+41 -8
View File
@@ -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<string> {
private async issueRefreshToken(
userId: string,
sessionStartedAt: Date = new Date(),
): Promise<string> {
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,
@@ -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 {