import { timingSafeEqual } from "node:crypto"; import { type CanActivate, type ExecutionContext, Injectable, Logger, UnauthorizedException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { Request } from "express"; import * as jwt from "jsonwebtoken"; import type { UserContext } from "./user-context.interface.js"; /** Constant-time string compare; false on length mismatch (no early return). */ function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); return ab.length === bb.length && timingSafeEqual(ab, bb); } @Injectable() export class AuthGuard implements CanActivate { private readonly logger = new Logger(AuthGuard.name); private jwtPublicKey: string | null = null; constructor(private configService: ConfigService) { const publicKey = this.configService.get("SYNC_JWT_PUBLIC_KEY"); if (publicKey) { this.jwtPublicKey = publicKey.replace(/\\n/g, "\n"); this.logger.log("JWT public key configured — cloud auth enabled"); } } canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const authHeader = request.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { throw new UnauthorizedException( "Missing or invalid authorization header", ); } const token = authHeader.substring(7); // Try SYNC_TOKEN first (self-hosted mode) const expectedToken = this.configService.get("SYNC_TOKEN"); if (expectedToken && safeEqual(token, expectedToken)) { (request as unknown as Record).user = { mode: "self-hosted", prefix: "", teamPrefix: null, profileLimit: 0, teamProfileLimit: 0, } satisfies UserContext; return true; } // Try JWT verification (cloud mode) if (this.jwtPublicKey) { try { const decoded = jwt.verify(token, this.jwtPublicKey, { algorithms: ["RS256"], }) as jwt.JwtPayload; // Validate the scope claims' SHAPE before trusting them as S3 key // prefixes. An empty/over-broad prefix would make validateKeyAccess // (`key.startsWith(prefix)`) authorize the entire bucket, so a signer // bug or permissive claim must not silently widen scope. const prefix = decoded.prefix || `users/${decoded.sub}/`; if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) { throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`); } const teamPrefix = decoded.teamPrefix === undefined || decoded.teamPrefix === null ? null : decoded.teamPrefix; if ( teamPrefix !== null && !/^teams\/[^/]+\/$/.test(String(teamPrefix)) ) { throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`); } (request as unknown as Record).user = { mode: "cloud", prefix, teamPrefix, profileLimit: decoded.profileLimit || 0, teamProfileLimit: decoded.teamProfileLimit || 0, } satisfies UserContext; return true; } catch (err) { this.logger.warn( `JWT verification failed: ${err instanceof Error ? err.message : err}`, ); } } // If SYNC_TOKEN is configured but didn't match, or JWT failed if (!expectedToken && !this.jwtPublicKey) { throw new UnauthorizedException( "No auth method configured on server (set SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY)", ); } throw new UnauthorizedException("Invalid sync token or JWT"); } }