// Multi-tenant SAML ISOLATION gate (Postgres :3002). mtGate proves OIDC // cross-tenant isolation and ldapMultiTenantGate proves LDAP; this proves SAML: // each tenant signs assertions with its OWN per-tenant certificate, and a // Response issued by t1 cannot be validated as t2's. // 1. t1 and t2 serve DISTINCT SAML signing certs + DISTINCT issuers/entityIDs // in their IdP metadata. // 2. An SP-initiated SSO Response from t1 embeds t1's signing cert (not t2's). // 3. The Response validates against t1's metadata-derived IdP (happy path) but // is REJECTED against t2's (t1's signature does not verify with t2's cert). // Tenants are addressed by Host header (tNN.localhost.localdomain:3002). Run: // node idp/test/samlMultiTenantGate.js (PG :3002 up, idp installed per-tenant). // Self-skips (exit 0) if :3002 is unreachable. const saml = require("samlify"); const validator = require("@authenio/samlify-node-xmllint"); const http = require("http"); const net = require("net"); saml.setSchemaValidator(validator); const PG_PORT = 3002; const TENANTS = ["t1", "t2"]; const ADMIN_PW = "AdminP@ss1"; const SP_ENTITY = "http://localhost:9097/mtsaml/sp"; const SP_ACS = "http://localhost:9097/mtsaml/acs"; const jars = { t1: {}, t2: {} }; let pass = 0; let fail = 0; const ok = (cond, msg) => { if (cond) { pass++; console.log(" PASS " + msg); } else { fail++; console.log(" FAIL " + msg); } }; const HOST = (t) => t + ".localhost.localdomain:" + PG_PORT; const ADMIN = (t) => "admin@" + t + ".local"; const portOpen = (port) => { return new Promise((resolve) => { const s = net.connect(port, "127.0.0.1"); const done = (up) => { s.destroy(); resolve(up); }; s.setTimeout(1000); s.on("connect", () => done(true)); s.on("timeout", () => done(false)); s.on("error", () => done(false)); }); }; const storeCookies = (t, 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) { jars[t][pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); } } }; const request = (t, method, path, opts) => { const options = opts || {}; return new Promise((resolve, reject) => { const headers = Object.assign({ Host: HOST(t) }, options.headers || {}); const jar = jars[t]; if (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: "127.0.0.1", port: PG_PORT, method: method, path: path, headers: headers }, (resp) => { storeCookies(t, 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 csrfOf = (h) => (h.match(/name="_csrf" value="([^"]+)"/) || [])[1] || ""; const adminCsrf = async (t, path) => csrfOf((await request(t, "GET", path)).body); const authed = async (t) => /true/.test((await request(t, "GET", "/auth/authenticated")).body); // First in an XML doc (metadata signing cert / signature cert), // whitespace-stripped for stable comparison. const x509Of = (xml) => { const m = xml.match(/<(?:[A-Za-z0-9]+:)?X509Certificate>([\s\S]*?)<\/(?:[A-Za-z0-9]+:)?X509Certificate>/); return m ? m[1].replace(/\s+/g, "") : null; }; const entityIdOf = (xml) => (xml.match(/entityID="([^"]+)"/) || [])[1] || null; const samlResponseOf = (body) => (body.match(/name="SAMLResponse" value="([^"]+)"/) || [])[1] || null; const bootstrapTenant = async (t) => { const lp = await request(t, "GET", "/auth/login"); await request(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp.body) } }); if (await authed(t)) return true; const cp = await request(t, "GET", "/auth/create_first_user"); const cc = csrfOf(cp.body); if (cc) { await request(t, "POST", "/auth/create_first_user", { body: { email: ADMIN(t), password: ADMIN_PW, default_language: "en", _csrf: cc } }); } if (await authed(t)) return true; const lp2 = await request(t, "GET", "/auth/login"); await request(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp2.body) } }); return await authed(t); }; const registerSp = async (t) => { await request(t, "POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SP_ENTITY, _csrf: await adminCsrf(t, "/admin/idp/saml-sps") } }); await request(t, "POST", "/admin/idp/saml-sps/create", { body: { entity_id: SP_ENTITY, label: "mtsaml", acs_urls: SP_ACS, _csrf: await adminCsrf(t, "/admin/idp/saml-sps") } }); }; const run = async () => { for (const t of TENANTS) { ok(await bootstrapTenant(t), t + " admin session established"); } // 1. Per-tenant metadata: distinct signing certs + distinct issuers. const md1 = (await request("t1", "GET", "/idp/saml/metadata")).body; const md2 = (await request("t2", "GET", "/idp/saml/metadata")).body; const c1 = x509Of(md1); const c2 = x509Of(md2); ok(c1 && c2, "both tenants serve a SAML signing cert in metadata"); ok(c1 && c2 && c1 !== c2, "t1 and t2 have DISTINCT SAML signing certs"); const e1 = entityIdOf(md1); const e2 = entityIdOf(md2); ok(e1 && e2 && e1 !== e2, "t1 and t2 have DISTINCT IdP entityIDs (" + e1 + " / " + e2 + ")"); // 2. Drive SP-initiated SSO on t1 -> Response embeds t1's cert, not t2's. await registerSp("t1"); const idp1 = saml.IdentityProvider({ metadata: md1 }); const idp2 = saml.IdentityProvider({ metadata: md2 }); const sp = saml.ServiceProvider({ entityID: SP_ENTITY, assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }], wantAssertionsSigned: true }); const loginReq = sp.createLoginRequest(idp1, "redirect"); const u = new URL(loginReq.context); const ssoResp = await request("t1", "GET", u.pathname + u.search); const samlResp = samlResponseOf(ssoResp.body); ok(ssoResp.status === 200 && !!samlResp, "t1 SP-initiated SSO returned a signed Response (HTTP " + ssoResp.status + ")"); if (samlResp) { const respXml = Buffer.from(samlResp, "base64").toString("utf8"); const respCert = x509Of(respXml); ok(respCert === c1, "t1 Response is signed with t1's cert (embedded cert matches t1 metadata)"); ok(respCert !== c2, "t1 Response is NOT signed with t2's cert (cross-tenant isolation)"); // 3. Validates against t1's IdP; rejected against t2's. let t1ok = false; try { const info = await sp.parseLoginResponse(idp1, "post", { body: { SAMLResponse: samlResp } }); t1ok = !!(info && info.extract); } catch (e) { t1ok = false; } ok(t1ok, "t1 Response validates against t1's IdP (happy path)"); let t2rejected = false; try { await sp.parseLoginResponse(idp2, "post", { body: { SAMLResponse: samlResp } }); t2rejected = false; } catch (e) { t2rejected = true; } ok(t2rejected, "t1 Response REJECTED against t2's IdP (signature fails with t2's cert)"); } // Cleanup. await request("t1", "POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SP_ENTITY, _csrf: await adminCsrf("t1", "/admin/idp/saml-sps") } }); console.log("\nRESULT: " + pass + " passed, " + fail + " failed"); process.exit(fail ? 1 : 0); }; const main = async () => { if (!(await portOpen(PG_PORT))) { console.log("SKIP: Postgres multi-tenant instance not reachable on 127.0.0.1:" + PG_PORT); process.exit(0); } try { await run(); } catch (e) { console.error("SAML MT GATE ERROR:", e); process.exit(2); } }; main();