295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
// Multi-tenant OIDC gate against the Postgres instance (:3002). Drives a full
|
|
// authorization-code + PKCE flow on tenant t1's issuer and asserts per-tenant
|
|
// isolation: an authorization code, access token, and id_token minted by t1 are
|
|
// REJECTED at t2 (per-tenant store + per-issuer Provider + per-tenant signing
|
|
// key). Tenants are addressed by Host header (tNN.localhost.localdomain:3002 ->
|
|
// tenant tNN; the token issuer is the 2-label base_url http://tNN.localhost:3002/idp).
|
|
// Each tenant uses a separate cookie jar. Run: node test/mtGate.js
|
|
// (PG :3002 up, tenants t1/t2 present with the plugin installed).
|
|
|
|
const http = require("http");
|
|
const crypto = require("crypto");
|
|
|
|
const PG_PORT = 3002;
|
|
const TENANTS = ["t1", "t2"];
|
|
const CLIENT_ID = "mt-rp";
|
|
const REDIRECT_URI = "http://localhost:9099/cb";
|
|
const ADMIN_PW = "AdminP@ss1";
|
|
|
|
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 ISSUER = (t) => "http://" + t + ".localhost:" + PG_PORT + "/idp";
|
|
const ADMIN = (t) => "admin@" + t + ".local";
|
|
|
|
|
|
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 = (html) => {
|
|
const m = html.match(/name="_csrf" value="([^"]+)"/);
|
|
return m ? m[1] : "";
|
|
};
|
|
|
|
|
|
const getJson = async (t, path) => {
|
|
const r = await request(t, "GET", path);
|
|
try {
|
|
return JSON.parse(r.body);
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
|
|
const authed = async (t) => {
|
|
return /true/.test((await request(t, "GET", "/auth/authenticated")).body);
|
|
};
|
|
|
|
|
|
const resolvePath = (loc) => {
|
|
const u = loc.indexOf("http") === 0 ? new URL(loc) : new URL(loc, "http://x");
|
|
return u.pathname + u.search;
|
|
};
|
|
|
|
|
|
// Ensure a tenant admin exists and the jar holds an authenticated session.
|
|
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 clientExists = async (t, clientId) => {
|
|
const html = (await request(t, "GET", "/admin/idp/clients")).body;
|
|
return html.indexOf("<code>" + clientId + "</code>") >= 0;
|
|
};
|
|
|
|
|
|
const registerRp = async (t) => {
|
|
const c1 = csrfOf((await request(t, "GET", "/admin/idp/clients")).body);
|
|
await request(t, "POST", "/admin/idp/clients/delete", { body: { client_id: CLIENT_ID, _csrf: c1 } });
|
|
const c2 = csrfOf((await request(t, "GET", "/admin/idp/clients")).body);
|
|
await request(t, "POST", "/admin/idp/clients/create", { body: {
|
|
client_id: CLIENT_ID, label: "MT RP", redirect_uris: REDIRECT_URI,
|
|
auth_method: "none", scope: "openid email profile groups", _csrf: c2
|
|
} });
|
|
return clientExists(t, CLIENT_ID);
|
|
};
|
|
|
|
|
|
// Run the auth-code + PKCE flow up to obtaining the authorization code (does NOT
|
|
// exchange it), so the caller can replay an unconsumed code cross-tenant.
|
|
const getAuthCode = async (t) => {
|
|
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
const challenge = crypto.createHash("sha256").update(verifier).digest().toString("base64url");
|
|
const q = new URLSearchParams({
|
|
client_id: CLIENT_ID,
|
|
response_type: "code",
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: "openid email profile groups",
|
|
state: crypto.randomBytes(8).toString("base64url"),
|
|
nonce: crypto.randomBytes(8).toString("base64url"),
|
|
code_challenge: challenge,
|
|
code_challenge_method: "S256"
|
|
});
|
|
let path = "/idp/auth?" + q.toString();
|
|
let code = null;
|
|
let hops = 0;
|
|
while (hops < 15) {
|
|
hops++;
|
|
const r = await request(t, "GET", path);
|
|
if (r.status >= 300 && r.status < 400 && r.headers.location) {
|
|
if (r.headers.location.indexOf(REDIRECT_URI) === 0) {
|
|
code = new URL(r.headers.location).searchParams.get("code");
|
|
break;
|
|
}
|
|
path = resolvePath(r.headers.location);
|
|
} else if (r.status === 200 && /\/confirm"/.test(r.body)) {
|
|
const m = r.body.match(/action="([^"]*\/confirm)"/);
|
|
if (!m) {
|
|
throw new Error("consent page without a confirm action");
|
|
}
|
|
const pr = await request(t, "POST", resolvePath(m[1]), { body: { allow: "1" } });
|
|
if (pr.status >= 300 && pr.status < 400 && pr.headers.location) {
|
|
path = resolvePath(pr.headers.location);
|
|
} else {
|
|
throw new Error("consent confirm did not redirect: HTTP " + pr.status);
|
|
}
|
|
} else {
|
|
throw new Error("auth flow stalled on " + t + ": HTTP " + r.status + " " + r.body.slice(0, 120));
|
|
}
|
|
}
|
|
return { code: code, verifier: verifier };
|
|
};
|
|
|
|
|
|
const exchangeToken = async (t, code, verifier) => {
|
|
const r = await request(t, "POST", "/idp/token", { body: {
|
|
grant_type: "authorization_code",
|
|
code: code,
|
|
redirect_uri: REDIRECT_URI,
|
|
client_id: CLIENT_ID,
|
|
code_verifier: verifier
|
|
} });
|
|
let tokens = {};
|
|
try {
|
|
tokens = JSON.parse(r.body);
|
|
} catch (e) {
|
|
/* leave empty */
|
|
}
|
|
let header = {};
|
|
let payload = {};
|
|
if (tokens.id_token) {
|
|
tokens.__parts = tokens.id_token.split(".");
|
|
header = JSON.parse(Buffer.from(tokens.__parts[0], "base64url").toString());
|
|
payload = JSON.parse(Buffer.from(tokens.__parts[1], "base64url").toString());
|
|
}
|
|
return { status: r.status, body: r.body, tokens: tokens, header: header, payload: payload };
|
|
};
|
|
|
|
|
|
const verifyIdToken = async (t, tok) => {
|
|
const jwks = await getJson(t, "/idp/jwks");
|
|
const jwk = (jwks.keys || []).find((k) => k.kid === tok.header.kid) || (jwks.keys || [])[0];
|
|
if (!jwk) {
|
|
return false;
|
|
}
|
|
const pub = crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
return crypto.verify("sha256", Buffer.from(tok.tokens.__parts[0] + "." + tok.tokens.__parts[1]), pub, Buffer.from(tok.tokens.__parts[2], "base64url"));
|
|
};
|
|
|
|
|
|
const run = async () => {
|
|
// Phase 0: distinct issuers + distinct keys per tenant
|
|
const d1 = await getJson("t1", "/idp/.well-known/openid-configuration");
|
|
const d2 = await getJson("t2", "/idp/.well-known/openid-configuration");
|
|
ok(d1.issuer === ISSUER("t1") && d2.issuer === ISSUER("t2"), "distinct per-tenant issuers (" + d1.issuer + " / " + d2.issuer + ")");
|
|
const k1 = await getJson("t1", "/idp/jwks");
|
|
const k2 = await getJson("t2", "/idp/jwks");
|
|
const kid1 = k1.keys && k1.keys[0] && k1.keys[0].kid;
|
|
const kid2 = k2.keys && k2.keys[0] && k2.keys[0].kid;
|
|
ok(kid1 && kid2 && kid1 !== kid2, "distinct per-tenant signing keys (" + kid1 + " / " + kid2 + ")");
|
|
|
|
// Bootstrap admins + register the RP in each tenant
|
|
for (const t of TENANTS) {
|
|
ok(await bootstrapTenant(t), t + " admin session established");
|
|
ok(await registerRp(t), t + " RP registered (" + CLIENT_ID + ")");
|
|
}
|
|
|
|
// Positive: full flow on t1
|
|
const c1 = await getAuthCode("t1");
|
|
ok(!!c1.code, "t1 issued an authorization code");
|
|
const tok = await exchangeToken("t1", c1.code, c1.verifier);
|
|
ok(tok.status === 200 && !!tok.tokens.access_token && !!tok.tokens.id_token, "t1 token endpoint issued access_token + id_token");
|
|
ok(await verifyIdToken("t1", tok), "t1 id_token verifies against t1 JWKS");
|
|
ok(tok.payload.iss === ISSUER("t1"), "t1 id_token iss = " + tok.payload.iss);
|
|
|
|
// Cross-tenant (A): an unconsumed t1 code is rejected at t2's token endpoint
|
|
const c1b = await getAuthCode("t1");
|
|
const xTok = await exchangeToken("t2", c1b.code, c1b.verifier);
|
|
let xErr = {};
|
|
try {
|
|
xErr = JSON.parse(xTok.body);
|
|
} catch (e) {
|
|
/* leave empty */
|
|
}
|
|
ok(xTok.status >= 400 && !xTok.tokens.access_token, "t1 authorization code REJECTED at t2 token endpoint (HTTP " + xTok.status + " " + (xErr.error || "") + ")");
|
|
|
|
// Cross-tenant (B): a t1 access token is rejected at t2 userinfo
|
|
const meT2 = await request("t2", "GET", "/idp/me", { headers: { Authorization: "Bearer " + tok.tokens.access_token } });
|
|
ok(meT2.status === 401, "t1 access_token REJECTED at t2 userinfo (HTTP " + meT2.status + ")");
|
|
|
|
// Cross-tenant (C): a t1 id_token does NOT verify against t2's JWKS
|
|
const sigVsT2 = await verifyIdToken("t2", tok);
|
|
ok(sigVsT2 === false, "t1 id_token signature does NOT verify against t2 JWKS");
|
|
ok(tok.payload.iss !== ISSUER("t2"), "t1 id_token iss is not t2's issuer");
|
|
|
|
// Positive control: the same t1 access token still works at t1 userinfo
|
|
const meT1 = await request("t1", "GET", "/idp/me", { headers: { Authorization: "Bearer " + tok.tokens.access_token } });
|
|
ok(meT1.status === 200, "t1 access_token still valid at t1 userinfo (rejection is tenant-specific, not blanket)");
|
|
|
|
// Cleanup
|
|
for (const t of TENANTS) {
|
|
const cc = csrfOf((await request(t, "GET", "/admin/idp/clients")).body);
|
|
await request(t, "POST", "/admin/idp/clients/delete", { body: { client_id: CLIENT_ID, _csrf: cc } });
|
|
}
|
|
|
|
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
|
|
process.exit(fail ? 1 : 0);
|
|
};
|
|
|
|
|
|
run().catch((e) => {
|
|
console.error("MT GATE ERROR:", e);
|
|
process.exit(2);
|
|
});
|