Extract shared `verifyWithRotation` helper and `makeSecretOrKeyProvider` into `jwt-rotation.ts` so both REST (passport-jwt strategy) and WebSocket (TokenService.verifyAccessToken) paths honour JWT_SECRET_PREVIOUS during secret rotation. Add env-validation for optional previous secrets and document the rotation policy for WebSocket sessions. Resolves GOO-237 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
46 lines
2.6 KiB
TypeScript
46 lines
2.6 KiB
TypeScript
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<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 };
|
|
}
|
|
|
|
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 */ } }
|
|
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;
|
|
}
|
|
}
|