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