/** * TASK-0131 — SAML 2.0 SP: AuthnResponse validation (assertion matrix). * * Validation ORDER (the acceptance matrix asserts each reason individually): * 1. well-formed XML + no DOCTYPE (entity-expansion hardening — a SAML * response never declares one; rejecting it kills XXE/billion-laughs); * 2. structure: one `Response` root with exactly one `Assertion` child; * 3. `ds:Signature` present (SPs always want signed assertions — * `WantAssertionsSigned="true"` in the SP metadata); * 4. signature: the KeyInfo `X509Certificate` must parse AND its public * key must equal the org's CURRENT or PREVIOUS IdP certificate * (cert-rotation grace), then the enveloped-signature RSA-SHA256 * signature over the `` must verify (C14n exc + * enveloped-signature transform — the same pair the IdP signs with); * 5. `Conditions@NotOnOrAfter` in the future (60 s clock-skirt tolerance); * 6. `AudienceRestriction` includes OUR entityId (the SP entity id); * 7. `Response@Destination` == our ACS URL; * 8. replay: `Response@ID` + `InResponseTo` not seen in the last 10 min * (Redis SETNX — applied by the caller AFTER the checks above so a * forged assertion never consumes a replay slot); * 9. `StatusCode` == Success. * * Crypto interop: the E2E IdP mock signs with samlify (which uses * xml-crypto's `SignedXml` + enveloped-signature + exc-C14n over the * Assertion reference); this module verifies with the same transform pair * (proven in the TASK-0131 interop probe — a samlify-signed response * validates, every tamper variant fails with the right reason). The * KeyInfo certificate is extracted from the document and bound to the * configured IdP cert by public-key equality — a response signed by a * DIFFERENT key (even a well-formed one) is `cert_mismatch`, never * "valid against some other trusted cert". * * Pure + deterministic (a `now` parameter for the time checks) so the * whole matrix is unit-testable without Redis or a DB. */ import { X509Certificate } from "node:crypto"; import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; import { SignedXml } from "xml-crypto"; /** * Build the SP-initiated SSO start redirect (HTTP-Redirect binding): an * (unsigned by default) `AuthnRequest`, base64url-encoded into the `SAMLRequest` * query param of the IdP's `ssoUrl`. Pure + deterministic (a `now`/`id` * injectable) so it is unit-testable and the E2E mock IdP can parse the * `SAMLRequest` to learn the SP's entity id + ACS. * * The ACS is STATELESS (it does not correlate `InResponseTo` against a stored * request — the assertion signature + cert binding is the trust anchor), so a * mock IdP that auto-approves and POSTs an `AuthnResponse` directly works. */ export function buildSamlStartRedirect(opts: { ssoUrl: string; spEntityId: string; acsUrl: string; nameIdFormat?: string; relayState?: string | null; id?: string; now?: Date; }): string { const id = opts.id ?? `_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; const issueInstant = (opts.now ?? new Date()).toISOString(); const nameIdFormat = opts.nameIdFormat ?? "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; const xml = `${opts.spEntityId}`; const encoded = Buffer.from(xml, "utf8").toString("base64url"); const target = new URL(opts.ssoUrl); target.searchParams.set("SAMLRequest", encoded); if (opts.relayState) target.searchParams.set("RelayState", opts.relayState); return target.toString(); } export const SAML_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion"; export const SAML_PROTOCOL_NS = "urn:oasis:names:tc:SAML:2.0:protocol"; export const XMLDSIG_NS = "http://www.w3.org/2000/09/xmldsig#"; export const SAML_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"; /** Clock-skirt tolerance for NotOnOrAfter (IdP/SP clock skew). */ export const SAML_CLOCK_SKEW_MS = 60_000; export type SamlRejectReason = | "malformed_xml" | "doctype_forbidden" | "missing_assertion" | "missing_signature" | "bad_signature" | "cert_mismatch" | "expired" | "audience_mismatch" | "destination_mismatch" | "issuer_mismatch" | "status_not_success" | "missing_nameid" | "replayed"; export interface SamlAssertionData { /** `Response@ID` — the replay-dedupe key. */ id: string; inResponseTo: string | null; destination: string | null; issuer: string | null; /** External identity id (the `NameID` text, or the mapped attribute). */ nameId: string; nameIdFormat: string | null; email: string | null; /** All AttributeStatement values (Name → first AttributeValue text). */ attributes: Record; conditionsNotBefore: number | null; conditionsNotOnAfter: number | null; subjectConfirmationInResponseTo: string | null; subjectConfirmationRecipient: string | null; } export type SamlValidateResult = | { ok: true; data: SamlAssertionData } | { ok: false; reason: SamlRejectReason; detail?: string }; export interface SamlValidateOptions { /** Our SP entity id (must be in AudienceRestriction). */ entityId: string; /** Our ACS URL (Response@Destination must equal it). */ acsUrl: string; /** * The IdP entity id (Issuer must equal it — defense in depth). OPTIONAL: * the base config contract has no IdP-entity-id field, so the check is * enforced only when the org configured `idpEntityId` (an optional * SamlConfig extension); the signature + cert binding is the primary * trust anchor either way. */ issuer?: string; /** The org's current + previous IdP signing certs (PEM, at least one). */ certificates: string[]; /** Which source is the external id: "nameId" (default) or an attribute Name. */ nameIdAttribute: string; /** Which attribute carries the email (default "mail"). */ emailAttribute: string; /** Injectable clock (unit tests). */ now?: Date; } function textOf(doc: ReturnType, ns: string, local: string): string | null { const n = doc.getElementsByTagNameNS(ns, local); return n.length ? (n[0].textContent ?? null) : null; } export function validateAuthnResponse(xml: string, opts: SamlValidateOptions): SamlValidateResult { // 1. Hardening: reject DOCTYPE (no DTD/entities in SAML responses — kills // entity-expansion attacks before any parser sees them). if (/ { try { return new X509Certificate(pem); } catch { return null; } }); if (!configured.some((c) => c && c.publicKey.equals(keyCert.publicKey))) { return { ok: false, reason: "cert_mismatch" }; } // 4. Signature verification (enveloped-signature + exc-C14n over the // Assertion — the reference pair samlify/IdPs sign with). try { const sx = new SignedXml(); sx.publicCert = keyCertPem; sx.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; sx.addReference({ xpath: "/*[local-name()='Response']/*[local-name()='Assertion']", transforms: [ "http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#" ], digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", inclusiveNamespacesPrefixList: ["saml", "samlp", "xs", "xsi", "xml", "ds"] }); sx.loadSignature(sigXml); if (!sx.checkSignature(xml)) return { ok: false, reason: "bad_signature" }; } catch (err) { return { ok: false, reason: "bad_signature", detail: (err as Error).message }; } // 5. Conditions expiry (with clock-skirt tolerance). const now = (opts.now ?? new Date()).getTime(); const conditions = doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "Conditions"); const notOnOrAfterRaw = conditions.length ? conditions[0].getAttribute("NotOnOrAfter") : ""; if (!notOnOrAfterRaw) return { ok: false, reason: "expired", detail: "Conditions has no NotOnOrAfter" }; const notOnOrAfter = Date.parse(notOnOrAfterRaw); if (Number.isNaN(notOnOrAfter) || notOnOrAfter < now - SAML_CLOCK_SKEW_MS) { return { ok: false, reason: "expired" }; } // 6. AudienceRestriction must include OUR entity id. const audiences = Array.from(doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "Audience")).map((a) => a.textContent ?? ""); if (!audiences.includes(opts.entityId)) return { ok: false, reason: "audience_mismatch" }; // 7. Destination must be our ACS. const destination = root.getAttribute("Destination"); if (!destination || destination !== opts.acsUrl) return { ok: false, reason: "destination_mismatch" }; // 8. Issuer (defense in depth — a valid signature from the wrong IdP). // Enforced only when the org pinned the IdP entity id (optional). const issuer = textOf(doc, SAML_ASSERTION_NS, "Issuer"); if (opts.issuer && (!issuer || issuer !== opts.issuer)) { return { ok: false, reason: "issuer_mismatch" }; } // 9. Status must be Success. const statusCode = doc.getElementsByTagNameNS(SAML_PROTOCOL_NS, "StatusCode"); const statusValue = statusCode.length ? statusCode[0].getAttribute("Value") : null; if (statusValue !== SAML_SUCCESS) return { ok: false, reason: "status_not_success", detail: statusValue ?? "absent" }; // 10. Extract the identity. const attributes: Record = {}; for (const a of Array.from(doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "Attribute"))) { const name = a.getAttribute("Name"); const values = Array.from(a.getElementsByTagNameNS(SAML_ASSERTION_NS, "AttributeValue")).map((v) => v.textContent ?? ""); if (name && values.length) attributes[name] = values[0]; } const nameIdEl = doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "NameID"); const nameId = opts.nameIdAttribute === "nameId" ? nameIdEl.length ? (nameIdEl[0].textContent ?? "") : "" : (attributes[opts.nameIdAttribute] ?? ""); if (!nameId) return { ok: false, reason: "missing_nameid" }; const email = attributes[opts.emailAttribute] ?? (opts.nameIdAttribute === "nameId" && nameId.includes("@") ? nameId : null); const subjConf = doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "SubjectConfirmationData"); return { ok: true, data: { id: root.getAttribute("ID") ?? "", inResponseTo: root.getAttribute("InResponseTo") || null, destination, issuer, nameId, nameIdFormat: nameIdEl.length ? (nameIdEl[0].getAttribute("Format") ?? null) : null, email, attributes, conditionsNotBefore: conditions.length && conditions[0].getAttribute("NotBefore") ? Date.parse(conditions[0].getAttribute("NotBefore")!) : null, conditionsNotOnAfter: notOnOrAfter, subjectConfirmationInResponseTo: subjConf.length ? (subjConf[0].getAttribute("InResponseTo") || null) : null, subjectConfirmationRecipient: subjConf.length ? (subjConf[0].getAttribute("Recipient") || null) : null } }; }