fix(auth): wire dual-key JWT verification into TokenService for WebSocket auth

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>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 14:44:23 +07:00
parent 455c959f44
commit 3705193f97
8 changed files with 345 additions and 252 deletions

View File

@@ -5,6 +5,7 @@ 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;
@@ -12,88 +13,33 @@ function extractJwtFromCookieOrHeader(req: Request): string | null {
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
}
/** Cached user status — JSON encoded in Redis. */
interface CachedUserStatus {
isActive: boolean;
deletedAt: string | null;
}
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,
) {
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',
});
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');
}
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 (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: cache population is best-effort.
}
}
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;
}
}