Compare commits
3 Commits
feat/feder
...
feat/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71c7b85026 | ||
|
|
53ee36239b | ||
| 89c733e0b9 |
@@ -5,10 +5,11 @@ import { EnrollmentController } from './enrollment.controller.js';
|
|||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
|
import { FederationAuthGuard } from './server/index.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController],
|
controllers: [EnrollmentController, FederationController],
|
||||||
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
providers: [AdminGuard, CaService, EnrollmentService, GrantsService, FederationAuthGuard],
|
||||||
exports: [CaService, EnrollmentService, GrantsService],
|
exports: [CaService, EnrollmentService, GrantsService, FederationAuthGuard],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
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 { DB } from '../database/database.module.js';
|
||||||
import { parseFederationScope } from './scope-schema.js';
|
import { parseFederationScope } from './scope-schema.js';
|
||||||
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||||
|
|
||||||
export type Grant = typeof federationGrants.$inferSelect;
|
export type Grant = typeof federationGrants.$inferSelect;
|
||||||
|
export type Peer = typeof federationPeers.$inferSelect;
|
||||||
|
export type GrantWithPeer = Grant & { peer: Peer };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GrantsService {
|
export class GrantsService {
|
||||||
@@ -60,6 +62,33 @@ export class GrantsService {
|
|||||||
return grant;
|
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<GrantWithPeer> {
|
||||||
|
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.
|
* List grants with optional filters for peerId, subjectUserId, and status.
|
||||||
*/
|
*/
|
||||||
|
|||||||
146
apps/gateway/src/federation/oid.util.ts
Normal file
146
apps/gateway/src/federation/oid.util.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
* Validates tag, length byte, and buffer bounds before decoding.
|
||||||
|
* Throws a descriptive Error on malformed input; caller wraps in try/catch.
|
||||||
|
*/
|
||||||
|
function decodeUtf8StringTlv(value: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(value);
|
||||||
|
|
||||||
|
// Need at least tag + length bytes
|
||||||
|
if (bytes.length < 2) {
|
||||||
|
throw new Error(`UTF8String TLV too short: expected at least 2 bytes, got ${bytes.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag byte must be 0x0C (ASN.1 UTF8String)
|
||||||
|
if (bytes[0] !== 0x0c) {
|
||||||
|
throw new Error(
|
||||||
|
`UTF8String TLV tag mismatch: expected 0x0C, got 0x${bytes[0]!.toString(16).toUpperCase()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only single-byte length form is supported (values 0–127); long form not needed
|
||||||
|
// for OID strings of this length.
|
||||||
|
const declaredLength = bytes[1]!;
|
||||||
|
if (declaredLength > 127) {
|
||||||
|
throw new Error(
|
||||||
|
`UTF8String TLV uses long-form length (0x${declaredLength.toString(16).toUpperCase()}), which is not supported`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared length must match actual remaining bytes
|
||||||
|
if (declaredLength !== bytes.length - 2) {
|
||||||
|
throw new Error(
|
||||||
|
`UTF8String TLV length mismatch: declared ${declaredLength}, actual ${bytes.length - 2}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: <code>, 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
/**
|
||||||
|
* 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> = {}): 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<typeof vi.fn>;
|
||||||
|
sendMock: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const { certPem, certSerialHex = CERT_SERIAL_HEX, hasTlsSocket = true } = opts;
|
||||||
|
|
||||||
|
// Build peerCert object that Node.js TLS socket.getPeerCertificate() returns
|
||||||
|
let peerCert: Record<string, unknown>;
|
||||||
|
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<Pick<GrantsService, 'getGrantWithPeer'>> = {},
|
||||||
|
): 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, sendMock } = makeContext({
|
||||||
|
certPem: certPem,
|
||||||
|
hasTlsSocket: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 401: Cert not presented ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 401 when the peer did not present a certificate', async () => {
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem: null });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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, sendMock } = makeContext({ certPem: corruptPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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, sendMock } = makeContext({ certPem: plainCert });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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<typeof cryptoProvider.set>[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, sendMock } = makeContext({ certPem: cert.toString('pem') });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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, sendMock } = makeContext({ certPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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, sendMock } = makeContext({ certPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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, sendMock } = makeContext({ certPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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, sendMock } = makeContext({ certPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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, sendMock } = makeContext({ certPem, certSerialHex: '01' });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 403: subjectUserId cert/DB mismatch (CRIT-1 regression test) ─────────
|
||||||
|
|
||||||
|
it('returns 403 when the cert subjectUserId does not match the DB grant subjectUserId', async () => {
|
||||||
|
// Build a cert that claims an attacker's subjectUserId
|
||||||
|
const attackerSubjectUserId = 'attacker-user-id';
|
||||||
|
const attackerCertPem = await makeMosaicIssuedCert({
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
subjectUserId: attackerSubjectUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// DB returns a grant with the legitimate USER_ID
|
||||||
|
const grantsService = makeGrantsService({
|
||||||
|
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ subjectUserId: USER_ID })),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cert presents attacker-user-id but DB has USER_ID — should be rejected
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({
|
||||||
|
certPem: attackerCertPem,
|
||||||
|
certSerialHex: CERT_SERIAL_HEX,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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<string, unknown> = {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
212
apps/gateway/src/federation/server/federation-auth.guard.ts
Normal file
212
apps/gateway/src/federation/server/federation-auth.guard.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
Inject,
|
||||||
|
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(@Inject(GrantsService) 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('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5b: Validate cert-extracted subjectUserId against DB (CRIT-1) ──
|
||||||
|
// The cert claim is untrusted input; the DB row is authoritative.
|
||||||
|
if (subjectUserId !== grant.subjectUserId) {
|
||||||
|
this.logger.warn(`subjectUserId mismatch for grant ${grantId}`);
|
||||||
|
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 7: Attach FederationContext to request ──────────────────────────
|
||||||
|
// Use grant.subjectUserId from DB (authoritative) — not the cert-extracted value.
|
||||||
|
const federationContext: FederationContext = {
|
||||||
|
grantId,
|
||||||
|
subjectUserId: grant.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=${grant.subjectUserId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/gateway/src/federation/server/federation-context.ts
Normal file
39
apps/gateway/src/federation/server/federation-context.ts
Normal file
@@ -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<string, unknown> because the full scope schema lives in
|
||||||
|
* scope-schema.ts; downstream handlers should narrow via parseFederationScope.
|
||||||
|
*/
|
||||||
|
scope: Record<string, unknown>;
|
||||||
|
}
|
||||||
13
apps/gateway/src/federation/server/index.ts
Normal file
13
apps/gateway/src/federation/server/index.ts
Normal file
@@ -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';
|
||||||
Reference in New Issue
Block a user