1
11import { beforeAll, describe, expect, it } from "vitest";
12import { execFileSync } from "node:child_process";
13import { generateKeyPairSync } from "node:crypto";
14import { writeFileSync } from "node:fs";
15import { SamlLib } from "samlify";
16import { validateAuthnResponse, type SamlValidateOptions } from "../saml.js";
17
18const SP_ENTITY = "http://factory.test/saml/metadata/acme";
19const ACS_URL = "http://factory.test/saml/acs";
20const IDP_ENTITY = "urn:unit:idp";
21
22interface KeyMaterial {
23 keyPem: string;
24 certPem: string;
25 certB64: string;
26 label: string;
27}
28
29async function makeIdp(label: string): Promise<KeyMaterial> {
30 const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
31 const keyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string;
32 const keyPath = `/tmp/sso-test-${label}-key-${Date.now()}.pem`;
33 const certPath = `/tmp/sso-test-${label}-cert-${Date.now()}.pem`;
34 writeFileSync(keyPath, keyPem);
35 execFileSync("openssl", ["req", "-x509", "-new", "-key", keyPath, "-subj", `/CN=${label}`, "-days", "2", "-out", certPath]);
36 const { readFileSync } = await import("node:fs");
37 const pem = readFileSync(certPath, "utf8");
38 const certB64 = pem.replace(/-----[A-Z ]+-----/g, "").replace(/\s+/g, "");
39 return { keyPem, certPem: pem, certB64, label };
40}
41
42const now = new Date();
43const inst = (offsetMs: number) => new Date(now.getTime() + offsetMs).toISOString();
44
45interface BuildArgs {
46 idp?: KeyMaterial;
47 nameId?: string;
48 mail?: string;
49 audience?: string;
50 destination?: string;
51 issuer?: string;
52 notOnOrAfter?: string;
53 statusValue?: string;
54 sign?: boolean;
55}
56
57async function buildAuthnResponse(args: BuildArgs = {}): Promise<string> {
58 const idp = args.idp ?? CURRENT;
59 const nameId = args.nameId ?? "alice@acme.test";
60 const mail = args.mail ?? "alice@acme.test";
61 const audience = args.audience ?? SP_ENTITY;
62 const destination = args.destination ?? ACS_URL;
63 const issuer = args.issuer ?? IDP_ENTITY;
64 const notOnOrAfter = args.notOnOrAfter ?? inst(10 * 60_000);
65 const statusValue = args.statusValue ?? "urn:oasis:names:tc:SAML:2.0:status:Success";
66
67 const xml = `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp_1" InResponseTo="_req_1" Version="2.0" IssueInstant="${inst(-60_000)}" Destination="${destination}"><saml:Issuer>${issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="${statusValue}"/></samlp:Status><saml:Assertion ID="_assert_1" Version="2.0" IssueInstant="${inst(-60_000)}"><saml:Issuer>${issuer}</saml:Issuer><saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">${nameId}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="${notOnOrAfter}" Recipient="${destination}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="${inst(-60_000)}" NotOnOrAfter="${notOnOrAfter}"><saml:AudienceRestriction><saml:Audience>${audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant="${inst(-60_000)}" SessionIndex="_sess_1"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement><saml:AttributeStatement><saml:Attribute Name="mail"><saml:AttributeValue Format="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">${mail}</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion></samlp:Response>`;
68
69 if (args.sign === false) return xml;
70
71
72
73
74 const signed = await (SamlLib as unknown as {
75 constructSAMLSignature(opts: {
76 rawSamlMessage: string;
77 privateKey: string;
78 signingCert: string;
79 referenceTagXPath: string;
80 isBase64Output: boolean;
81 }): Promise<string>;
82 }).constructSAMLSignature({
83 rawSamlMessage: xml,
84 privateKey: idp.keyPem,
85 signingCert: idp.certB64,
86 referenceTagXPath: "/*[local-name()='Response']/*[local-name()='Assertion']",
87 isBase64Output: false
88 });
89 return String(signed);
90}
91
92let CURRENT: KeyMaterial;
93let WRONG: KeyMaterial;
94let opts: SamlValidateOptions;
95
96beforeAll(async () => {
97 CURRENT = await makeIdp("current-idp");
98 WRONG = await makeIdp("wrong-idp");
99 opts = {
100 entityId: SP_ENTITY,
101 acsUrl: ACS_URL,
102 issuer: IDP_ENTITY,
103 certificates: [CURRENT.certPem],
104 nameIdAttribute: "nameId",
105 emailAttribute: "mail",
106 now: new Date()
107 };
108});
109
110describe("TASK-0131 SAML AuthnResponse validation matrix", () => {
111 it("accepts a well-formed signed response (happy path)", async () => {
112 const res = validateAuthnResponse(await buildAuthnResponse(), opts);
113 expect(res.ok).toBe(true);
114 if (res.ok) {
115 expect(res.data.nameId).toBe("alice@acme.test");
116 expect(res.data.email).toBe("alice@acme.test");
117 expect(res.data.issuer).toBe(IDP_ENTITY);
118 expect(res.data.id).toBe("_resp_1");
119 }
120 });
121
122 it("rejects a DOCTYPE (entity-expansion hardening)", async () => {
123 const xml = `<!DOCTYPE foo [<!ENTITY x "y">]>` + (await buildAuthnResponse());
124 expect(validateAuthnResponse(xml, opts)).toEqual({ ok: false, reason: "doctype_forbidden" });
125 });
126
127 it("rejects malformed XML", () => {
128 expect(validateAuthnResponse("<samlp:Response", opts)).toMatchObject({ ok: false, reason: "malformed_xml" });
129 });
130
131 it("rejects an unsigned response", async () => {
132 const xml = await buildAuthnResponse({ sign: false });
133 expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "missing_signature" });
134 });
135
136 it("rejects a tampered NameID (bad signature)", async () => {
137 const good = await buildAuthnResponse({ nameId: "alice@acme.test" });
138 const tampered = good.replace("alice@acme.test", "mallory@acme.test");
139 expect(validateAuthnResponse(tampered, opts).ok).toBe(false);
140 });
141
142 it("rejects a response addressed to another SP (audience_mismatch)", async () => {
143
144
145 const xml = await buildAuthnResponse({ audience: "http://evil.test/saml/metadata/other" });
146 expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "audience_mismatch" });
147 });
148
149 it("rejects a wrong Destination", async () => {
150 const xml = await buildAuthnResponse({ destination: "http://evil.test/acs" });
151 expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "destination_mismatch" });
152 });
153
154 it("rejects an expired assertion", async () => {
155 const xml = await buildAuthnResponse({ notOnOrAfter: inst(-120_000) });
156 expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "expired" });
157 });
158
159 it("accepts an assertion within the 60s clock-skirt tolerance", async () => {
160 const xml = await buildAuthnResponse({ notOnOrAfter: inst(-30_000) });
161 expect(validateAuthnResponse(xml, opts).ok).toBe(true);
162 });
163
164 it("rejects a non-Success status", async () => {
165 const xml = await buildAuthnResponse({ statusValue: "urn:oasis:names:tc:SAML:2.0:status:Requester" });
166 expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "status_not_success" });
167 });
168
169 it("rejects a response signed by the WRONG IdP key (cert_mismatch)", async () => {
170 const xml = await buildAuthnResponse({ idp: WRONG });
171 expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "cert_mismatch" });
172 });
173
174 it("accepts a response signed by the PREVIOUS cert (rotation grace)", async () => {
175
176
177 const xml = await buildAuthnResponse({ idp: WRONG });
178 const rotated: SamlValidateOptions = { ...opts, certificates: [CURRENT.certPem, WRONG.certPem] };
179 expect(validateAuthnResponse(xml, rotated).ok).toBe(true);
180 });
181
182 it("rejects when the configured cert set excludes the signer", async () => {
183
184
185 const xml = await buildAuthnResponse({ idp: WRONG });
186 expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "cert_mismatch" });
187 });
188
189 it("rejects a missing NameID", async () => {
190 const xml = (await buildAuthnResponse({ nameId: "alice@acme.test" })).replace(
191 "<saml:NameID Format=\"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress\">alice@acme.test</saml:NameID>",
192 ""
193 );
194
195
196 expect(validateAuthnResponse(xml, opts).ok).toBe(false);
197 });
198
199 it("rejects when the Issuer differs from the pinned IdP entity id", async () => {
200
201 const xml = await buildAuthnResponse({ issuer: "urn:other:idp" });
202 expect(validateAuthnResponse(xml, opts)).toMatchObject({ ok: false, reason: "issuer_mismatch" });
203 });
204
205 it("extracts the email from the configured attribute (not nameId)", async () => {
206 const xml = await buildAuthnResponse({ nameId: "alice-idp-id", mail: "alice@acme.test" });
207 const res = validateAuthnResponse(xml, opts);
208 expect(res.ok).toBe(true);
209 if (res.ok) expect(res.data.email).toBe("alice@acme.test");
210 });
211});
212