/** * FederationAuthGuard — NestJS CanActivate guard for inbound federation requests. * * Validates the mTLS client certificate presented by a peer gateway, extracts * custom OIDs to identify the grant + subject user, loads the grant from DB, * asserts it is active, and verifies the cert serial against the registered peer * cert serial as a defense-in-depth measure. * * On success, attaches `request.federationContext` for downstream verb controllers. * On failure, responds with the federation wire-format error envelope (not raw * NestJS exception JSON) to match the federation protocol contract. * * ## Cert-serial check decision * The guard validates that the inbound client cert's serial number matches the * `certSerial` stored on the associated `federation_peers` row. This is a * defense-in-depth measure: even if the mTLS handshake is compromised at the * transport layer (e.g. misconfigured TLS terminator that forwards arbitrary * client certs), an attacker cannot replay a cert with a different serial than * what was registered during enrollment. This check is NOT loosened because: * 1. It is O(1) — no additional DB round-trip (peerId is on the grant row, * so we join to federationPeers in the same query). * 2. Cert renewal MUST update the stored serial — enforced by M6 scheduler. * 3. The OID-only path (without serial check) would allow any cert from the * same CA bearing the same grantId OID to succeed after cert compromise. * * ## FastifyRequest typing path * NestJS + Fastify wraps the raw Node.js IncomingMessage in a FastifyRequest. * The underlying TLS socket is accessed via `request.raw.socket`, which is a * `tls.TLSSocket` when the server is listening on HTTPS. In development/test * the gateway may run over plain HTTP, in which case `getPeerCertificate` is * not available. The guard safely handles both cases by checking for the * method's existence before calling it. * * Note: The guard reads the peer certificate from the *already-completed* * TLS handshake via `socket.getPeerCertificate(detailed=true)`. This relies * on the server being configured with `requestCert: true` at the TLS level * so Fastify/Node.js requests the client cert during the handshake. * The guard does NOT verify the cert chain itself — that is handled by the * TLS layer (Node.js `rejectUnauthorized: true` with the CA cert pinned). */ import { type CanActivate, type ExecutionContext, Injectable, Logger } from '@nestjs/common'; import type { FastifyReply, FastifyRequest } from 'fastify'; import * as tls from 'node:tls'; import { X509Certificate } from '@peculiar/x509'; import { FederationForbiddenError, FederationUnauthorizedError } from '@mosaicstack/types'; import { extractMosaicOids } from '../oid.util.js'; import { GrantsService } from '../grants.service.js'; import type { FederationContext } from './federation-context.js'; import './federation-context.js'; // side-effect import: applies FastifyRequest module augmentation // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * Send a federation wire-format error response directly on the Fastify reply. * Returns false — callers return this value from canActivate. */ function sendFederationError( reply: FastifyReply, error: FederationUnauthorizedError | FederationForbiddenError, ): boolean { const statusCode = error.code === 'unauthorized' ? 401 : 403; void reply.status(statusCode).header('content-type', 'application/json').send(error.toEnvelope()); return false; } // --------------------------------------------------------------------------- // Guard // --------------------------------------------------------------------------- @Injectable() export class FederationAuthGuard implements CanActivate { private readonly logger = new Logger(FederationAuthGuard.name); constructor(private readonly grantsService: GrantsService) {} async canActivate(context: ExecutionContext): Promise { const http = context.switchToHttp(); const request = http.getRequest(); const reply = http.getResponse(); // ── Step 1: Extract peer certificate from TLS socket ──────────────────── const rawSocket = request.raw.socket; // Check TLS socket: getPeerCertificate is only available on TLS connections. if ( !rawSocket || typeof (rawSocket as Partial).getPeerCertificate !== 'function' ) { this.logger.warn('No TLS socket — client cert unavailable (non-mTLS connection)'); return sendFederationError( reply, new FederationUnauthorizedError('Client certificate required'), ); } const tlsSocket = rawSocket as tls.TLSSocket; const peerCert = tlsSocket.getPeerCertificate(true); // Node.js returns an object with empty string fields when no cert was presented. if (!peerCert || !peerCert.raw) { this.logger.warn('Peer certificate not presented (mTLS handshake did not supply cert)'); return sendFederationError( reply, new FederationUnauthorizedError('Client certificate required'), ); } // ── Step 2: Parse the DER-encoded certificate via @peculiar/x509 ──────── let cert: X509Certificate; try { // peerCert.raw is a Buffer containing the DER-encoded cert cert = new X509Certificate(peerCert.raw); } catch (err) { this.logger.warn( `Failed to parse peer certificate: ${err instanceof Error ? err.message : String(err)}`, ); return sendFederationError( reply, new FederationUnauthorizedError('Client certificate could not be parsed'), ); } // ── Step 3: Extract Mosaic custom OIDs ────────────────────────────────── const oidResult = extractMosaicOids(cert); if (!oidResult.ok) { const message = oidResult.error === 'MISSING_GRANT_ID' ? 'Client certificate is missing required OID: mosaic_grant_id (1.3.6.1.4.1.99999.1)' : oidResult.error === 'MISSING_SUBJECT_USER_ID' ? 'Client certificate is missing required OID: mosaic_subject_user_id (1.3.6.1.4.1.99999.2)' : `Client certificate OID extraction failed: ${oidResult.detail ?? 'unknown error'}`; this.logger.warn(`OID extraction failure [${oidResult.error}]: ${message}`); return sendFederationError(reply, new FederationUnauthorizedError(message)); } const { grantId, subjectUserId } = oidResult.value; // ── Step 4: Load grant from DB ─────────────────────────────────────────── let grant: Awaited>; try { grant = await this.grantsService.getGrantWithPeer(grantId); } catch { // getGrantWithPeer throws NotFoundException when not found this.logger.warn(`Grant not found: ${grantId}`); return sendFederationError(reply, new FederationForbiddenError(`Grant ${grantId} not found`)); } // ── Step 5: Assert grant is active ────────────────────────────────────── if (grant.status !== 'active') { this.logger.warn(`Grant ${grantId} is not active — status=${grant.status}`); return sendFederationError( reply, new FederationForbiddenError(`Grant ${grantId} is not active (status: ${grant.status})`), ); } // ── Step 6: Defense-in-depth — cert serial must match registered peer ─── // The serial number from Node.js TLS is upper-case hex without colons. // The @peculiar/x509 serialNumber is decimal. We compare using the native // Node.js crypto cert serial which is uppercase hex, matching DB storage. // Both are derived from the peerCert.serialNumber Node.js provides. const inboundSerial: string = peerCert.serialNumber ?? ''; if (!grant.peer.certSerial) { // Peer row exists but has no stored serial — something is wrong with enrollment this.logger.error(`Peer ${grant.peerId} has no stored certSerial — enrollment incomplete`); return sendFederationError( reply, new FederationForbiddenError( 'Peer registration incomplete — no certificate serial on record', ), ); } // Normalize both to uppercase for comparison (Node.js serialNumber is // already uppercase hex; DB value was stored from extractSerial() which // returns crypto.X509Certificate.serialNumber — also uppercase hex). if (inboundSerial.toUpperCase() !== grant.peer.certSerial.toUpperCase()) { this.logger.warn( `Cert serial mismatch for grant ${grantId}: ` + `inbound=${inboundSerial} registered=${grant.peer.certSerial}`, ); return sendFederationError( reply, new FederationForbiddenError( 'Client certificate serial does not match registered peer certificate', ), ); } // ── Step 7: Attach FederationContext to request ────────────────────────── const federationContext: FederationContext = { grantId, subjectUserId, peerId: grant.peerId, scope: grant.scope as Record, }; request.federationContext = federationContext; this.logger.debug( `Federation auth OK — grantId=${grantId} peerId=${grant.peerId} subjectUserId=${subjectUserId}`, ); return true; } }