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_REFRESH_SECRET="change-me-refresh-secret"
|
||||||
JWT_ACCESS_EXPIRES_IN="15m"
|
JWT_ACCESS_EXPIRES_IN="15m"
|
||||||
JWT_REFRESH_EXPIRES_IN="7d"
|
JWT_REFRESH_EXPIRES_IN="7d"
|
||||||
|
# Hard limit from login, however often the session is refreshed.
|
||||||
|
SESSION_MAX_AGE="7d"
|
||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
FRONTEND_ORIGIN="http://localhost:3000"
|
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
|
expiresAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
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])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,7 +323,8 @@ describe('AuthService', () => {
|
|||||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||||
id: 'rt-1',
|
id: 'rt-1',
|
||||||
userId: activeUser.id,
|
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),
|
expiresAt: new Date(Date.now() + 1000),
|
||||||
});
|
});
|
||||||
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
|
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 () => {
|
it('rotates the refresh token on success', async () => {
|
||||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||||
id: 'rt-1',
|
id: 'rt-1',
|
||||||
userId: activeUser.id,
|
userId: activeUser.id,
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
expiresAt: new Date(Date.now() + 1000),
|
expiresAt: new Date(Date.now() + 1000),
|
||||||
|
sessionStartedAt: new Date(),
|
||||||
});
|
});
|
||||||
usersService.findById.mockResolvedValue(activeUser);
|
usersService.findById.mockResolvedValue(activeUser);
|
||||||
prisma.refreshToken.update.mockResolvedValue({});
|
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
|
||||||
prisma.refreshToken.create.mockResolvedValue({});
|
prisma.refreshToken.create.mockResolvedValue({});
|
||||||
|
|
||||||
const result = await authService.refresh('valid-token');
|
const result = await authService.refresh('valid-token');
|
||||||
|
|
||||||
expect(result.accessToken).toBe('signed.jwt.token');
|
expect(result.accessToken).toBe('signed.jwt.token');
|
||||||
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
|
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ where: { id: 'rt-1' } }),
|
expect.objectContaining({ where: { id: 'rt-1', revokedAt: null } }),
|
||||||
);
|
);
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export interface CurrentUserProfile {
|
|||||||
|
|
||||||
const REFRESH_TOKEN_BYTES = 40;
|
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
|
// Same answer for unknown email, wrong password, deactivated or locked
|
||||||
// account - nothing in the response tells which one it was.
|
// account - nothing in the response tells which one it was.
|
||||||
const INVALID_CREDENTIALS =
|
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 token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
|
||||||
const expiresAt = new Date(
|
const slidingExpiry =
|
||||||
Date.now() +
|
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({
|
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;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -238,6 +254,14 @@ export class AuthService {
|
|||||||
if (!stored) {
|
if (!stored) {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
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) {
|
if (stored.revokedAt) {
|
||||||
// Reuse of an already-rotated-out refresh token is a strong signal
|
// Reuse of an already-rotated-out refresh token is a strong signal
|
||||||
// it was stolen (a legitimate client only ever presents the newest
|
// it was stolen (a legitimate client only ever presents the newest
|
||||||
@@ -260,16 +284,25 @@ export class AuthService {
|
|||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
await this.prisma.refreshToken.update({
|
// Atomic: of two requests presenting the same token, exactly one wins
|
||||||
where: { id: stored.id },
|
// 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() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
if (claimed.count !== 1) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
const authenticatedUser = {
|
const authenticatedUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
};
|
};
|
||||||
const accessToken = this.signAccessToken(authenticatedUser);
|
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({
|
await this.auditLog.record({
|
||||||
entityType: 'AuthSession',
|
entityType: 'AuthSession',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
|
|||||||
@@ -5,24 +5,32 @@ import { parseDurationToMs } from './duration';
|
|||||||
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||||
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||||
|
|
||||||
export function setRefreshTokenCookie(
|
function cookieOptions() {
|
||||||
res: Response,
|
return {
|
||||||
refreshToken: string,
|
|
||||||
): void {
|
|
||||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
// Explicit opt-out for local HTTP dev only; every real deployment must
|
// Explicit opt-out for local HTTP dev only; every real deployment must
|
||||||
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
||||||
// over plaintext HTTP.
|
// over plaintext HTTP.
|
||||||
secure: process.env.COOKIE_SECURE !== 'false',
|
secure: process.env.COOKIE_SECURE !== 'false',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax' as const,
|
||||||
path: REFRESH_TOKEN_COOKIE_PATH,
|
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'),
|
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 {
|
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 {
|
export function extractRefreshToken(req: Request): string {
|
||||||
|
|||||||
Reference in new issue
Block a user