274 lines
14 KiB
Markdown
274 lines
14 KiB
Markdown
---
|
|
id: 488ce49b-afa4-42a5-a2ce-de2e0653cd82
|
|
kind: CASE
|
|
slug: split-custody-access-token
|
|
title: Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
|
topic: oauth-oidc-auth-boundary
|
|
topicName: OAuth/OIDC 인증 경계
|
|
project: KeyCloak Patterns
|
|
status: 게시 중
|
|
version: 22
|
|
verifiedOn: 2026-08-24
|
|
studio: "https://hyeonworks.com/studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit"
|
|
public: "https://hyeonworks.com/cases/split-custody-access-token"
|
|
assets:
|
|
- key: ap2-mediator-architecture
|
|
file: ../../../final/assets/ap2-mediator-architecture/ap2-mediator-architecture.svg
|
|
- key: ap2-mediator-handoff-flow
|
|
file: ../../../final/assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg
|
|
sourceRevision: keycloak-patterns-lab@2026-08
|
|
source:
|
|
- final/document.md#검토한-선택지와-막힌-지점-ap2
|
|
- final/document.md#선택의-이유와-지킨-경계-ap2
|
|
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap2-완주
|
|
- final/document.md#결정이-지켜지는지-확인하는-방법-ap2
|
|
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
|
---
|
|
|
|
# Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
|
|
|
confidential client인 mediator가 authorization code를 교환하고, 받은 두 토큰을 서버 쪽 authorized client에 넣는다. 그런데 보호 자원 서버(Resource Server)를 부르는 쪽은 브라우저라서 액세스 토큰이 브라우저에도 있어야 하고, 그 값은 JSON 응답으로 다시 내려온다. 그래서 이 구조가 브라우저 밖으로 옮긴 것은 client secret과 리프레시 토큰까지이고, 액세스 토큰을 다루는 일은 브라우저가 계속 한다.
|
|
|
|
## 관계
|
|
|
|
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
|
SPA에서는 브라우저가 코드 교환과 토큰 보관을 모두 맡는데, 여기서는 그중 리프레시 토큰을 서버로 옮겼다.
|
|
- **Public Client와 Confidential Client 구분 기준**
|
|
mediator는 confidential client인데도 액세스 토큰을 브라우저 응답으로 돌려준다. 클라이언트를 어느 종류로 등록했는지가 토큰이 브라우저까지 가는지를 정하지는 않는다.
|
|
- **OAuth Token과 Application Session을 구분하는 기준**
|
|
액세스 토큰은 응답 본문과 JavaScript 지역 변수와 Authorization 헤더를 지나가고, 애플리케이션 쪽 로그인 상태는 그와 별도로 관리된다.
|
|
- **OAuth/OIDC 인증 패턴 선택 기준**
|
|
리프레시 토큰은 서버에 두면서 액세스 토큰은 브라우저로 보내는 구성이라, 이 패턴을 고르면 mediator의 서버 상태까지 함께 운영해야 한다.
|
|
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
|
|
여기서는 리프레시 토큰 rotation과 재사용 0회로 설정했다. 여러 replica에서 갱신이 겹치는 경우는 이 구성으로 확인하지 못해 그 질문에서 따로 다룬다.
|
|
|
|
## 문제
|
|
|
|
Spring mediator가 confidential client가 되어 code를 교환한다. 받은 액세스 토큰과 리프레시 토큰은 server-side authorized-client service에 저장하고, 브라우저가 로그인 상태로 들고 있는 것은 HttpOnly가 붙은 AP2_SESSION뿐이다.
|
|
|
|
서버가 토큰을 보관한다는 점만 보면 BFF와 비슷한데, 이 구조에서는 보호 자원 서버를 브라우저가 직접 부른다. 그 요청에 액세스 토큰이 필요하기 때문에 mediator가 액세스 토큰을 다시 응답으로 돌려준다.
|
|
|
|
/token/access 응답부터 보호 자원 서버 요청까지 따라가면 서버가 가져간 것은 리프레시 토큰까지였고 액세스 토큰을 다루는 일은 브라우저가 계속 하고 있었다.
|
|
|
|
## 결론
|
|
|
|
서버로 옮긴 것은 client secret과 리프레시 토큰이다. 액세스 토큰은 브라우저에서 다음 세 곳을 지나간다.
|
|
|
|
액세스 토큰이 사용되는 위치
|
|
/token/access 응답 본문 : o
|
|
JavaScript 지역 변수 : o
|
|
/api/me Authorization 헤더 : o
|
|
|
|
서버 상태 : mediator의 session과 authorized-client 저장소를 운영해야 한다.
|
|
브라우저 노출 : 액세스 토큰이 브라우저 실행 영역 안에서 쓰이는 것은 막지 못했다.
|
|
|
|
## 검증 환경
|
|
|
|
Keycloak 26.7.0
|
|
|
|
realms
|
|
client-confidential : o
|
|
implicit flow, direct grant : x
|
|
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 : http://localhost:8082
|
|
method : GET, OPTIONS
|
|
header : Authorization, Content-Type
|
|
|
|
HTTPS : x
|
|
HTTP : o
|
|
|
|
## 재현 조건
|
|
|
|
1. Mediator 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. 브라우저가 그 토큰으로 보호 자원 서버를 직접 호출했을 때 200을 받는지 확인한다.
|
|
|
|
6. 쿠키가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
|
|
|
|
7. Local Storage와 Session Storage에 액세스 토큰 원문이나 refresh_token 문자열이 없는지 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
confidential client는 client secret을 서버에 두고 자기를 인증할 수 있는 애플리케이션이다. 여기 나오는 mediator가 그런 클라이언트이고, 브라우저 대신 authorization code를 토큰으로 바꿔 서버에 보관한다. 다만 보호 자원 서버(Resource Server)는 브라우저가 직접 부른다. 리프레시 토큰만 브라우저에서 걷어내면 브라우저가 무엇을 계속 다루게 되는지 확인했다.
|
|
|
|
## 서버로 옮긴 값과 브라우저로 돌아오는 값
|
|
|
|

