diff --git a/README.md b/README.md index 20c5ed1..75b6a95 100644 --- a/README.md +++ b/README.md @@ -127,3 +127,8 @@ token 저장 위치와 XSS 범위는 [`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다. E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer 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 동작을 검증합니다. diff --git a/docs/ap1-refresh-logout.md b/docs/ap1-refresh-logout.md new file mode 100644 index 0000000..1c22943 --- /dev/null +++ b/docs/ap1-refresh-logout.md @@ -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 검증 특성을 의도적으로 +유지한다. diff --git a/e2e/pattern1.mjs b/e2e/pattern1.mjs index 0f4ddf5..6c0d3a2 100644 --- a/e2e/pattern1.mjs +++ b/e2e/pattern1.mjs @@ -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();