|
|
|
@@ -3,9 +3,62 @@ import { chromium } from "playwright-core";
|
|
|
|
|
|
|
|
|
|
const username = process.env.E2E_USERNAME ?? "regular-user";
|
|
|
|
|
const password = process.env.E2E_PASSWORD;
|
|
|
|
|
const frontendUrl = "http://localhost:8088/";
|
|
|
|
|
const tokenEndpoint =
|
|
|
|
|
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token";
|
|
|
|
|
const revokeEndpoint =
|
|
|
|
|
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/revoke";
|
|
|
|
|
|
|
|
|
|
assert.ok(password, "E2E_PASSWORD must be set");
|
|
|
|
|
|
|
|
|
|
async function login(page) {
|
|
|
|
|
await page.locator("#login").click();
|
|
|
|
|
await page.waitForURL(/localhost:8080/u);
|
|
|
|
|
await page.locator("#username").waitFor();
|
|
|
|
|
|
|
|
|
|
const tokenResponsePromise = page.waitForResponse((response) =>
|
|
|
|
|
response.url() === tokenEndpoint
|
|
|
|
|
&& response.request().postData()?.includes("grant_type=authorization_code"),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await page.locator("#username").fill(username);
|
|
|
|
|
await page.locator("#password").fill(password);
|
|
|
|
|
await page.locator("#kc-login").click();
|
|
|
|
|
|
|
|
|
|
const tokenResponse = await tokenResponsePromise;
|
|
|
|
|
assert.equal(tokenResponse.status(), 200);
|
|
|
|
|
const tokenSet = await tokenResponse.json();
|
|
|
|
|
assert.ok(tokenSet.access_token);
|
|
|
|
|
assert.ok(tokenSet.refresh_token);
|
|
|
|
|
assert.ok(tokenSet.id_token);
|
|
|
|
|
|
|
|
|
|
await page.waitForURL(frontendUrl);
|
|
|
|
|
await page.locator('[data-authenticated="true"]').waitFor();
|
|
|
|
|
return tokenSet;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function postForm(url, values) {
|
|
|
|
|
return fetch(url, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
|
|
|
body: new URLSearchParams(values),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refresh(refreshToken) {
|
|
|
|
|
return postForm(tokenEndpoint, {
|
|
|
|
|
grant_type: "refresh_token",
|
|
|
|
|
client_id: "spa-public",
|
|
|
|
|
refresh_token: refreshToken,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function callResource(accessToken, url = "http://localhost:8081/api/me") {
|
|
|
|
|
return fetch(url, {
|
|
|
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const browser = await chromium.launch({
|
|
|
|
|
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
|
|
|
|
headless: true,
|
|
|
|
@@ -23,39 +76,27 @@ try {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await page.goto("http://localhost:8088");
|
|
|
|
|
await page.locator("#login").click();
|
|
|
|
|
await page.waitForURL(/localhost:8080/u);
|
|
|
|
|
await page.locator("#username").fill(username);
|
|
|
|
|
await page.locator("#password").fill(password);
|
|
|
|
|
await page.locator("#kc-login").click();
|
|
|
|
|
await page.waitForURL("http://localhost:8088/");
|
|
|
|
|
await page.locator('[data-authenticated="true"]').waitFor();
|
|
|
|
|
await page.goto(frontendUrl);
|
|
|
|
|
const firstTokenSet = await login(page);
|
|
|
|
|
|
|
|
|
|
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
|
|
|
|
|
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
|
|
|
|
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
|
|
|
|
|
|
|
|
|
|
const accessToken = await page.evaluate(() => window.__pattern1.getAccessToken());
|
|
|
|
|
assert.ok(accessToken, "access token must exist in browser memory");
|
|
|
|
|
const payload = JSON.parse(
|
|
|
|
|
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
|
|
|
|
);
|
|
|
|
|
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
|
|
|
assert.ok(
|
|
|
|
|
audiences.includes("keycloak-pattern-api"),
|
|
|
|
|
"access token must target keycloak-pattern-api",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const storageSnapshot = await page.evaluate(() => ({
|
|
|
|
|
localStorage: Object.values(localStorage),
|
|
|
|
|
sessionStorage: Object.values(sessionStorage),
|
|
|
|
|
}));
|
|
|
|
|
assert.equal(
|
|
|
|
|
JSON.stringify(storageSnapshot).includes(accessToken),
|
|
|
|
|
false,
|
|
|
|
|
"access token must not be persisted in Web Storage",
|
|
|
|
|
);
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
const originalFetch = window.fetch.bind(window);
|
|
|
|
|
window.__xssProbe = { authorization: null };
|
|
|
|
|
window.fetch = (input, init = {}) => {
|
|
|
|
|
const headers = new Headers(
|
|
|
|
|
init.headers ?? (input instanceof Request ? input.headers : undefined),
|
|
|
|
|
);
|
|
|
|
|
const authorization = headers.get("Authorization");
|
|
|
|
|
if (authorization) {
|
|
|
|
|
window.__xssProbe.authorization = authorization;
|
|
|
|
|
}
|
|
|
|
|
return originalFetch(input, init);
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await page.locator("#call-api").click();
|
|
|
|
|
await page.waitForFunction(() => {
|
|
|
|
@@ -63,38 +104,106 @@ try {
|
|
|
|
|
return text.includes('"httpStatus": 200');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const capturedAuthorization = await page.evaluate(
|
|
|
|
|
() => window.__xssProbe.authorization,
|
|
|
|
|
);
|
|
|
|
|
assert.match(capturedAuthorization, /^Bearer /u);
|
|
|
|
|
const accessToken = capturedAuthorization.slice("Bearer ".length);
|
|
|
|
|
assert.equal(accessToken, firstTokenSet.access_token);
|
|
|
|
|
|
|
|
|
|
const payload = JSON.parse(
|
|
|
|
|
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
|
|
|
|
);
|
|
|
|
|
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
|
|
|
assert.ok(audiences.includes("keycloak-pattern-api"));
|
|
|
|
|
|
|
|
|
|
const storageSnapshot = await page.evaluate(() => ({
|
|
|
|
|
localStorage: Object.values(localStorage),
|
|
|
|
|
sessionStorage: Object.values(sessionStorage),
|
|
|
|
|
}));
|
|
|
|
|
assert.equal(JSON.stringify(storageSnapshot).includes(accessToken), false);
|
|
|
|
|
assert.ok(capturedAuthorization);
|
|
|
|
|
|
|
|
|
|
if (process.env.WRONG_AUDIENCE_URL) {
|
|
|
|
|
const response = await fetch(process.env.WRONG_AUDIENCE_URL, {
|
|
|
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(
|
|
|
|
|
response.status,
|
|
|
|
|
(await callResource(accessToken, process.env.WRONG_AUDIENCE_URL)).status,
|
|
|
|
|
401,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (process.env.WRONG_ISSUER_URL) {
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await callResource(accessToken, process.env.WRONG_ISSUER_URL)).status,
|
|
|
|
|
401,
|
|
|
|
|
"the same signed token must fail when the Resource Server expects another audience",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (process.env.WRONG_ISSUER_URL) {
|
|
|
|
|
const response = await fetch(process.env.WRONG_ISSUER_URL, {
|
|
|
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(
|
|
|
|
|
response.status,
|
|
|
|
|
401,
|
|
|
|
|
"the same signed token must fail when the Resource Server expects another issuer",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const logoutRequestPromise = page.waitForRequest((request) =>
|
|
|
|
|
request.url().includes("/protocol/openid-connect/logout"),
|
|
|
|
|
);
|
|
|
|
|
await page.locator("#logout").click();
|
|
|
|
|
const logoutRequest = await logoutRequestPromise;
|
|
|
|
|
assert.ok(new URL(logoutRequest.url()).searchParams.get("id_token_hint"));
|
|
|
|
|
await page.waitForURL(frontendUrl);
|
|
|
|
|
await page.locator('[data-authenticated="false"]').waitFor();
|
|
|
|
|
|
|
|
|
|
const secondTokenSet = await login(page);
|
|
|
|
|
const rotatedResponse = await refresh(secondTokenSet.refresh_token);
|
|
|
|
|
assert.equal(rotatedResponse.status, 200);
|
|
|
|
|
const rotated = await rotatedResponse.json();
|
|
|
|
|
assert.ok(rotated.refresh_token);
|
|
|
|
|
assert.notEqual(rotated.refresh_token, secondTokenSet.refresh_token);
|
|
|
|
|
|
|
|
|
|
const reusedOldResponse = await refresh(secondTokenSet.refresh_token);
|
|
|
|
|
assert.notEqual(
|
|
|
|
|
reusedOldResponse.status,
|
|
|
|
|
200,
|
|
|
|
|
"a consumed refresh token must not be accepted again",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const rotatedAfterReuseResponse = await refresh(rotated.refresh_token);
|
|
|
|
|
const rotatedAfterReuseStatus = rotatedAfterReuseResponse.status;
|
|
|
|
|
assert.ok([200, 400, 401].includes(rotatedAfterReuseStatus));
|
|
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await callResource(rotated.access_token)).status,
|
|
|
|
|
200,
|
|
|
|
|
"a locally validated access JWT remains usable until exp",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await context.clearCookies();
|
|
|
|
|
await page.reload();
|
|
|
|
|
await page.locator('[data-authenticated="false"]').waitFor();
|
|
|
|
|
const thirdTokenSet = await login(page);
|
|
|
|
|
|
|
|
|
|
const revokeResponse = await postForm(revokeEndpoint, {
|
|
|
|
|
token: thirdTokenSet.refresh_token,
|
|
|
|
|
token_type_hint: "refresh_token",
|
|
|
|
|
client_id: "spa-public",
|
|
|
|
|
});
|
|
|
|
|
assert.equal(revokeResponse.status, 200);
|
|
|
|
|
assert.notEqual((await refresh(thirdTokenSet.refresh_token)).status, 200);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await callResource(thirdTokenSet.access_token)).status,
|
|
|
|
|
200,
|
|
|
|
|
"refresh revoke is not an immediate deny-list for a stateless access JWT",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
await page.locator('[data-authenticated="false"]').waitFor();
|
|
|
|
|
assert.equal(
|
|
|
|
|
await page.evaluate(() => window.__pattern1.getAccessToken()),
|
|
|
|
|
null,
|
|
|
|
|
"reload must clear the memory-only token",
|
|
|
|
|
await page.evaluate(
|
|
|
|
|
(token) => JSON.stringify({
|
|
|
|
|
localStorage: Object.values(localStorage),
|
|
|
|
|
sessionStorage: Object.values(sessionStorage),
|
|
|
|
|
}).includes(token),
|
|
|
|
|
thirdTokenSet.access_token,
|
|
|
|
|
),
|
|
|
|
|
false,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
"pattern1 browser verified: code+PKCE S256, audience/issuer negatives 401, Web Storage token 0, reload clears token",
|
|
|
|
|
"pattern1 verified: PKCE, aud/iss negatives, memory/XSS boundary, logout, RT rotation/reuse, revoke-vs-stateless JWT"
|
|
|
|
|
+ ` (RT2 after RT1 reuse: ${rotatedAfterReuseStatus})`,
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
await browser.close();
|
|
|
|
|