import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { type Request } from 'express'; 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; @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'); } super({ jwtFromRequest: extractJwtFromCookieOrHeader, ignoreExpiration: false, secretOrKey: jwtSecret, 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; } }