sc-idp/test/samlGate.js
2026-06-01 16:40:54 -05:00

279 lines
15 KiB
JavaScript

// Phase 5 gate: drive SAML against the IdP (MAIN :3000) as a samlify SP.
// Covers SP-initiated SSO (signed assertion + real AuthnStatement), the SP
// registry + ACS allow-list (unregistered SP and out-of-allow-list ACS are
// rejected), DTD/ENTITY (XXE) rejection, IdP-initiated SSO (no InResponseTo),
// and Single Logout. Tests run in order and share state (login + a registered
// SP); the SLO test is LAST because it destroys the session. Run:
// node idp/test/samlGate.js (MAIN up, SP registered automatically).
const saml = require("samlify");
const validator = require("@authenio/samlify-node-xmllint");
const http = require("http");
const zlib = require("zlib");
saml.setSchemaValidator(validator);
const PORT = 3000;
const ADMIN_EMAIL = "admin@local";
const ADMIN_PW = "AdminP@ss1";
const SP_ENTITY = "http://localhost:9099/saml/sp";
const SP_ACS = "http://localhost:9099/saml/acs";
const jar = {};
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const storeCookies = (headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const request = (method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({}, options.headers || {});
if (!options.noCookies && Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "localhost", port: PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const adminCsrf = async (path) => {
const p = await request("GET", path);
return (p.body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
};
const samlResponseOf = (body) => {
const m = body.match(/name="SAMLResponse" value="([^"]+)"/);
return m ? m[1] : null;
};
// Build a redirect-binding SAMLRequest query value from raw XML (deflate+base64).
const deflateReq = (xml) => {
return zlib.deflateRawSync(Buffer.from(xml, "utf8")).toString("base64");
};
const run = async () => {
// 1. Saltcorn login
const lp = await request("GET", "/auth/login");
const csrf = (lp.body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
const lr = await request("POST", "/auth/login", { body: { email: ADMIN_EMAIL, password: ADMIN_PW, _csrf: csrf } });
ok(lr.status === 302 || lr.status === 303, "Saltcorn login redirected");
ok(/true/.test((await request("GET", "/auth/authenticated")).body), "session authenticated");
// 2. Register the test SP (delete+create for a deterministic ACS allow-list)
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SP_ENTITY, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
const reg = await request("POST", "/admin/idp/saml-sps/create", { body: { entity_id: SP_ENTITY, label: "gate sp", acs_urls: SP_ACS, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
ok(reg.status === 302 || reg.status === 303, "registered test SP via admin UI");
// 3. IdP metadata -> build idp + sp
const md = await request("GET", "/idp/saml/metadata");
ok(md.status === 200 && /EntityDescriptor/.test(md.body), "IdP metadata served (HTTP " + md.status + ")");
ok(/SingleLogoutService/.test(md.body), "IdP metadata advertises SingleLogoutService");
const idp = saml.IdentityProvider({ metadata: md.body });
const sp = saml.ServiceProvider({
entityID: SP_ENTITY,
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }],
wantAssertionsSigned: true,
wantLogoutResponseSigned: true
});
// 4. SP-initiated AuthnRequest (redirect binding) -> hit IdP SSO with cookie
const loginReq = sp.createLoginRequest(idp, "redirect");
const url = new URL(loginReq.context);
ok(!!url.searchParams.get("SAMLRequest"), "SP created AuthnRequest");
const ssoResp = await request("GET", url.pathname + url.search);
ok(ssoResp.status === 200 && /SAMLResponse/.test(ssoResp.body), "IdP SSO returned a Response form (HTTP " + ssoResp.status + ")");
// 5. Verify the SAMLResponse as the SP
const samlResponse = samlResponseOf(ssoResp.body);
ok(!!samlResponse, "extracted SAMLResponse from auto-POST form");
if (samlResponse) {
const parsed = await sp.parseLoginResponse(idp, "post", { body: { SAMLResponse: samlResponse } });
const ex = parsed.extract;
ok(ex.nameID === ADMIN_EMAIL, "assertion NameID = " + ex.nameID + " (signature verified vs IdP cert)");
const attrs = ex.attributes || {};
const emailVal = Array.isArray(attrs.email) ? attrs.email[0] : attrs.email;
ok(emailVal === ADMIN_EMAIL, "email attribute = " + JSON.stringify(attrs.email));
const grp = attrs.groups;
const grpStr = Array.isArray(grp) ? grp.join(",") : String(grp || "");
ok(grpStr.indexOf("role:admin") >= 0, "groups attribute includes role:admin (" + JSON.stringify(grp) + ")");
const decoded = Buffer.from(samlResponse, "base64").toString("utf8");
ok(/AuthnStatement/.test(decoded) && /PasswordProtectedTransport/.test(decoded), "assertion carries a real AuthnStatement");
}
// 6. Adversarial: an UNREGISTERED SP entityID is rejected
const evilSp = saml.ServiceProvider({
entityID: "http://localhost:9099/saml/UNREGISTERED",
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }]
});
const evilReq = evilSp.createLoginRequest(idp, "redirect");
const evilUrl = new URL(evilReq.context);
const evilResp = await request("GET", evilUrl.pathname + evilUrl.search);
ok(evilResp.status === 403 && !/SAMLResponse/.test(evilResp.body), "unregistered SP rejected (403, no assertion)");
// 7. Adversarial: registered SP requesting an ACS NOT in its allow-list
const badAcsSp = saml.ServiceProvider({
entityID: SP_ENTITY,
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: "http://evil.example/acs" }]
});
const badReq = badAcsSp.createLoginRequest(idp, "redirect");
const badUrl = new URL(badReq.context);
const badResp = await request("GET", badUrl.pathname + badUrl.search);
ok(badResp.status === 403 && !/SAMLResponse/.test(badResp.body), "out-of-allow-list ACS rejected (403, no assertion)");
// 8. Adversarial: a DTD/ENTITY (XXE) AuthnRequest is rejected before parse
const xxeXml = `<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>`
+ `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"`
+ ` ID="_x" Version="2.0" IssueInstant="2026-01-01T00:00:00Z" AssertionConsumerServiceURL="${SP_ACS}">`
+ `<saml:Issuer>${SP_ENTITY}</saml:Issuer></samlp:AuthnRequest>`;
const xxeResp = await request("GET", "/idp/saml/sso?SAMLRequest=" + encodeURIComponent(deflateReq(xxeXml)));
ok(xxeResp.status === 400 && !/SAMLResponse/.test(xxeResp.body), "DTD/ENTITY AuthnRequest rejected (400, no XXE)");
// 9. IdP-initiated SSO (unsolicited Response, no InResponseTo)
const initResp = await request("GET", "/idp/saml/init?sp=" + encodeURIComponent(SP_ENTITY) + "&acs=" + encodeURIComponent(SP_ACS));
ok(initResp.status === 200 && /SAMLResponse/.test(initResp.body), "IdP-initiated SSO returned a Response (HTTP " + initResp.status + ")");
const initSaml = samlResponseOf(initResp.body);
if (initSaml) {
const iparsed = await sp.parseLoginResponse(idp, "post", { body: { SAMLResponse: initSaml } });
ok(iparsed.extract.nameID === ADMIN_EMAIL, "IdP-initiated NameID = admin@local");
const idecoded = Buffer.from(initSaml, "base64").toString("utf8");
ok(!/InResponseTo=/.test(idecoded), "IdP-initiated Response has no InResponseTo");
}
// 9a2. Signed-SP path: register an SP WITH a signing cert + want_signed, then
// a correctly SIGNED AuthnRequest must be accepted (signature verified against
// the registered cert) and an UNSIGNED one rejected. Exercises getSignedIdp +
// the redirect-binding octetString reconstruction.
const selfsigned = require("selfsigned");
const spPems = await selfsigned.generate([{ name: "commonName", value: "gate-signed-sp" }], { keyType: "rsa", keySize: 2048, algorithm: "sha256" });
const SIGNED_SP = "http://localhost:9099/saml/signed-sp";
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SIGNED_SP, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
await request("POST", "/admin/idp/saml-sps/create", { body: { entity_id: SIGNED_SP, label: "signed sp", acs_urls: SP_ACS, signing_cert: spPems.cert, want_signed: "1", _csrf: await adminCsrf("/admin/idp/saml-sps") } });
const signedSp = saml.ServiceProvider({
entityID: SIGNED_SP,
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }],
signingCert: spPems.cert,
privateKey: spPems.private,
authnRequestsSigned: true
});
// Client-side IdP view with WantAuthnRequestsSigned=true so samlify lets a
// signing SP build the request (built directly rather than from metadata,
// whose advertised flag is false and would override the constructor; the
// SERVER still decides verification via the SP's registered want_signed flag).
const ISSUER = "http://localhost:" + PORT + "/idp";
const signedIdp = saml.IdentityProvider({
entityID: ISSUER + "/saml",
singleSignOnService: [
{ Binding: saml.Constants.namespace.binding.redirect, Location: ISSUER + "/saml/sso" },
{ Binding: saml.Constants.namespace.binding.post, Location: ISSUER + "/saml/sso" }
],
wantAuthnRequestsSigned: true
});
const sgReq = signedSp.createLoginRequest(signedIdp, "redirect");
const sgUrl = new URL(sgReq.context);
const sgResp = await request("GET", sgUrl.pathname + sgUrl.search);
ok(sgResp.status === 200 && /SAMLResponse/.test(sgResp.body), "signed-SP: signed AuthnRequest accepted (signature verified, HTTP " + sgResp.status + ")");
const unsignedSp = saml.ServiceProvider({ entityID: SIGNED_SP, assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }] });
const usReq = unsignedSp.createLoginRequest(idp, "redirect");
const usUrl = new URL(usReq.context);
const usResp = await request("GET", usUrl.pathname + usUrl.search);
ok(usResp.status === 403 && !/SAMLResponse/.test(usResp.body), "signed-SP: UNSIGNED AuthnRequest rejected (403)");
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SIGNED_SP, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
// 9b. Adversarial DoS: a deflate "zip bomb" (10 MiB inflating from a tiny
// base64 blob) must be rejected (400) by the inflate output cap, NOT crash
// the worker. The server must still serve afterwards.
const bomb = zlib.deflateRawSync(Buffer.alloc(10 * 1024 * 1024, 0x41)).toString("base64");
const bombResp = await request("GET", "/idp/saml/sso?SAMLRequest=" + encodeURIComponent(bomb));
ok(bombResp.status === 400 && !/SAMLResponse/.test(bombResp.body), "decompression-bomb SAMLRequest rejected (400, output cap)");
ok((await request("GET", "/idp/saml/metadata")).status === 200, "server survived the decompression bomb (metadata still served)");
// 9c. Adversarial: an SP registered with NO ACS URL must be refused, so the
// SLO/SSO handlers can never fall back to an attacker-supplied destination.
const noAcsSp = "http://localhost:9099/saml/NOACS";
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: noAcsSp, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
await request("POST", "/admin/idp/saml-sps/create", { body: { entity_id: noAcsSp, label: "no acs", acs_urls: "", _csrf: await adminCsrf("/admin/idp/saml-sps") } });
const spsList = (await request("GET", "/admin/idp/saml-sps")).body;
ok(spsList.indexOf(noAcsSp) < 0, "SP with empty ACS list refused at registration (defence-in-depth)");
// 9d. Adversarial SLO: a LogoutRequest with NO authenticated session must be
// rejected (an unauthenticated forgery cannot destroy a session).
const forgedReq = sp.createLogoutRequest(idp, "redirect", { logoutNameID: ADMIN_EMAIL });
const fUrl = new URL(forgedReq.context);
const noSessSlo = await request("GET", fUrl.pathname + fUrl.search, { noCookies: true });
ok(noSessSlo.status === 403 && !/SAMLResponse/.test(noSessSlo.body), "SLO without a session rejected (403, no forced logout)");
// 9e. Adversarial SLO: a LogoutRequest naming a DIFFERENT subject than the
// session user must be rejected (cannot log out a session it does not name).
const wrongReq = sp.createLogoutRequest(idp, "redirect", { logoutNameID: "intruder@local" });
const wUrl = new URL(wrongReq.context);
const wrongSlo = await request("GET", wUrl.pathname + wUrl.search);
ok(wrongSlo.status === 403 && !/SAMLResponse/.test(wrongSlo.body), "SLO with a mismatched NameID rejected (403)");
// 10. Single Logout (LAST: destroys the session) -- authenticated, correct NameID.
const logoutReq = sp.createLogoutRequest(idp, "redirect", { logoutNameID: ADMIN_EMAIL });
const lurl = new URL(logoutReq.context);
const sloResp = await request("GET", lurl.pathname + lurl.search);
ok(sloResp.status === 200 && /SAMLResponse/.test(sloResp.body), "SLO returned a LogoutResponse form (HTTP " + sloResp.status + ")");
const sloSaml = samlResponseOf(sloResp.body);
if (sloSaml) {
const lparsed = await sp.parseLogoutResponse(idp, "post", { body: { SAMLResponse: sloSaml } });
ok(!!(lparsed && lparsed.extract), "LogoutResponse verified (signature vs IdP cert)");
const ir = lparsed.extract.response ? lparsed.extract.response.inResponseTo : null;
ok(ir === logoutReq.id, "LogoutResponse InResponseTo matches the LogoutRequest id");
}
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
run().catch((e) => {
console.error("SAML GATE ERROR:", e);
process.exit(2);
});