|
|
|
|
SPA 구조에서는 브라우저가 코드를 직접 교환하고 받은 토큰도 브라우저에서 관리했다. mediator를 두면 그 코드를 교환하는 쪽이 Spring mediator로 바뀌고, 액세스 토큰과 리프레시 토큰은 둘 다 서버 쪽 authorized client에 저장된다. 그런데 보호 자원 서버를 부르는 쪽은 여전히 브라우저라서, 액세스 토큰은 `/token/access`를 통해 다시 브라우저로 건너온다.
|
|
|
|
각 값의 위치는 다음과 같다.
|
|
|
|
| 무엇 | 브라우저에 있나 | 서버에 있나 |
|
|
|---|---|---|
|
|
| client secret | x | o |
|
|
| refresh token | x | o |
|
|
| access token | o | o |
|
|
| 로그인 상태 | AP2_SESSION | HttpSession |
|
|
|
|
## AP2_SESSION이 생성되는 시점
|
|
|
|
`AP2_SESSION`은 토큰 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
|
|
|
|
Spring Security는 로그인을 시작하면서 인가 요청과 `state`를 `HttpSession`에 저장한다. 브라우저가 KeyCloak으로 갔다가 callback으로 돌아왔을 때 앞에서 시작한 그 로그인 요청을 찾아야 하기 때문에, 세션 쿠키가 이 시점에 먼저 만들어진다.
|
|
|
|
```text label="callback 하나가 두 개의 상태로 나뉜다"
|
|
AP2_SESSION
|
|
→ servlet HttpSession의 login SecurityContext
|
|
→ Authentication(principal name = preferred_username)
|
|
|
|
("keycloak", principal name)
|
|
→ OAuth2AuthorizedClientService
|
|
→ access token + refresh token
|
|
```
|
|
|
|
`AP2_SESSION` 안에 토큰이 들어 있는 것은 아니다. 이 쿠키는 `HttpSession`을 찾는 세션 ID이고, 그 `HttpSession`에 로그인 `SecurityContext`가 저장되어 있다. 토큰은 거기서 확인한 principal로 별도 저장소의 authorized client를 조회해서 찾는다.
|
|
|
|
:::warning
|
|
|
|
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 토큰 상태가 둘 다 지금 프로세스의 메모리에 있다. 세션과 authorized client를 여러 인스턴스가 공유하는지는 이 구성으로 확인하지 못했다.
|
|
|
|
:::
|
|
|
|
## /token/access가 반환하는 세 가지 값
|
|
|
|

