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

217 lines
8.3 KiB
JavaScript

// 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 <X509Certificate> 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();