## 토큰 관리 경계가 나뉘는 지점 :::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" ::: mediator는 access token과 refresh token을 모두 보관한다. 다만 브라우저가 Resource Server를 직접 호출해야 해서, access token은 `/token/access`를 통해 다시 브라우저로 전달된다. ## Mediator가 담당하는 OAuth 처리 SPA 구조에서는 브라우저가 authorization code를 직접 token으로 교환한다. Mediator 구조에서는 Spring backend가 confidential client로 등록되어 code 교환과 authorized client 저장을 처리한다. SPA와 Mediator에서 각 동작을 수행하는 주체는 다음과 같다. | 무엇 | 브라우저에 있나 | 서버에 있나 | |---|---|---| | client secret | x | o | | refresh token | x | o | | access token | o | o | | 로그인 상태 | AP2_SESSION | HttpSession | 세 번째 줄이 이 Case의 관측이다. access token은 양쪽에 있다. ## AP2_SESSION이 생성되는 시점 `AP2_SESSION`이 token 교환을 마친 뒤에 발급된다고 읽기 쉽지만 그렇지 않다. Spring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장하고, 그 transaction을 찾기 위한 cookie를 먼저 발급한다. Browser가 KeyCloak으로 이동했다가 다시 Spring으로 다시 돌아왔을 때, Spring이 이 사용자가 아까 시작했던 로그인 요청이 무었이었는지 찾을 수 있어야 하기 때문에 로그인 시작 시점에 session 쿠키를 먼저 만들게 된다. ```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는 HttpSession을 식별하는 세션 ID이고, 그 HttpSession 안에 로그인 SecurityContext가 저장되어 있다. token은 이 cookie session에 담겨져 있는 principal을 가지고 별도 store에서 관리되고 있는 token을 찾는 것이다. :::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= ``` 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": "", "token_type": "Bearer", "expires_at": "" } ``` access token만 HTTP 응답 본문에 반환한다. authorized client나 access token이 없으면 401이 된다. ## 브라우저에서 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 Origin: http://localhost:8082 ``` 실행 중 access token 원문은 다음 세 지점에서 확인된다. ```text /token/access response body → JavaScript local variable → /api/me Authorization header ``` 응답 처리와 JavaScript 변수, fetch 호출은 모두 같은 브라우저 실행 영역에서 처리된다. memory-only는 영구 저장소에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻이 아니다. ## /token/access는 일회성 전달이 아니다 이 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 ``` 이 mediator가 허용하는 부분은 브라우저에 **access-only**다. ## 이 구조에서 감수한 것 - server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다 - browser 노출 : access token은 여전히 응답 본문과 헤더에 있다 이 구조를 고를 이유는 브라우저가 Resource Server를 직접 호출해야 한다는 요구가 있을 때다. 브라우저에서 access token까지 없애려는 목적이라면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다. ## 확인한 것과 확인하지 않은 것 아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다. | 항목 | 확인했나? | |---|---| | server access·refresh boolean이 true | o | | `browserReceivesRefreshToken`이 false | o | | 응답이 세 개 | o | | `Cache-Control`에 `no-store` | o | | audience에 `keycloak-pattern-api` 포함 | o | | Resource Server 직접 호출 200 | o | | cookie HttpOnly · SameSite=Lax | o | | Web Storage에 token 문자열 없음 | o | | 두 번째 `/token/access` 거부 | x | | 만료 뒤 실제 refresh | x | | logout 때 두 상태 삭제 | x | | 재시작·replica 이동 뒤 복구 | x | | 허용 밖 origin의 CORS 거부 | x | 만료 뒤 refresh 같은 경우는 manager에는 authorization-code와 refresh-token provider가 함께 구성돼 있다. 갱신을 시도할 수 있도록 만들 수도 있지만, 실제로 만료를 기다려 갱신이 성공하고 rotate된 token이 저장되는지는 확인하지 않았다.