/** * TASK-0131 — mock SAML 2.0 IdP for the E2E SSO round trip. * * A per-run self-signed IdP (2048-bit RSA, openssl) serves: * GET /authorize — the SP-initiated entry. The ACS is STATELESS (the SP * never correlates InResponseTo beyond the replay key), so the mock * does NOT parse the SAMLRequest: it immediately builds + signs an * AuthnResponse for the configured user and form-POSTs it to the ACS. * (A real IdP shows its login form here; auto-approve is the * DoD's "IdP auto-approves and POSTs back".) * GET /metadata — an IdP metadata document (entityID + HTTP-Redirect SSO * service + the signing cert) so the admin test-connection is green. * * The ACS URL + SP entity id (Audience) are passed EXPLICITLY by the test. * That makes the mock deterministic and lets a test build the wrong-origin * variant (web origin instead of API origin) to exercise the `SSO_ORIGIN` * two-origin regression. The AuthnResponse is signed with samlify's * `SamlLib.constructSAMLSignature` (RS256 enveloped over the Assertion, * exc-C14N) — the SAME recipe the api unit tests use, so the mock exercises * the exact production validation path. */ import { execFileSync } from "node:child_process"; import { generateKeyPairSync } from "node:crypto"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AddressInfo } from "node:net"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { SamlLib } from "samlify"; export interface SamlIdpUser { /** The NameID text (the SSO external identity id). */ nameId: string; /** The `mail` attribute value (the provisioning email). */ email?: string; /** The `displayName` attribute value. */ displayName?: string; } export interface SamlIdpOptions { /** The SP ACS URL (where the IdP POSTs the response). */ acsUrl: string; /** The SP entity id (the Audience). */ spEntityId: string; /** The user the IdP auto-approves as. */ user: SamlIdpUser; /** The IdP Issuer to embed (defaults to a random `urn:mock-idp:*`). */ issuer?: string; /** Response expiry offset from now (default +10 min). */ notOnOrAfterOffsetMs?: number; /** Issue a non-Success Status (tamper: status_not_success). */ statusValue?: string; /** Skip signing (tamper: missing_signature). */ sign?: boolean; /** Sign (AND present in KeyInfo) a DIFFERENT keypair → `cert_mismatch`. */ rogueKey?: boolean; /** Override the NameID (tamper). */ nameIdOverride?: string; /** Override the mail attribute (tamper). */ emailOverride?: string; /** Fixed Response ID (tamper: replay). */ responseId?: string; } interface IdpMaterial { keyPem: string; certPem: string; certB64: string; } /** Generate a self-signed IdP keypair/cert (per-run, 2048-bit). */ export function makeIdpMaterial(label: string): IdpMaterial { const dir = mkdtempSync(join(tmpdir(), `e2e-idp-${label}-`)); const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const keyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; const keyPath = join(dir, "key.pem"); const certPath = join(dir, "cert.pem"); writeFileSync(keyPath, keyPem); execFileSync("openssl", [ "req", "-x509", "-new", "-key", keyPath, "-subj", `/CN=e2e-mock-idp-${label}`, "-days", "2", "-out", certPath ]); const certPem = readFileSync(certPath, "utf8"); const certB64 = certPem.replace(/-----[A-Z ]+-----/g, "").replace(/\s+/g, ""); return { keyPem, certPem, certB64 }; } const inst = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString(); const xml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); async function signXml(xmlDoc: string, material: IdpMaterial): Promise { // samlify's `constructSAMLSignature` runtime API takes a plain options // object; its `.d.ts` mis-types the parameter (a signature-algorithm // constructor) — cast to the real runtime shape (same as the api unit tests). return String( await ( SamlLib as unknown as { constructSAMLSignature(opts: { rawSamlMessage: string; privateKey: string; signingCert: string; referenceTagXPath: string; isBase64Output: boolean; }): Promise; } ).constructSAMLSignature({ rawSamlMessage: xmlDoc, privateKey: material.keyPem, signingCert: material.certB64, referenceTagXPath: "/*[local-name()='Response']/*[local-name()='Assertion']", isBase64Output: false }) ); } export interface MockSamlIdp { url: string; /** The IdP signing cert PEM — paste into the SP provider config. */ certPem: string; /** The IdP metadata URL (the admin test-connection target). */ metadataUrl: string; /** The IdP entity id (the Assertion Issuer). */ issuer: string; /** The ACS URL the IdP POSTs to. */ acsUrl: string; /** The SP entity id (Audience) the IdP signs for. */ spEntityId: string; /** The user the IdP auto-approves. */ user: SamlIdpUser; /** Build a signed AuthnResponse for the current user (for direct POSTs). */ buildAuthnResponse(over?: { nameId?: string; email?: string; notOnOrAfterOffsetMs?: number; }): Promise; /** How many AuthnResponses were emitted (replay assertions). */ responsesEmitted: () => number; close(): Promise; } let SEQ = 0; /** Start the mock IdP. Resolves once it is listening. */ export async function startMockSamlIdp(opts: SamlIdpOptions): Promise { const material = makeIdpMaterial("main"); const signingMaterial = opts.rogueKey ? makeIdpMaterial("rogue") : material; const issuer = opts.issuer ?? `urn:mock-idp:e2e-${Math.random().toString(36).slice(2, 8)}`; let emitted = 0; const build = async (over?: { nameId?: string; email?: string; notOnOrAfterOffsetMs?: number; }): Promise => { SEQ += 1; const nameId = over?.nameId ?? opts.nameIdOverride ?? opts.user.nameId; const email = over?.email ?? opts.emailOverride ?? opts.user.email ?? opts.user.nameId; const displayName = opts.user.displayName; const notOnOrAfter = inst(over?.notOnOrAfterOffsetMs ?? opts.notOnOrAfterOffsetMs ?? 10 * 60_000); const statusValue = opts.statusValue ?? "urn:oasis:names:tc:SAML:2.0:status:Success"; const respId = opts.responseId ?? `_${SEQ}r${Date.now().toString(36)}`; const assertId = `_${SEQ}a${Date.now().toString(36)}`; const doc = `${issuer}${issuer}${xml(nameId)}${opts.spEntityId}urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified${xml(email)}${ displayName ? `${xml(displayName)}` : "" }`; if (opts.sign === false) return doc; return await signXml(doc, signingMaterial); }; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const url = new URL(req.url ?? "/", "http://localhost"); if (req.method === "GET" && url.pathname === "/metadata") { res.writeHead(200, { "content-type": "application/xml" }); res.end( `\n` + `` + `` + `${material.certB64}` + `` + `` ); return; } if (req.method === "GET" && (url.pathname === "/authorize" || url.pathname === "/sso")) { const signed = await build(); emitted += 1; const b64 = Buffer.from(signed, "utf8").toString("base64"); const relay = url.searchParams.get("RelayState") ?? "/"; res.writeHead(200, { "content-type": "text/html" }); res.end( `Mock IdP` + `
` + `` + `` + `
` ); return; } res.writeHead(404, { "content-type": "text/plain" }); res.end("not found"); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address() as AddressInfo; const serverUrl = `http://127.0.0.1:${port}`; return { url: serverUrl, certPem: material.certPem, metadataUrl: `${serverUrl}/metadata`, issuer, acsUrl: opts.acsUrl, spEntityId: opts.spEntityId, user: opts.user, buildAuthnResponse: build, responsesEmitted: () => emitted, close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))) }; }