299 lines
12 KiB
JavaScript
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);
|
|
});
|