Files
llm-wiki/raw/branch-notes/feature-keycloak-token-mediating-confidential-client.md
T

206 lines
15 KiB
Markdown

---
title: branch / feature-keycloak-token-mediating-confidential-client
source_type: branch-note
status: raw
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-008
kind: project-work-item
project: keycloak-patterns-overview
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-008
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SECRET-BOUNDARY-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-TOKEN-MEDIATING-001@1]
refines: []
overrides: []
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-002]
imports: []
delegates: []
accepts_delegations: []
contract_packet: 1
contract_packet_sha256: 86daea4a925a49815e0f059211614d4a3646864432a3b8ab8b00a75243e7eb19
branch: feature-keycloak-token-mediating-confidential-client
parent_branch:
related_projects: [keycloak-patterns-overview]
tags: [branch]
created: 2026-07-24
target_merge:
status_label: in-progress
---
# branch: feature-keycloak-token-mediating-confidential-client
<!-- section-id: branch-parent -->
## 부모 (필수)
- [[raw/project-notes/keycloak-patterns-overview]]
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: confidential backend의 code-token 교환과 server-side refresh 보관이 검증된다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SECRET-BOUNDARY-001@1` | confidential client secret은 env var로 주입하고 commit·realm export 평문을 금지한다 | `WI-KEYCLOAK-PATTERNS-OVERVIEW-008` 완료 조건에 적용 | `[[raw/project-notes/keycloak-patterns-overview]]` |
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-TOKEN-MEDIATING-001@1` | AP2 backend가 token을 획득하고 access token만 browser에 전달한다 | `WI-KEYCLOAK-PATTERNS-OVERVIEW-008` 완료 조건에 적용 | `[[raw/project-notes/keycloak-patterns-overview]]` |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- GENERATED: artifact-imports:start -->
### 가져온 artifact 계약
| Artifact Ref | Owner | Producer | Schema Ref |
|---|---|---|---|
<!-- GENERATED: artifact-imports:end -->
<!-- GENERATED: project-contract-imports:start -->
## 가져온 프로젝트 계약
| Ref | Owner | 요약 | Branch 적용 |
|---|---|---|---|
<!-- GENERATED: project-contract-imports:end -->
<!-- GENERATED: received-delegations:start -->
### 수신한 위임
| Delegation Ref | From | Concern | Status |
|---|---|---|---|
<!-- GENERATED: received-delegations:end -->
<!-- GENERATED: flow:start -->
### 가져온 흐름 단계
| Stage Ref | Order | Owner | Input | Action | Output |
|---|---:|---|---|---|---|
<!-- GENERATED: flow:end -->
<!-- section-id: branch-goal -->
## 목표
- `WI-KEYCLOAK-PATTERNS-OVERVIEW-008`의 완료 조건을 구현한다: confidential backend의 code-token 교환과 server-side refresh 보관이 검증된다
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- 백엔드(confidential client)가 authorization code → token 교환을 **서버측**에서 수행(client_secret 사용).
- 획득한 **refresh token 을 서버측에 보관**(browser 미노출)하고 access 만료 시 자동 refresh 경로 확보.
- 위 두 가지를 로컬 스택에서 재현·검증(code→token `200`, refresh 서버측 보관).
### 제외 범위
> 의도적으로 제외. 면접 등에서 "이건 범위에 없었습니다" 근거.
- **access token 을 browser 로 handoff + refresh 가 네트워크탭/응답바디에 부재 확인** → `WI-009`(`feature-keycloak-token-mediating-access-handoff`) 소유. 본 branch 는 *획득·보관*까지.
- BFF proxy(모든 API 를 backend 가 대리 호출) 및 CSRF/SameSite 방어 → AP3(`feature-keycloak-bff-*`).
- Resource Server 측 `aud`/`iss` 검증 → AP1(`feature-keycloak-spring-rs-audience-validator`).
- project decision registry 변경.
## 근거 (필수, 최소 1개+)
| Source | 정당화하는 결정 |
|---|---|
| `[[raw/official-docs/spring-security-oauth2-login-servlet-official]]` | D1 — `oauth2Login()` 이 backend(Spring Boot) 소유의 **서버측** OAuth2 client 기능이며 code 교환 콜백(`{baseUrl}/login/oauth2/code/{registrationId}`)이 backend route 임 (`SPRING-OAUTH2LOGIN-C1`·`C2`·`C4`) |
| `[[raw/official-docs/spring-security-oauth2-authorized-client-servlet-official]]` | D2 — 획득 토큰이 `OAuth2AuthorizedClient`**서버측 저장**(principal-scoped)되고 만료 시 자동 refresh (`SPRING-AUTHZCLIENT-C2`·`C5`, `SPRING-OAUTH2LOGIN-C5`) |
| `[[raw/project-notes/keycloak-patterns-overview]]` | D1·D2·D3 — AP2 정의(backend=confidential client, 토큰 backend 획득) + 상속 결정 `DEC-…-TOKEN-MEDIATING-001@1`·`DEC-…-SECRET-BOUNDARY-001@1` |
> ⚠️ 근거 범위 한계: 위 두 공식 자료는 **AP3/BFF(full-session)** 맥락에서 수집됐다. D1·D2 의 *메커니즘*(backend confidential client 의 code→token 교환·서버측 토큰 보관)은 직접 뒷받침하나, **AP2 특유의 "access 만 browser 로, refresh 는 서버 보관"** 분기는 이 quote 들이 직접 서술하지 않는다(그 명제·검증은 WI-009). 아래 Open Risk 참조.
## TODO
- [ ] Keycloak realm 에 confidential client 등록(WI-002 산출물) + `client_secret` env 주입 — 등급: `planned`
- [ ] backend 가 authorization code → token 교환 성공(`200`, client_secret 사용) — 등급: `planned`
- [ ] refresh token 서버측 보관 확인(backend 재조회/자동 refresh 가능, browser 미보유) — 등급: `planned`
## 진행 중 메모
- 백킹 코드 repo: hub 는 `/home/donghyeon/workspace/keycloak-patterns/` 로 표기하나 해당 경로 부재. 실제 후보 = `keycloak-pattern`(README 만) / `Project-Auth-Server`(Spring Boot). **AP2 구현 코드 아직 없음**(grep: `AuthorizedClient`/`oauth2Login`/`confidential` 0건) → 본 branch 결정은 전부 `planned`, `NO_GROUND_TRUTH`(구현 SSOT 부재). repo 경로 정합은 착수 전 확정 필요.
## 결정 사항
- 2026-07-24: **D1** backend 를 confidential OAuth2 client 로 두고 code→token 교환을 서버측 수행 / 이유: AP2 정의(토큰을 backend 가 획득) + client_secret 은 browser 에 둘 수 없음 / 대안: AP1(public client + PKCE, browser 가 교환·토큰 보유) / 근거: `[[raw/official-docs/spring-security-oauth2-login-servlet-official]]`, `[[raw/project-notes/keycloak-patterns-overview]]`
- 2026-07-24: **D2** refresh token 을 `OAuth2AuthorizedClient` 로 서버측 보관(browser 미노출), access 만료 시 자동 refresh / 이유: refresh 노출 시 XSS 1건=장기 세션 탈취, AP2 는 refresh 를 backend 만 보유 / 대안: refresh 도 browser 저장(AP1 스타일, 보안강도 하락) / 근거: `[[raw/official-docs/spring-security-oauth2-authorized-client-servlet-official]]`
- 2026-07-24: **D3**(상속 `DEC-…-SECRET-BOUNDARY-001@1`) `client_secret` env var 주입, commit·realm export 평문 금지 / 본 branch 적용점: D1 교환에 쓰는 secret 의 출처
<!-- section-id: decision-evidence -->
## Decision Evidence Map / 결정-근거 매핑
> `official-vendor-doc`(Spring) · `project-decision(inherited)` 구분. 두 Spring 자료는 AP3/BFF 맥락 수집이라 메커니즘 근거로만 쓰고 AP2 분기는 Open Risk 로 명시.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | backend 를 confidential OAuth2 client 로 두고 authorization code→token 교환을 서버측 수행(client_secret) | AP2(backend 가 토큰 획득) → 이 결정 / AP1(public client + PKCE, browser 가 교환) → 대안 | `raw/official-docs/spring-security-oauth2-login-servlet-official.md#SPRING-OAUTH2LOGIN-C1`, `#SPRING-OAUTH2LOGIN-C2`, `#SPRING-OAUTH2LOGIN-C4` | `official-vendor-doc` | `oauth2Login()` 은 full-session 수립(AP3 근접) — AP2 의 "access 만 browser 로" 분기는 이 quote 밖(WI-009). **핵심 gap: confidential client 가 token endpoint 에서 `client_secret` 으로 인증하는 메커니즘 자체를 어느 cited claim 도 증명 안 함**(C1/C2/C4 는 route·소유만 닫음) → RFC 6749 §4.1.3 또는 Spring Security "OAuth2 Client Authentication"(`client-authentication-method`) 챕터 **후속 archive 필요**(`spring-security-oauth2-login-servlet-official.md:84` 이 이미 UNSUPPORTED_DECISION 으로 표기). WI-008 완료 조건의 정중앙 메커니즘이라 착수 전 닫기 권장 |
| D2 | 획득 refresh token 을 `OAuth2AuthorizedClient` 로 서버측 보관(principal-scoped), access 만료 시 자동 refresh | 서버측 보관(AP2/AP3) → 이 결정 / browser 보관(AP1) → 대안 | `raw/official-docs/spring-security-oauth2-authorized-client-servlet-official.md#SPRING-AUTHZCLIENT-C2`, `#SPRING-AUTHZCLIENT-C5`, `raw/official-docs/spring-security-oauth2-login-servlet-official.md#SPRING-OAUTH2LOGIN-C5` | `official-vendor-doc` | 저장 API 존재는 증명하나 "browser 가 refresh 를 절대 미보유"는 이 quote 가 직접 증명 X(WI-009 검증). 기본 in-memory store 프로덕션 적합성 미보증 |
| D3 | `client_secret` 을 env var 로 주입, commit·realm export 평문 금지 | 항상(confidential client) / N/A | `raw/project-notes/keycloak-patterns-overview.md` (`DEC-KEYCLOAK-PATTERNS-OVERVIEW-SECRET-BOUNDARY-001@1`, 상속) | `project-decision(inherited)` | secret 저장·주입 매체(env vs vault/SOPS) 구체는 배포 결정(본 branch 범위 밖) |
<!-- section-id: implementation -->
## 구현 가이드
> **Trace**: D1(`SPRING-OAUTH2LOGIN-C1/C2/C4`) + D2(`SPRING-AUTHZCLIENT-C2/C5`, `SPRING-OAUTH2LOGIN-C5`) + D3(inherited). AP2 구현 코드 부재(`NO_GROUND_TRUTH`)라 아래는 *착수 전 사전 명세*이며 등급 전부 `planned`.
>
> - **UNSUPPORTED_IMPL_DECISION**: (a) **backend 획득 메커니즘** — `oauth2Login()`(full-session, 자동 authorized-client 저장) vs 수동 `authorization_code` grant(세션 없이 토큰만 획득해 access 를 browser 로 넘김). 소스는 `oauth2Login()`(=session 수립, C3)만 서술 → AP2 의 "access handoff" 요구와는 결이 다름. trade-off: `oauth2Login` 은 배선 최소지만 session 부작용, 수동 grant 는 AP2 에 정합하나 refresh 보관·자동갱신을 직접 배선해야 함. **방향(유보이나 편향 있음): C3(full-session)이 AP2 의 "access 만 browser 로"(WI-009) 요구와 상충하므로 수동 grant 로 lean — 단 소스가 직접 규정하진 않으니 최종 확정은 WI-009 handoff 메커니즘 결정과 함께 착수 시**. (b) **refresh 저장소** — 기본 in-memory `OAuth2AuthorizedClientService` vs 영속(JDBC) 구현. 소스는 API 존재만 증명, 선택 미권고. trade-off: in-memory = 재시작 시 authorized-client 소실→재로그인·배선 최소 / JDBC(`JdbcOAuth2AuthorizedClientService`) = 영속·재시작 생존이나 스키마·배선↑. **학습 스택이면 in-memory 로 시작**.
| 항목 | 사전 명세 (planned) | Trace |
|---|---|---|
| Client 등록 | Keycloak realm 에 `Access Type: confidential` client + `client_secret` 발급(WI-002 산출물) | D3 · depends WI-002 |
| Secret 주입 | `client_secret``SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_*_CLIENT_SECRET` env 로 주입(하드코딩·평문 export 금지) | D3(`SECRET-BOUNDARY`) |
| Code 교환 | backend callback route `{{baseUrl}}/login/oauth2/code/{{registrationId}}` 에서 code→token 교환(client_secret 사용) | D1(`SPRING-OAUTH2LOGIN-C4`) · UNSUPPORTED_IMPL_DECISION(a) |
| 토큰 보관 | 획득 토큰을 `OAuth2AuthorizedClient`(principal-scoped)로 서버측 보관, 만료 시 `OAuth2AuthorizedClientManager` 자동 refresh — **전제(C5 조건부)**: registration 에 `refresh_token` grant / `offline_access` scope + refresh 수행 가능한 `OAuth2AuthorizedClientProvider` 구성이 있어야 자동 refresh 동작 | D2(`SPRING-AUTHZCLIENT-C2/C5`, `SPRING-OAUTH2LOGIN-C5`) · UNSUPPORTED_IMPL_DECISION(b) |
<!-- section-id: edge-failure-dependency -->
## 엣지·실패·의존
- **실패·엣지 경로**:
- refresh token 자체가 만료/폐기되어 자동 refresh 실패 → 재인증 유도. 근거 quote 밖("Handling Failure"/`OAuth2AuthorizationFailureHandler` — 소스 미발췌) → 착수 시 별도 확인.
- code 교환 실패(`invalid_client`: secret 불일치 / redirect_uri mismatch) → 401/400. WI-002 client 설정 정합 의존.
- **다른 계약 의존**: `WI-KEYCLOAK-PATTERNS-OVERVIEW-002`(`feature-keycloak-realm-client-export`, pin 결정 `DEC-KEYCLOAK-PATTERNS-OVERVIEW-REALM-CLIENT-001@1` + `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SECRET-BOUNDARY-001@1`) 의 confidential client 등록 + `client_secret` 발급을 consume. 그 계약(client id/secret/redirect_uri)이 바뀌면 D1·D3 영향.
<!-- section-id: claims-to-verify -->
## 검증해야 할 주장 / Claims To Verify
> 공식 문서는 메커니즘 근거지만 본 프로젝트 실제 동작을 자동 보장하지 않는다. AP2 코드 미구현이라 전부 구현 후 검증 대상.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| backend 가 confidential client 로 code→token 교환에 성공(`200`, client_secret 사용) | AP2 코드 미구현. oauth2Login vs 수동 grant 미결(UNSUPPORTED_IMPL_DECISION a) | 로컬 스택에서 로그인 → backend callback `200` + token endpoint 요청에 `client_secret` 포함(네트워크/로그) 확인 | `planned` |
| 획득 refresh token 이 서버측에 보관되고 backend 가 재조회/자동 refresh 가능 | 저장소 구현(in-memory vs JDBC) 미결(UNSUPPORTED_IMPL_DECISION b) | access 만료 유도 후 자동 refresh 동작 + `OAuth2AuthorizedClientService` 로 서버측 조회 확인 | `planned` |
| `client_secret` 이 env var 에서 로드되고 commit/realm export 에 평문 부재 | 주입 배선 미구현 | `grep` 로 하드코딩 부재 + realm export JSON 에 secret 평문 부재 확인 | `planned` |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
- **coverage 판정: EXEMPT** (2026-07-25) — `wiki_structure_lint.py --coverage-pre` exit=3. governing_docs 부재 + `related_projects``ca-*` 프로젝트 없음(keycloak-patterns-overview) → 완전성 게이트 비적용. 완전성은 프로젝트 hub 의 WI-008 분해로 이미 고정.
- **depth 판정: Ready** (2026-07-25) — `wiki_structure_lint.py --file` PASS + `branch-depth-auditor` Blocking 0 / Should-fix 3 / Advisory 2. Should-fix #2·#3·#5 + Advisory #4 는 본 세션에서 반영. **미해결 Should-fix #1**: confidential client 의 token-endpoint `client_secret` 인증 메커니즘 L1 근거(RFC 6749 §4.1.3 / Spring "OAuth2 Client Authentication") **후속 archive 필요** — 착수 전 닫기 권장(Decision Evidence Map D1 Open Risk 참조).
## 마주친 문제
아직 없음.
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: branches:start -->
<!-- GENERATED: branches:end -->
## 관련 일일 노트
해당 없음.
## 완료 후 정리
- PR 링크:
- 리뷰 메모: