Files
keycloak-pattern/e2e/pattern3.mjs
T

199 lines
7.0 KiB
JavaScript

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