import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { JwtService } from "@nestjs/jwt"; import { Request } from "express"; import * as crypto from "crypto"; import { AbuseService } from "../../abuse/abuse.service"; import { requestContextFrom } from "../../abuse/abuse.types"; import { PrismaService } from "../../prisma/prisma.service"; export const IS_PUBLIC_KEY = "isPublic"; @Injectable() export class JwtAuthGuard implements CanActivate { constructor( private readonly jwtService: JwtService, private readonly reflector: Reflector, private readonly abuseService: AbuseService, private readonly prisma: PrismaService, ) {} async canActivate(context: ExecutionContext): Promise { const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); if (isPublic) { return true; } const request = context.switchToHttp().getRequest(); const token = this.extractToken(request); if (!token) { throw new UnauthorizedException("Missing bearer token."); } try { const payload = this.jwtService.verify<{ sub: string; sid?: string }>(token, { secret: process.env.JWT_SECRET, }); if (!payload.sid) { throw new UnauthorizedException("Session-bound token required."); } const session = await this.prisma.session.findUnique({ where: { id: payload.sid } }); const requestContext = requestContextFrom(request); if ( !session || session.userId !== payload.sub || session.revokedAt || session.expiresAt < new Date() || session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip") || session.userAgentHash !== this.hashBindingValue(requestContext.userAgent ?? "unknown-user-agent") || !session.nonceHash || !requestContext.sessionNonce || session.nonceHash !== this.hashBindingValue(requestContext.sessionNonce) ) { throw new UnauthorizedException("Invalid session binding."); } const nextNonce = crypto.randomBytes(32).toString("base64url"); const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>(); await this.prisma.session.update({ where: { id: session.id }, data: { lastSeenAt: new Date(), nonceHash: this.hashBindingValue(nextNonce), }, }); response.setHeader("X-LedgerOne-Next-Nonce", nextNonce); (request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid }; return true; } catch { await this.abuseService.recordInvalidToken(requestContextFrom(request)); throw new UnauthorizedException("Invalid or expired token."); } } private extractToken(request: Request): string | null { const auth = request.headers.authorization; if (auth?.startsWith("Bearer ")) { return auth.slice(7); } return null; } private hashBindingValue(value: string): string { return crypto.createHash("sha256").update(value).digest("hex"); } }