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:
Ho Ngoc Hai
2026-04-24 12:08:34 +07:00
parent 7cb12be97f
commit 6afe4fd626
5 changed files with 250 additions and 446 deletions

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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;
}
}

View File

@@ -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}`;