test(ap1): verify refresh rotation and logout
This commit is contained in:
@@ -127,3 +127,8 @@ token 저장 위치와 XSS 범위는
|
|||||||
[`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다.
|
[`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다.
|
||||||
E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer
|
E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer
|
||||||
header를 관찰할 수 있음을 재현합니다.
|
header를 관찰할 수 있음을 재현합니다.
|
||||||
|
|
||||||
|
refresh rotation, 소비된 refresh token 재사용, RP-Initiated Logout,
|
||||||
|
revocation과 stateless JWT의 차이는
|
||||||
|
[`docs/ap1-refresh-logout.md`](docs/ap1-refresh-logout.md)에 정리했으며 같은
|
||||||
|
E2E에서 실제 Keycloak 26.7.0 동작을 검증합니다.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# AP1 refresh rotation and logout
|
||||||
|
|
||||||
|
Realm 실행 profile:
|
||||||
|
|
||||||
|
- Access Token Lifespan: 300초
|
||||||
|
- Revoke Refresh Token: 활성화
|
||||||
|
- Refresh Token Max Reuse: 0
|
||||||
|
|
||||||
|
`e2e/pattern1.mjs`는 token 원문을 출력하지 않고 다음 순서를 실행한다.
|
||||||
|
|
||||||
|
1. browser Authorization Code + PKCE 로그인으로 AT₁/RT₁/ID Token을 받는다.
|
||||||
|
2. `signoutRedirect()`가 `id_token_hint`를 포함한 Keycloak logout endpoint를
|
||||||
|
호출하는지 확인한다.
|
||||||
|
3. logout 이후 새 authorization 요청에서 로그인 화면이 다시 필요한지
|
||||||
|
확인한다.
|
||||||
|
4. 새 RT₁으로 refresh하여 AT₂/RT₂를 받고 RT₂가 RT₁과 다른지 확인한다.
|
||||||
|
5. 이미 소비된 RT₁을 재사용해 성공하지 않는지 확인한다.
|
||||||
|
6. RT₁ 재사용 뒤 RT₂와 realm session 상태가 어떤 결과를 내는지 status로
|
||||||
|
기록한다. 이 결과를 사전에 family invalidation이라고 단정하지 않는다.
|
||||||
|
7. refresh token을 revoke한 뒤 같은 refresh token의 재사용은 실패하지만,
|
||||||
|
이미 발급된 self-contained access JWT는 `exp` 전까지 Resource Server에서
|
||||||
|
계속 `200`인 stateless 함정을 확인한다.
|
||||||
|
|
||||||
|
logout은 브라우저 SSO session을 종료하는 흐름이고 token revocation은 특정
|
||||||
|
token grant를 폐기하는 흐름이다. 둘은 목적과 endpoint가 다르다.
|
||||||
|
|
||||||
|
즉시 access 차단이 필요한 시스템이라면 짧은 access token TTL 외에
|
||||||
|
introspection, reference token 또는 별도 deny-list 같은 stateful 검증을
|
||||||
|
검토해야 한다. 이 AP1 구현은 JWT의 stateless 검증 특성을 의도적으로
|
||||||
|
유지한다.
|
||||||
+120
-39
@@ -3,9 +3,62 @@ import { chromium } from "playwright-core";
|
|||||||
|
|
||||||
const username = process.env.E2E_USERNAME ?? "regular-user";
|
const username = process.env.E2E_USERNAME ?? "regular-user";
|
||||||
const password = process.env.E2E_PASSWORD;
|
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");
|
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({
|
const browser = await chromium.launch({
|
||||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -23,14 +76,8 @@ try {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("http://localhost:8088");
|
await page.goto(frontendUrl);
|
||||||
await page.locator("#login").click();
|
const firstTokenSet = await login(page);
|
||||||
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();
|
|
||||||
|
|
||||||
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
|
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
|
||||||
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
||||||
@@ -62,50 +109,84 @@ try {
|
|||||||
);
|
);
|
||||||
assert.match(capturedAuthorization, /^Bearer /u);
|
assert.match(capturedAuthorization, /^Bearer /u);
|
||||||
const accessToken = capturedAuthorization.slice("Bearer ".length);
|
const accessToken = capturedAuthorization.slice("Bearer ".length);
|
||||||
|
assert.equal(accessToken, firstTokenSet.access_token);
|
||||||
|
|
||||||
const payload = JSON.parse(
|
const payload = JSON.parse(
|
||||||
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
||||||
);
|
);
|
||||||
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||||
assert.ok(
|
assert.ok(audiences.includes("keycloak-pattern-api"));
|
||||||
audiences.includes("keycloak-pattern-api"),
|
|
||||||
"access token must target keycloak-pattern-api",
|
|
||||||
);
|
|
||||||
|
|
||||||
const storageSnapshot = await page.evaluate(() => ({
|
const storageSnapshot = await page.evaluate(() => ({
|
||||||
localStorage: Object.values(localStorage),
|
localStorage: Object.values(localStorage),
|
||||||
sessionStorage: Object.values(sessionStorage),
|
sessionStorage: Object.values(sessionStorage),
|
||||||
}));
|
}));
|
||||||
assert.equal(
|
assert.equal(JSON.stringify(storageSnapshot).includes(accessToken), false);
|
||||||
JSON.stringify(storageSnapshot).includes(accessToken),
|
assert.ok(capturedAuthorization);
|
||||||
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",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (process.env.WRONG_AUDIENCE_URL) {
|
if (process.env.WRONG_AUDIENCE_URL) {
|
||||||
const response = await fetch(process.env.WRONG_AUDIENCE_URL, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
assert.equal(
|
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,
|
401,
|
||||||
"the same signed token must fail when the Resource Server expects another audience",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.WRONG_ISSUER_URL) {
|
const logoutRequestPromise = page.waitForRequest((request) =>
|
||||||
const response = await fetch(process.env.WRONG_ISSUER_URL, {
|
request.url().includes("/protocol/openid-connect/logout"),
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
);
|
||||||
});
|
await page.locator("#logout").click();
|
||||||
assert.equal(
|
const logoutRequest = await logoutRequestPromise;
|
||||||
response.status,
|
assert.ok(new URL(logoutRequest.url()).searchParams.get("id_token_hint"));
|
||||||
401,
|
await page.waitForURL(frontendUrl);
|
||||||
"the same signed token must fail when the Resource Server expects another issuer",
|
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.reload();
|
||||||
await page.locator('[data-authenticated="false"]').waitFor();
|
await page.locator('[data-authenticated="false"]').waitFor();
|
||||||
@@ -115,14 +196,14 @@ try {
|
|||||||
localStorage: Object.values(localStorage),
|
localStorage: Object.values(localStorage),
|
||||||
sessionStorage: Object.values(sessionStorage),
|
sessionStorage: Object.values(sessionStorage),
|
||||||
}).includes(token),
|
}).includes(token),
|
||||||
accessToken,
|
thirdTokenSet.access_token,
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
"reload must clear the memory-only token without persisting it",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
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 {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user