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:
1 parent
5c666fe49a
commit
2de93bc087
6 files changed
+125
-19
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user