Files

54 lines
55 KiB
JavaScript

async (page) => {
const DOCS = [{"id": "488ce49b-afa4-42a5-a2ce-de2e0653cd82", "name": "case-ap2-split-custody", "jobs": [["요약", "confidential client인 mediator가 code를 교환하고 refresh token을 server-side authorized client에 보관한다. 그런데 브라우저가 Resource Server를 직접 부르려면 access token이 필요해서, mediator가 그것을 JSON으로 반환한다. refresh custody는 서버로 갔고 access custody는 가지 않았다."], ["문제", "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처음에는 refresh token을 서버로 옮기면 브라우저의 credential 책임도 대부분 사라진다고 봤다. `/token/access` 응답을 따라가면서 무엇이 실제로 옮겨졌고 무엇이 그대로 노출되는지 나눠야 했다."], ["결론", "옮겨진 것은 client secret과 refresh token이다. access token 원문은 세 자리를 지난다.\n\naccess token이 남기는 흔적\n/token/access 응답 본문 : o\nJavaScript 지역 변수 : o\n/api/me Authorization 헤더 : o\n\nserver state : mediator의 session과 authorized-client 저장소를 운영해야 한다.\nbrowser 노출 : access token은 브라우저 실행 영역 안에 그대로 있다."], ["검증 환경", "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"], ["재현 조건", "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 문자열이 없는지 확인."], ["관계 1 이유", "SPA은 브라우저가 code 교환과 token 보관을 모두 맡는다. 이 기록은 거기서 refresh token 관리만 서버로 옮긴 다음 단계다."], ["관계 2 이유", "confidential client를 쓰면서도 access token이 브라우저 응답에 실린다. 종류와 token 노출이 별개라는 근거다."], ["관계 3 이유", "access token 원문이 응답 본문과 지역 변수와 헤더를 지난다. 상태별 이름을 나눠야 하는 이유다."], ["관계 4 이유", "refresh만 옮기고 access 노출과 server state를 함께 지는 경우다. 선택 기준의 한 칸이다."], ["관계 5 이유", "refresh token rotation과 재사용 0회를 쓰는 구성이다. replica 경쟁 질문의 전제다."], ["본문 Markdown", "## 토큰 관리 경계가 나뉘는 지점\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## 서버로 옮겨진 책임\n\nSPA 구조에서는 브라우저가 code를 직접 교환후 token 교환을 통해 브라우저에서 토큰 관리를 했지만, Mediator 구조에서는 Spring mediator가 confidential client가 되어 그 책임을 맡게 된다.\n\n옮겨진 것과 그대로인 것을 나누면 다음과 같다.\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원문이 지나는 자리를 세면 셋이다.\n\n```text\n/token/access response body\n → JavaScript local variable\n → /api/me Authorization header\n```\n\n세 자리 모두 같은 실행 영역 안이다.\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이 저장되는지는 확인하지 않았다."]], "groups": {}}, {"id": "d85bd6af-7599-4ef7-9407-6609927d5b5c", "name": "case-ap3-bff-session-csrf", "jobs": [["요약", "브라우저 network에서 OAuth token이 사라지고 AP3_SESSION cookie 하나만 남았다. 로그인 상태와 token은 BFF가 들고 있다. cookie가 credential이 되면서 상태 변경 요청에는 CSRF 검증이 붙었고, BFF로 넘어온 책임 중 지금 구현된 것은 거기까지다."], ["문제", "BFF에서는 confidential-client인 BFF서버가 code를 교환하고\naccess token과 refresh token은 server-side authorized client에 관리하게 된다.\n브라우저에는 HttpOnly AP3_SESSION만 전달된다.\n\n그런데 브라우저는 여전히 요청마다 cookie를 보낸다.\ncookie가 credential이면 상태를 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다.\nBFF는 이제 재시작과 replica 이동에 따른 저장소가 필요하다.\n\n브라우저에서 token이 사라진 자리에 무엇이 새로 필요해지는지 확인해야 했다."], ["결론", "브라우저 요청에 남은 것은 cookie 두 개다.\n\nAP3_SESSION : HttpOnly, JavaScript 읽기 x\nXSRF-TOKEN : JavaScript 읽기 o\n\ncookie는 요청마다 자동으로 붙으므로 상태를 바꾸는 요청에는 의도를 확인할 값이 하나 더 필요하다. 그것이 XSRF-TOKEN이고, 그래서 이 값만 HttpOnly가 아니다.\n\nXSS는 그대로 남는다. same-origin 악성 script는 같은 session으로 BFF를 부를 수 있고 읽을 수 있는 XSRF cookie도 읽는다. 이 구조에서 줄어드는 것은 token 원문이 유출돼 다른 client나 직접 API 호출에 재사용되는 범위다.\n\nBFF로 옮겨진 책임 중 지금 구현된 것은 CSRF 검증이다.\n재시작 뒤 로그인 유지, replica 공유 session, 저장 token 암호화, logout, downstream 오류 변환, timeout, 경로별 인가 : x"], ["검증 환경", "Keycloak 26.7.0\n\nrealms\nconfidential, client_secret_basic\nPKCE S256 : o\nprovider : authorization-code, refresh-token\n\nstore : memory o\nCSRF : o \nHTTP : o"], ["재현 조건", "1. UI에서 로그인하고 authorization request를 확인.\nclient_id : bff-confidential\ncode_challenge_method : S256\n\n2. 브라우저 요청 목록에 Keycloak token endpoint와 8081 직접 호출이 없는지 확인.\n\n3. cookie가 AP3_SESSION이며 HttpOnly와 SameSite=Lax인지, Web Storage가 비었는지 확인.\n\n4. /bff/token-boundary를 호출.\naccessTokenStoredOnServer : true\nrefreshTokenStoredOnServer : true\nbrowserTokenCount : 0\ncsrfProtectionEnabled : true\n\n5. /bff/api/me가 200이고 downstream 응답에 username과 audience가 있는지 확인.\n\n6. GET /bff/csrf로 XSRF-TOKEN cookie와 token metadata를 받는거 확인.\n응답 본문의 token과 cookie 값이 같은 문자열이 아님을 확인.\n\n7. session cookie는 있고 CSRF 헤더가 없는 POST /bff/api/preferences가 403인지 확인.\n\n8. cookie의 raw 값을 X-XSRF-TOKEN에 넣은 같은 POST가 200이고 theme이 dark인지 확인.\n\n9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 AP3_SESSION이 요청에 실리지 않는지 확인."], ["관계 1 이유", "이 기준이 요구하는 항목 중 무엇이 구현됐고 무엇이 구현되지 않았는지"], ["관계 2 이유", "session cookie와 CSRF token, server-side token을 각각 다뤄야 하는 이유"], ["관계 3 이유", "token 비노출을 고른 자리에서 CSRF와 공유 저장소가 따라온다"], ["관계 4 이유", "이 결정의 구조를 실제로 실행해 본 문서"], ["관계 5 이유", "두 상태가 모두 process-local memory에 있다는 점이 질문의 시작이다"], ["관계 6 이유", "session과 authorized client의 2가지 흐름"], ["본문 Markdown", "## token의 호출 책임 BFF로 이전\n\n:::evidence key=\"ap3-bff-custody-82fa18bd\" alt=\"브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다.\" caption=\"\" zoom=\"true\"\n:::\n\n브라우저에서 이제 더 이상 OAuth token을 가지고 호출하지 않는다. 그 대신 BFF에서 authorized client가 해당 토큰을 관리하도록 하고, Resource Server로 호출하는 부분도 BFF에서 진행하게 된다.\n\n## 브라우저에 남는 상태\n\n| 무엇 | 브라우저에 있나 | JavaScript가 읽나 |\n|---|---|---|\n| AP3_SESSION | o | x |\n| XSRF-TOKEN | o | o |\n| access token | x | x |\n| refresh token | x | x |\n\nCSRF를 확인하려면 JavaScript가 읽을 수 있는 값이 하나 필요하다. 그래서 `XSRF-TOKEN`만 `HttpOnly`가 아니다.\n\nsame-origin 악성 script는 이 두 cookie를 그대로 쓸 수 있다. 사용자의 session으로 BFF endpoint를 부를 수 있고 읽을 수 있는 XSRF cookie도 읽는다. 이 구조에서 줄어드는 것은 token 원문이 유출돼 다른 client나 직접 API 호출에 재사용되는 범위다.\n\n## session이 Bearer로 바뀌는 자리\n\n브라우저 요청에는 `Authorization` 헤더도 없고 코드에도 access token 지역 변수도 없다.\n\n```http label=\"브라우저 입력 — cookie 하나\"\nGET http://localhost:8083/bff/api/me\nAccept: application/json\nCookie: AP3_SESSION=<opaque-session-id>\n```\n\ncookie 자체는 token을 들고 있지 않다. cookie가 session을 식별하고, 그 session에서 얻은 인증 주체로 authorized client를 찾는다.\n\n```text label=\"cookie에서 Bearer까지\"\nAP3_SESSION\n → HttpSession\n → SecurityContext\n → Authentication.getName()\n → (\"keycloak\", principal name)\n → OAuth2AuthorizedClientService\n → access token + refresh token\n```\n\n`BffController.currentUser(Authentication)`는 `OAuth2AuthorizeRequest`를 만들어 `OAuth2AuthorizedClientManager.authorize()`를 부른다. manager bean은 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고 authorization-code와 refresh-token provider를 함께 쓴다. 그래서 BFF는 발급 이후의 수명주기까지 맡게 된다.\n\n없으면 401이 된다.\n\n있으면 BFF의 `RestClient`가 downstream 입력을 **새로** 조립한다.\n\n```http label=\"cookie로 조회된 토큰을 넣어서 조립\"\nGET http://app:8081/api/me\nAuthorization: Bearer <server-held-access-token>\n```\n\n`AP3_SESSION`은 downstream으로 전달되지 않는다. \nBFF가 session을 해당 session에 맞는 token을 조회 후, Resource Server가 아는 Bearer credential로 바꾼다. \n두 credential은 같은 요청 안에 있지만 서로 다른 경계로 나뉘게 된다.\n\n:::warning\n\nCompose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인.\n\n:::\n\n## browserTokenCount는 무엇을 증명하나\n\n진단용 endpoint가 server custody를 boolean으로 보여 준다.\n\n```json label=\"/bff/token-boundary 응답\"\n{\n \"pattern\": \"AP3-backend-for-frontend\",\n \"principal\": \"regular-user\",\n \"accessTokenStoredOnServer\": true,\n \"refreshTokenStoredOnServer\": true,\n \"browserTokenCount\": 0,\n \"csrfProtectionEnabled\": true\n}\n```\n\n`browserTokenCount: 0`은 브라우저를 실제로 검사해 센 값이 아니라 controller가 넣는 literal이다. 이 field 하나로는 token 비노출을 말할 수 없다.\n\n밖에서 따로 봤다. 로그인 이후 개발자 도구에서 요청 목록과 저장소를 확인했더니 Keycloak token endpoint 호출이 없었고 Resource Server의 8081 직접 호출도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다.\n\n```text label=\"같은 주장에 대한 두 종류의 근거\"\nself-report /bff/token-boundary → browserTokenCount: 0\nexternal observation 브라우저 network → token endpoint 없음\n Web Storage → token 문자열 없음\n```\n\n자기 자신을 보고하는 값과 밖에서 관측한 값을 같은 증거로 취급하지 않는다.\n\n이 endpoint는 manager의 `authorize()`를 부르지 않고 `OAuth2AuthorizedClientService`를 직접 조회한다. refresh를 수행하는 자리가 아니다.\n\n## cookie가 credential이면 CSRF가 필요하다\n\n브라우저는 session cookie를 요청마다 자동으로 붙인다. `GET`만 보면 이것이 문제로 보이지 않는다. 값을 바꾸는 요청에서 보이게 되는데, 먼저 브라우저가 CSRF material을 받는다.\n\n```http label=\"응답 헤더 — cookie에는 raw 값이 들어간다\"\nHTTP/1.1 200 OK\nCache-Control: no-store\nPragma: no-cache\nSet-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/\n```\n\n```json label=\"응답 본문 — 여기 token은 가려진 값이다\"\n{\n \"headerName\": \"X-XSRF-TOKEN\",\n \"parameterName\": \"_csrf\",\n \"token\": \"<xor-masked-csrf-token>\"\n}\n```\n\n같은 endpoint가 두 값을 반환하게 되는데, 이 **둘은 같은 문자열이 아니다.**\n\n:::evidence key=\"ap3-csrf-split-501dd1f7\" alt=\"BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다.\" caption=\"\" zoom=\"true\"\n:::\n\n`CookieCsrfTokenRepository.withHttpOnlyFalse()`가 cookie에 raw 값을 넣는다. `XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 XOR와 Base64로 가리기 때문에 응답 본문에는 가려진 값이 보인다.\n\nSPA는 본문의 `token`을 쓰지 않는다. 본문에서는 `headerName`만 읽고, `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 헤더 값으로 넣는다.\n\n```text label=\"세 자리의 값이 서로 다르다\"\nbody.token masked token\ncookie XSRF-TOKEN raw token\nX-XSRF-TOKEN raw token\n```\n\n\n```http label=\"다음 요청 헤더에 X-XSRF-TOKEN가 들어간다\"\nPOST /bff/theme HTTP/1.1\nHost: localhost:8083\nContent-Type: application/json\n\nCookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>\nX-XSRF-TOKEN: <raw-csrf-token>\n```\n\n```json label=\"요청 본문\"\n{\n \"theme\":\"dark\"\n}\n```\n\n`SpaCsrfTokenRequestHandler`가 노출하는 형태와 제출받는 형태를 나눠 처리한다. 요청 헤더에 raw 값이 실려 오면 그 값을 cookie와 대조한다.\n\n:::note\n\n응답 본문의 token을 가리는 것은 BREACH 완화다. HTTP 응답 압축 크기의 차이로 응답 안의 비밀값을 좁혀 가는 공격이라 노출되는 형태를 매번 다르게 만든다.\n\n:::\n\n\n## SameSite와 CSRF token이 막는 입력\n\n네 가지 입력으로 나눠 보면 둘이 갈린다.\n\n| 입력 | 막는 것 | 응답 |\n|---|---|---|\n| same-origin, 헤더 없음 | CSRF token | 403 |\n| same-site 다른 port, 헤더 없음 | CSRF token | 403 |\n| cross-site POST | SameSite | cookie 누락 |\n| same-origin, 값 일치 | 통과 | 200 |\n\n앞의 두 줄에서는 cookie가 실린다. 그래서 막는 것이 CSRF token이다. 셋째 줄에서는 cookie 자체가 요청에서 빠진다. **port가 달라도 site 계산상 같은 경우가 있어** SameSite만으로는 둘째 줄을 막아주지 못한다.\n\n셋째 줄의 관측 지점은 최종 status가 아니라 **cookie가 요청에서 빠졌다는 부분**이다.\n\n## 서버로 넘어온 책임\n\nBFF가 로그인 상태와 token을 들고 있게 되면서 다음 항목이 BFF의 책임이 됐다.\n\n| 새로 생긴 책임 | 현재 구현에 있나 |\n|---|---|\n| 상태 변경 요청의 CSRF 검증 | o |\n| 재시작 뒤 로그인 유지 | x |\n| replica가 함께 쓰는 session | x |\n| 저장 token 암호화 | x |\n| logout 때 session과 authorized client 삭제 | x |\n| downstream 오류를 화면 오류로 변환 | x |\n| timeout · retry · circuit breaker | x |\n| 경로별 인가 | x |\n\n첫 줄만 구현돼 있다. 나머지는 이 BFF가 단일 인스턴스 memory와 한 번의 BFF 호출로만 보여 준다.\n\n저장소도 생각한 모양이 아니다. 현재 store는 session ID마다 독립된 token 저장소가 아니라 registration과 principal name으로 authorized client를 찾는 **애플리케이션 수준 store**다. 같은 principal이 여러 브라우저 session에서 로그인하면 같은 항목을 공유하거나 덮어쓸 수 있다.\n\n\n## 확인한 것과 확인하지 않은 것\n\n아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다.\n\n| 항목 | 확인했나 |\n|---|---|\n| `bff-confidential` + S256 challenge | o |\n| 브라우저 요청에 token endpoint 없음 | o |\n| 브라우저 요청에 8081 직접 호출 없음 | o |\n| `AP3_SESSION` HttpOnly · SameSite=Lax | o |\n| Web Storage 비어 있음 | o |\n| server access·refresh boolean이 true | o |\n| `/bff/api/me` 200 · username · audience | o |\n| CSRF 헤더 없는 POST 403 | o |\n| raw 값을 헤더에 넣은 POST 200 | o |\n| cross-site POST에서 cookie 누락 | o |\n| preference의 사용자별 격리 | x |\n| preference 영속성 | x |\n| 공유 session store | x |\n| 저장 token 암호화 | x |\n| logout | x |\n| downstream 401의 전달 모양 | x |\n| timeout · 경로별 인가 | x |\n\n## 이 구조에서 관측한 것\n\n브라우저 network에서 Keycloak token endpoint 호출과 `Authorization: Bearer`가 사라졌다. `/bff/api/me` 요청에 붙은 것은 `AP3_SESSION` 하나였다. 상태 변경 요청을 추가하자 이 cookie가 자동으로 실리기 때문에 CSRF 검증이 필요해졌고, 로그인 상태와 token은 BFF process memory에 남았다.\n\n브라우저가 OAuth token을 받으면 안 되고 backend가 화면에 맞춰 여러 API를 조합해야 한다면 이 구조를 고른다. OAuth 흐름을 브라우저에서 직접 보는 것이 목적이면 SPA 구조가, 브라우저의 직접 API 호출을 남겨야 하면 Mediator가 맞는다."]], "groups": {}}, {"id": "a0e1cc05-92b3-4dac-bce1-513ab8cd862b", "name": "case-ap4-identity-header-trust", "jobs": [["요약", "X-Auth-Request-User는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다. \nupstream이 받는 요청에서는 동일한 구조. \n그래서 header overwrite, backend direct path 차단, internal credential 검증을 서로 독립된 세 곳에서 방어할 수 있도록 해야 한다."], ["문제", "앞단 proxy가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다.\n\n대신 upstream은 X-Auth-Request-User 하나로 사용자를 판단하게 된다.\n이 헤더는 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다.\nupstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문제가 된다.\n\nbackend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면\n공격자가 인증된 사용자처럼 보낼 수 있다.\n\n그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다."], ["결론", "헤더를 믿으려면 서로 독립된 곳에서 방어를 해야 된다.\n\nhost port 닫힘 : 외부에서 upstream·proxy로 바로 가는 경로를 막는다\nNginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다\nupstream internal token : edge를 거치지 않은 내부 요청을 막는다\n\nnetwork isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 문제를 막지 못한다.\ncontroller의 공유 token만으로는 외부 직접 접근이 어려워지는 network 속성을 대신할 수 없다."], ["검증 환경", "Keycloak 26.7.0, oauth2-proxy 7.15.2\n\nclient : edge-proxy\nconfidential, PKCE S256 : o\n\n외부 공개\nNginx : 8088\napp 8081, oauth2-proxy 4180 : Compose network에 expose만, host publish x\n\nNginx\nauth_request /oauth2/auth\nlocation = /oauth2/auth : internal\nauth_request_set으로 user, email, Set-Cookie 복사\nclient 제공 동명 헤더 : 덮어쓰기\ntrusted proxy : 단일 IP\n\nupstream\nEdgeIdentityController.currentUser(HttpServletRequest)\nX-Internal-Auth-Token 비교 : MessageDigest.isEqual\nSecurityConfig의 /edge/** : permitAll\n\nAP4_SESSION\nHttpOnly : true\nSameSite : Lax\nSecure : false in local HTTP fixture\nexpire : 1 hour in proxy configuration\nsession-cookie-minimal : true\n\nserver-side session store : x\nautomatic discovery : x\nlogin, token, JWKS, userinfo URL을 각각 관리.\n\nHTTP : o"], ["재현 조건", "1. cookie 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인.\n\n2. cookie 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인.\n\n3. authorization request에 client_id=edge-proxy와 code_challenge_method=S256이 있는지 확인.\n\n4. 로그인 뒤 cookie가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인.\n브라우저 요청 목록에 Keycloak token endpoint가 없어야 함.\nWeb Storage가 비어 있고 document.cookie로 session cookie를 읽을 수 없어야 함.\n\n5. 정상 session에 다음 헤더를 얹어 GET /api/edge를 보냄.\nX-Auth-Request-User : spoofed-admin\nX-Auth-Request-Email : spoofed-admin@example.test\nX-Internal-Auth-Token : attacker-controlled-token\n\n응답은 200이고 user는 spoofed-admin이 아니라 실제 authenticated user여야 함.\n\n6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인.\n\n7. host의 4180과 8081에 접근할 수 없는지 확인.\n\n8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고,\n둘 다 맞으면 200인지 확인."], ["관계 1 이유", "이 기준의 다섯 조건이 실제로 어떻게 구성되는지 코드와 설정으로 확인한 자리다."], ["관계 2 이유", "proxy session cookie와 identity 헤더를 JWT와 구분해야 하는 실례다."], ["관계 3 이유", "OAuth를 모르는 upstream 앞의 공통 관문을 얻고 network·헤더 신뢰 계약을 내주는 경우다."], ["관계 4 이유", "edge가 user와 email만 전달한다는 사실이 이 질문의 출발점이다."], ["본문 Markdown", "## 같은 이름의 헤더\n\n:::evidence key=\"ap4-edge-trust-1cff2399\" alt=\"왼쪽 외부 영역의 브라우저에 AP4_SESSION과 점선으로 표시된 client 제공 header가 있다. 가운데 Nginx는 8088만 공개하고 header 덮어쓰기를 맡는다. 오른쪽 점선 영역은 host port가 닫혀 있고 oauth2-proxy와 Spring upstream이 들어 있다. Nginx가 oauth2-proxy에 auth_request를 보내 user와 email을 받고, nginx-owned header와 internal token으로 upstream 요청을 만든다.\" caption=\"\" zoom=\"true\"\n:::\n\n`X-Auth-Request-User`는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.\n\n그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다.\n\n## 위조 요청의 모양\n\n로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자.\n\n```http label=\"공격자가 보낸 요청\"\nGET http://localhost:8088/api/edge\nCookie: AP4_SESSION=<opaque-session>\nX-Auth-Request-User: spoofed-admin\nX-Auth-Request-Email: spoofed-admin@example.test\nX-Internal-Auth-Token: attacker-controlled-token\n```\n\n이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다.\n\n## 세 개의 독립된 경계\n\n현재 OAuth2-Proxy구조에선 이 문제를 서로 독립된 세 곳에서 막는다.\n\n| 위치 | 여기서 어떻게 막지? |\n|---|---|\n| host port 닫힘 | 외부에서 upstream·proxy로 가는 직접 경로 |\n| Nginx header 덮어쓰기 | client가 보낸 동명 헤더 |\n| upstream internal token | edge를 거치지 않은 내부 요청 |\n\n이 중 하나라도 막지 않는다면 안된다. host port가 열려 있으면 헤더 검사만으로 막을 수 없고, 덮어쓰기가 없으면 인증을 안 거친 헤더가 그대로 upstream에 들어가고, internal token이 없으면 내부 workload가 edge처럼 동작할 수 있는 여지가 생긴다.\n\n**network isolation만으로는 내부 위조를 막지 못한다. controller의 공유 token만으로는 외부 직접 접근을 막지 못한다.**\n\n## Nginx가 헤더를 만드는 경계\n\nNginx는 먼저 internal subrequest를 만든다. \n`location = /oauth2/auth`는 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있다.\n\n```nginx label=\"upstream을 부르기 전에 먼저 물어본다\"\nauth_request /oauth2/auth;\n```\n\noauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다.\n\n```text label=\"auth_request_set — 값의 출처가 여기서 고정\"\n$auth_user ← oauth2-proxy X-Auth-Request-User\n$auth_email ← oauth2-proxy X-Auth-Request-Email\n$auth_cookie ← oauth2-proxy Set-Cookie\n```\n\n그 다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 세 헤더는 **merge가 아니라 덮어쓰기**로 채워진다.\n\n```http label=\"upstream이 실제로 받는 요청\"\nGET http://app:8081/edge/me\nX-Auth-Request-User: <oauth2-proxy-authenticated-user>\nX-Auth-Request-Email: <oauth2-proxy-authenticated-email>\nX-Internal-Auth-Token: <nginx-environment-secret>\n```\n\n그럼 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다.\n\n## upstream은 무엇을 확인하나\n\n`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는다.\n\n1. `X-Auth-Request-User`를 읽고 비어 있는지 확인한다.\n2. `X-Internal-Auth-Token`을 읽어 설정값과 `MessageDigest.isEqual`로 비교한다.\n\n두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다.\n\n```json label=\"정상 응답 — 4가지 필드\"\n{\n \"pattern\": \"AP4-edge-forward-auth\",\n \"user\": \"regular-user\",\n \"email\": \"regular-user@example.test\",\n \"identityHeader\": \"X-Auth-Request-User\"\n}\n```\n\n하나라도 다르면 401이 된다.\n\n```json label=\"user 헤더가 없거나 internal token이 틀릴 때\"\n{\n \"error\": \"trusted edge authentication is required\"\n}\n```\n\ninternal token 비교에는 일반 문자열 비교 대신 `MessageDigest.isEqual`을 썼다. 비교 시간 차이로 값이 어디까지 맞았는지 새어 나가는 것을 줄이려는 선택이다.\n\n:::danger\n\n현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 보호 되지 않는다.\n\n:::\n\n운영으로 넘어갈 때는 이 검사를 filter나 interceptor, security chain처럼 **대상 endpoint 전체에 걸리는 공통 경계**로 옮겨야 한다.\n\n## 경로마다 달라지는 결과\n\n같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다.\n\n| 외부 입력 | 인증 상태 | 결과 |\n|---|---|---|\n| `GET /` | 미인증 | `/oauth2/start` 302 |\n| `GET /api/edge` | 미인증 | redirect 없는 401 |\n| `GET /oauth2/auth` | 무관 | 404 |\n| `GET /` + 위조 헤더 | 정상 session | 실제 user 200 |\n| `/edge/me` + user 헤더만 | edge token 없음 | 401 |\n| `/edge/me` + 틀린 token | token 불일치 | 401 |\n\n아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 `Location` 없는 401을 받아야 한다.\n\n**redirect 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.** \n다른 경로는 로그인 redirect 규칙을 따른다.\n\n셋째 줄도 중요하다. 외부에서 `/oauth2/auth`를 직접 부르면 404다. `internal` 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다.\n\n## 브라우저가 가지고 있는 것\n\nOAuth2-Proxy 구조는 server-side session store를 두지 않는다.\n\n```text label=\"AP4_SESSION cookie 설정\"\nname = AP4_SESSION\nHttpOnly = true\nSameSite = Lax\nSecure = false in local HTTP fixture\nexpire = 1 hour in proxy configuration\n```\n\n`session-cookie-minimal=true`를 쓰면 cookie에는 access·refresh·ID token 대신 edge가 필요한 최소 정보만 남는다. 브라우저에 남은 부분은 cookie를 JavaScript로 읽을 수 없고 다음 요청에 자동으로 붙는 opaque cookie뿐이다.\n\n지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다.\n\n## endpoint를 외부용과 내부용으로 나눈 이유\n\n브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다. \n그래서 자동 discovery를 끄고 네 주소를 각각 관리한다.\n\n```text label=\"issuer는 브라우저가 접속하는 부분\"\nissuer expected value = http://localhost:8080/realms/keycloak-patterns\nlogin URL = http://localhost:8080/.../auth\nredeem/token URL = http://keycloak:8080/.../token\nJWKS/userinfo URL = http://keycloak:8080/...\n```\n\nissuer는 실제로 요청을 보내기 위한 주소가 아니라 KeyCloak이 발급한 토큰의 iss claim이 우리가 기대한 값과 같은지 검증하기 위한 기준값이다. 반면 token url, userinfo url 같은 경우는 실제로 내부에서 oauth2-proxy가 요청을 보내기 위해 사용되는 내부 네트워크 주소다.\n\n따라서 둘다 keycloak realm을 가리키지만 용도가 다르고 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없기에 로그인에는 `localhost:8080`을 사용하고 컨테이너는 자신의 `localhost:8080`이 keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 사용한다.\n\n## upstream이 JWT를 받지 않는다\n\n앞의 3가지 구조에서는 Resource Server는 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 `/edge/me`는 **JWT를 입력으로 받지 않는다.**\n\n| 무엇을 믿나 | AP1~AP3 | AP4 |\n|---|---|---|\n| 서명된 JWT | o | x |\n| network topology | x | o |\n| internal token | x | o |\n| edge의 user·email | x | o |\n\n오른쪽 열이 이 패턴이 신뢰 하는 부분이다. 그래서 edge가 인증 경계 자체가 되고, backend 직접 경로나 사용자 제공 헤더를 허용하는 순간 다른 사용자처럼 보낼 수 있게 된다.\n\n## 헤더를 늘릴 때 정해야 하는 것\n\n현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다.\n\n- claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가\n- allowlist : Nginx가 어느 응답 헤더만 복사하는가\n- 덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가\n- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가\n- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지\n- 갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가\n\n\n## 확인한 것과 확인하지 않은 것\n\n아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분** 이다.\n\n| 항목 | 확인한 부분 |\n|---|---|\n| cookie 없는 root의 302 | o |\n| cookie 없는 `/api/edge`의 401 | o |\n| `edge-proxy` + S256 challenge | o |\n| `AP4_SESSION` HttpOnly · SameSite=Lax | o |\n| 브라우저 요청에 token endpoint 없음 | o |\n| Web Storage 비어 있고 cookie 읽기 불가 | o |\n| 위조 헤더를 보내도 실제 user로 200 | o |\n| 외부 `/oauth2/auth` 404 | o |\n| host의 4180 · 8081 접근 불가 | o |\n| user 헤더 없음 · token 없음 · token 불일치 401 | o |\n| role 전달 | x |\n| 새 endpoint의 공통 강제 | x |\n| 상태 변경 요청의 CSRF | x |\n| session 갱신 | x |\n| replica 간 secret 공유 | x |\n| internal secret 교체 | x |\n\n일곱째 줄이 핵심이다. 요청이 실패하는지 보는 것이 아니라, **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다.\n\n## 증명하지 않는 것\n\n현재 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾼다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy가 아니다. 그래서 path, method, body, streaming, websocket 같은 큰 헤더 동작은 입증하지 못했다."]], "groups": {}}, {"id": "bf675775-4f3e-4744-8014-f0efff51422a", "name": "case-browser-credential-boundary", "jobs": [["요약", "SPA가 public OAuth client로 code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에만 두는 AP1을 실행했다. memory-only는 새로고침 뒤 남는 복사본만 없앨 뿐, 실행 중 script가 fetch를 가로채거나 사용자 대신 API를 부르는 부분은 남아있다."], ["문제", "token을 Web Storage에 저장하지 않고 memory에만 두면 XSS 위험도 사라지는지 확인할 필요가 있었다.\n\nAP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다. \n\n이 구성에서 실제로 무엇이 브라우저에 남고, PKCE가 어느 구간을 막으며, memory-only 보관이 어느 위험을 막고 어느 위험을 막아주지 않는지 구분해야 했다."], ["결론", "memory-only 보관이 막아주는 것은 새로고침 뒤에도 남는 token 복사본이지 실행 중 XSS의 권한이 아니다.\n\n실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. \ntoken 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다.\n\nResource Server가 STATELESS라 서버에 지울 session이 없고, 이미 발급된 self-contained JWT를 logout 순간에 없앨 방법도 없다. 그래서 이를 짧은 수명과 rotation, issuer·audience 검증이 커버하게 된다.\n \nPKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 access token을 숨기지 않는다."], ["검증 환경", "Keycloak 26.7.0\n\nrealms 설정\npublic-client, standard flow : o \nimplicit flow, direct grant : x\nauthority : http://localhost:8080/realms/keycloak-patterns\nredirect_uri : http://localhost:8088/OAuth2callback.html\nscope : openid profile email\nuserStore : InMemoryWebStorage\nstateStore : sessionStorage\nautomaticSilentRenew : true\n\nResource Server\nSessionCreationPolicy.STATELESS \nCSRF x \nCORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type\n\nHTTPS : x \nHTTP : o"], ["재현 조건", "1. SPA를 열고 로그인후 Keycloak authorization request의 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인.\n\n2. token 응답에 access·refresh·ID token이 비어 있지 않은지 확인.\n\n3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 Bearer access token을 확인.\n\n4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인.\n\n5. 같은 정상 JWT를 expected issuer·audience가 다른 diagnostic server 두 곳에 제출해 401을 확인.\n\n6. refresh token으로 새 token을 받고 이전 refresh token이 거부되는지, revocation 뒤 refresh가 실패하는지, 이미 발급된 access JWT가 만료 전까지 200인지 확인."], ["관계 1 이유", "브라우저가 authorization endpoint와 token endpoint를 모두 지나는 흐름이다. 이 기준의 endpoint별 이동을 코드로 확인한 자리다."], ["관계 2 이유", "SPA가 secret을 숨길 수 없어 public client가 되고 PKCE가 그 자리를 대신한 실례다."], ["관계 3 이유", "memory-only 보관과 IdP SSO cookie를 나눠 본 자리다. 브라우저에 없다는 말의 대상을 여기서 좁혔다."], ["관계 4 이유", "이 기록이 성숙도 모델의 출발점으로 오해되기 쉬운 자리다. 그 오해를 막는 결정이다."], ["본문 Markdown", "## credential이 머무는 자리\n\n:::evidence key=\"ap1-custody-v3-6e0376d2\" alt=\"브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다.\" caption=\" \" zoom=\"true\"\n:::\n\ncode 교환, token 보관, `Authorization` 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다.\n\n## 브라우저에 실제로 남는 것\n\noidc-client-ts의 `InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다. \n새로고침하면 로그인 상태가 사라지지만 Web Storage에 남아있는 복사본은 없다.\n\n아래 표는 새로고침을 기준으로 무엇이 남고 무엇이 사라지는지 나눈 것이다.\n\n| 위치 | reload 전 | reload 후 |\n|---|---|---|\n| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |\n| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |\n| Local Storage | 해당 없음 | 해당 없음 |\n| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |\n\nmemory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다.\n\n## memory-only가 줄이는 위험\n\n앞의 표는 \"무엇이 어디 남지?\"만 표현하고 있는데, 교차 사이트 스크립팅(XSS)으로 script가 실행되면 저장 위치는 더 이상 경계가 아니다. 같은 실행 영역 안이기 때문이다.\n\n| 위협 | memory-only가 막아주나 |\n|---|---|\n| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |\n| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |\n| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |\n| network 요청 헤더에 실린 access token | 막아주지 않는다 |\n| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |\n\n네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다.\n\n```http label=\"브라우저가 Resource Server를 직접 부를 때\"\nGET http://localhost:8081/api/me\nAuthorization: Bearer <access-token>\n```\n\ntoken 원문은 memory에도 있고 network 헤더에도 실린다.\n\nResource Server가 `SessionCreationPolicy.STATELESS`라서 서버에 지울 session이 없다. \n이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다. \n\n**그래서 수명을 짧게 두는 것이 안전하다.**\naccess token : 300초\nrefresh token rotation, 재사용 허용 : x\nissuer·audience : 검증\n\nLocal Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다. \nHttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다. \nserver가 session이나 token 중계를 맡는 구조가 필요하다.\n\n## PKCE가 막는 구간\n\nPKCE(Proof Key for Code Exchange)는 authorization request에 `code_challenge`를 싣고, code를 token으로 바꿀 때 원본인 `code_verifier`를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다.\n\n```text label=\"oidc-client-ts가 만드는 authorization request의 핵심 query\"\nresponse_type=code\nclient_id=spa-public\nredirect_uri=http://localhost:8088/OAuth2callback.html\nscope=openid profile email\nstate=<opaque-state>\ncode_challenge=<opaque-challenge>\ncode_challenge_method=S256\n```\n\n`response_type=code`가 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`이 PKCE 사용을 나타낸다.\n막는 구간은 code 교환까지다. 이미 발급된 access token은 막아주지 않는다.\n\n## 확인한 것과 확인하지 않은 것\n\n아래는 **커밋된 테스트가 확인하도록 정의한 부분**이다. 왼쪽이 정의 여부, 오른쪽이 정의 내용.\n\n| 정의 여부 | 정의 내용 |\n|---|---|\n| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |\n| o | token 응답에 비어 있지 않은 access·refresh·ID token |\n| o | `/api/me` 200과 decoded access token의 audience 포함 |\n| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측 |\n| o | Local Storage와 Session Storage에 access token substring 없음 |\n| o | refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패 |\n| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |\n| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |\n| x | 서명이 깨진 JWT, 만료된 JWT |\n| x | 브라우저 간 요청(CORS)의 preflight 응답 |\n| x | callback에 error가 실려 돌아왔을 때의 화면 |\n| x | `automaticSilentRenew`의 실제 갱신 경로 |\n\n첫 줄과 여덟째 줄을 같이 보자. \n**authorization request의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다.**\n\n:::warning\n\nSPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지 대신 JSON parse error가 먼저 노출되게 된다.\n\n:::\n\n## 추가로 설정에서 확인해야될 것\n\nlocal realm의 redirect allowlist는 \n`http://localhost:8088/*`와 `http://127.0.0.1:8088/*` wildcard다. \nSPA : `/OAuth2callback.html`만 o, \nexact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x\n\nfrontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute `http://localhost:8081/api/me`를 쓴다. 그래서 CORS allowlist는 실제로 지나가는 경계이다. \n상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다."]], "groups": {}}, {"id": "19b55c39-c583-4161-9775-df954280a568", "name": "decision-bff-owns-token", "jobs": [["요약", "브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 API 조합과 인가를 직접 맡아야 하는 경우에 BFF가 code 교환과 token 보관, downstream 호출을 소유한다. edge에 인증을 맡기는 구조와 구분되는 지점이 여기다. 아직 기본값으로 채택한 기록이 없어 PROPOSED로 둔다."], ["결정문", "브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다.\n\n브라우저에는 애플리케이션 session만 제공한다."], ["판단 이유", "브라우저에서 token을 없애려면 code 교환과 API 호출을 server가 대신하게 된다.\n\n앞서 refresh token만 server로 옮기는 구조를 먼저 봤는데, 이 구조에서는 브라우저가 Resource Server를 직접 부르기 때문에 access token이 필요하고 mediator가 그것을 응답 본문으로 건네게 된다. 그래서 원문이 응답 본문과 지역 변수, Authorization 헤더를 차례로 지나게 되고, 결국 브라우저에 token이 남는다.\n\nedge에 인증을 맡기는 구조도 브라우저에 token을 주지 않는데, 여기서는 upstream이 JWT를 받지 못하고 edge가 붙인 identity 헤더를 믿게 된다. 그래서 애플리케이션이 사용자별 API 조합과 세밀한 인가를 직접 맡아야 하는 경우에는 맞지 않는다.\n\n그렇기 때문에 「브라우저에 token 금지」가 요구로 들어오면 BFF만 남는다.\n\n다만 상태를 ADOPTED로 올리지는 않는다. 지금 자료는 네 구조를 나란히 실행한 비교 실험이고 이 프로젝트가 BFF를 기본값으로 고른 기록이 없기 때문이다. 기본값으로 고른 시점과 그 근거가 생기면 그때 올리게 되고, 그 전까지 실제 적용 기준은 「BFF 인증 구조 설계 기준」 Reference다."], ["영향 1", "BFF가 로그인 상태와 token을 가진 보안 구성요소가 되어서 단순 proxy로 취급할 수 없게 된다."], ["영향 2", "상태 변경 요청마다 CSRF 검증이 필요해지고, 노출 값과 제출 값이 다를 수 있어서 클라이언트 코드도 그 구분을 알아야 한다."], ["영향 3", "재시작과 replica 이동을 견딜 공유 저장소와 저장 token 암호화, 암호화 key 교체를 설계해야 하는데 아직 정하지 않은 문제로 남아 있다."], ["영향 4", "logout이 애플리케이션 session과 authorized client를 함께 지워야 하는데, 열쇠가 달라서 한 번의 삭제로 두 상태가 함께 지워지지 않는다."], ["영향 5", "모든 UI 요청이 BFF를 지나게 되어서 지연과 단일 장애 지점을 준비해야 한다."], ["영향 6", "브라우저에서 token을 없애도 XSS가 무해해지지 않고, same-origin script는 피해자 session으로 BFF를 그대로 부를 수 있다."], ["영향 7", "이 결정이 PROPOSED인 동안은 「BFF 인증 구조 설계 기준」 Reference가 실제 적용 기준이다."], ["근거 1 이유", "이 결정이 가리키는 구조를 실제로 실행해 본 기록이다."], ["근거 2 이유", "이 결정이 PROPOSED인 동안의 실제 적용 기준이다."], ["근거 3 이유", "이 결정을 적용할 조건과 피해야 할 조건이 여기 있다."], ["근거 4 이유", "access token이 브라우저로 나가 이 요구를 만족하지 못한 경우다."]], "groups": {"영향": 7}}];
const RAIL = 'aside[class*="studio-document-status"]';
const out = [];
for (const d of DOCS) {
const rec = { name: d.name, filled: [], skipped: 0, miss: [], rows: [] };
await page.goto('https://hyeonworks.com/studio/documents/' + d.id + '/edit');
try { await page.waitForSelector(RAIL, { timeout: 25000 }); }
catch { rec.error = 'AUTH? ' + page.url(); out.push(rec); return out; }
await page.waitForTimeout(400);
rec.version = await page.locator(RAIL + ' dd').first().innerText();
for (const [legend, want] of Object.entries(d.groups)) {
const fs = page.locator('xpath=//fieldset[./legend[normalize-space(.)="' + legend + '"]]').first();
let cur = await fs.locator('.studio-ordered-item').count();
const from = cur;
while (cur > want) {
await fs.locator('.studio-ordered-item').last().getByRole('button', { name: '삭제' }).click();
await page.waitForTimeout(60); cur--;
}
while (cur < want) {
await fs.getByRole('button', { name: legend + ' 추가' }).click();
await page.waitForTimeout(60); cur++;
}
if (from !== want) rec.rows.push(legend + ' ' + from + '→' + want);
}
for (const [label, wantv] of d.jobs) {
const loc = page.locator('xpath=//label[./span[normalize-space(.)="' + label + '"]]')
.locator('textarea, input').first();
const n = await loc.count().catch(() => 0);
if (n !== 1) { rec.miss.push(label); continue; }
if ((await loc.inputValue()) === wantv) { rec.skipped++; continue; }
await loc.fill(wantv);
rec.filled.push(label);
}
if (rec.filled.length === 0 && rec.rows.length === 0) { rec.saved = 'CLEAN'; out.push(rec); continue; }
await page.locator(RAIL).getByRole('button', { name: '저장' }).first().click();
try {
await page.waitForFunction(() => {
const el = document.querySelector('aside[class*="studio-document-status"]');
return el && /저장됨/.test(el.innerText);
}, null, { timeout: 30000 });
rec.saved = 'OK v' + (await page.locator(RAIL + ' dd').first().innerText());
} catch {
rec.saved = 'FAIL ' + (await page.locator(RAIL).innerText()).replace(/\s+/g, ' ').slice(0, 140);
}
out.push({ name: rec.name, version: rec.version, saved: rec.saved,
filled: rec.filled.length, rows: rec.rows, miss: rec.miss });
}
return out;
}