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)); }