docs(keycloak): adopt the decomposition contract, fix the redirect URI, strip evaluative prose
- 계약 채택 — 독자 질문, 후보 29건(PROMOTE 24 · MERGE_INTO 4 · KEEP_IN_SSOT 1). 게시 중 17건은 전부 유지. 저장소 keycloak-pattern 은 패턴 넷이 브랜치로 갈라져 있어 revisions 로 tip 넷을 적었다. keycloak-session-store 는 같은 저장소 @ cdac9b8 - 게시된 기록의 redirect_uri 가 SSOT·코드와 달랐다 — OAuth2callback.html → callback.html (frontend/src/app.js 에서 확인). 계약 title 이 기록과 다른 7건도 기록 쪽으로 맞췄다 - 미작성 1건 작성 — 패턴 검증을 실제로 돌릴 때의 안전한 순서(Reference) - 리뷰 100건 반영 — 설명 뒤에 붙은 평가·차례 예고·독자 오해 가정·작성 지시를 지웠다. 삭제가 남긴 조각 4건을 고치고, 원래부터 잘려 있던 로컬 미리보기 라벨 1건도 닫았다 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
62520a4dce
commit
4d50bb939a
+48
-49
@@ -14,44 +14,49 @@ public: "https://hyeonworks.com/cases/split-custody-access-token"
|
||||
assets:
|
||||
- key: ap2-split-custody-779cb791
|
||||
file: ../../../final/assets/tech-log-studio/ap2-split-custody.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap2
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap2
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-ap2
|
||||
---
|
||||
|
||||
# Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
||||
|
||||
confidential client인 mediator가 code를 교환하고 refresh token을 server-side authorized client에 보관한다. 브라우저는 Resource Server를 직접 호출하기 때문에 access token이 필요하고, mediator는 JSON 응답으로 access token을 반환한다. refresh token은 서버에 남아 있지만 access token은 브라우저까지 전달된다.
|
||||
confidential client인 mediator가 authorization code를 교환하고, 받은 두 토큰을 서버 쪽 authorized client에 넣는다. 그런데 보호 자원 서버(Resource Server)를 부르는 쪽은 브라우저라서 액세스 토큰이 브라우저에도 있어야 하고, 그 값은 JSON 응답으로 다시 내려온다. 그래서 이 구조가 브라우저 밖으로 옮긴 것은 client secret과 리프레시 토큰까지이고, 액세스 토큰을 다루는 일은 브라우저가 계속 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
SPA에서는 브라우저가 code 교환과 token 보관을 모두 맡는다. 여기서는 그중 refresh token 관리를 서버로 옮긴다.
|
||||
SPA에서는 브라우저가 코드 교환과 토큰 보관을 모두 맡는데, 여기서는 그중 리프레시 토큰을 서버로 옮겼다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
Mediator는 confidential client지만 access token을 브라우저 응답으로 반환한다. client 종류와 token 노출 위치가 같은 기준이 아니라는 사례다.
|
||||
mediator는 confidential client인데도 액세스 토큰을 브라우저 응답으로 돌려준다. 클라이언트를 어느 종류로 등록했는지가 토큰이 브라우저까지 가는지를 정하지는 않는다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
access token은 응답 본문, JavaScript 지역 변수, Authorization 헤더를 지나고 application session은 별도로 관리된다. 어떤 상태를 말하는지 이름을 나눠야 하는 이유다.
|
||||
액세스 토큰은 응답 본문과 JavaScript 지역 변수와 Authorization 헤더를 지나가고, 애플리케이션 쪽 로그인 상태는 그와 별도로 관리된다.
|
||||
- **OAuth/OIDC 인증 패턴 선택 기준**
|
||||
refresh token은 서버에 두지만 access token은 브라우저에 전달되고 mediator의 server state도 함께 관리해야 하는 구조다.
|
||||
리프레시 토큰은 서버에 두면서 액세스 토큰은 브라우저로 보내는 구성이라, 이 패턴을 고르면 mediator의 서버 상태까지 함께 운영해야 한다.
|
||||
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
|
||||
여기서는 refresh token rotation과 재사용 0회 구성을 사용한다. 여러 replica에서 refresh가 겹치는 문제는 별도로 남아 있다.
|
||||
여기서는 리프레시 토큰 rotation과 재사용 0회로 설정했다. 여러 replica에서 갱신이 겹치는 경우는 이 구성으로 확인하지 못해 그 질문에서 따로 다룬다.
|
||||
|
||||
## 문제
|
||||
|
||||
Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고 access token과 refresh token을 server-side authorized-client service에 저장한다. 브라우저에는 HttpOnly AP2_SESSION만 관리하게 된다.
|
||||
Spring mediator가 confidential client가 되어 code를 교환한다. 받은 액세스 토큰과 리프레시 토큰은 server-side authorized-client service에 저장하고, 브라우저가 로그인 상태로 들고 있는 것은 HttpOnly가 붙은 AP2_SESSION뿐이다.
|
||||
|
||||
서버에서 token을 보관한다는 점만 보면 BFF와 비슷하다. 하지만 Mediator에서는 브라우저가 Resource Server를 직접 호출한다. Resource Server를 호출하려면 access token이 필요하기 때문에 mediator가 access token을 응답으로 다시 반환한다.
|
||||
서버가 토큰을 보관한다는 점만 보면 BFF와 비슷한데, 이 구조에서는 보호 자원 서버를 브라우저가 직접 부른다. 그 요청에 액세스 토큰이 필요하기 때문에 mediator가 액세스 토큰을 다시 응답으로 돌려준다.
|
||||
|
||||
처음에는 refresh token을 서버로 옮기면 브라우저가 credential을 직접 다뤄야 하는 범위도 대부분 줄어든다고 봤다. /token/access 응답부터 Resource Server 요청까지 따라가 보니 refresh token은 서버에 남지만 access token은 계속 브라우저에서 사용되고 있었다.
|
||||
/token/access 응답부터 보호 자원 서버 요청까지 따라가면 서버가 가져간 것은 리프레시 토큰까지였고 액세스 토큰을 다루는 일은 브라우저가 계속 하고 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
서버로 옮긴 것은 client secret과 refresh token이다. access token은 브라우저에서 다음 세 곳에 나타난다.
|
||||
서버로 옮긴 것은 client secret과 리프레시 토큰이다. 액세스 토큰은 브라우저에서 다음 세 곳을 지나간다.
|
||||
|
||||
access token이 사용되는 위치
|
||||
액세스 토큰이 사용되는 위치
|
||||
/token/access 응답 본문 : o
|
||||
JavaScript 지역 변수 : o
|
||||
/api/me Authorization 헤더 : o
|
||||
|
||||
server state : mediator의 session과 authorized-client 저장소를 운영해야 한다.
|
||||
browser 노출 : access token은 브라우저 실행 영역 안에 그대로 있다.
|
||||
서버 상태 : mediator의 session과 authorized-client 저장소를 운영해야 한다.
|
||||
브라우저 노출 : 액세스 토큰이 브라우저 실행 영역 안에서 쓰이는 것은 막지 못했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -94,26 +99,24 @@ HTTP : o
|
||||
|
||||
4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인한다.
|
||||
|
||||
5. 브라우저가 해당 token으로 Resource Server를 직접 호출했을 때 200을 받는지 확인한다.
|
||||
5. 브라우저가 그 토큰으로 보호 자원 서버를 직접 호출했을 때 200을 받는지 확인한다.
|
||||
|
||||
6. cookie가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
|
||||
6. 쿠키가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
|
||||
|
||||
7. Local Storage와 Session Storage에 access token 원문이나 refresh_token 문자열이 없는지 확인한다.
|
||||
7. Local Storage와 Session Storage에 액세스 토큰 원문이나 refresh_token 문자열이 없는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## Mediator에서 Access Token과 Refresh Token을 관리하는 위치
|
||||
confidential client는 client secret을 서버에 두고 자기를 인증할 수 있는 애플리케이션이다. 여기 나오는 mediator가 그런 클라이언트이고, 브라우저 대신 authorization code를 토큰으로 바꿔 서버에 보관한다. 다만 보호 자원 서버(Resource Server)는 브라우저가 직접 부른다. 리프레시 토큰만 브라우저에서 걷어내면 브라우저가 무엇을 계속 다루게 되는지 확인했다.
|
||||
|
||||
## 서버로 옮긴 값과 브라우저로 돌아오는 값
|
||||
|
||||
:::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에서 서버에 보관하는 값
|
||||
|
||||
SPA 구조에서는 브라우저가 code를 직접 교환하고 받은 token도 브라우저에서 관리했다. Mediator에서는 Spring mediator가 confidential client가 되어 code 교환을 맡고 token을 server-side authorized client에 저장한다.
|
||||
SPA 구조에서는 브라우저가 코드를 직접 교환하고 받은 토큰도 브라우저에서 관리했다. mediator를 두면 그 코드를 교환하는 쪽이 Spring mediator로 바뀌고, 액세스 토큰과 리프레시 토큰은 둘 다 서버 쪽 authorized client에 저장된다. 그런데 보호 자원 서버를 부르는 쪽은 여전히 브라우저라서, 액세스 토큰은 `/token/access`를 통해 다시 브라우저로 건너온다.
|
||||
|
||||
각 값의 위치는 다음과 같다.
|
||||
|
||||
@@ -124,13 +127,11 @@ SPA 구조에서는 브라우저가 code를 직접 교환하고 받은 token도
|
||||
| access token | o | o |
|
||||
| 로그인 상태 | AP2_SESSION | HttpSession |
|
||||
|
||||
access token은 서버에도 저장되지만 브라우저에도 전달된다.
|
||||
|
||||
## AP2_SESSION이 생성되는 시점
|
||||
|
||||
`AP2_SESSION`은 token 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
|
||||
`AP2_SESSION`은 토큰 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
|
||||
|
||||
Spring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장한다. Browser가 KeyCloak으로 이동했다가 callback으로 다시 Spring에 돌아왔을 때 앞에서 시작한 로그인 요청을 찾을 수 있어야 하기 때문에 이 시점에 session cookie가 먼저 만들어진다.
|
||||
Spring Security는 로그인을 시작하면서 인가 요청과 `state`를 `HttpSession`에 저장한다. 브라우저가 KeyCloak으로 갔다가 callback으로 돌아왔을 때 앞에서 시작한 그 로그인 요청을 찾아야 하기 때문에, 세션 쿠키가 이 시점에 먼저 만들어진다.
|
||||
|
||||
```text label="callback 하나가 두 개의 상태로 나뉜다"
|
||||
AP2_SESSION
|
||||
@@ -142,17 +143,17 @@ AP2_SESSION
|
||||
→ access token + refresh token
|
||||
```
|
||||
|
||||
`AP2_SESSION` 안에 token이 들어 있는 것은 아니다. 이 cookie는 HttpSession을 찾기 위한 session ID이고, HttpSession에는 로그인 SecurityContext가 저장되어 있다. token은 여기서 확인한 principal을 이용해 별도의 store에 저장된 authorized client에서 찾는다.
|
||||
`AP2_SESSION` 안에 토큰이 들어 있는 것은 아니다. 이 쿠키는 `HttpSession`을 찾는 세션 ID이고, 그 `HttpSession`에 로그인 `SecurityContext`가 저장되어 있다. 토큰은 거기서 확인한 principal로 별도 저장소의 authorized client를 조회해서 찾는다.
|
||||
|
||||
:::warning
|
||||
|
||||
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 token 상태가 모두 현재 process의 memory에 있다.
|
||||
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 토큰 상태가 둘 다 지금 프로세스의 메모리에 있다. 세션과 authorized client를 여러 인스턴스가 공유하는지는 이 구성으로 확인하지 못했다.
|
||||
|
||||
:::
|
||||
|
||||
## /token/access가 반환하는 세 가지 값
|
||||
|
||||
브라우저가 Resource Server를 직접 호출하려면 access token이 필요하다. mediator는 `/token/access`를 통해 현재 access token을 반환한다.
|
||||
브라우저가 보호 자원 서버를 직접 부르려면 액세스 토큰이 있어야 하고, 그 값을 주는 것이 `/token/access`다.
|
||||
|
||||
```http label="브라우저 입력 — cookie 한 개"
|
||||
GET http://localhost:8082/token/access
|
||||
@@ -160,7 +161,7 @@ Accept: application/json
|
||||
Cookie: AP2_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
controller는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣어 `OAuth2AuthorizedClientManager.authorize()`를 호출한다. 반환된 authorized client에서 access token을 꺼내 다음 세 값을 응답한다.
|
||||
컨트롤러는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣는다. 이 요청으로 `OAuth2AuthorizedClientManager.authorize()`를 부른 뒤, 돌아온 authorized client에서 액세스 토큰을 꺼내 다음 세 값만 응답에 담는다.
|
||||
|
||||
```http label="응답 헤더"
|
||||
HTTP/1.1 200 OK
|
||||
@@ -177,13 +178,11 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
HTTP 응답 본문에는 access token만 포함되고 refresh token은 포함되지 않는다.
|
||||
응답 본문에 실리는 토큰은 액세스 토큰 하나이고 리프레시 토큰은 넣지 않는다. authorized client나 액세스 토큰이 없으면 401을 돌려준다.
|
||||
|
||||
authorized client나 access token이 없으면 401을 반환한다.
|
||||
## 액세스 토큰이 브라우저에서 지나가는 세 곳
|
||||
|
||||
## Access Token이 브라우저에서 사용되는 위치
|
||||
|
||||
브라우저 JavaScript는 `/token/access` 응답에서 access token을 읽어 지역 변수에 넣는다.
|
||||
브라우저 JavaScript는 `/token/access` 응답에서 액세스 토큰을 읽어 지역 변수에 넣는다.
|
||||
|
||||
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
|
||||
const {
|
||||
@@ -192,7 +191,7 @@ const {
|
||||
} = await tokenResponse.json();
|
||||
```
|
||||
|
||||
이 값은 바로 다음 Resource Server 요청의 `Authorization` 헤더에 사용된다.
|
||||
이 값은 바로 다음 보호 자원 서버 요청의 `Authorization` 헤더에 들어간다.
|
||||
|
||||
```http label="mediator를 지나지 않는 경로"
|
||||
GET http://localhost:8081/api/me
|
||||
@@ -201,7 +200,7 @@ Authorization: Bearer <raw-keycloak-jwt>
|
||||
Origin: http://localhost:8082
|
||||
```
|
||||
|
||||
access token은 다음 세 곳에서 사용된다.
|
||||
액세스 토큰이 지나가는 곳은 다음 세 곳이다.
|
||||
|
||||
```text
|
||||
/token/access response body
|
||||
@@ -209,13 +208,11 @@ access token은 다음 세 곳에서 사용된다.
|
||||
→ /api/me Authorization header
|
||||
```
|
||||
|
||||
세 곳 모두 브라우저에서 요청을 처리하는 동안의 흐름 안에 있다.
|
||||
|
||||
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 token을 쓰지 않는다는 뜻이다. 실행 중인 script가 응답이나 지역 변수의 token에 접근할 수 없다는 뜻은 아니다.
|
||||
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 토큰을 쓰지 않는다는 뜻이고, 실행 중인 스크립트가 응답이나 지역 변수의 토큰에 닿지 못한다는 뜻은 아니다.
|
||||
|
||||
## /token/access는 일회성 전달이 아니다
|
||||
|
||||
`/token/access`가 access token을 한 번만 전달하고 이후에는 다시 받을 수 없는 방식인지 확인했다.
|
||||
`/token/access`의 동작을 one-time handoff라고 부를 수 있을지 살펴봤다. 한 번 건넨 뒤에는 같은 토큰을 다시 받을 수 없어야 그렇게 부를 수 있다.
|
||||
|
||||
| one-time handoff 요건 | 있나 |
|
||||
|---|---|
|
||||
@@ -225,7 +222,7 @@ memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 token
|
||||
| 건넨 뒤 삭제 | x |
|
||||
| 재호출 거부 | x |
|
||||
|
||||
현재 구현에는 한 번 전달한 token을 사용 처리하거나 이후 호출을 거부하는 동작이 없다. 같은 인증된 session에서는 현재 access token을 다시 요청할 수 있다.
|
||||
지금 구현에는 한 번 건넨 토큰을 사용 처리하거나 다음 호출을 거부하는 코드가 없어서, 같은 인증된 세션이라면 현재 액세스 토큰을 다시 요청할 수 있다. 그래서 이 구현이 보장하는 것은 리프레시 토큰을 응답에서 빼는 것까지이고, 액세스 토큰을 한 번만 건네는 기능은 없다. 리프레시 토큰을 응답에서 뺀 것만 확인했고, 액세스 토큰을 한 번만 주는 기능은 없다.
|
||||
|
||||
```text
|
||||
repeatable GET
|
||||
@@ -233,18 +230,20 @@ repeatable GET
|
||||
→ current raw access token response
|
||||
```
|
||||
|
||||
브라우저에 반환하는 값은 access token뿐이고 refresh token은 응답에 넣지 않는다.
|
||||
정말 한 번만 건네야 한다면 이 raw access token endpoint를 재사용할 수 없다. 짧게 사는 일회용 code를 만들고 audience가 제한된 exchange endpoint에서 한 번만 소비하는 별도 protocol이 필요하다.
|
||||
|
||||
## Mediator에서 서버 상태와 브라우저 노출
|
||||
## 이 구조를 고를 때 함께 오는 서버 상태와 액세스 토큰 노출
|
||||
|
||||
- server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
|
||||
- browser 노출 : access token은 여전히 응답 본문과 헤더에 있다
|
||||
mediator를 넣은 이유는 하나다. 리프레시 토큰은 브라우저 JavaScript 메모리에서 서버로 옮기고, 브라우저가 보호 자원 서버를 직접 부르는 방식은 바꾸지 않으려고 했다. 둘을 같이 두려면 client secret을 서버에 보관할 수 있는 confidential client가 필요하다. 구현을 마치고 보니 이 선택은 두 비용을 함께 남겼다.
|
||||
|
||||
브라우저가 Resource Server를 직접 호출해야 한다면 이 구조를 사용할 수 있다. 브라우저에서 access token까지 없애야 한다면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
|
||||
- 서버 상태 : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
|
||||
- 브라우저 노출 : 액세스 토큰이 응답 본문과 `Authorization` 헤더를 지나가는 것은 막지 못했다
|
||||
|
||||
브라우저가 보호 자원 서버를 직접 불러야 한다는 요구가 분명하면 이 구조를 고를 수 있다. 이 구조는 두 구조의 비용을 함께 갖는다 — mediator 상태를 확장하고 복구해야 하는데 액세스 토큰은 여전히 XSS에 노출된다. 브라우저에서 액세스 토큰까지 없애야 한다면 이 구조로는 답이 되지 않고, 서버 상태 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
|
||||
|
||||
## 현재 자동 테스트로 확인한 범위
|
||||
|
||||
아래 항목은 커밋된 자동 테스트에서 확인하도록 정의한 내용이다.
|
||||
아래 항목은 커밋된 자동 테스트가 확인하도록 적어 둔 것들이다.
|
||||
|
||||
| 항목 | 확인했나? |
|
||||
|---|---|
|
||||
@@ -262,6 +261,6 @@ repeatable GET
|
||||
| 재시작·replica 이동 뒤 복구 | x |
|
||||
| 허용 밖 origin의 CORS 거부 | x |
|
||||
|
||||
manager에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 만료된 token을 갱신할 수 있는 구성은 들어가 있지만, 실제로 만료를 기다린 뒤 refresh가 성공하는지와 rotation된 token이 저장되는지는 아직 확인하지 않았다.
|
||||
`OAuth2AuthorizedClientManager`에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 다만 실제로 만료를 기다린 뒤 갱신이 성공하는지, rotation된 토큰이 저장되는지는 아직 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user