1
0
forked from baron/baron-sso
Files
baron-sso/test/rp_claims_live_e2e.mjs

605 lines
20 KiB
JavaScript

import { mkdir, writeFile } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import playwright from "../devfront/node_modules/playwright/index.js";
const { chromium } = playwright;
const gatewayUrl = process.env.RP_CLAIMS_E2E_GATEWAY_URL ?? "http://localhost:5000";
const devfrontUrl = process.env.RP_CLAIMS_E2E_DEVFRONT_URL ?? "http://localhost:5174";
const reportDir = process.env.RP_CLAIMS_E2E_REPORT_DIR ?? "reports/rp-claims-live-e2e";
const tenantSlug = process.env.RP_CLAIMS_E2E_TENANT_SLUG ?? "public-org";
const runId = process.env.RP_CLAIMS_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const email = `rp-claims-${runId}@example.test`;
const loginId = `rp${runId.replaceAll("-", "").slice(0, 18)}`;
const loginIdentifier = email;
const password = `RpClaims${runId.slice(0, 6)}!1aA`;
const redirectUri = "http://127.0.0.1:18080/callback";
const subjectByLoginId = new Map();
const scenarios = [
{
name: "rp-a",
clientId: `rp-claims-a-${runId}`,
defaults: {
approvalLevel: "A",
activeMember: true,
score: 1,
featureList: ["default"],
preferences: { theme: "light", density: "comfortable" },
contractDate: "2026-06-09",
approvedAt: "2026-06-09T09:30",
adminManagedNote: "admin-default",
},
updates: [
{
label: "admin-update",
metadata: {
approvalLevel: "B",
activeMember: false,
score: 42,
featureList: ["sso", "claims"],
preferences: { theme: "dark", density: "compact" },
contractDate: "2026-06-10",
approvedAt: "2026-06-09T10:30",
adminManagedNote: "admin-updated",
approvalLevel_permissions: {
readPermission: "admin_only",
writePermission: "user_and_admin",
},
},
expected: {
approvalLevel: "B",
activeMember: false,
score: 42,
featureList: ["sso", "claims"],
preferences: { theme: "dark", density: "compact" },
contractDate: "2026-06-10",
approvedAt: "2026-06-09T10:30",
adminManagedNote: "admin-updated",
},
},
{
label: "second-update",
metadata: {
approvalLevel: "C",
activeMember: true,
score: 7.5,
featureList: ["delta", "omega", "42"],
preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
contractDate: "2026-06-11",
approvedAt: "2026-06-11T14:45:30Z",
adminManagedNote: "admin-updated-again",
approvalLevel_permissions: {
readPermission: "user_and_admin",
writePermission: "user_and_admin",
},
},
expected: {
approvalLevel: "C",
activeMember: true,
score: 7.5,
featureList: ["delta", "omega", "42"],
preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
contractDate: "2026-06-11",
approvedAt: "2026-06-11T14:45:30Z",
adminManagedNote: "admin-updated-again",
},
},
],
},
{
name: "rp-b",
clientId: `rp-claims-b-${runId}`,
defaults: {
approvalLevel: "B-default",
activeMember: false,
score: 100,
featureList: ["rp-b-default"],
preferences: { theme: "blue", density: "wide" },
contractDate: "2026-07-01",
approvedAt: "2026-07-01T08:00",
},
updates: [
{
label: "rp-b-isolated-update",
metadata: {
approvalLevel: "B-only",
activeMember: true,
score: 314,
featureList: ["only", "rp-b"],
preferences: { theme: "green", density: "narrow" },
contractDate: "2026-07-02",
approvedAt: "2026-07-02T09:15",
},
expected: {
approvalLevel: "B-only",
activeMember: true,
score: 314,
featureList: ["only", "rp-b"],
preferences: { theme: "green", density: "narrow" },
contractDate: "2026-07-02",
approvedAt: "2026-07-02T09:15",
},
},
],
},
];
const report = {
runId,
gatewayUrl,
devfrontUrl,
loginId,
loginIdentifier,
email,
createdAt: new Date().toISOString(),
steps: [],
scenarios: [],
passed: false,
};
function assertDeepEqual(actual, expected, label) {
const actualJson = JSON.stringify(canonicalize(actual));
const expectedJson = JSON.stringify(canonicalize(expected));
if (actualJson !== expectedJson) {
throw new Error(`${label}: expected ${expectedJson}, got ${actualJson}`);
}
}
function canonicalize(value) {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)]),
);
}
return value;
}
function assertAbsent(record, key, label) {
if (Object.hasOwn(record ?? {}, key)) {
throw new Error(`${label}: ${key} must not be present`);
}
}
function base64urlDecode(input) {
const padded = input.padEnd(input.length + ((4 - (input.length % 4)) % 4), "=");
return Buffer.from(padded.replaceAll("-", "+").replaceAll("_", "/"), "base64").toString("utf8");
}
function decodeJwtPayload(token) {
const parts = String(token).split(".");
if (parts.length < 2) {
throw new Error("id_token is not a JWT");
}
return JSON.parse(base64urlDecode(parts[1]));
}
function base64url(buffer) {
return Buffer.from(buffer)
.toString("base64")
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
function pkceChallenge(verifier) {
return base64url(createHash("sha256").update(verifier).digest());
}
function claimDefinitions(defaults) {
const claims = [
{ key: "approvalLevel", valueType: "text", value: defaults.approvalLevel },
{ key: "activeMember", valueType: "boolean", value: String(defaults.activeMember) },
{ key: "score", valueType: "number", value: String(defaults.score) },
{ key: "featureList", valueType: "array", value: JSON.stringify(defaults.featureList) },
{ key: "preferences", valueType: "object", value: JSON.stringify(defaults.preferences) },
{ key: "contractDate", valueType: "date", value: defaults.contractDate },
{ key: "approvedAt", valueType: "datetime", value: defaults.approvedAt },
].filter((claim) => claim.value !== undefined);
if (defaults.adminManagedNote !== undefined) {
claims.push({
key: "adminManagedNote",
valueType: "text",
value: defaults.adminManagedNote,
writePermission: "admin_only",
});
}
return claims.map((claim) => ({
namespace: "rp_claims",
readPermission: "user_and_admin",
writePermission: claim.writePermission ?? "user_and_admin",
...claim,
}));
}
async function requestJson(url, options = {}) {
const { cookieJar, ...fetchOptions } = options;
const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
const response = await fetch(url, {
...fetchOptions,
headers: {
"Content-Type": "application/json",
"X-Test-Role": "super_admin",
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(options.headers ?? {}),
},
});
rememberCookies(cookieJar, response);
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!response.ok) {
throw new Error(`${options.method ?? "GET"} ${url} failed: ${response.status} ${text}`);
}
return { response, body };
}
async function createUser() {
const body = {
email,
loginId,
password,
name: `RP Claims ${runId}`,
role: "user",
tenantSlug,
};
const result = await requestJson(`${gatewayUrl}/api/v1/admin/users`, {
method: "POST",
body: JSON.stringify(body),
});
const id = result.body?.id ?? result.body?.user?.id ?? result.body?.identityId;
if (!id) {
throw new Error(`created user response has no id: ${JSON.stringify(result.body)}`);
}
subjectByLoginId.set(loginIdentifier, id);
report.steps.push({ step: "create-user", status: "ok", subject: id });
return id;
}
async function createClient(scenario) {
const body = {
id: scenario.clientId,
name: `RP Claims E2E ${scenario.name} ${runId}`,
type: "pkce",
status: "active",
redirectUris: [redirectUri],
scopes: ["openid", "profile", "email"],
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
tokenEndpointAuthMethod: "none",
skipConsent: false,
metadata: {
id_token_claims: claimDefinitions(scenario.defaults),
tenant_id: "tenant-rp-claims-live-e2e",
},
};
await requestJson(`${devfrontUrl}/api/v1/dev/clients`, {
method: "POST",
body: JSON.stringify(body),
});
report.steps.push({ step: "create-client", status: "ok", clientId: scenario.clientId });
}
function cookieHeaderFromJar(cookieJar) {
return Object.entries(cookieJar ?? {})
.map(([name, value]) => `${name}=${value}`)
.join("; ");
}
function rememberCookies(cookieJar, response) {
if (!cookieJar || !response?.headers) {
return;
}
const setCookies =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: response.headers.get("set-cookie")
? [response.headers.get("set-cookie")]
: [];
for (const header of setCookies) {
const firstPart = String(header ?? "").split(";")[0];
const separator = firstPart.indexOf("=");
if (separator <= 0) {
continue;
}
cookieJar[firstPart.slice(0, separator)] = firstPart.slice(separator + 1);
}
}
async function manualFetch(url, options = {}) {
try {
const { cookieJar, ...fetchOptions } = options;
const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
const response = await fetch(url, {
redirect: "manual",
...fetchOptions,
headers: {
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(options.headers ?? {}),
},
});
rememberCookies(cookieJar, response);
return response;
} catch (error) {
throw new Error(`manual fetch failed for ${url}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function locationFrom(response, label) {
const location = response.headers.get("location");
if (!location) {
throw new Error(`${label}: redirect location missing, status=${response.status}`);
}
return new URL(location, gatewayUrl).toString();
}
function searchParam(url, name, label) {
const value = new URL(url).searchParams.get(name);
if (!value) {
throw new Error(`${label}: ${name} missing from ${url}`);
}
return value;
}
async function authorizeAndDecodeClaims(clientId, label) {
const state = `${label}-${Math.random().toString(16).slice(2, 8)}`;
const nonce = `${label}-${Math.random().toString(16).slice(2, 8)}`;
const verifier = base64url(randomBytes(32));
const cookieJar = {};
const authorize = new URL(`${gatewayUrl}/oidc/oauth2/auth`);
authorize.searchParams.set("client_id", clientId);
authorize.searchParams.set("redirect_uri", redirectUri);
authorize.searchParams.set("response_type", "code");
authorize.searchParams.set("scope", "openid profile email");
authorize.searchParams.set("state", state);
authorize.searchParams.set("nonce", nonce);
authorize.searchParams.set("code_challenge", pkceChallenge(verifier));
authorize.searchParams.set("code_challenge_method", "S256");
const trace = { label, clientId, authorize: authorize.toString(), redirects: [] };
report.steps.push({ step: "authorize-token", status: "started", trace });
const first = await manualFetch(authorize, { cookieJar });
let next = locationFrom(first, `${label}: authorize`);
trace.redirects.push({ from: "authorize", status: first.status, location: next });
const loginChallenge = searchParam(next, "login_challenge", `${label}: login redirect`);
const login = await requestJson(`${gatewayUrl}/api/v1/auth/password/login`, {
method: "POST",
cookieJar,
body: JSON.stringify({ loginId: loginIdentifier, password, login_challenge: loginChallenge }),
});
next = login.body.redirectTo;
if (!next) {
throw new Error(`${label}: password login response missing redirectTo`);
}
trace.redirects.push({ from: "password-login", location: next });
let afterLogin = await manualFetch(next, { cookieJar });
next = locationFrom(afterLogin, `${label}: after login`);
trace.redirects.push({ from: "after-login", status: afterLogin.status, location: next });
if (new URL(next).searchParams.has("consent_challenge")) {
const consentChallenge = searchParam(next, "consent_challenge", `${label}: consent redirect`);
const consent = await requestJson(`${gatewayUrl}/api/v1/auth/consent/accept`, {
method: "POST",
cookieJar,
body: JSON.stringify({
consent_challenge: consentChallenge,
grant_scope: ["openid", "profile", "email"],
}),
});
next = consent.body.redirectTo;
if (!next) {
throw new Error(`${label}: consent response missing redirectTo`);
}
trace.redirects.push({ from: "consent-accept", location: next });
afterLogin = await manualFetch(next, { cookieJar });
next = locationFrom(afterLogin, `${label}: after consent`);
trace.redirects.push({ from: "after-consent", status: afterLogin.status, location: next });
}
if (new URL(next).searchParams.has("error")) {
const callback = new URL(next);
throw new Error(`${label}: callback error ${callback.searchParams.get("error")} ${callback.searchParams.get("error_description") ?? ""}`.trim());
}
if (!new URL(next).searchParams.has("code")) {
if (new URL(next).hostname === "127.0.0.1") {
throw new Error(`${label}: callback has no code: ${next}`);
}
const final = await manualFetch(next, { cookieJar });
next = locationFrom(final, `${label}: final code redirect`);
trace.redirects.push({ from: "final", status: final.status, location: next });
}
const code = searchParam(next, "code", `${label}: callback`);
const returnedState = searchParam(next, "state", `${label}: callback`);
if (returnedState !== state) {
throw new Error(`${label}: state mismatch`);
}
const tokenResponse = await fetch(`${gatewayUrl}/oidc/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId,
redirect_uri: redirectUri,
code,
code_verifier: verifier,
}),
});
const tokenText = await tokenResponse.text();
let tokenBody;
try {
tokenBody = JSON.parse(tokenText);
} catch {
tokenBody = tokenText;
}
if (!tokenResponse.ok) {
throw new Error(`${label}: token failed ${tokenResponse.status} ${tokenText}`);
}
const payload = decodeJwtPayload(tokenBody.id_token);
report.steps.push({ step: "authorize-token", status: "ok", trace });
return {
idTokenPayload: payload,
rpClaims: payload.rp_claims,
tokenResponse: {
token_type: tokenBody.token_type,
expires_in: tokenBody.expires_in,
scope: tokenBody.scope,
has_access_token: Boolean(tokenBody.access_token),
has_id_token: Boolean(tokenBody.id_token),
},
};
}
async function updateClaimsFromDevfrontBrowser(page, scenario, metadata, label) {
const result = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata);
if (!result.ok) {
throw new Error(`${label}: devfront metadata update failed ${result.status} ${JSON.stringify(result.body)}`);
}
return result;
}
async function requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata) {
const subject = subjectByLoginId.get(loginIdentifier);
return page.evaluate(
async ({ clientId, subject, metadata }) => {
const response = await fetch(`/api/v1/dev/clients/${clientId}/users/${subject}/metadata`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"X-Test-Role": "super_admin",
"X-Tenant-ID": "tenant-rp-claims-live-e2e",
},
body: JSON.stringify({ metadata }),
});
const text = await response.text();
let body;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { ok: response.ok, status: response.status, body };
},
{ clientId: scenario.clientId, subject, metadata },
);
}
let browser;
try {
await mkdir(reportDir, { recursive: true });
const subject = await createUser();
report.subject = subject;
for (const scenario of scenarios) {
await createClient(scenario);
}
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
extraHTTPHeaders: {
"X-Test-Role": "super_admin",
"X-Tenant-ID": "tenant-rp-claims-live-e2e",
},
});
await page.goto(devfrontUrl, { waitUntil: "domcontentloaded" });
for (const scenario of scenarios) {
const scenarioReport = {
name: scenario.name,
clientId: scenario.clientId,
defaultCheck: null,
negativeChecks: [],
updates: [],
};
const defaults = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-default`);
assertDeepEqual(defaults.rpClaims, scenario.defaults, `${scenario.name}: default rp_claims`);
scenarioReport.defaultCheck = {
expected: scenario.defaults,
actual: defaults.rpClaims,
token: defaults.tokenResponse,
};
const undefinedClaimResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
internalMemo: "must-be-rejected",
});
if (undefinedClaimResult.status !== 400) {
throw new Error(`${scenario.name}: undefined claim update should be rejected with 400, got ${undefinedClaimResult.status}`);
}
scenarioReport.negativeChecks.push({
label: "undefined-claim-rejected",
status: undefinedClaimResult.status,
body: undefinedClaimResult.body,
});
if (Object.hasOwn(scenario.defaults, "score")) {
const invalidTypeResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
score: "not-a-number",
});
if (invalidTypeResult.status !== 400) {
throw new Error(`${scenario.name}: invalid number update should be rejected with 400, got ${invalidTypeResult.status}`);
}
scenarioReport.negativeChecks.push({
label: "invalid-number-rejected",
status: invalidTypeResult.status,
body: invalidTypeResult.body,
});
}
for (const update of scenario.updates) {
const updateResult = await updateClaimsFromDevfrontBrowser(page, scenario, update.metadata, `${scenario.name}-${update.label}`);
const decoded = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-${update.label}`);
assertDeepEqual(decoded.rpClaims, update.expected, `${scenario.name}.${update.label}: updated rp_claims`);
assertAbsent(decoded.rpClaims, "internalMemo", `${scenario.name}.${update.label}`);
assertAbsent(decoded.rpClaims, "approvalLevel_permissions", `${scenario.name}.${update.label}`);
assertAbsent(decoded.rpClaims, "adminManagedNote_permissions", `${scenario.name}.${update.label}`);
scenarioReport.updates.push({
label: update.label,
devfrontUpdateStatus: updateResult.status,
expected: update.expected,
actual: decoded.rpClaims,
token: decoded.tokenResponse,
});
}
report.scenarios.push(scenarioReport);
}
const rpAAfter = report.scenarios.find((item) => item.name === "rp-a")?.updates.at(-1)?.actual;
const rpBAfter = report.scenarios.find((item) => item.name === "rp-b")?.updates.at(-1)?.actual;
if (rpAAfter?.approvalLevel === rpBAfter?.approvalLevel) {
throw new Error("rp isolation failed: rp-a and rp-b approvalLevel unexpectedly match");
}
assertAbsent(rpBAfter, "internalMemo", "rp-b isolation");
assertAbsent(rpBAfter, "adminManagedNote", "rp-b isolation");
report.passed = true;
} catch (error) {
report.passed = false;
report.error = {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
};
process.exitCode = 1;
} finally {
if (browser) {
await browser.close();
}
report.finishedAt = new Date().toISOString();
const reportPath = `${reportDir}/rp-claims-live-e2e-${runId}.json`;
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify({ passed: report.passed, reportPath, runId }, null, 2));
}