Files
document-haness/docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md
T
DongHyeonkaandClaude Fable 5.1 4d50bb939a 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>
2026-09-07 12:39:20 +09:00

14 KiB

id, kind, slug, title, topic, topicName, project, status, version, verifiedOn, studio, public, assets, sourceRevision, source
id kind slug title topic topicName project status version verifiedOn studio public assets sourceRevision source
488ce49b-afa4-42a5-a2ce-de2e0653cd82 CASE split-custody-access-token Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출 oauth-oidc-auth-boundary OAuth/OIDC 인증 경계 KeyCloak Patterns 게시 중 22 2026-08-24 https://hyeonworks.com/studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit https://hyeonworks.com/cases/split-custody-access-token
key file
ap2-split-custody-779cb791 ../../../final/assets/tech-log-studio/ap2-split-custody.svg
keycloak-patterns-lab@2026-08
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 문자열이 없는지 확인한다.

본문

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" :::

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는 로그인을 시작하면서 인가 요청과 stateHttpSession에 저장한다. 브라우저가 KeyCloak으로 갔다가 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다.

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/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json
{
  "access_token": "<raw-keycloak-jwt>",
  "token_type": "Bearer",
  "expires_at": "<ISO-8601-instant>"
}

응답 본문에 실리는 토큰은 액세스 토큰 하나이고 리프레시 토큰은 넣지 않는다. authorized client나 액세스 토큰이 없으면 401을 돌려준다.

액세스 토큰이 브라우저에서 지나가는 세 곳

브라우저 JavaScript는 /token/access 응답에서 액세스 토큰을 읽어 지역 변수에 넣는다.

const {
  access_token: accessToken,
  expires_at: expiresAt
} = await tokenResponse.json();

이 값은 바로 다음 보호 자원 서버 요청의 Authorization 헤더에 들어간다.

GET http://localhost:8081/api/me
Accept: application/json
Authorization: Bearer <raw-keycloak-jwt>
Origin: http://localhost:8082

액세스 토큰이 지나가는 곳은 다음 세 곳이다.

/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

지금 구현에는 한 번 건넨 토큰을 사용 처리하거나 다음 호출을 거부하는 코드가 없어서, 같은 인증된 세션이라면 현재 액세스 토큰을 다시 요청할 수 있다. 그래서 이 구현이 보장하는 것은 리프레시 토큰을 응답에서 빼는 것까지이고, 액세스 토큰을 한 번만 건네는 기능은 없다. 리프레시 토큰을 응답에서 뺀 것만 확인했고, 액세스 토큰을 한 번만 주는 기능은 없다.

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-Controlno-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된 토큰이 저장되는지는 아직 확인하지 않았다.