- CRIT-1: Validate cert subjectUserId against grant.subjectUserId from DB; use authoritative DB value in FederationContext - CRIT-2: Add @Inject(GrantsService) decorator (tsx/esbuild requirement) - HIGH-1: Validate UTF8String TLV tag, length, and bounds in OID parser - HIGH-2: Collapse all 403 wire messages to a generic string to prevent grant enumeration; keep internal logger detail - HIGH-3: Assert federation wire envelope shape in all guard tests - HIGH-4: Regression test for subjectUserId cert/DB mismatch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
522 lines
19 KiB
TypeScript
522 lines
19 KiB
TypeScript
/**
|
|
* 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,
|
|
});
|
|
});
|
|
});
|