diff --git a/apps/gateway/src/federation/federation.module.ts b/apps/gateway/src/federation/federation.module.ts index ebb6295..c3818ba 100644 --- a/apps/gateway/src/federation/federation.module.ts +++ b/apps/gateway/src/federation/federation.module.ts @@ -5,10 +5,11 @@ import { EnrollmentController } from './enrollment.controller.js'; import { EnrollmentService } from './enrollment.service.js'; import { FederationController } from './federation.controller.js'; import { GrantsService } from './grants.service.js'; +import { FederationAuthGuard } from './server/index.js'; @Module({ controllers: [EnrollmentController, FederationController], - providers: [AdminGuard, CaService, EnrollmentService, GrantsService], - exports: [CaService, EnrollmentService, GrantsService], + providers: [AdminGuard, CaService, EnrollmentService, GrantsService, FederationAuthGuard], + exports: [CaService, EnrollmentService, GrantsService, FederationAuthGuard], }) export class FederationModule {} diff --git a/apps/gateway/src/federation/grants.service.ts b/apps/gateway/src/federation/grants.service.ts index 81906fc..317a891 100644 --- a/apps/gateway/src/federation/grants.service.ts +++ b/apps/gateway/src/federation/grants.service.ts @@ -10,12 +10,14 @@ */ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { type Db, and, eq, federationGrants } from '@mosaicstack/db'; +import { type Db, and, eq, federationGrants, federationPeers } from '@mosaicstack/db'; import { DB } from '../database/database.module.js'; import { parseFederationScope } from './scope-schema.js'; import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js'; export type Grant = typeof federationGrants.$inferSelect; +export type Peer = typeof federationPeers.$inferSelect; +export type GrantWithPeer = Grant & { peer: Peer }; @Injectable() export class GrantsService { @@ -60,6 +62,33 @@ export class GrantsService { return grant; } + /** + * Fetch a single grant by ID, joined with its associated peer row. + * Used by FederationAuthGuard to perform grant status + cert serial checks + * in a single DB round-trip. + * + * Throws NotFoundException if the grant does not exist. + * Throws NotFoundException if the associated peer row is missing (data integrity issue). + */ + async getGrantWithPeer(id: string): Promise { + const rows = await this.db + .select() + .from(federationGrants) + .innerJoin(federationPeers, eq(federationGrants.peerId, federationPeers.id)) + .where(eq(federationGrants.id, id)) + .limit(1); + + const row = rows[0]; + if (!row) { + throw new NotFoundException(`Grant ${id} not found`); + } + + return { + ...row.federation_grants, + peer: row.federation_peers, + }; + } + /** * List grants with optional filters for peerId, subjectUserId, and status. */ diff --git a/apps/gateway/src/federation/oid.util.ts b/apps/gateway/src/federation/oid.util.ts new file mode 100644 index 0000000..da9cee4 --- /dev/null +++ b/apps/gateway/src/federation/oid.util.ts @@ -0,0 +1,116 @@ +/** + * Shared OID extraction helpers for Mosaic federation certificates. + * + * Custom OID registry (PRD §6, docs/federation/SETUP.md): + * 1.3.6.1.4.1.99999.1 — mosaic_grant_id + * 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id + * + * The encoding convention: each extension value is an OCTET STRING wrapping + * an ASN.1 UTF8String TLV: + * 0x0C (tag) + 1-byte length + UTF-8 bytes + * + * CaService encodes values this way via encodeUtf8String(), and this module + * decodes them with the corresponding `.slice(2)` to skip tag + length byte. + * + * This module is intentionally pure — no NestJS, no DB, no network I/O. + */ + +import { X509Certificate } from '@peculiar/x509'; + +// --------------------------------------------------------------------------- +// OID constants +// --------------------------------------------------------------------------- + +export const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1'; +export const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2'; + +// --------------------------------------------------------------------------- +// Extraction result types +// --------------------------------------------------------------------------- + +export interface MosaicOids { + grantId: string; + subjectUserId: string; +} + +export type OidExtractionResult = + | { ok: true; value: MosaicOids } + | { + ok: false; + error: 'MISSING_GRANT_ID' | 'MISSING_SUBJECT_USER_ID' | 'PARSE_ERROR'; + detail?: string; + }; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const decoder = new TextDecoder(); + +/** + * Decode an extension value encoded as ASN.1 UTF8String TLV + * (tag 0x0C + 1-byte length + UTF-8 bytes). + * Slices off the 2-byte TLV header and decodes the remainder as UTF-8. + */ +function decodeUtf8StringTlv(value: ArrayBuffer): string { + const bytes = new Uint8Array(value); + // Skip: tag (1 byte) + length (1 byte) + return decoder.decode(bytes.slice(2)); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Extract Mosaic custom OIDs (grantId, subjectUserId) from an X.509 certificate + * already parsed via @peculiar/x509. + * + * Returns `{ ok: true, value: MosaicOids }` on success, or + * `{ ok: false, error: , detail? }` on any failure — never throws. + */ +export function extractMosaicOids(cert: X509Certificate): OidExtractionResult { + try { + const grantIdExt = cert.getExtension(OID_MOSAIC_GRANT_ID); + if (!grantIdExt) { + return { ok: false, error: 'MISSING_GRANT_ID' }; + } + + const subjectUserIdExt = cert.getExtension(OID_MOSAIC_SUBJECT_USER_ID); + if (!subjectUserIdExt) { + return { ok: false, error: 'MISSING_SUBJECT_USER_ID' }; + } + + const grantId = decodeUtf8StringTlv(grantIdExt.value); + const subjectUserId = decodeUtf8StringTlv(subjectUserIdExt.value); + + return { + ok: true, + value: { grantId, subjectUserId }, + }; + } catch (err) { + return { + ok: false, + error: 'PARSE_ERROR', + detail: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Parse a PEM-encoded certificate and extract Mosaic OIDs. + * Returns an OidExtractionResult — never throws. + */ +export function extractMosaicOidsFromPem(certPem: string): OidExtractionResult { + let cert: X509Certificate; + try { + cert = new X509Certificate(certPem); + } catch (err) { + return { + ok: false, + error: 'PARSE_ERROR', + detail: err instanceof Error ? err.message : String(err), + }; + } + return extractMosaicOids(cert); +} diff --git a/apps/gateway/src/federation/server/__tests__/federation-auth.guard.spec.ts b/apps/gateway/src/federation/server/__tests__/federation-auth.guard.spec.ts new file mode 100644 index 0000000..a8454e9 --- /dev/null +++ b/apps/gateway/src/federation/server/__tests__/federation-auth.guard.spec.ts @@ -0,0 +1,438 @@ +/** + * Unit tests for FederationAuthGuard (FED-M3-03). + * + * Coverage: + * - Missing cert (no TLS socket / no getPeerCertificate) → 401 + * - Cert parse failure (corrupt DER raw bytes) → 401 + * - Missing grantId OID → 401 + * - Missing subjectUserId OID → 401 + * - Grant not found (GrantsService throws NotFoundException) → 403 + * - Grant in `pending` status → 403 + * - Grant in `revoked` status → 403 + * - Grant in `expired` status → 403 + * - Cert serial mismatch → 403 + * - Happy path: active grant + matching cert serial → context attached, returns true + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ExecutionContext } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; +import { FederationAuthGuard } from '../federation-auth.guard.js'; +import { makeMosaicIssuedCert } from '../../__tests__/helpers/test-cert.js'; +import type { GrantsService, GrantWithPeer } from '../../grants.service.js'; + +// --------------------------------------------------------------------------- +// Test constants +// --------------------------------------------------------------------------- + +const GRANT_ID = 'a1111111-1111-1111-1111-111111111111'; +const USER_ID = 'b2222222-2222-2222-2222-222222222222'; +const PEER_ID = 'c3333333-3333-3333-3333-333333333333'; + +// Node.js TLS serialNumber is uppercase hex (no colons) +const CERT_SERIAL_HEX = '01'; + +const VALID_SCOPE = { resources: ['tasks'], max_rows_per_query: 100 }; + +// --------------------------------------------------------------------------- +// Mock builders +// --------------------------------------------------------------------------- + +/** + * Build a minimal GrantWithPeer-shaped mock. + */ +function makeGrantWithPeer(overrides: Partial = {}): GrantWithPeer { + return { + id: GRANT_ID, + peerId: PEER_ID, + subjectUserId: USER_ID, + scope: VALID_SCOPE, + status: 'active', + expiresAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + revokedAt: null, + revokedReason: null, + peer: { + id: PEER_ID, + commonName: 'test-peer', + displayName: 'Test Peer', + certPem: '', + certSerial: CERT_SERIAL_HEX, + certNotAfter: new Date(Date.now() + 86_400_000), + clientKeyPem: null, + state: 'active', + endpointUrl: null, + lastSeenAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + revokedAt: null, + }, + ...overrides, + }; +} + +/** + * Build a mock ExecutionContext with a pre-built TLS peer certificate. + * + * `certPem` — PEM string to present as the raw DER cert (converted to Buffer). + * Pass null to simulate "no cert presented". + * `certSerialHex` — serialNumber string returned by the TLS socket. + * Node.js returns uppercase hex. + * `hasTlsSocket` — if false, raw.socket has no getPeerCertificate (plain HTTP). + */ +function makeContext(opts: { + certPem: string | null; + certSerialHex?: string; + hasTlsSocket?: boolean; +}): { + ctx: ExecutionContext; + statusMock: ReturnType; + sendMock: ReturnType; +} { + const { certPem, certSerialHex = CERT_SERIAL_HEX, hasTlsSocket = true } = opts; + + // Build peerCert object that Node.js TLS socket.getPeerCertificate() returns + let peerCert: Record; + if (certPem === null) { + // Simulate no cert: Node.js returns object with empty string fields + peerCert = { raw: null, serialNumber: '' }; + } else { + // Convert PEM to DER Buffer (strip headers + base64 decode) + const b64 = certPem + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\s+/g, ''); + const raw = Buffer.from(b64, 'base64'); + peerCert = { raw, serialNumber: certSerialHex }; + } + + const getPeerCertificate = vi.fn().mockReturnValue(peerCert); + + const socket = hasTlsSocket ? { getPeerCertificate } : {}; // No getPeerCertificate → non-TLS + + // Fastify reply mocks + const sendMock = vi.fn().mockReturnValue(undefined); + const headerMock = vi.fn().mockReturnValue({ send: sendMock }); + const statusMock = vi.fn().mockReturnValue({ header: headerMock }); + + const request = { + raw: { + socket, + }, + }; + + const reply = { + status: statusMock, + }; + + const ctx = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => reply, + }), + } as unknown as ExecutionContext; + + return { ctx, statusMock, sendMock }; +} + +/** + * Build a mock GrantsService. + */ +function makeGrantsService( + overrides: Partial> = {}, +): GrantsService { + return { + getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer()), + ...overrides, + } as unknown as GrantsService; +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('FederationAuthGuard', () => { + let certPem: string; + + beforeEach(async () => { + // Generate a real Mosaic-issued cert with the standard OIDs + certPem = await makeMosaicIssuedCert({ grantId: GRANT_ID, subjectUserId: USER_ID }); + }); + + // ── 401: No TLS socket ──────────────────────────────────────────────────── + + it('returns 401 when there is no TLS socket (plain HTTP connection)', async () => { + const { ctx, statusMock } = makeContext({ + certPem: certPem, + hasTlsSocket: false, + }); + + const guard = new FederationAuthGuard(makeGrantsService()); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(401); + }); + + // ── 401: Cert not presented ─────────────────────────────────────────────── + + it('returns 401 when the peer did not present a certificate', async () => { + const { ctx, statusMock } = makeContext({ certPem: null }); + + const guard = new FederationAuthGuard(makeGrantsService()); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(401); + }); + + // ── 401: Cert parse failure ─────────────────────────────────────────────── + + it('returns 401 when the certificate DER bytes are corrupt', async () => { + // Build context with a cert that has garbage DER bytes + const corruptPem = '-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----'; + const { ctx, statusMock } = makeContext({ certPem: corruptPem }); + + const guard = new FederationAuthGuard(makeGrantsService()); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(401); + }); + + // ── 401: Missing grantId OID ───────────────────────────────────────────── + + it('returns 401 when the cert is missing the grantId OID', async () => { + // makeSelfSignedCert produces a cert without any Mosaic OIDs + const { makeSelfSignedCert } = await import('../../__tests__/helpers/test-cert.js'); + const plainCert = await makeSelfSignedCert(); + const { ctx, statusMock } = makeContext({ certPem: plainCert }); + + const guard = new FederationAuthGuard(makeGrantsService()); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(401); + }); + + // ── 401: Missing subjectUserId OID ─────────────────────────────────────── + + it('returns 401 when the cert has grantId OID but is missing subjectUserId OID', async () => { + // Build a cert with only the grantId OID by importing cert generator internals + const { webcrypto } = await import('node:crypto'); + const { + X509CertificateGenerator, + Extension, + KeyUsagesExtension, + KeyUsageFlags, + BasicConstraintsExtension, + cryptoProvider, + } = await import('@peculiar/x509'); + + cryptoProvider.set(webcrypto as unknown as Parameters[0]); + + const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; + const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']); + const now = new Date(); + const tomorrow = new Date(now.getTime() + 86_400_000); + + // Encode grantId only — missing subjectUserId extension + const utf8 = new TextEncoder().encode(GRANT_ID); + const encoded = new Uint8Array(2 + utf8.length); + encoded[0] = 0x0c; + encoded[1] = utf8.length; + encoded.set(utf8, 2); + + const cert = await X509CertificateGenerator.createSelfSigned({ + serialNumber: '01', + name: 'CN=partial-oid-test', + notBefore: now, + notAfter: tomorrow, + signingAlgorithm: alg, + keys, + extensions: [ + new BasicConstraintsExtension(false), + new KeyUsagesExtension(KeyUsageFlags.digitalSignature), + new Extension('1.3.6.1.4.1.99999.1', false, encoded), // grantId only + ], + }); + + const { ctx, statusMock } = makeContext({ certPem: cert.toString('pem') }); + + const guard = new FederationAuthGuard(makeGrantsService()); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(401); + }); + + // ── 403: Grant not found ───────────────────────────────────────────────── + + it('returns 403 when the grantId from the cert does not exist in DB', async () => { + const grantsService = makeGrantsService({ + getGrantWithPeer: vi + .fn() + .mockRejectedValue(new NotFoundException(`Grant ${GRANT_ID} not found`)), + }); + + const { ctx, statusMock } = makeContext({ certPem }); + + const guard = new FederationAuthGuard(grantsService); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(403); + }); + + // ── 403: Grant in `pending` status ─────────────────────────────────────── + + it('returns 403 when the grant is in pending status', async () => { + const grantsService = makeGrantsService({ + getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'pending' })), + }); + + const { ctx, statusMock } = makeContext({ certPem }); + + const guard = new FederationAuthGuard(grantsService); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(403); + }); + + // ── 403: Grant in `revoked` status ─────────────────────────────────────── + + it('returns 403 when the grant is in revoked status', async () => { + const grantsService = makeGrantsService({ + getGrantWithPeer: vi + .fn() + .mockResolvedValue(makeGrantWithPeer({ status: 'revoked', revokedAt: new Date() })), + }); + + const { ctx, statusMock } = makeContext({ certPem }); + + const guard = new FederationAuthGuard(grantsService); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(403); + }); + + // ── 403: Grant in `expired` status ─────────────────────────────────────── + + it('returns 403 when the grant is in expired status', async () => { + const grantsService = makeGrantsService({ + getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'expired' })), + }); + + const { ctx, statusMock } = makeContext({ certPem }); + + const guard = new FederationAuthGuard(grantsService); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(403); + }); + + // ── 403: Cert serial mismatch ───────────────────────────────────────────── + + it('returns 403 when the cert serial does not match the registered peer cert serial', async () => { + // Return a grant whose peer has a different stored serial + const grantsService = makeGrantsService({ + getGrantWithPeer: vi.fn().mockResolvedValue( + makeGrantWithPeer({ + peer: { + id: PEER_ID, + commonName: 'test-peer', + displayName: 'Test Peer', + certPem: '', + certSerial: 'DEADBEEF', // different from CERT_SERIAL_HEX='01' + certNotAfter: new Date(Date.now() + 86_400_000), + clientKeyPem: null, + state: 'active', + endpointUrl: null, + lastSeenAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + revokedAt: null, + }, + }), + ), + }); + + // Context presents cert with serial '01' but DB has 'DEADBEEF' + const { ctx, statusMock } = makeContext({ certPem, certSerialHex: '01' }); + + const guard = new FederationAuthGuard(grantsService); + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(403); + }); + + // ── Happy path ──────────────────────────────────────────────────────────── + + it('returns true and attaches federationContext on happy path', async () => { + const grant = makeGrantWithPeer({ + status: 'active', + peer: { + id: PEER_ID, + commonName: 'test-peer', + displayName: 'Test Peer', + certPem: '', + certSerial: CERT_SERIAL_HEX, + certNotAfter: new Date(Date.now() + 86_400_000), + clientKeyPem: null, + state: 'active', + endpointUrl: null, + lastSeenAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + revokedAt: null, + }, + }); + + const grantsService = makeGrantsService({ + getGrantWithPeer: vi.fn().mockResolvedValue(grant), + }); + + // Build context manually to capture what gets set on request.federationContext + const b64 = certPem + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\s+/g, ''); + const raw = Buffer.from(b64, 'base64'); + const peerCert = { raw, serialNumber: CERT_SERIAL_HEX }; + + const sendMock = vi.fn().mockReturnValue(undefined); + const headerMock = vi.fn().mockReturnValue({ send: sendMock }); + const statusMock = vi.fn().mockReturnValue({ header: headerMock }); + + const request: Record = { + raw: { + socket: { getPeerCertificate: vi.fn().mockReturnValue(peerCert) }, + }, + }; + + const reply = { status: statusMock }; + + const ctx = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => reply, + }), + } as unknown as ExecutionContext; + + const guard = new FederationAuthGuard(grantsService); + const result = await guard.canActivate(ctx); + + expect(result).toBe(true); + expect(statusMock).not.toHaveBeenCalled(); + + // Verify the context was attached correctly + expect(request['federationContext']).toEqual({ + grantId: GRANT_ID, + subjectUserId: USER_ID, + peerId: PEER_ID, + scope: VALID_SCOPE, + }); + }); +}); diff --git a/apps/gateway/src/federation/server/federation-auth.guard.ts b/apps/gateway/src/federation/server/federation-auth.guard.ts new file mode 100644 index 0000000..feb04fe --- /dev/null +++ b/apps/gateway/src/federation/server/federation-auth.guard.ts @@ -0,0 +1,211 @@ +/** + * 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; + } +} diff --git a/apps/gateway/src/federation/server/federation-context.ts b/apps/gateway/src/federation/server/federation-context.ts new file mode 100644 index 0000000..0dcb56f --- /dev/null +++ b/apps/gateway/src/federation/server/federation-context.ts @@ -0,0 +1,39 @@ +/** + * FederationContext — attached to inbound federation requests after successful + * mTLS + grant validation by FederationAuthGuard. + * + * Downstream verb controllers access this via `request.federationContext`. + */ + +/** + * Augment FastifyRequest so TypeScript knows about the federation context + * property that FederationAuthGuard attaches on success. + */ +declare module 'fastify' { + interface FastifyRequest { + federationContext?: FederationContext; + } +} + +/** + * Typed context object attached to the request by FederationAuthGuard. + * Carries all data extracted from the mTLS cert + grant DB row needed + * by downstream federation verb handlers. + */ +export interface FederationContext { + /** The federation grant ID extracted from OID 1.3.6.1.4.1.99999.1 */ + grantId: string; + + /** The local subject user whose data is accessible under this grant */ + subjectUserId: string; + + /** The peer gateway ID (from the grant's peerId FK) */ + peerId: string; + + /** + * Grant scope — determines which resources the peer may query. + * Typed as Record because the full scope schema lives in + * scope-schema.ts; downstream handlers should narrow via parseFederationScope. + */ + scope: Record; +} diff --git a/apps/gateway/src/federation/server/index.ts b/apps/gateway/src/federation/server/index.ts new file mode 100644 index 0000000..d369e00 --- /dev/null +++ b/apps/gateway/src/federation/server/index.ts @@ -0,0 +1,13 @@ +/** + * Federation server-side barrel — inbound request handling. + * + * Exports the mTLS auth guard and the FederationContext interface + * for use by verb controllers (M3-05/06/07). + * + * Usage: + * import { FederationAuthGuard } from './server/index.js'; + * @UseGuards(FederationAuthGuard) + */ + +export { FederationAuthGuard } from './federation-auth.guard.js'; +export type { FederationContext } from './federation-context.js';