import assert from "node:assert/strict"; import { chromium } from "playwright-core"; const password = process.env.E2E_PASSWORD; assert.ok(password, "E2E_PASSWORD must be set"); async function completeKeycloakLogin(page) { for (let attempt = 1; attempt <= 2; attempt += 1) { await page.locator("#username").fill( process.env.E2E_USERNAME ?? "regular-user", ); await page.locator("#password").fill(password); await page.locator("#kc-login").click(); await page.waitForLoadState("domcontentloaded"); if (page.url() === "http://localhost:8083/") { return; } if (attempt === 1) { await page.goto( "http://localhost:8083/oauth2/authorization/keycloak", ); await page.waitForURL(/localhost:8080/u); } } throw new Error(`Keycloak login did not return to AP3: ${page.url()}`); } const browser = await chromium.launch({ executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox"], }); try { const context = await browser.newContext(); const page = await context.newPage(); const browserRequests = []; page.on("request", (request) => browserRequests.push({ method: request.method(), url: request.url() }), ); await page.goto("http://localhost:8083"); const authorizationRequestPromise = page.waitForRequest((request) => request.url().includes( "/protocol/openid-connect/auth?response_type=code", ), ); await page.locator("#login").click(); const authorizationRequest = await authorizationRequestPromise; const authorizationUrl = new URL(authorizationRequest.url()); assert.equal(authorizationUrl.searchParams.get("client_id"), "bff-confidential"); assert.equal(authorizationUrl.searchParams.get("code_challenge_method"), "S256"); assert.ok(authorizationUrl.searchParams.get("code_challenge")); await page.waitForURL(/localhost:8080/u); await completeKeycloakLogin(page); const callbackRequest = browserRequests.find(({ url }) => url.startsWith("http://localhost:8083/login/oauth2/code/keycloak?"), ); assert.ok(callbackRequest, "authorization response must use the BFF callback"); assert.equal(callbackRequest.method, "GET"); const boundaryResponsePromise = page.waitForResponse((response) => response.url().endsWith("/bff/token-boundary"), ); await page.locator("#inspect").click(); const boundaryResponse = await boundaryResponsePromise; assert.equal(boundaryResponse.status(), 200); const boundary = await boundaryResponse.json(); assert.equal(boundary.accessTokenStoredOnServer, true); assert.equal(boundary.refreshTokenStoredOnServer, true); assert.equal(boundary.browserTokenCount, 0); assert.equal(boundary.csrfProtectionEnabled, true); assert.equal(JSON.stringify(boundary).includes("access_token"), false); assert.equal(JSON.stringify(boundary).includes("refresh_token"), false); const proxyResponsePromise = page.waitForResponse((response) => response.url().endsWith("/bff/api/me"), ); await page.locator("#call-bff").click(); const proxyResponse = await proxyResponsePromise; assert.equal(proxyResponse.status(), 200); const resource = await proxyResponse.json(); assert.equal(resource.username, "regular-user"); assert.ok(resource.audience.includes("keycloak-pattern-api")); assert.equal( browserRequests.some(({ url }) => url.startsWith("http://localhost:8081/"), ), false, "the browser must not bypass the BFF", ); assert.equal( browserRequests.some(({ url }) => url.includes("/protocol/openid-connect/token"), ), false, "the token exchange must be server-to-server", ); const cookies = await context.cookies("http://localhost:8083/"); const sessionCookie = cookies.find((cookie) => cookie.name === "AP3_SESSION"); assert.ok(sessionCookie); assert.equal(sessionCookie.httpOnly, true); assert.equal(sessionCookie.sameSite, "Lax"); const storage = await page.evaluate(() => ({ localStorage: Object.values(localStorage), sessionStorage: Object.values(sessionStorage), readableCookies: document.cookie, })); assert.deepEqual(storage.localStorage, []); assert.deepEqual(storage.sessionStorage, []); assert.equal(storage.readableCookies.includes("AP3_SESSION"), false); const missingCsrfResponse = await page.evaluate(async () => { const response = await fetch("/bff/api/preferences", { method: "POST", body: new URLSearchParams({ theme: "missing-csrf" }), headers: { "Content-Type": "application/x-www-form-urlencoded" }, }); return response.status; }); assert.equal(missingCsrfResponse, 403); const csrfResponsePromise = page.waitForResponse((response) => response.url().endsWith("/bff/csrf"), ); const validChangeResponsePromise = page.waitForResponse( (response) => response.url().endsWith("/bff/api/preferences") && response.request().method() === "POST" && response.status() === 200, ); await page.locator("#change-with-csrf").click(); const csrfResponse = await csrfResponsePromise; const validChangeResponse = await validChangeResponsePromise; assert.equal(csrfResponse.status(), 200); assert.equal(validChangeResponse.status(), 200); const validChange = await validChangeResponse.json(); assert.equal(validChange.theme, "dark"); const csrfCookies = await context.cookies("http://localhost:8083/"); const csrfCookie = csrfCookies.find((cookie) => cookie.name === "XSRF-TOKEN"); assert.ok(csrfCookie); assert.equal(csrfCookie.httpOnly, false); await page.goto("http://localhost:8088"); const [forgedResponse] = await Promise.all([ page.waitForNavigation(), page.evaluate(() => { const form = document.createElement("form"); form.method = "POST"; form.action = "http://localhost:8083/bff/api/preferences"; const input = document.createElement("input"); input.name = "theme"; input.value = "attacker"; form.append(input); document.body.append(form); form.submit(); }), ]); assert.ok(forgedResponse); assert.equal(forgedResponse.status(), 403); await page.goto("http://127.0.0.1:8088"); const crossSiteRequestPromise = page.waitForRequest( (request) => request.url() === "http://localhost:8083/bff/api/preferences" && request.method() === "POST", ); await page.evaluate(() => { const form = document.createElement("form"); form.method = "POST"; form.action = "http://localhost:8083/bff/api/preferences"; const input = document.createElement("input"); input.name = "theme"; input.value = "cross-site-attacker"; form.append(input); document.body.append(form); form.submit(); }); const crossSiteRequest = await crossSiteRequestPromise; const crossSiteHeaders = await crossSiteRequest.allHeaders(); assert.equal( (crossSiteHeaders.cookie ?? "").includes("AP3_SESSION="), false, "SameSite=Lax must omit the session cookie on a cross-site POST", ); console.log( "pattern3 BFF verified: tokenless POST 403, CSRF header 200, SameSite=Lax session", ); } finally { await browser.close(); }