/** * TASK-0131 — SAML AuthnResponse validation matrix (unit). * * A per-run self-signed IdP keypair/cert (openssl, 2048-bit RSA) signs each * AuthnResponse via samlify's `SamlLib.constructSAMLSignature` (RS256, * enveloped-signature over the Assertion, exc-C14N — the exact transform * pair production IdPs use; the E2E mock IdP reuses the same recipe). The * matrix asserts `validateAuthnResponse` accepts the good doc and rejects * every tamper variant with its DISTINCT reason (acceptance matrix). */ import { beforeAll, describe, expect, it } from "vitest"; import { execFileSync } from "node:child_process"; import { generateKeyPairSync } from "node:crypto"; import { writeFileSync } from "node:fs"; import { SamlLib } from "samlify"; import { validateAuthnResponse, type SamlValidateOptions } from "../saml.js"; const SP_ENTITY = "http://factory.test/saml/metadata/acme"; const ACS_URL = "http://factory.test/saml/acs"; const IDP_ENTITY = "urn:unit:idp"; interface KeyMaterial { keyPem: string; certPem: string; certB64: string; label: string; } async function makeIdp(label: string): Promise { const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const keyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; const keyPath = `/tmp/sso-test-${label}-key-${Date.now()}.pem`; const certPath = `/tmp/sso-test-${label}-cert-${Date.now()}.pem`; writeFileSync(keyPath, keyPem); execFileSync("openssl", ["req", "-x509", "-new", "-key", keyPath, "-subj", `/CN=${label}`, "-days", "2", "-out", certPath]); const { readFileSync } = await import("node:fs"); const pem = readFileSync(certPath, "utf8"); const certB64 = pem.replace(/-----[A-Z ]+-----/g, "").replace(/\s+/g, ""); return { keyPem, certPem: pem, certB64, label }; } const now = new Date(); const inst = (offsetMs: number) => new Date(now.getTime() + offsetMs).toISOString(); interface BuildArgs { idp?: KeyMaterial; nameId?: string; mail?: string; audience?: string; destination?: string; issuer?: string; notOnOrAfter?: string; statusValue?: string; sign?: boolean; } async function buildAuthnResponse(args: BuildArgs = {}): Promise { const idp = args.idp ?? CURRENT; const nameId = args.nameId ?? "alice@acme.test"; const mail = args.mail ?? "alice@acme.test"; const audience = args.audience ?? SP_ENTITY; const destination = args.destination ?? ACS_URL; const issuer = args.issuer ?? IDP_ENTITY; const notOnOrAfter = args.notOnOrAfter ?? inst(10 * 60_000); const statusValue = args.statusValue ?? "urn:oasis:names:tc:SAML:2.0:status:Success"; const xml = `${issuer}${issuer}${nameId}${audience}urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified${mail}`; if (args.sign === false) return xml; // samlify's `constructSAMLSignature` runtime API takes a plain options // object (rawSamlMessage / privateKey / signingCert / …); its `.d.ts` // mis-types the parameter as `SignatureConstructor` (a separate // signature-algorithm constructor). Cast to the real runtime shape. const signed = await (SamlLib as unknown as { constructSAMLSignature(opts: { rawSamlMessage: string; privateKey: string; signingCert: string; referenceTagXPath: string; isBase64Output: boolean; }): Promise; }).constructSAMLSignature({ rawSamlMessage: xml, privateKey: idp.keyPem, signingCert: idp.certB64, referenceTagXPath: "/*[local-name()='Response']/*[local-name()='Assertion']", isBase64Output: false }); return String(signed); } let CURRENT: KeyMaterial; let WRONG: KeyMaterial; let opts: SamlValidateOptions; beforeAll(async () => { CURRENT = await makeIdp("current-idp"); WRONG = await makeIdp("wrong-idp"); opts = { entityId: SP_ENTITY, acsUrl: ACS_URL, issuer: IDP_ENTITY, certificates: [CURRENT.certPem], nameIdAttribute: "nameId", emailAttribute: "mail", now: new Date() }; }); describe("TASK-0131 SAML AuthnResponse validation matrix", () => { it("accepts a well-formed signed response (happy path)", async () => { const res = validateAuthnResponse(await buildAuthnResponse(), opts); expect(res.ok).toBe(true); if (res.ok) { expect(res.data.nameId).toBe("alice@acme.test"); expect(res.data.email).toBe("alice@acme.test"); expect(res.data.issuer).toBe(IDP_ENTITY); expect(res.data.id).toBe("_resp_1"); } }); it("rejects a DOCTYPE (entity-expansion hardening)", async () => { const xml = `]>` + (await buildAuthnResponse()); expect(validateAuthnResponse(xml, opts)).toEqual({ ok: false, reason: "doctype_forbidden" }); }); it("rejects malformed XML", () => { expect(validateAuthnResponse(" { const xml = await buildAuthnResponse({ sign: false }); expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "missing_signature" }); }); it("rejects a tampered NameID (bad signature)", async () => { const good = await buildAuthnResponse({ nameId: "alice@acme.test" }); const tampered = good.replace("alice@acme.test", "mallory@acme.test"); expect(validateAuthnResponse(tampered, opts).ok).toBe(false); }); it("rejects a response addressed to another SP (audience_mismatch)", async () => { // Signed by the right key, but the Audience is a different SP — the // signature verifies; the Audience check must reject with the reason. const xml = await buildAuthnResponse({ audience: "http://evil.test/saml/metadata/other" }); expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "audience_mismatch" }); }); it("rejects a wrong Destination", async () => { const xml = await buildAuthnResponse({ destination: "http://evil.test/acs" }); expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "destination_mismatch" }); }); it("rejects an expired assertion", async () => { const xml = await buildAuthnResponse({ notOnOrAfter: inst(-120_000) }); expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "expired" }); }); it("accepts an assertion within the 60s clock-skirt tolerance", async () => { const xml = await buildAuthnResponse({ notOnOrAfter: inst(-30_000) }); expect(validateAuthnResponse(xml, opts).ok).toBe(true); }); it("rejects a non-Success status", async () => { const xml = await buildAuthnResponse({ statusValue: "urn:oasis:names:tc:SAML:2.0:status:Requester" }); expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "status_not_success" }); }); it("rejects a response signed by the WRONG IdP key (cert_mismatch)", async () => { const xml = await buildAuthnResponse({ idp: WRONG }); expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "cert_mismatch" }); }); it("accepts a response signed by the PREVIOUS cert (rotation grace)", async () => { // The doc is signed by `WRONG` (the previous IdP key), but the org has // BOTH current + previous configured → the rotation grace applies. const xml = await buildAuthnResponse({ idp: WRONG }); const rotated: SamlValidateOptions = { ...opts, certificates: [CURRENT.certPem, WRONG.certPem] }; expect(validateAuthnResponse(xml, rotated).ok).toBe(true); }); it("rejects when the configured cert set excludes the signer", async () => { // Only the current cert configured; the doc is signed by the previous // key that is NOT in the set → no grace. const xml = await buildAuthnResponse({ idp: WRONG }); expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "cert_mismatch" }); }); it("rejects a missing NameID", async () => { const xml = (await buildAuthnResponse({ nameId: "alice@acme.test" })).replace( "alice@acme.test", "" ); // Removing the NameID invalidates the signature; the check must fail // (bad_signature is fine — the point is no successful extract). expect(validateAuthnResponse(xml, opts).ok).toBe(false); }); it("rejects when the Issuer differs from the pinned IdP entity id", async () => { // Same key signs it, but the Issuer element names another IdP. const xml = await buildAuthnResponse({ issuer: "urn:other:idp" }); expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "issuer_mismatch" }); }); it("extracts the email from the configured attribute (not nameId)", async () => { const xml = await buildAuthnResponse({ nameId: "alice-idp-id", mail: "alice@acme.test" }); const res = validateAuthnResponse(xml, opts); expect(res.ok).toBe(true); if (res.ok) expect(res.data.email).toBe("alice@acme.test"); }); });