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

+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,