// 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 = `]>` + `` + `${SP_ENTITY}`; 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); });