Files
document-haness/.playwright-mcp/page-2026-08-23T07-43-24-815Z.yml
T

140 lines
18 KiB
YAML

- generic [ref=f4e3]:
- link "본문으로 건너뛰기" [ref=f4e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f4e5]:
- generic [ref=f4e6]:
- link "TechLog Studio" [ref=f4e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f4e8]: Studio
- navigation "Studio 주 탐색" [ref=f4e10]:
- link "작업본" [ref=f4e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f4e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f4e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f4e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f4e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f4e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f4e17]
- main [ref=f4e18]:
- generic [ref=f4e90]:
- tablist "문서 편집 화면" [ref=f4e91]:
- tab "편집" [selected] [ref=f4e92]
- tab "즉시 미리보기" [ref=f4e93]
- generic [ref=f4e94]:
- tabpanel "편집" [ref=f4e96]:
- generic [ref=f4e97]:
- paragraph [ref=f4e98]: CASE · VERSION 1
- heading "문서 편집" [level=1] [ref=f4e99]
- paragraph [ref=f4e100]: Refresh Token만 서버로 옮겼지만 Access Token은 여전히 Browser에 남은 문제
- region [ref=f4e101]:
- generic [ref=f4e102]:
- paragraph [ref=f4e103]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f4e104]
- generic [ref=f4e105]:
- generic [ref=f4e106]:
- generic [ref=f4e107]: 제목
- textbox "제목" [ref=f4e108]: Refresh Token만 서버로 옮겼지만 Access Token은 여전히 Browser에 남은 문제
- generic [ref=f4e109]:
- generic [ref=f4e110]: slug
- textbox "slug" [ref=f4e111]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: ap2-split-custody-access-token
- generic [ref=f4e112]:
- generic [ref=f4e113]: 요약
- textbox "요약" [ref=f4e114]: AP2는 confidential mediator가 code를 교환하고 refresh token을 server-side authorized client에 보관한다. 그런데 브라우저가 Resource Server를 직접 부르려면 access token이 필요하고, mediator는 그것을 JSON으로 돌려준다. 옮겨진 것은 refresh 하나다.
- generic [ref=f4e115]:
- generic [ref=f4e116]: Topic
- combobox "Topic" [ref=f4e117]:
- option "선택하지 않음"
- option "OAuth/OIDC 인증 경계" [selected]
- generic [ref=f4e118]:
- generic [ref=f4e119]: Project
- combobox "Project" [ref=f4e120]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns" [selected]
- option "Liner N + 1문제"
- group "관계" [ref=f4e121]:
- generic [ref=f4e212]:
- generic [ref=f4e213]:
- generic [ref=f4e214]: 관계 1 대상
- combobox "관계 1 대상" [ref=f4e215]:
- option "대상 선택" [selected]
- generic [ref=f4e216]:
- generic [ref=f4e217]: 관계 1 이유
- textbox "관계 1 이유" [ref=f4e218]
- generic [ref=f4e219]:
- button "위로" [disabled] [ref=f4e220]
- button "아래로" [disabled] [ref=f4e221]
- button "삭제" [ref=f4e222]
- button "관계 추가" [active] [ref=f4e124]
- region [ref=f4e125]:
- generic [ref=f4e126]:
- paragraph [ref=f4e127]: CASE
- heading "문제와 검증" [level=2] [ref=f4e128]
- generic [ref=f4e129]:
- generic [ref=f4e130]:
- generic [ref=f4e131]: 문제
- textbox "문제" [ref=f4e132]: refresh token을 서버로 옮기면 브라우저에서 token이 사라진다고 읽기 쉽다. AP2에서는 Spring mediator가 confidential client가 되어 code를 교환하고 access token과 refresh token을 server-side authorized-client service에 저장한다. 브라우저에는 HttpOnly AP2_SESSION만 남는다. 여기까지만 보면 AP3와 같아 보인다. 그런데 AP2의 브라우저는 여전히 Resource Server를 직접 부른다. 그러려면 access token이 필요하고, mediator가 그것을 응답 본문으로 건넨다. 무엇이 서버로 옮겨졌고 무엇이 그대로인지 나눠야 했다.
- generic [ref=f4e133]:
- generic [ref=f4e134]: 결론
- textbox "결론" [ref=f4e135]: "옮겨진 것은 client secret과 refresh token이다. access token은 그대로 남는다. access token 원문은 세 자리를 지난다. /token/access 응답 본문 : o JavaScript 지역 변수 : o /api/me Authorization 헤더 : o 세 자리 모두 브라우저 실행 영역 안이다. memory-only는 영구 저장소에 쓰지 않는다는 뜻이지, 실행 중 script가 읽을 수 없다는 뜻이 아니다. 그래서 AP2는 AP1과 AP3 사이가 아니라 둘의 비용을 함께 진다. server state : mediator의 session과 authorized-client 저장소를 운영해야 한다 browser 노출 : access token은 여전히 XSS가 닿는 곳에 있다 /token/access는 one-time handoff가 아니다. handoff ID, nonce, 사용 표시, 건넨 뒤 삭제, 재호출 거부가 모두 없다. 같은 session이 몇 번이든 현재 access token을 다시 받을 수 있다. 이 예제가 보장하는 범위는 access-only handoff까지다."
- generic [ref=f4e136]:
- generic [ref=f4e137]: 검증 환경
- textbox "검증 환경" [ref=f4e138]: "Keycloak 26.7.0 client token-mediating-confidential confidential, standard flow : o implicit flow, direct grant : x callback : exact Spring mediator registration keycloak client_authentication : client_secret_basic grant_type : authorization_code scopes : openid profile email callback : http://localhost:8082/login/oauth2/code/keycloak principal claim : preferred_username OAuth2AuthorizedClientService : Spring Boot 자동구성의 in-memory 구현 Spring Session, Redis, JDBC token store 의존성 : x Resource Server CORS allowlist origin : AP2 UI method : GET, OPTIONS header : Authorization, Content-Type AP2 client 등록에는 S256을 강제하는 속성이 없고 테스트도 authorization request의 challenge를 검사하지 않는다. Authorization Code confidential client라는 사실까지만 확인했다. HTTPS가 아닌 HTTP로 cookie와 redirect를 눈으로 확인하는 한 대짜리 학습 환경이다. 운영 환경을 검증한 것이 아니다."
- generic [ref=f4e139]:
- generic [ref=f4e140]: 재현 조건
- textbox "재현 조건" [ref=f4e141]: "1. AP2 UI에서 로그인한 뒤 /token/boundary를 호출한다. accessTokenStored : true refreshTokenStored : true browserReceivesRefreshToken : false 2. /token/access 응답의 key가 정확히 세 개인지 확인한다. access_token, token_type, expires_at 3. 같은 응답의 Cache-Control에 no-store가 있는지 확인한다. 4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인한다. 5. 브라우저가 그 token으로 Resource Server를 직접 호출해 200을 받는지 확인한다. 6. cookie가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다. 7. Local Storage와 Session Storage에 access token 원문이나 refresh_token 문자열이 없는지 확인한다."
- generic [ref=f4e142]:
- generic [ref=f4e143]: 마지막 검증일
- textbox "마지막 검증일" [ref=f4e144]
- generic [ref=f4e145]:
- generic [ref=f4e146]: 본문 Markdown
- textbox "본문 Markdown" [ref=f4e147]: "## custody가 갈리는 자리 :::evidence key=\"ap2-split-custody-1c2d10b1\" alt=\"Spring mediator의 authorized client 안에 access token과 refresh token이 함께 있고, 그중 access token만 브라우저 실행 영역으로 돌아오는 그림. 브라우저에서 Resource Server로 가는 Authorization Bearer 화살표는 mediator를 지나지 않는다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다.\" caption=\"\" zoom=\"true\" ::: mediator는 두 token을 모두 들고 있다. 그중 하나만 브라우저로 돌아온다. 그리고 API로 가는 화살표는 mediator를 지나지 않는다. 이 세 가지가 AP2다. ## 무엇이 서버로 옮겨졌나 AP1에서는 브라우저가 code를 직접 교환했다. AP2에서는 Spring mediator가 confidential client가 되어 그 일을 맡는다. 옮겨진 것과 그대로인 것을 나누면 이렇다. | | 브라우저에 있나..? | 서버에 있나..? | |---|---|---| | client secret | x | o | | refresh token | x | o | | access token | o | o | | 로그인 상태 | AP2_SESSION | HttpSession | 세 번째 줄이 이 기록의 전부다. access token은 **양쪽에** 있다. ## AP2_SESSION은 언제 생기나 `AP2_SESSION`이 token 교환을 마친 뒤에 발급된다고 읽기 쉽지만 그렇지 않다. Spring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장하고, 그 transaction을 찾기 위한 cookie를 먼저 발급한다. Keycloak으로 갔다 돌아오는 왕복을 건너려면 그 전에 있어야 하기 때문이다. ```text label=\"callback 하나가 두 갈래 상태로 갈린다\" AP2_SESSION → servlet HttpSession의 login SecurityContext → Authentication(principal name = preferred_username) (\"keycloak\", principal name) → OAuth2AuthorizedClientService → access token + refresh token ``` cookie가 token을 직렬화해 담고 있는 것이 아니다. cookie는 왼쪽 갈래만 가리키고, token은 오른쪽 갈래인 별도 store에 있다. :::warning `OAuth2AuthorizedClientService` 구현을 코드가 직접 선언하지 않는다. Spring Boot 자동구성이 고르는 것은 in-memory 구현이고 Spring Session·Redis·JDBC token store 의존성도 없다. 로그인 상태와 token 상태가 **둘 다** process-local memory에 있다. ::: ## /token/access가 돌려주는 세 field 브라우저가 API를 부르려면 access token이 필요하다. mediator는 이 endpoint로 그것을 건넨다. ```http label=\"브라우저 입력 — cookie 하나뿐이다\" GET http://localhost:8082/token/access Accept: application/json Cookie: AP2_SESSION=<opaque-session-id> ``` controller는 `OAuth2AuthorizeRequest.withClientRegistrationId(\"keycloak\")`을 만들고 현재 `Authentication`을 principal로 넣어 `OAuth2AuthorizedClientManager.authorize()`를 부른다. 돌아온 authorized client에서 access token만 꺼내 세 field로 줄인다. ```http label=\"응답 헤더\" HTTP/1.1 200 OK Cache-Control: no-store Pragma: no-cache Content-Type: application/json ``` ```json label=\"응답 본문 — refresh_token이 없다\" { \"access_token\": \"<raw-keycloak-jwt>\", \"token_type\": \"Bearer\", \"expires_at\": \"<ISO-8601-instant>\" } ``` `refresh_token`은 없다. 하지만 access token 원문은 분명히 HTTP 응답 본문에 있다. authorized client나 access token이 없으면 401이 된다. 이유는 `No authorized Keycloak client is available`이지만 error body 모양을 고정한 handler나 테스트는 없다. ## access token은 어디를 지나나 브라우저 JavaScript는 이 응답을 지역 변수로 구조 분해한다. ```javascript label=\"Web Storage에도 cookie에도 쓰지 않는다\" const { access_token: accessToken, expires_at: expiresAt } = await tokenResponse.json(); ``` 그리고 바로 다음 요청의 헤더가 된다. ```http label=\"mediator를 지나지 않는 경로\" GET http://localhost:8081/api/me Accept: application/json Authorization: Bearer <raw-keycloak-jwt> Origin: http://localhost:8082 ``` 원문이 지나는 자리를 세면 셋이다. ```text /token/access response body → JavaScript local variable → /api/me Authorization header ``` memory-only는 **영구 저장소에 쓰지 않는다**는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻이 아니다. 세 자리 모두 같은 실행 영역 안이다. ## one-time handoff인가..? 한 번만 건네고 끝나는 교환이라면 이 endpoint를 안전하게 볼 수 있다. 코드를 보면 그렇지 않다. | one-time handoff라면 있어야 할 것 | 현재 구현 | |---|---| | handoff ID | x | | nonce | x | | 사용 표시(consume flag) | x | | 건넨 뒤 삭제 | x | | 재호출 거부 | x | 같은 인증된 session은 현재 access token을 몇 번이든 다시 받을 수 있다. 그래서 흐름을 이렇게 적어야 한다. ```text repeatable GET → current authorized client lookup/refresh opportunity → current raw access token response ``` 「한 번만 교환 가능한 code」로 바꿔 말하면 안 된다. 이 예제가 보장하는 범위는 **access-only handoff**까지다. ## 두 비용을 함께 진다 AP2는 AP1과 AP3 사이에 있는 것이 아니라 둘의 비용을 함께 진다. - server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다 - browser 노출 : access token은 여전히 응답 본문과 헤더에 있다 그래서 판단 기준은 번호가 아니다. server state를 둘 수 없다면 AP1이 더 단순하다. access token까지 브라우저에서 없애야 한다면 AP3가 더 직접적이다. AP2가 맞는 경우는 브라우저가 Resource Server를 직접 부르는 것이 실제 요구이고, 분리해야 할 것이 오래 사는 credential 하나일 때다. ## 확인한 것과 확인하지 않은 것 아래는 실행 성적표가 아니라 **커밋된 자동 테스트가 확인하도록 정의한 계약**이다. | 항목 | 계약에 있나..? | |---|---| | `/token/boundary`의 server access·refresh boolean이 true | o | | 같은 응답의 `browserReceivesRefreshToken`이 false | o | | `/token/access` 응답의 key가 정확히 세 개 | o | | `Cache-Control`에 `no-store` | o | | 반환된 access JWT의 audience에 `keycloak-pattern-api` 포함 | o | | 브라우저의 Resource Server 직접 호출 200 | o | | cookie가 `AP2_SESSION` · HttpOnly · SameSite=Lax | o | | Web Storage에 access token 원문이나 `refresh_token` 문자열 없음 | o | | `/token/access`를 두 번 불렀을 때 두 번째 거부 | x | | 만료 뒤 실제 refresh 성공·실패 | x | | logout 때 session과 authorized client 삭제 | x | | mediator 재시작이나 replica 이동 뒤 복구 | x | | 허용 목록 밖 origin의 CORS 거부 | x | 아래 다섯 줄은 테스트가 빠진 것이 아니라 **코드에 기능이 없는 것**이다. 아홉째 줄은 특히 그렇다. 두 번째 호출을 거부하는 코드가 없으니 거부를 확인할 테스트도 없다. 만료 뒤 refresh는 조금 다르다. manager에는 authorization-code와 refresh-token provider가 함께 구성돼 있다. 갱신을 시도할 자리는 있지만, 실제로 만료를 기다려 갱신이 성공하고 rotate된 token이 저장되는지는 확인하지 않았다. ## 두 주장을 나눠서 읽는다 AP2를 볼 때 섞이기 쉬운 두 문장이 있다. ```text assertion A: refresh token은 browser response에 없다 assertion B: access token은 browser response와 Authorization header에 있다 ``` A가 통과했다고 B까지 사라진 것으로 읽으면 AP2와 AP3의 경계를 혼동한다. 확인한 것은 A뿐이다."
- generic [ref=f4e148]:
- paragraph [ref=f4e149]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f4e150]
- paragraph [ref=f4e151]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f4e152]:
- generic [ref=f4e153]:
- generic [ref=f4e154]: 업로드 종류
- combobox "업로드 종류" [ref=f4e155]:
- option "이미지"
- option "다이어그램" [selected]
- option "첨부파일"
- button "Asset 업로드" [ref=f4e156]
- generic [ref=f4e157]:
- search [ref=f4e158]:
- generic [ref=f4e159]: Asset 검색
- generic [ref=f4e160]:
- searchbox "Asset 검색" [ref=f4e161]: ap2
- button "검색" [ref=f4e162]
- generic [ref=f4e163]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f4e164]
- generic [ref=f4e165]: 삽입할 때 크게 보기 허용
- status [ref=f4e166]: 삽입할 수 있는 Asset 1개
- list [ref=f4e167]:
- listitem [ref=f4e223]:
- button "ap2-split-custody-1c2d10b1" [ref=f4e224]
- button "삭제" [ref=f4e225]
- complementary [ref=f4e180]:
- paragraph [ref=f4e181]: WORKING COPY
- heading "작업 상태" [level=2] [ref=f4e182]
- status "편집 상태" [ref=f4e183]: 저장되지 않음
- generic [ref=f4e184]:
- generic [ref=f4e185]:
- term [ref=f4e186]: 저장 버전
- definition [ref=f4e187]: "1"
- generic [ref=f4e188]:
- term [ref=f4e189]: 종류
- definition [ref=f4e190]: CASE
- button "저장" [ref=f4e191]
- button "게시" [ref=f4e192]
- paragraph [ref=f4e193]: 불완전한 초안도 저장할 수 있습니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- paragraph [ref=f4e58]: Case 작업본을 만들었습니다.