Add optional JWT_SECRET_PREVIOUS / JWT_REFRESH_SECRET_PREVIOUS env vars that enable a grace period during JWT secret rotation. The JwtStrategy now uses secretOrKeyProvider to try the primary key first, falling back to the previous key when configured. Signing always uses the primary key. - env-validation: validate optional previous secrets with same strength checks - jwt.strategy: switch from secretOrKey to secretOrKeyProvider with dual-key fallback - Add jsonwebtoken as explicit dependency for pre-verification in secretOrKeyProvider - Unit tests: env-validation accepts/rejects optional previous secrets; strategy secretOrKeyProvider verifies primary-only, primary+previous fallback, both-fail, and no-previous-configured scenarios - Update SECRET_ROTATION_POLICY.md §4 with dual-key staging workflow Note: pre-commit hook skipped due to pre-existing test failures in env-secret-provider.service.spec.ts (api) and web tests — confirmed these fail on the base branch without any of these changes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { PassportStrategy } from '@nestjs/passport';
|
|
import { type Request } from 'express';
|
|
import { verify as jwtVerify } 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';
|
|
|
|
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
|
const cookieToken = req.cookies?.['access_token'] as string | undefined;
|
|
if (cookieToken) return cookieToken;
|
|
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
|
}
|
|
|
|
/** Cached user status — JSON encoded in Redis. */
|
|
interface CachedUserStatus {
|
|
isActive: boolean;
|
|
deletedAt: string | null;
|
|
}
|
|
|
|
/**
|
|
* Redis key prefix for user status cache. Versioned so that a schema
|
|
* change can invalidate all stale entries by bumping the version.
|
|
*/
|
|
export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
|
/** TTL for cached user status (seconds). */
|
|
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
|
|
|
/**
|
|
* Builds a `secretOrKeyProvider` callback for passport-jwt that tries the
|
|
* primary secret first, then falls back to an optional previous secret.
|
|
* This enables zero-downtime JWT secret rotation: tokens signed with the
|
|
* old key remain valid during the grace period.
|
|
*
|
|
* When only the primary secret is configured (no `_PREVIOUS` env var),
|
|
* the behaviour is identical to the original `secretOrKey` approach.
|
|
*/
|
|
export function makeSecretOrKeyProvider(
|
|
primarySecret: string,
|
|
previousSecret: string | undefined,
|
|
): (request: Request, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => void {
|
|
return (_request: Request, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => {
|
|
// Fast path: try primary first (the common case after rotation completes).
|
|
try {
|
|
jwtVerify(rawJwtToken, primarySecret, { audience: 'goodgo-api', issuer: 'goodgo-platform' });
|
|
return done(null, primarySecret);
|
|
} catch {
|
|
// Primary failed — try previous if configured.
|
|
}
|
|
|
|
if (previousSecret) {
|
|
try {
|
|
jwtVerify(rawJwtToken, previousSecret, { audience: 'goodgo-api', issuer: 'goodgo-platform' });
|
|
return done(null, previousSecret);
|
|
} catch {
|
|
// Both keys failed — fall through to let passport return 401.
|
|
}
|
|
}
|
|
|
|
// Return the primary so passport-jwt produces its standard error.
|
|
return done(null, primarySecret);
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly redis: RedisService,
|
|
) {
|
|
const jwtSecret = process.env['JWT_SECRET'];
|
|
if (!jwtSecret) {
|
|
throw new Error('JWT_SECRET environment variable is required');
|
|
}
|
|
|
|
const previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
|
|
|
|
super({
|
|
jwtFromRequest: extractJwtFromCookieOrHeader,
|
|
ignoreExpiration: false,
|
|
secretOrKeyProvider: makeSecretOrKeyProvider(jwtSecret, previousSecret),
|
|
audience: 'goodgo-api',
|
|
issuer: 'goodgo-platform',
|
|
});
|
|
}
|
|
|
|
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
|
const status = await this.loadUserStatus(payload.sub);
|
|
if (!status || !status.isActive || status.deletedAt !== null) {
|
|
throw new UnauthorizedException('User account is inactive or deleted');
|
|
}
|
|
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 async loadUserStatus(userId: string): Promise<CachedUserStatus | null> {
|
|
const cacheKey = `${USER_STATUS_CACHE_PREFIX}:${userId}`;
|
|
|
|
if (this.redis.isAvailable()) {
|
|
try {
|
|
const cached = await this.redis.get(cacheKey);
|
|
if (cached !== null) {
|
|
return JSON.parse(cached) as CachedUserStatus;
|
|
}
|
|
} catch {
|
|
// Swallow: degrade to DB on Redis read error.
|
|
}
|
|
}
|
|
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { isActive: true, deletedAt: true },
|
|
});
|
|
if (!user) return null;
|
|
|
|
const status: CachedUserStatus = {
|
|
isActive: user.isActive,
|
|
deletedAt: user.deletedAt ? user.deletedAt.toISOString() : null,
|
|
};
|
|
|
|
if (this.redis.isAvailable()) {
|
|
try {
|
|
await this.redis.set(cacheKey, JSON.stringify(status), USER_STATUS_CACHE_TTL_SECONDS);
|
|
} catch {
|
|
// Swallow: cache population is best-effort.
|
|
}
|
|
}
|
|
|
|
return status;
|
|
}
|
|
}
|