Adds FederationAuthGuard that validates inbound mTLS client certs on federation API routes. Extracts custom OIDs (grantId, subjectUserId), loads the grant+peer from DB in one query, asserts active status, and validates cert serial as defense-in-depth. Attaches FederationContext to requests on success and uses federation wire-format error envelopes (not raw NestJS exceptions) for 401/403 responses. New files: - apps/gateway/src/federation/oid.util.ts — shared OID extraction (no dupe ASN.1 logic) - apps/gateway/src/federation/server/federation-auth.guard.ts — guard impl - apps/gateway/src/federation/server/federation-context.ts — FederationContext type + module augment - apps/gateway/src/federation/server/index.ts — barrel export - apps/gateway/src/federation/server/__tests__/federation-auth.guard.spec.ts — 11 unit tests Modified: - apps/gateway/src/federation/grants.service.ts — adds getGrantWithPeer() with join - apps/gateway/src/federation/federation.module.ts — registers FederationAuthGuard as provider Closes #462 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
212 lines
9.6 KiB
TypeScript
212 lines
9.6 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
const http = context.switchToHttp();
|
|
const request = http.getRequest<FastifyRequest>();
|
|
const reply = http.getResponse<FastifyReply>();
|
|
|
|
// ── 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<tls.TLSSocket>).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<ReturnType<GrantsService['getGrantWithPeer']>>;
|
|
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<string, unknown>,
|
|
};
|
|
|
|
request.federationContext = federationContext;
|
|
|
|
this.logger.debug(
|
|
`Federation auth OK — grantId=${grantId} peerId=${grant.peerId} subjectUserId=${subjectUserId}`,
|
|
);
|
|
|
|
return true;
|
|
}
|
|
}
|