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

299 lines
12 KiB
JavaScript

// End-to-end test suite for saltcorn-idp. Self-contained (node:http + node:crypto,
// no deps). Run with both instances up: node test/e2e.js (or: npm test)
//
// Phase 0 - discovery + JWKS on both instances (MAIN :3000, TEST :3001)
// Phase 1 - register a client (admin UI), authorization-code + PKCE flow with a
// consent screen, token, id_token verify, userinfo
// Phase 2 - groups claim (custom group via admin UI + role-as-group)
// Phase 3 - clients registry (confidential client secret issuance)
//
// Tests run in order and share login/session state.
const http = require("http");
const crypto = require("crypto");
const MAIN = 3000;
const TEST = 3001;
const ADMIN_EMAIL = "admin@local";
const ADMIN_PW = "AdminP@ss1";
const CLIENT_ID = "test-rp";
const REDIRECT_URI = "http://localhost:9099/cb";
const ADMIN_BASE = "/admin/idp";
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 section = (name) => {
console.log("\n== " + name + " ==");
};
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 cookieHeader = () => {
return Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
};
const request = (port, method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({}, options.headers || {});
if (port === MAIN && Object.keys(jar).length > 0) {
headers["Cookie"] = cookieHeader();
}
let data = null;
if (options.body) {
data = typeof options.body === "string" ? options.body : new URLSearchParams(options.body).toString();
headers["Content-Type"] = 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) => {
if (port === MAIN) {
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 getJson = async (port, path) => {
const r = await request(port, "GET", path);
try {
return JSON.parse(r.body);
} catch (e) {
return { __status: r.status, __raw: r.body };
}
};
const csrfOf = (html) => {
const m = html.match(/name="_csrf" value="([^"]+)"/);
return m ? m[1] : "";
};
const resolvePath = (loc) => {
const abs = loc.indexOf("http") === 0 ? new URL(loc) : new URL(loc, "http://localhost:" + MAIN);
return abs.pathname + abs.search;
};
const login = async () => {
const page = await request(MAIN, "GET", "/auth/login");
const csrf = csrfOf(page.body);
const resp = await request(MAIN, "POST", "/auth/login", { body: { email: ADMIN_EMAIL, password: ADMIN_PW, _csrf: csrf } });
return resp.status;
};
const adminCsrf = async (page) => {
return csrfOf((await request(MAIN, "GET", ADMIN_BASE + page)).body);
};
const findGroupId = async (name) => {
const html = (await request(MAIN, "GET", ADMIN_BASE + "/groups")).body;
const m = html.match(new RegExp("<code>" + name + "</code>[\\s\\S]*?name=\"group_id\" value=\"(\\d+)\""));
return m ? parseInt(m[1], 10) : null;
};
const clientExists = async (clientId) => {
const html = (await request(MAIN, "GET", ADMIN_BASE + "/clients")).body;
return html.indexOf("<code>" + clientId + "</code>") >= 0;
};
// Runs the authorization-code + PKCE flow, following interaction redirects and
// submitting the consent screen when it appears. Returns tokens + decoded id_token.
const authCodeFlow = async (scope) => {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest().toString("base64url");
const state = crypto.randomBytes(8).toString("base64url");
const nonce = crypto.randomBytes(8).toString("base64url");
const q = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: scope,
state: state,
nonce: nonce,
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(MAIN, "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(MAIN, "POST", 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: HTTP " + r.status + " " + r.body.slice(0, 150));
}
}
const tok = await request(MAIN, "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(tok.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 { tokenStatus: tok.status, nonce: nonce, tokens: tokens, header: header, payload: payload };
};
const userInfo = async (accessToken) => {
const r = await request(MAIN, "GET", "/idp/me", { headers: { Authorization: "Bearer " + accessToken } });
let body = {};
try {
body = JSON.parse(r.body);
} catch (e) {
/* leave empty */
}
return { status: r.status, body: body };
};
const run = async () => {
section("Phase 0: discovery + JWKS (both instances)");
for (const pair of [["MAIN", MAIN], ["TEST", TEST]]) {
const label = pair[0];
const port = pair[1];
const disc = await getJson(port, "/idp/.well-known/openid-configuration");
ok(disc.issuer === "http://localhost:" + port + "/idp", label + " issuer = " + disc.issuer);
const jwks = await getJson(port, "/idp/jwks");
const key = jwks.keys && jwks.keys[0];
ok(jwks.keys && jwks.keys.length === 1 && key.kty === "RSA", label + " JWKS: one RSA key");
ok(key && !key.d && !key.p && !key.q, label + " JWKS public-only");
}
section("Phase 1: client registration + auth-code + PKCE + consent");
const loginStatus = await login();
ok(loginStatus === 302 || loginStatus === 303, "Saltcorn login redirected (HTTP " + loginStatus + ")");
ok(/true/.test((await request(MAIN, "GET", "/auth/authenticated")).body), "Saltcorn session authenticated");
// register the test relying party via the clients admin UI (idempotent)
await request(MAIN, "POST", ADMIN_BASE + "/clients/delete", { body: { client_id: CLIENT_ID, _csrf: await adminCsrf("/clients") } });
await request(MAIN, "POST", ADMIN_BASE + "/clients/create", { body: {
client_id: CLIENT_ID, label: "E2E Test RP", redirect_uris: REDIRECT_URI,
auth_method: "none", scope: "openid email profile groups", _csrf: await adminCsrf("/clients")
} });
ok(await clientExists(CLIENT_ID), "test-rp registered via clients admin UI");
const r1 = await authCodeFlow("openid email profile");
ok(r1.tokenStatus === 200, "token endpoint HTTP 200");
ok(!!r1.tokens.access_token && !!r1.tokens.id_token, "received access_token + id_token");
const jwks = await getJson(MAIN, "/idp/jwks");
const jwk = (jwks.keys || []).find((k) => k.kid === r1.header.kid) || (jwks.keys || [])[0];
const pub = crypto.createPublicKey({ key: jwk, format: "jwk" });
const sigOk = crypto.verify("sha256", Buffer.from(r1.tokens.__parts[0] + "." + r1.tokens.__parts[1]), pub, Buffer.from(r1.tokens.__parts[2], "base64url"));
ok(sigOk, "id_token RS256 signature verifies (kid=" + r1.header.kid + ")");
ok(r1.payload.iss === "http://localhost:3000/idp", "id_token iss = " + r1.payload.iss);
ok(r1.payload.aud === CLIENT_ID, "id_token aud = " + r1.payload.aud);
ok(r1.payload.nonce === r1.nonce, "id_token nonce matches request");
ok(!!r1.payload.sub, "id_token sub = " + r1.payload.sub);
const ui1 = await userInfo(r1.tokens.access_token);
ok(ui1.status === 200 && ui1.body.sub === r1.payload.sub, "userinfo sub matches id_token");
ok(ui1.body.email === ADMIN_EMAIL, "userinfo email = " + ui1.body.email);
section("Phase 2: groups claim (custom group + role-as-group)");
const grp = "e2e-grp-" + crypto.randomBytes(4).toString("hex");
await request(MAIN, "POST", ADMIN_BASE + "/groups/create", { body: { name: grp, _csrf: await adminCsrf("/groups") } });
const gid = await findGroupId(grp);
ok(!!gid, "group '" + grp + "' created via admin UI (id=" + gid + ")");
const addResp = await request(MAIN, "POST", ADMIN_BASE + "/groups/addmember", { body: { group_id: gid, email: ADMIN_EMAIL, _csrf: await adminCsrf("/groups") } });
ok(addResp.status === 302 || addResp.status === 303, "added admin to group via admin UI");
const r2 = await authCodeFlow("openid email profile groups");
ok(r2.tokenStatus === 200, "token endpoint HTTP 200 (groups scope)");
const idGroups = r2.payload.groups || [];
ok(Array.isArray(idGroups) && idGroups.indexOf("role:admin") >= 0, "id_token groups includes role:admin (" + JSON.stringify(idGroups) + ")");
ok(idGroups.indexOf("group:" + grp) >= 0, "id_token groups includes group:" + grp);
const ui2 = await userInfo(r2.tokens.access_token);
ok(Array.isArray(ui2.body.groups) && ui2.body.groups.indexOf("group:" + grp) >= 0, "userinfo groups includes group:" + grp);
await request(MAIN, "POST", ADMIN_BASE + "/groups/delete", { body: { id: gid, _csrf: await adminCsrf("/groups") } });
ok((await findGroupId(grp)) === null, "test group removed (cleanup)");
section("Phase 3: clients registry (confidential secret)");
const confResp = await request(MAIN, "POST", ADMIN_BASE + "/clients/create", { body: {
client_id: "e2e-conf", label: "Conf", redirect_uris: REDIRECT_URI,
auth_method: "client_secret_basic", scope: "openid", _csrf: await adminCsrf("/clients")
} });
ok(confResp.status === 200 && /Client secret/.test(confResp.body), "confidential client create shows a one-time secret");
await request(MAIN, "POST", ADMIN_BASE + "/clients/delete", { body: { client_id: "e2e-conf", _csrf: await adminCsrf("/clients") } });
ok(!(await clientExists("e2e-conf")), "confidential client deleted");
await request(MAIN, "POST", ADMIN_BASE + "/clients/delete", { body: { client_id: CLIENT_ID, _csrf: await adminCsrf("/clients") } });
ok(!(await clientExists(CLIENT_ID)), "test-rp cleaned up");
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
run().catch((e) => {
console.error("E2E ERROR:", e);
process.exit(2);
});