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'; import { makeSecretOrKeyProvider } from '../utils/jwt-rotation'; function extractJwtFromCookieOrHeader(req: Request): string | null { const cookieToken = req.cookies?.['access_token'] as string | undefined; if (cookieToken) return cookieToken; return ExtractJwt.fromAuthHeaderAsBearerToken()(req); } interface CachedUserStatus { isActive: boolean; deletedAt: string | null; } export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1'; 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'); 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 }; } 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 */ } } 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 */ } } return status; } }