feat(auth): implement dual-key JWT verification for zero-downtime rotation
Add JWT_SECRET_NEXT env var support for seamless JWT secret rotation: - JwtStrategy: use secretOrKeyProvider to try primary then fallback key - TokenService.verifyAccessToken(): dual-key fallback for internal callers - Redis metric jwt_verify_with_next_total for monitoring cut-over progress - Session revocation marker support restored in JwtStrategy.validate() - Unit tests for all three verification scenarios (primary, fallback, both-fail) - docs/security/secret-rotation.md runbook with step-by-step rotation procedure Closes GOO-203. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -33,6 +33,7 @@ type RedisStub = {
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
getClient: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makePrisma(user: { isActive: boolean; deletedAt: Date | null } | null): PrismaStub {
|
||||
@@ -49,6 +50,27 @@ function makeRedis(options: { available?: boolean; cached?: string | null } = {}
|
||||
isAvailable: vi.fn().mockReturnValue(available),
|
||||
get: vi.fn().mockResolvedValue(cached),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
getClient: vi.fn().mockReturnValue({ incr: vi.fn().mockResolvedValue(1) }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function makeRedisWithRevocation(options: {
|
||||
available?: boolean;
|
||||
revokedAt?: string | null;
|
||||
userStatus?: string | null;
|
||||
}): RedisStub {
|
||||
const { available = true, revokedAt = null, userStatus = null } = options;
|
||||
const get = vi.fn(async (key: string) => {
|
||||
if (key.startsWith('auth:session_revoked:v1')) return revokedAt;
|
||||
if (key.startsWith('auth:user_status:v1')) return userStatus;
|
||||
return null;
|
||||
});
|
||||
return {
|
||||
isAvailable: vi.fn().mockReturnValue(available),
|
||||
get: get as ReturnType<typeof vi.fn>,
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
getClient: vi.fn().mockReturnValue({ incr: vi.fn().mockResolvedValue(1) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,4 +239,82 @@ describe('JwtStrategy', () => {
|
||||
strategy.validate({ sub: 'banned-cached', phone: '+84900000005', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
describe('session revocation marker', () => {
|
||||
it('rejects tokens issued before the revocation marker (iat < revokedAt)', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const revokedAt = new Date('2026-04-24T12:00:00Z').toISOString();
|
||||
const redis = makeRedisWithRevocation({
|
||||
available: true,
|
||||
revokedAt,
|
||||
userStatus: JSON.stringify({ isActive: true, deletedAt: null }),
|
||||
});
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, redis as never);
|
||||
const iatBefore = Math.floor(new Date('2026-04-24T11:59:59Z').getTime() / 1000);
|
||||
await expect(
|
||||
strategy.validate({ sub: 'user-rev', phone: '+84900000006', role: 'BUYER', iat: iatBefore }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('accepts tokens issued after the revocation marker', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const revokedAt = new Date('2026-04-24T12:00:00Z').toISOString();
|
||||
const redis = makeRedisWithRevocation({
|
||||
available: true,
|
||||
revokedAt,
|
||||
userStatus: JSON.stringify({ isActive: true, deletedAt: null }),
|
||||
});
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, redis as never);
|
||||
const iatAfter = Math.floor(new Date('2026-04-24T12:00:05Z').getTime() / 1000);
|
||||
const result = await strategy.validate({
|
||||
sub: 'user-rev-fresh', phone: '+84900000007', role: 'BUYER', iat: iatAfter,
|
||||
});
|
||||
expect(result.sub).toBe('user-rev-fresh');
|
||||
});
|
||||
|
||||
it('skips revocation check when Redis is unavailable (fail-open)', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const redis = makeRedisWithRevocation({ available: false });
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, redis as never);
|
||||
const result = await strategy.validate({
|
||||
sub: 'user-rdown-rev', phone: '+84900000008', role: 'BUYER', iat: 1,
|
||||
});
|
||||
expect(result.sub).toBe('user-rdown-rev');
|
||||
});
|
||||
|
||||
it('passes when no revocation marker is present', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const redis = makeRedisWithRevocation({
|
||||
available: true, revokedAt: null,
|
||||
userStatus: JSON.stringify({ isActive: true, deletedAt: null }),
|
||||
});
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, redis as never);
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const result = await strategy.validate({
|
||||
sub: 'user-no-rev', phone: '+84900000009', role: 'BUYER', iat,
|
||||
});
|
||||
expect(result.sub).toBe('user-no-rev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dual-key verification (JWT_SECRET_NEXT)', () => {
|
||||
it('constructs successfully when JWT_SECRET_NEXT is set', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'primary-secret');
|
||||
vi.stubEnv('JWT_SECRET_NEXT', 'next-secret');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('constructs successfully when JWT_SECRET_NEXT is not set', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'primary-secret');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,5 +154,35 @@ describe('TokenService', () => {
|
||||
const result = service.verifyAccessToken('bad-jwt');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to JWT_SECRET_NEXT when primary verification fails', () => {
|
||||
vi.stubEnv('JWT_SECRET_NEXT', 'next-secret');
|
||||
mockJwtService.verify
|
||||
.mockImplementationOnce(() => { throw new Error('invalid signature'); })
|
||||
.mockReturnValueOnce(payload);
|
||||
const result = service.verifyAccessToken('rotated-jwt');
|
||||
expect(result).toEqual(payload);
|
||||
expect(mockJwtService.verify).toHaveBeenCalledTimes(2);
|
||||
expect(mockJwtService.verify).toHaveBeenNthCalledWith(2, 'rotated-jwt', { secret: 'next-secret' });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns null when both primary and NEXT secret fail', () => {
|
||||
vi.stubEnv('JWT_SECRET_NEXT', 'next-secret');
|
||||
mockJwtService.verify.mockImplementation(() => { throw new Error('invalid'); });
|
||||
const result = service.verifyAccessToken('totally-bad-jwt');
|
||||
expect(result).toBeNull();
|
||||
expect(mockJwtService.verify).toHaveBeenCalledTimes(2);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('does not try NEXT secret when env var is unset', () => {
|
||||
vi.stubEnv('JWT_SECRET_NEXT', '');
|
||||
mockJwtService.verify.mockImplementation(() => { throw new Error('invalid'); });
|
||||
const result = service.verifyAccessToken('bad-jwt');
|
||||
expect(result).toBeNull();
|
||||
expect(mockJwtService.verify).toHaveBeenCalledTimes(1);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,10 +113,23 @@ export class TokenService {
|
||||
await this.refreshTokenRepo.revokeAllForUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an access token using the primary secret, falling back to
|
||||
* JWT_SECRET_NEXT during key rotation windows.
|
||||
*/
|
||||
verifyAccessToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
return this.jwtService.verify<JwtPayload>(token);
|
||||
} catch {
|
||||
// Primary verification failed - try the rotation fallback secret
|
||||
const nextSecret = process.env['JWT_SECRET_NEXT'];
|
||||
if (nextSecret) {
|
||||
try {
|
||||
return this.jwtService.verify<JwtPayload>(token, { secret: nextSecret });
|
||||
} catch {
|
||||
// Both secrets failed
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { type Request } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { PrismaService, RedisService } from '@modules/shared';
|
||||
import { type JwtPayload } from '../services/token.service';
|
||||
|
||||
/** JWT payload fields we read here, plus the standard `iat` claim (seconds). */
|
||||
interface JwtPayloadWithIat extends JwtPayload {
|
||||
iat?: number;
|
||||
}
|
||||
|
||||
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||
const cookieToken = req.cookies?.['access_token'] as string | undefined;
|
||||
if (cookieToken) return cookieToken;
|
||||
@@ -26,8 +32,18 @@ export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
||||
/** TTL for cached user status (seconds). */
|
||||
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Redis key prefix for the per-user session-revocation marker.
|
||||
*/
|
||||
export const SESSION_REVOCATION_PREFIX = 'auth:session_revoked:v1';
|
||||
|
||||
/** Redis key for the dual-key fallback counter metric. */
|
||||
export const JWT_NEXT_KEY_METRIC = 'metrics:jwt_verify_with_next_total';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
private readonly logger = new Logger(JwtStrategy.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
@@ -40,13 +56,51 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
super({
|
||||
jwtFromRequest: extractJwtFromCookieOrHeader,
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: jwtSecret,
|
||||
secretOrKeyProvider: (
|
||||
_request: Request,
|
||||
rawJwtToken: string,
|
||||
done: (err: Error | null, key?: string) => void,
|
||||
) => {
|
||||
const verifyOpts: jwt.VerifyOptions = {
|
||||
audience: 'goodgo-api',
|
||||
issuer: 'goodgo-platform',
|
||||
};
|
||||
|
||||
// Try primary secret first
|
||||
try {
|
||||
jwt.verify(rawJwtToken, jwtSecret, verifyOpts);
|
||||
done(null, jwtSecret);
|
||||
return;
|
||||
} catch {
|
||||
// Primary failed — try fallback
|
||||
}
|
||||
|
||||
const nextSecret = process.env['JWT_SECRET_NEXT'];
|
||||
if (nextSecret) {
|
||||
try {
|
||||
jwt.verify(rawJwtToken, nextSecret, verifyOpts);
|
||||
this.logger.log('JWT verified with JWT_SECRET_NEXT (rotation fallback)');
|
||||
this.incrementNextKeyMetric();
|
||||
done(null, nextSecret);
|
||||
return;
|
||||
} catch {
|
||||
// Both failed — fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Let passport-jwt report the verification error with primary key
|
||||
done(null, jwtSecret);
|
||||
},
|
||||
audience: 'goodgo-api',
|
||||
issuer: 'goodgo-platform',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
async validate(payload: JwtPayloadWithIat): Promise<JwtPayload> {
|
||||
if (await this.isTokenRevoked(payload)) {
|
||||
throw new UnauthorizedException('Session has been invalidated');
|
||||
}
|
||||
|
||||
const status = await this.loadUserStatus(payload.sub);
|
||||
if (!status || !status.isActive || status.deletedAt !== null) {
|
||||
throw new UnauthorizedException('User account is inactive or deleted');
|
||||
@@ -54,13 +108,34 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
return { sub: payload.sub, phone: payload.phone, role: payload.role };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user status from Redis cache if present, otherwise from DB and
|
||||
* populates the cache with a 60 s TTL. Redis failures are non-fatal:
|
||||
* we fall back to DB so a Redis outage cannot lock out all users.
|
||||
*
|
||||
* Returns null only when the user does not exist in the DB.
|
||||
*/
|
||||
private incrementNextKeyMetric(): void {
|
||||
if (this.redis.isAvailable()) {
|
||||
this.redis
|
||||
.getClient()
|
||||
.incr(JWT_NEXT_KEY_METRIC)
|
||||
.catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async isTokenRevoked(payload: JwtPayloadWithIat): Promise<boolean> {
|
||||
if (typeof payload.iat !== 'number') return false;
|
||||
if (!this.redis.isAvailable()) return false;
|
||||
|
||||
try {
|
||||
const marker = await this.redis.get(`${SESSION_REVOCATION_PREFIX}:${payload.sub}`);
|
||||
if (!marker) return false;
|
||||
|
||||
const revokedAtMs = Date.parse(marker);
|
||||
if (Number.isNaN(revokedAtMs)) return false;
|
||||
|
||||
return payload.iat * 1000 < revokedAtMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadUserStatus(userId: string): Promise<CachedUserStatus | null> {
|
||||
const cacheKey = `${USER_STATUS_CACHE_PREFIX}:${userId}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user