217 lines
8.3 KiB
JavaScript
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();
|