1
22import { execFileSync } from "node:child_process";
23import { generateKeyPairSync } from "node:crypto";
24import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
25import { tmpdir } from "node:os";
26import { join } from "node:path";
27import type { AddressInfo } from "node:net";
28import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
29import { SamlLib } from "samlify";
30
31export interface SamlIdpUser {
32
33 nameId: string;
34
35 email?: string;
36
37 displayName?: string;
38}
39
40export interface SamlIdpOptions {
41
42 acsUrl: string;
43
44 spEntityId: string;
45
46 user: SamlIdpUser;
47
48 issuer?: string;
49
50 notOnOrAfterOffsetMs?: number;
51
52 statusValue?: string;
53
54 sign?: boolean;
55
56 rogueKey?: boolean;
57
58 nameIdOverride?: string;
59
60 emailOverride?: string;
61
62 responseId?: string;
63}
64
65interface IdpMaterial {
66 keyPem: string;
67 certPem: string;
68 certB64: string;
69}
70
71
72export function makeIdpMaterial(label: string): IdpMaterial {
73 const dir = mkdtempSync(join(tmpdir(), `e2e-idp-${label}-`));
74 const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
75 const keyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string;
76 const keyPath = join(dir, "key.pem");
77 const certPath = join(dir, "cert.pem");
78 writeFileSync(keyPath, keyPem);
79 execFileSync("openssl", [
80 "req",
81 "-x509",
82 "-new",
83 "-key",
84 keyPath,
85 "-subj",
86 `/CN=e2e-mock-idp-${label}`,
87 "-days",
88 "2",
89 "-out",
90 certPath
91 ]);
92 const certPem = readFileSync(certPath, "utf8");
93 const certB64 = certPem.replace(/-----[A-Z ]+-----/g, "").replace(/\s+/g, "");
94 return { keyPem, certPem, certB64 };
95}
96
97const inst = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString();
98const xml = (v: string) => v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
99
100async function signXml(xmlDoc: string, material: IdpMaterial): Promise<string> {
101
102
103
104 return String(
105 await (
106 SamlLib as unknown as {
107 constructSAMLSignature(opts: {
108 rawSamlMessage: string;
109 privateKey: string;
110 signingCert: string;
111 referenceTagXPath: string;
112 isBase64Output: boolean;
113 }): Promise<string>;
114 }
115 ).constructSAMLSignature({
116 rawSamlMessage: xmlDoc,
117 privateKey: material.keyPem,
118 signingCert: material.certB64,
119 referenceTagXPath: "/*[local-name()='Response']/*[local-name()='Assertion']",
120 isBase64Output: false
121 })
122 );
123}
124
125export interface MockSamlIdp {
126 url: string;
127
128 certPem: string;
129
130 metadataUrl: string;
131
132 issuer: string;
133
134 acsUrl: string;
135
136 spEntityId: string;
137
138 user: SamlIdpUser;
139
140 buildAuthnResponse(over?: {
141 nameId?: string;
142 email?: string;
143 notOnOrAfterOffsetMs?: number;
144 }): Promise<string>;
145
146 responsesEmitted: () => number;
147 close(): Promise<void>;
148}
149
150let SEQ = 0;
151
152
153export async function startMockSamlIdp(opts: SamlIdpOptions): Promise<MockSamlIdp> {
154 const material = makeIdpMaterial("main");
155 const signingMaterial = opts.rogueKey ? makeIdpMaterial("rogue") : material;
156 const issuer = opts.issuer ?? `urn:mock-idp:e2e-${Math.random().toString(36).slice(2, 8)}`;
157 let emitted = 0;
158
159 const build = async (over?: {
160 nameId?: string;
161 email?: string;
162 notOnOrAfterOffsetMs?: number;
163 }): Promise<string> => {
164 SEQ += 1;
165 const nameId = over?.nameId ?? opts.nameIdOverride ?? opts.user.nameId;
166 const email = over?.email ?? opts.emailOverride ?? opts.user.email ?? opts.user.nameId;
167 const displayName = opts.user.displayName;
168 const notOnOrAfter = inst(over?.notOnOrAfterOffsetMs ?? opts.notOnOrAfterOffsetMs ?? 10 * 60_000);
169 const statusValue = opts.statusValue ?? "urn:oasis:names:tc:SAML:2.0:status:Success";
170 const respId = opts.responseId ?? `_${SEQ}r${Date.now().toString(36)}`;
171 const assertId = `_${SEQ}a${Date.now().toString(36)}`;
172
173 const doc = `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${respId}" InResponseTo="$_mock_req_1" Version="2.0" IssueInstant="${inst(-30_000)}" Destination="${opts.acsUrl}"><saml:Issuer>${issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="${statusValue}"/></samlp:Status><saml:Assertion ID="${assertId}" Version="2.0" IssueInstant="${inst(-30_000)}"><saml:Issuer>${issuer}</saml:Issuer><saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">${xml(nameId)}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="${notOnOrAfter}" Recipient="${opts.acsUrl}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="${inst(-30_000)}" NotOnOrAfter="${notOnOrAfter}"><saml:AudienceRestriction><saml:Audience>${opts.spEntityId}</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant="${inst(-30_000)}" SessionIndex="_mock_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>${xml(email)}</saml:AttributeValue></saml:Attribute>${
174 displayName ? `<saml:Attribute Name="displayName"><saml:AttributeValue>${xml(displayName)}</saml:AttributeValue></saml:Attribute>` : ""
175 }</saml:AttributeStatement></saml:Assertion></samlp:Response>`;
176 if (opts.sign === false) return doc;
177 return await signXml(doc, signingMaterial);
178 };
179
180 const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
181 const url = new URL(req.url ?? "/", "http://localhost");
182 if (req.method === "GET" && url.pathname === "/metadata") {
183 res.writeHead(200, { "content-type": "application/xml" });
184 res.end(
185 `<?xml version="1.0"?>\n` +
186 `<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="${issuer}">` +
187 `<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">` +
188 `<md:KeyDescriptor use="signing"><ds:KeyInfo><ds:X509Data><ds:X509Certificate>${material.certB64}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor>` +
189 `<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="${serverUrl}/authorize"/>` +
190 `</md:IDPSSODescriptor></md:EntityDescriptor>`
191 );
192 return;
193 }
194 if (req.method === "GET" && (url.pathname === "/authorize" || url.pathname === "/sso")) {
195 const signed = await build();
196 emitted += 1;
197 const b64 = Buffer.from(signed, "utf8").toString("base64");
198 const relay = url.searchParams.get("RelayState") ?? "/";
199 res.writeHead(200, { "content-type": "text/html" });
200 res.end(
201 `<!doctype html><html><head><title>Mock IdP</title></head><body onload="document.f.submit()">` +
202 `<form name="f" method="post" action="${opts.acsUrl}">` +
203 `<input type="hidden" name="SAMLResponse" value="${b64}"/>` +
204 `<input type="hidden" name="RelayState" value="${xml(relay)}"/>` +
205 `</form><noscript>Submit manually.</noscript></body></html>`
206 );
207 return;
208 }
209 res.writeHead(404, { "content-type": "text/plain" });
210 res.end("not found");
211 });
212
213 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
214 const { port } = server.address() as AddressInfo;
215 const serverUrl = `http://127.0.0.1:${port}`;
216
217 return {
218 url: serverUrl,
219 certPem: material.certPem,
220 metadataUrl: `${serverUrl}/metadata`,
221 issuer,
222 acsUrl: opts.acsUrl,
223 spEntityId: opts.spEntityId,
224 user: opts.user,
225 buildAuthnResponse: build,
226 responsesEmitted: () => emitted,
227 close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
228 };
229}
230