test(ap1): verify refresh rotation and logout
This commit is contained in:
+120
-39
@@ -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,14 +76,8 @@ 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");
|
||||
@@ -62,50 +109,84 @@ try {
|
||||
);
|
||||
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"),
|
||||
"access token must target keycloak-pattern-api",
|
||||
);
|
||||
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,
|
||||
"access token must not be persisted in Web Storage",
|
||||
);
|
||||
assert.ok(
|
||||
capturedAuthorization,
|
||||
"runtime XSS-style fetch hooking can still observe a memory-only bearer token",
|
||||
);
|
||||
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();
|
||||
@@ -115,14 +196,14 @@ try {
|
||||
localStorage: Object.values(localStorage),
|
||||
sessionStorage: Object.values(sessionStorage),
|
||||
}).includes(token),
|
||||
accessToken,
|
||||
thirdTokenSet.access_token,
|
||||
),
|
||||
false,
|
||||
"reload must clear the memory-only token without persisting it",
|
||||
);
|
||||
|
||||
console.log(
|
||||
"pattern1 browser verified: PKCE, audience/issuer negatives, persistent token 0, runtime fetch hook observes bearer, 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();
|
||||
|
||||
Reference in New Issue
Block a user