Files
document-haness/.run/keycloak-four-patterns/records/case-ap2-split-custody.json
T

13 lines
10 KiB
JSON

{
"kind": "CASE",
"title": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"slug": "split-custody-access-token",
"summary": "confidential client인 mediator가 authorization code를 token으로 교환하고 refresh token을 server-side authorized client에 보관한다. 브라우저는 Resource Server를 직접 호출하므로 mediator의 `/token/access`에서 access token을 받아 `Authorization` 헤더에 사용한다.",
"problem": "Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고\naccess token과 refresh token을 server-side authorized-client service에 저장한다.\n브라우저에는 HttpOnly AP2_SESSION만 관리하게 된다.\n\n여기까지만 보면 BFF 구조와 같아 보이지만,\nMediator의 브라우저는 여전히 Resource Server를 직접 호출하고 있다.\n그러면 access token이 필요하고, mediator가 그것을 응답으로 반환하게 된다.\n\n`/token/access` 응답을 확인해 보니 mediator가 refresh token을 보관하더라도 access token은 브라우저에 전달되고 있었다. 브라우저가 Resource Server를 직접 호출하는 구조에서는 access token 전달이 필요했다.",
"conclusion": "client secret과 refresh token은 mediator가 관리한다. access token은 `/token/access` 응답 본문, JavaScript 변수, `Authorization` 헤더에서 확인된다.\n\naccess token을 확인할 수 있는 지점\n/token/access 응답 본문 : o\nJavaScript 지역 변수 : o\n/api/me Authorization 헤더 : o\n\nserver state : mediator의 session과 authorized-client 저장소를 운영해야 한다.\nbrowser 노출 : access token은 브라우저 실행 영역 안에 그대로 있다.",
"environment": "Keycloak 26.7.0\n\nrealms\nclient-confidential : o\nimplicit flow, direct grant : x\nclient_authentication : client_secret_basic\ngrant_type : authorization_code\nscopes : openid profile email\ncallback : http://localhost:8082/login/oauth2/\ncode/keycloak\nprincipal claim : preferred_username\n\nOAuth2AuthorizedClientService : Spring Boot의 in-memory\nSpring Session, Redis, JDBC token store 의존성 : x\n\nResource Server CORS allowlist\norigin : http://localhost:8082\nmethod : GET, OPTIONS\nheader : Authorization, Content-Type\n\nHTTPS : x\nHTTP : o",
"reproduction": "1. Mediator UI에서 로그인한 뒤 /token/boundary를 호출.\naccessTokenStored : true\nrefreshTokenStored : true\nbrowserReceivesRefreshToken : false\n\n2. /token/access 응답의 key가 정확히 세 개인지 확인.\naccess_token, token_type, expires_at\n\n3. 같은 응답의 Cache-Control에 no-store가 있는지 확인.\n\n4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인.\n\n5. 브라우저가 그 token으로 Resource Server를 직접 호출해 200을 받는지 확인.\n\n6. cookie가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인.\n\n7. Local Storage와 Session Storage에 access token 원문이나 refresh_token 문자열이 없는지 확인.",
"lastVerifiedOn": "2026-08-24",
"bodyMarkdown": "## 토큰 관리 경계가 나뉘는 지점\n\n:::evidence key=\"ap2-split-custody-779cb791\" alt=\"Spring mediator의 authorized client 안에 access token과 refresh token이 함께 있고, 그중 access token만 브라우저 실행 영역으로 돌아오는 그림. 브라우저에서 Resource Server로 가는 Authorization Bearer 화살표는 mediator를 지나지 않는다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다.\" caption=\"\" zoom=\"true\"\n:::\n\nmediator는 access token과 refresh token을 모두 보관한다. 다만 브라우저가 Resource Server를 직접 호출해야 해서, access token은 `/token/access`를 통해 다시 브라우저로 전달된다.\n\n## Mediator가 담당하는 OAuth 처리\n\nSPA 구조에서는 브라우저가 authorization code를 직접 token으로 교환한다. Mediator 구조에서는 Spring backend가 confidential client로 등록되어 code 교환과 authorized client 저장을 처리한다.\n\nSPA와 Mediator에서 각 동작을 수행하는 주체는 다음과 같다.\n\n| 무엇 | 브라우저에 있나 | 서버에 있나 |\n|---|---|---|\n| client secret | x | o |\n| refresh token | x | o |\n| access token | o | o |\n| 로그인 상태 | AP2_SESSION | HttpSession |\n\n세 번째 줄이 이 Case의 관측이다. access token은 양쪽에 있다.\n\n## AP2_SESSION이 생성되는 시점\n\n`AP2_SESSION`이 token 교환을 마친 뒤에 발급된다고 읽기 쉽지만 그렇지 않다.\n\nSpring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장하고, 그 transaction을 찾기 위한 cookie를 먼저 발급한다. Browser가 KeyCloak으로 이동했다가 다시 Spring으로 다시 돌아왔을 때, Spring이 이 사용자가 아까 시작했던 로그인 요청이 무었이었는지 찾을 수 있어야 하기 때문에 로그인 시작 시점에 session 쿠키를 먼저 만들게 된다.\n\n```text label=\"callback 하나가 두 개의 상태로 나뉜다\"\nAP2_SESSION\n → servlet HttpSession의 login SecurityContext\n → Authentication(principal name = preferred_username)\n\n(\"keycloak\", principal name)\n → OAuth2AuthorizedClientService\n → access token + refresh token\n```\n\ncookie가 token을 직렬화해 담고 있는 것이 아니다. cookie는 HttpSession을 식별하는 세션 ID이고, 그 HttpSession 안에 로그인 SecurityContext가 저장되어 있다. token은 이 cookie session에 담겨져 있는 principal을 가지고 별도 store에서 관리되고 있는 token을 찾는 것이다.\n\n:::warning\n\n`OAuth2AuthorizedClientService` 은 Spring Boot 자동구성이 고르는 in-memory 구현이고 Spring Session·Redis·JDBC token store 의존성도 없다. 로그인 상태와 token 상태가 **둘 다** process-local memory에 있다.\n\n:::\n\n## /token/access가 반환하는 세 가지 field\n\n브라우저가 API를 호출하려면 access token이 필요하다. mediator는 이 endpoint로 반환하게 된다.\n\n```http label=\"브라우저 입력 — cookie 한 개\"\nGET http://localhost:8082/token/access\nAccept: application/json\nCookie: AP2_SESSION=<opaque-session-id>\n```\n\ncontroller는 `OAuth2AuthorizeRequest.withClientRegistrationId(\"keycloak\")`을 만들고 현재 `Authentication`을 principal로 넣어 `OAuth2AuthorizedClientManager.authorize()`를 부른다. 돌아온 authorized client에서 access token만 꺼내 세 field로 만든다.\n\n```http label=\"응답 헤더\"\nHTTP/1.1 200 OK\nCache-Control: no-store\nPragma: no-cache\nContent-Type: application/json\n```\n\n```json label=\"응답 본문 — refresh_token은 없음\"\n{\n \"access_token\": \"<raw-keycloak-jwt>\",\n \"token_type\": \"Bearer\",\n \"expires_at\": \"<ISO-8601-instant>\"\n}\n```\n\naccess token만 HTTP 응답 본문에 반환한다.\n\nauthorized client나 access token이 없으면 401이 된다.\n\n## 브라우저에서 access token을 확인한 지점\n\n브라우저 JavaScript는 이 응답을 지역 변수로 분해한다.\n\n```javascript label=\"Web Storage에도 cookie에도 쓰지 않는다\"\nconst {\n access_token: accessToken,\n expires_at: expiresAt\n} = await tokenResponse.json();\n```\n\n그리고 바로 다음 요청의 헤더가 된다.\n\n```http label=\"mediator를 지나지 않는 경로\"\nGET http://localhost:8081/api/me\nAccept: application/json\nAuthorization: Bearer <raw-keycloak-jwt>\nOrigin: http://localhost:8082\n```\n\n실행 중 access token 원문은 다음 세 지점에서 확인된다.\n\n```text\n/token/access response body\n → JavaScript local variable\n → /api/me Authorization header\n```\n\n응답 처리와 JavaScript 변수, fetch 호출은 모두 같은 브라우저 실행 영역에서 처리된다.\n\nmemory-only는 영구 저장소에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻이 아니다.\n\n## /token/access는 일회성 전달이 아니다\n\n이 endpoint가 한 번만 건네고 끝나는 교환인지 확인했다.\n\n| one-time handoff 요건 | 있나 |\n|---|---|\n| handoff ID | x |\n| nonce | x |\n| 사용 표시(consume flag) | x |\n| 건넨 뒤 삭제 | x |\n| 재호출 거부 | x |\n\n같은 인증된 session은 현재 access token을 몇 번이든 다시 받을 수 있다.\n\n```text\nrepeatable GET\n → current authorized client lookup/refresh opportunity\n → current raw access token response\n```\n\n이 mediator가 허용하는 부분은 브라우저에 **access-only**다.\n\n## 이 구조에서 감수한 것\n\n- server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다\n- browser 노출 : access token은 여전히 응답 본문과 헤더에 있다\n\n이 구조를 고를 이유는 브라우저가 Resource Server를 직접 호출해야 한다는 요구가 있을 때다. 브라우저에서 access token까지 없애려는 목적이라면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다.\n\n## 확인한 것과 확인하지 않은 것\n\n아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다.\n\n| 항목 | 확인했나? |\n|---|---|\n| server access·refresh boolean이 true | o |\n| `browserReceivesRefreshToken`이 false | o |\n| 응답이 세 개 | o |\n| `Cache-Control`에 `no-store` | o |\n| audience에 `keycloak-pattern-api` 포함 | o |\n| Resource Server 직접 호출 200 | o |\n| cookie HttpOnly · SameSite=Lax | o |\n| Web Storage에 token 문자열 없음 | o |\n| 두 번째 `/token/access` 거부 | x |\n| 만료 뒤 실제 refresh | x |\n| logout 때 두 상태 삭제 | x |\n| 재시작·replica 이동 뒤 복구 | x |\n| 허용 밖 origin의 CORS 거부 | x |\n\n만료 뒤 refresh 같은 경우는 manager에는 authorization-code와 refresh-token provider가 함께 구성돼 있다. 갱신을 시도할 수 있도록 만들 수도 있지만, 실제로 만료를 기다려 갱신이 성공하고 rotate된 token이 저장되는지는 확인하지 않았다."
}