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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user