1
36import { X509Certificate } from "node:crypto";
37import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
38import { SignedXml } from "xml-crypto";
39
40
51export function buildSamlStartRedirect(opts: {
52 ssoUrl: string;
53 spEntityId: string;
54 acsUrl: string;
55 nameIdFormat?: string;
56 relayState?: string | null;
57 id?: string;
58 now?: Date;
59}): string {
60 const id = opts.id ?? `_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
61 const issueInstant = (opts.now ?? new Date()).toISOString();
62 const nameIdFormat =
63 opts.nameIdFormat ?? "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
64 const xml = `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${id}" Version="2.0" IssueInstant="${issueInstant}" Destination="${opts.ssoUrl}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"><saml:Issuer>${opts.spEntityId}</saml:Issuer><samlp:NameIDPolicy Format="${nameIdFormat}" AllowCreate="true"/><samlp:RequestedAuthnContext><saml:AuthnContext ClassRef="urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified"/></samlp:RequestedAuthnContext></samlp:AuthnRequest>`;
65 const encoded = Buffer.from(xml, "utf8").toString("base64url");
66 const target = new URL(opts.ssoUrl);
67 target.searchParams.set("SAMLRequest", encoded);
68 if (opts.relayState) target.searchParams.set("RelayState", opts.relayState);
69 return target.toString();
70}
71
72export const SAML_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion";
73export const SAML_PROTOCOL_NS = "urn:oasis:names:tc:SAML:2.0:protocol";
74export const XMLDSIG_NS = "http://www.w3.org/2000/09/xmldsig#";
75export const SAML_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
76
77export const SAML_CLOCK_SKEW_MS = 60_000;
78
79export type SamlRejectReason =
80 | "malformed_xml"
81 | "doctype_forbidden"
82 | "missing_assertion"
83 | "missing_signature"
84 | "bad_signature"
85 | "cert_mismatch"
86 | "expired"
87 | "audience_mismatch"
88 | "destination_mismatch"
89 | "issuer_mismatch"
90 | "status_not_success"
91 | "missing_nameid"
92 | "replayed";
93
94export interface SamlAssertionData {
95
96 id: string;
97 inResponseTo: string | null;
98 destination: string | null;
99 issuer: string | null;
100
101 nameId: string;
102 nameIdFormat: string | null;
103 email: string | null;
104
105 attributes: Record<string, string>;
106 conditionsNotBefore: number | null;
107 conditionsNotOnAfter: number | null;
108 subjectConfirmationInResponseTo: string | null;
109 subjectConfirmationRecipient: string | null;
110}
111
112export type SamlValidateResult =
113 | { ok: true; data: SamlAssertionData }
114 | { ok: false; reason: SamlRejectReason; detail?: string };
115
116export interface SamlValidateOptions {
117
118 entityId: string;
119
120 acsUrl: string;
121
128 issuer?: string;
129
130 certificates: string[];
131
132 nameIdAttribute: string;
133
134 emailAttribute: string;
135
136 now?: Date;
137}
138
139function textOf(doc: ReturnType<typeof DOMParser.prototype.parseFromString>, ns: string, local: string): string | null {
140 const n = doc.getElementsByTagNameNS(ns, local);
141 return n.length ? (n[0].textContent ?? null) : null;
142}
143
144export function validateAuthnResponse(xml: string, opts: SamlValidateOptions): SamlValidateResult {
145
146
147 if (/<!\s*DOCTYPE/i.test(xml)) return { ok: false, reason: "doctype_forbidden" };
148 let doc;
149 try {
150 doc = new DOMParser().parseFromString(xml, "text/xml");
151 } catch {
152 return { ok: false, reason: "malformed_xml" };
153 }
154 const parserError = doc.getElementsByTagName("parsererror");
155 if (parserError.length) return { ok: false, reason: "malformed_xml" };
156
157 const root = doc.documentElement;
158 if (!root || root.localName !== "Response" || root.namespaceURI !== SAML_PROTOCOL_NS) {
159 return { ok: false, reason: "malformed_xml", detail: "root is not samlp:Response" };
160 }
161 const assertions = doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "Assertion");
162 if (assertions.length !== 1) return { ok: false, reason: "missing_assertion" };
163
164
165 const signatureEls = doc.getElementsByTagNameNS(XMLDSIG_NS, "Signature");
166 if (!signatureEls.length) return { ok: false, reason: "missing_signature" };
167 const sigXml = new XMLSerializer().serializeToString(signatureEls[0]);
168
169
170 const keyCertEls = doc.getElementsByTagNameNS(XMLDSIG_NS, "X509Certificate");
171 if (!keyCertEls.length) return { ok: false, reason: "bad_signature", detail: "no X509Certificate in KeyInfo" };
172 const keyCertB64 = (keyCertEls[0].textContent ?? "").replace(/\s+/g, "");
173 if (!keyCertB64) return { ok: false, reason: "bad_signature", detail: "empty X509Certificate" };
174 const keyCertPem = `-----BEGIN CERTIFICATE-----\n${keyCertB64.match(/.{1,64}/g)?.join("\n") ?? ""}\n-----END CERTIFICATE-----`;
175 let keyCert: X509Certificate;
176 try {
177 keyCert = new X509Certificate(keyCertPem);
178 } catch {
179 return { ok: false, reason: "cert_mismatch", detail: "KeyInfo certificate does not parse" };
180 }
181
182
183
184 const configured = opts.certificates.map((pem) => {
185 try {
186 return new X509Certificate(pem);
187 } catch {
188 return null;
189 }
190 });
191 if (!configured.some((c) => c && c.publicKey.equals(keyCert.publicKey))) {
192 return { ok: false, reason: "cert_mismatch" };
193 }
194
195
196
197 try {
198 const sx = new SignedXml();
199 sx.publicCert = keyCertPem;
200 sx.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
201 sx.addReference({
202 xpath: "/*[local-name()='Response']/*[local-name()='Assertion']",
203 transforms: [
204 "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
205 "http://www.w3.org/2001/10/xml-exc-c14n#"
206 ],
207 digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256",
208 inclusiveNamespacesPrefixList: ["saml", "samlp", "xs", "xsi", "xml", "ds"]
209 });
210 sx.loadSignature(sigXml);
211 if (!sx.checkSignature(xml)) return { ok: false, reason: "bad_signature" };
212 } catch (err) {
213 return { ok: false, reason: "bad_signature", detail: (err as Error).message };
214 }
215
216
217 const now = (opts.now ?? new Date()).getTime();
218 const conditions = doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "Conditions");
219 const notOnOrAfterRaw = conditions.length ? conditions[0].getAttribute("NotOnOrAfter") : "";
220 if (!notOnOrAfterRaw) return { ok: false, reason: "expired", detail: "Conditions has no NotOnOrAfter" };
221 const notOnOrAfter = Date.parse(notOnOrAfterRaw);
222 if (Number.isNaN(notOnOrAfter) || notOnOrAfter < now - SAML_CLOCK_SKEW_MS) {
223 return { ok: false, reason: "expired" };
224 }
225
226
227 const audiences = Array.from(doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "Audience")).map((a) => a.textContent ?? "");
228 if (!audiences.includes(opts.entityId)) return { ok: false, reason: "audience_mismatch" };
229
230
231 const destination = root.getAttribute("Destination");
232 if (!destination || destination !== opts.acsUrl) return { ok: false, reason: "destination_mismatch" };
233
234
235
236 const issuer = textOf(doc, SAML_ASSERTION_NS, "Issuer");
237 if (opts.issuer && (!issuer || issuer !== opts.issuer)) {
238 return { ok: false, reason: "issuer_mismatch" };
239 }
240
241
242 const statusCode = doc.getElementsByTagNameNS(SAML_PROTOCOL_NS, "StatusCode");
243 const statusValue = statusCode.length ? statusCode[0].getAttribute("Value") : null;
244 if (statusValue !== SAML_SUCCESS) return { ok: false, reason: "status_not_success", detail: statusValue ?? "absent" };
245
246
247 const attributes: Record<string, string> = {};
248 for (const a of Array.from(doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "Attribute"))) {
249 const name = a.getAttribute("Name");
250 const values = Array.from(a.getElementsByTagNameNS(SAML_ASSERTION_NS, "AttributeValue")).map((v) => v.textContent ?? "");
251 if (name && values.length) attributes[name] = values[0];
252 }
253 const nameIdEl = doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "NameID");
254 const nameId =
255 opts.nameIdAttribute === "nameId"
256 ? nameIdEl.length
257 ? (nameIdEl[0].textContent ?? "")
258 : ""
259 : (attributes[opts.nameIdAttribute] ?? "");
260 if (!nameId) return { ok: false, reason: "missing_nameid" };
261 const email = attributes[opts.emailAttribute] ?? (opts.nameIdAttribute === "nameId" && nameId.includes("@") ? nameId : null);
262
263 const subjConf = doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "SubjectConfirmationData");
264 return {
265 ok: true,
266 data: {
267 id: root.getAttribute("ID") ?? "",
268 inResponseTo: root.getAttribute("InResponseTo") || null,
269 destination,
270 issuer,
271 nameId,
272 nameIdFormat: nameIdEl.length ? (nameIdEl[0].getAttribute("Format") ?? null) : null,
273 email,
274 attributes,
275 conditionsNotBefore: conditions.length && conditions[0].getAttribute("NotBefore") ? Date.parse(conditions[0].getAttribute("NotBefore")!) : null,
276 conditionsNotOnAfter: notOnOrAfter,
277 subjectConfirmationInResponseTo: subjConf.length ? (subjConf[0].getAttribute("InResponseTo") || null) : null,
278 subjectConfirmationRecipient: subjConf.length ? (subjConf[0].getAttribute("Recipient") || null) : null
279 }
280 };
281}
282