// 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("" + name + "[\\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("" + clientId + "") >= 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); });