|
|
|
|
브라우저가 보호 자원 서버를 직접 부르려면 액세스 토큰이 있어야 하고, 그 값을 주는 것이 `/token/access`다.
|
|
|
|
```http label="브라우저 입력 — cookie 한 개"
|
|
GET http://localhost:8082/token/access
|
|
Accept: application/json
|
|
Cookie: AP2_SESSION=<opaque-session-id>
|
|
```
|
|
|
|
컨트롤러는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣는다. 이 요청으로 `OAuth2AuthorizedClientManager.authorize()`를 부른 뒤, 돌아온 authorized client에서 액세스 토큰을 꺼내 다음 세 값만 응답에 담는다.
|
|
|
|
```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>"
|
|
}
|
|
```
|
|
|
|
응답 본문에 실리는 토큰은 액세스 토큰 하나이고 리프레시 토큰은 넣지 않는다. authorized client나 액세스 토큰이 없으면 401을 돌려준다.
|
|
|
|
## 액세스 토큰이 브라우저에서 지나가는 세 곳
|
|
|
|
브라우저 JavaScript는 `/token/access` 응답에서 액세스 토큰을 읽어 지역 변수에 넣는다.
|
|
|
|
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
|
|
const {
|
|
access_token: accessToken,
|
|
expires_at: expiresAt
|
|
} = await tokenResponse.json();
|
|
```
|
|
|
|
이 값은 바로 다음 보호 자원 서버 요청의 `Authorization` 헤더에 들어간다.
|
|
|
|
```http label="mediator를 지나지 않는 경로"
|
|
GET http://localhost:8081/api/me
|
|
Accept: application/json
|
|
Authorization: Bearer <raw-keycloak-jwt>
|
|
Origin: http://localhost:8082
|
|
```
|
|
|
|
이 요청에 Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
|
|
|
|
액세스 토큰이 지나가는 곳은 다음 세 곳이다.
|
|
|
|
```text
|
|
/token/access response body
|
|
→ JavaScript local variable
|
|
→ /api/me Authorization header
|
|
```
|
|
|
|
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 토큰을 쓰지 않는다는 뜻이고, 실행 중인 스크립트가 응답이나 지역 변수의 토큰에 닿지 못한다는 뜻은 아니다.
|
|
|
|
## /token/access는 일회성 전달이 아니다
|
|
|
|
`/token/access`의 동작을 one-time handoff라고 부를 수 있을지 살펴봤다. 한 번 건넨 뒤에는 같은 토큰을 다시 받을 수 없어야 그렇게 부를 수 있다.
|
|
|
|
| one-time handoff 요건 | 있나 |
|
|
|---|---|
|
|
| handoff ID | x |
|
|
| nonce | x |
|
|
| 사용 표시(consume flag) | x |
|
|
| 건넨 뒤 삭제 | x |
|
|
| 재호출 거부 | x |
|
|
|
|
지금 구현에는 한 번 건넨 토큰을 사용 처리하거나 다음 호출을 거부하는 코드가 없어서, 같은 인증된 세션이라면 현재 액세스 토큰을 다시 요청할 수 있다. 그래서 이 구현이 보장하는 것은 리프레시 토큰을 응답에서 빼는 것까지이고, 액세스 토큰을 한 번만 건네는 기능은 없다. 리프레시 토큰을 응답에서 뺀 것만 확인했고, 액세스 토큰을 한 번만 주는 기능은 없다.
|
|
|
|
```text
|
|
repeatable GET
|
|
→ current authorized client lookup/refresh opportunity
|
|
→ current raw access token response
|
|
```
|
|
|
|
정말 한 번만 건네야 한다면 이 raw access token endpoint를 재사용할 수 없다. 짧게 사는 일회용 code를 만들고 audience가 제한된 exchange endpoint에서 한 번만 소비하는 별도 protocol이 필요하다.
|
|
|
|
## 이 구조를 고를 때 함께 오는 서버 상태와 액세스 토큰 노출
|
|
|
|
mediator를 넣은 이유는 하나다. 리프레시 토큰은 브라우저 JavaScript 메모리에서 서버로 옮기고, 브라우저가 보호 자원 서버를 직접 부르는 방식은 바꾸지 않으려고 했다. 둘을 같이 두려면 client secret을 서버에 보관할 수 있는 confidential client가 필요하다. 구현을 마치고 보니 이 선택은 두 비용을 함께 남겼다.
|
|
|
|
- 서버 상태 : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
|
|
- 브라우저 노출 : 액세스 토큰이 응답 본문과 `Authorization` 헤더를 지나가는 것은 막지 못했다
|
|
|
|
브라우저가 보호 자원 서버를 직접 불러야 한다는 요구가 분명하면 이 구조를 고를 수 있다. 이 구조는 두 구조의 비용을 함께 갖는다 — mediator 상태를 확장하고 복구해야 하는데 액세스 토큰은 여전히 XSS에 노출된다. 브라우저에서 액세스 토큰까지 없애야 한다면 이 구조로는 답이 되지 않고, 서버 상태 자체를 둘 수 없다면 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 |
|
|
|
|
`OAuth2AuthorizedClientManager`에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 다만 실제로 만료를 기다린 뒤 갱신이 성공하는지, rotation된 토큰이 저장되는지는 아직 확인하지 않았다.
|
|
|
|
<!-- body:end -->
|