1import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2import type { Request } from "express"; 3import { loadEnv } from "../../config/env.js"; 4import { safeEqual } from "../../lib/crypto.js"; 5import { ApiError } from "../errors.js"; 6import { logger } from "../logger.js"; 7 8/** 9 * Internal service-token guard (TASK-0092 slice 2 — the `/internal/*` 10 * endpoints). 11 * 12 * The internal endpoints are the service-to-service surface the realtime 13 * gateway uses to hydrate a room and to persist (flush) a CRDT doc against 14 * Postgres. They are NOT user-facing: the room's permission model already 15 * gated the edits, so the endpoints carry **no permission re-check** and 16 * authenticate with a shared bearer token (`INTERNAL_SERVICE_TOKEN`) 17 * instead of a user JWT / API key. 18 * 19 * Security model (recorded in the task file): 20 * - The token is compared with `safeEqual` (constant-time) — never `===`. 21 * - **Fail-closed**: when `INTERNAL_SERVICE_TOKEN` is empty (dev default), 22 * every internal request is rejected (401) — the endpoints can never be 23 * opened by accident. The gateway's HTTP store degrades to the in-memory 24 * seam in that case (`REALTIME_DB=0` semantics). 25 * - A request that carries a valid user JWT / API key is still rejected 26 * (`token_not_service`): the internal surface accepts ONLY the service 27 * token, so a user credential can never be replayed against it. 28 * - Failures are logged (without the token) for the abuse trail. 29 */ 30@Injectable() 31export class InternalServiceGuard implements CanActivate { 32 canActivate(context: ExecutionContext): boolean { 33 const req = context.switchToHttp().getRequest<Request>(); 34 const expected = loadEnv().INTERNAL_SERVICE_TOKEN; 35 36 if (expected.length === 0) { 37 // Fail closed: no configured token → the internal surface is closed. 38 logger.warn(`internal endpoint rejected: INTERNAL_SERVICE_TOKEN not configured (fail-closed)`); 39 throw ApiError.unauthorized("Internal service token is not configured.", [ 40 { reason: "service_token_not_configured" } 41 ]); 42 } 43 44 const header = req.headers.authorization; 45 if (!header || !header.startsWith("Bearer ")) { 46 throw ApiError.unauthorized("Missing service bearer token.", [{ reason: "service_token_missing" }]); 47 } 48 const token = header.slice(7).trim(); 49 if (!token || !safeEqual(token, expected)) { 50 // Do not echo the token; a reason code lets ops distinguish 51 // missing vs mismatched without a timing oracle. 52 throw ApiError.unauthorized("Invalid internal service token.", [ 53 { reason: token ? "service_token_mismatch" : "service_token_missing" } 54 ]); 55 } 56 57 // The internal surface authenticates ONLY as the shared service — set a 58 // distinct principal so audit rows (and any downstream check) can tell a 59 // system write apart from a user/API-key write. 60 req.auth = { kind: "api", workspaceId: undefined, scopes: ["READ", "WRITE", "ADMIN"] }; 61 return true; 62 } 63} 64