Files
llm-wiki/raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md

303 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-keycloak-refresh-rotation-and-logout (refresh token rotation + 로그아웃 흐름 + JWT stateless 한계)
source_type: branch-note
status: raw
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-007
kind: project-work-item
project: keycloak-patterns-overview
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-007
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
refines: []
overrides: []
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-004]
contract_packet: 1
branch: feature-keycloak-refresh-rotation-and-logout
parent_branch:
related_projects: [keycloak-patterns]
tags: [branch, keycloak-patterns, p3a, implementation, refresh-token, rotation, logout, jwt-revocation]
created: 2026-05-25
target_merge:
status_label: in-progress
contract_packet_sha256: 64fd889a4fded12eee171744601a4a80a43037d485ca72fbf8491e80bc3067bd
---
# branch: feature-keycloak-refresh-rotation-and-logout (refresh token rotation + 로그아웃 흐름)
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]] Work Item의 직접 자식 branch.
> **P3A는 실 구현 대상**. 본 sub-sub는 학습 + 작업 plan 기록 — 실 구현은 `/home/donghyeon/workspace/keycloak-patterns/`.
<!-- section-id: branch-parent -->
## 부모 (필수)
[[raw/project-notes/keycloak-patterns-overview]]
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: refresh rotation과 logout 후 session·token 무효화가 검증된다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1` | AP1은 public client와 Authorization Code + PKCE를 사용한다 | public SPA의 refresh rotation과 logout 흐름에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | rotation·logout 후 session과 token 무효화 검증 evidence에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
없음.
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
[[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D4가 정의한 rotation 정책·검증 계약을 target Keycloak 버전에서 실행한다. `Max Reuse=0/1`의 실제 의미와 RT 재사용 후 영향 범위를 관찰하고, logout/revoke 흐름과 **JWT의 stateless 한계**도 시연한다.
면접 질문: "JWT를 즉시 무효화할 수 있나요?"
→ "self-contained access token을 로컬 검증하면 revoke 결과가 즉시 반영되지 않을 수 있어 짧은 TTL을 사용합니다. refresh token rotation은 사용된 RT를 무효화하고 새 RT를 발급하지만, 예전 RT 재사용 시 후속 RT나 session까지 어떻게 영향받는지는 Keycloak 버전별 실험으로 확인해야 합니다. 초 단위 무효화가 필요하면 introspection 같은 stateful 검증의 비용을 별도로 평가합니다."
- 이슈:
- PR: (별도 keycloak-patterns repo)
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- concept owner의 candidate profile을 소비해 `Revoke Refresh Token = ON``Refresh Token Max Reuse = 0/1`을 각각 설정·비교
- SPA가 silent renew 호출 시마다 새 refresh token 받는 것 확인 (DevTools)
- 동일 refresh token 2회 사용 시도 후 RT_1 응답, RT_2 후속 사용, realm session 상태를 분리 관찰
- `/protocol/openid-connect/revoke` 엔드포인트 호출 (refresh token revoke)
- `/protocol/openid-connect/logout?id_token_hint=...&post_logout_redirect_uri=...` 흐름
- 함정 시연: access token revoke 즉시 적용 안 됨 (다음 만료까지 유효)
- 짧은 access token 만료(5분)의 트레이드오프 측정
- backend가 `iat`/`exp` 확인하는 방식 (Spring 기본 동작)
### 제외 범위
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
- backend가 매 요청 introspection 호출 (stateless 포기 패턴)
- Keycloak event listener / custom SPI
- distributed token blacklist 캐시 (Redis 등)
- back-channel logout receiver 구현과 provider-trigger E2E — 현재 문서에서는 옵션 시연도 하지 않으며 endpoint를 가정하지 않음. 필요 시 전용 branch 신설
## 근거 (필수, 최소 1개+)
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**. 같은 자료가 여러 결정의 근거면 결정 표시와 함께 여러 번 등장 가능. Claim ID 는 각 raw 의 `## Claims Extracted` 표에서 안정적으로 유지.
| Source | 정당화하는 결정 (Claim) |
|---|---|
| [[raw/official-docs/oauth2-token-revocation-rfc-7009]] | **D4** revoke 계약(`RFC7009-C1`~`C3`: endpoint·`token`·`token_type_hint` 파라미터), **D4** refresh revoke 시 관련 access token SHOULD 무효화(`RFC7009-C4`), **D3** stateless JWT trap 의 표준 원인(`RFC7009-C5`: self-contained AT → RS 추가 상호작용 불필요), **D2** 짧은 TTL 이 RFC 자신이 제시하는 설계 대안(`RFC7009-C6`, 방향성만·수치 미권고) |
| [[raw/official-docs/keycloak-oidc-logout-endpoint-official]] | **D4** logout 흐름 — RP-Initiated Logout endpoint(`KC-LOGOUT-C1/C2`), `id_token_hint` 미전달 시 confirm(`C3`), `post_logout_redirect_uri` auto redirect + 필수 동반 파라미터(`C4/C5`), `Valid Post Logout Redirect URIs` 매칭 검증(`C6`), Backchannel Logout URL(`C7`) |
| [[raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official]] | **D1/D5** "Revoke Refresh Token" 토글 = rotation(사용된 RT 무효화 + 새 토큰 발급) 공식 정의(`KC-ROT-C1`), **D2** "Access Token Lifespan" 설정(`C3`) + 짧은 lifespan = 유출 완화 공식 원칙(`C5`). ⚠️ "Refresh Token Max Reuse" 설정명 + "재사용 시 family invalidate" 자동 동작은 이 공식 문서(v26.7.0)에 **부재**함을 전수 검색으로 확인(`KC-ROT-C6`, negative finding) → D1/D5 의 그 부분은 `UNSUPPORTED_DECISION` 유지 |
| [[raw/official-docs/security-jwt-rfc-7519-validation]] | **D3** `exp` 시각 도달 이후 JWT MUST NOT be accepted(`JWT-RFC7519-C2`) — "revoke 직후엔 200, 만료 후 401" 함정의 표준 근거 |
| [[raw/official-docs/spring-security-resource-server-jwt]] | **D3** backend(Spring RS)가 `issuer-uri` 로 self-config + JWKS 서명/`iss`/`exp` 만 검증하고 매 요청 introspection 안 함(`SSRS-JWT-C1/C2`) — self-contained 검증 구성 확인 |
| [[raw/official-docs/oidc-client-ts-library]] | **D5** SPA silent renew 메커니즘 — Refresh Token Grant(`OIDCTS-C4`) + Silent Refresh in iframe(`OIDCTS-C5`). `signoutRedirect()``/logout` redirect(라이브러리 API, `needs-confirmation`) |
| [[raw/official-docs/oauth-v2-1-draft-ietf]] | **D1/D5** rotation 권고 배경 — refresh token MUST be bound to scope/resource server(`OA21-C3`), code grant 가 AT+RT 발급 표준 경로(`OA21-C6`) |
| [[raw/official-docs/keycloak-securing-apps-overview-official]] | (일반 배경) Keycloak 통합 시 표준 protocol 우선 / adapter 는 last resort(`KC-SECAPP-C1/C2`). rotation/revoke/logout 구체 동작의 verbatim 은 이 overview 에 없음 — 위 전용 raw 들이 대체 |
## TODO
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
- [ ] [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1 profile을 소비해 `Revoke Refresh Token = ON`에서 `Refresh Token Max Reuse = 0``1`을 각각 설정; UI/export key도 기록 — 등급: `planned`
- [ ] Keycloak admin: Access Token Lifespan 5분 (짧게) — 등급: `planned`
- [ ] SPA login → access_token / refresh_token 1 발급 — 등급: `planned`
- [ ] silent renew 호출 1회 → 새 access_token + 새 refresh_token 2 수신 확인 → refresh_token 1 invalidate — 등급: `planned`
- [ ] **reuse 관찰 시연**: refresh_token 1을 다시 사용한 응답 status/body를 기록하고, refresh_token 2의 후속 사용과 realm session 상태를 별도로 확인. 0/1 profile 결과를 비교하며 family invalidation을 expected result로 두지 않음 — 등급: `planned`
- [ ] `/protocol/openid-connect/revoke` 엔드포인트로 refresh_token 명시적 revoke (curl) — 등급: `planned`
- [ ] **함정 시연**: revoke 직후 동일 access_token으로 `/api/me` 호출 → 200 OK (만료 전이므로 유효) → 5분 뒤 호출 → 401 — 등급: `planned`
- [ ] DevTools Network 탭에서 `/token` (`grant_type=refresh_token`) 호출 시 응답 body 캡처 (rotation 전후 refresh_token 값 비교) — 등급: `planned`
- [ ] SPA logout button → `userManager.signoutRedirect()` → Keycloak `/logout?id_token_hint=...&post_logout_redirect_uri=http://localhost/` 호출 확인 — 등급: `planned`
- [ ] logout 후 Keycloak session 종료 → SPA `/api/me` 호출 시 access token 만료 전이면 여전히 200 → 함정 재확인 — 등급: `planned`
- [ ] logout 후 brower에서 Keycloak 다시 접근 시 SSO session 없어 재로그인 필요 확인 — 등급: `planned`
- [ ] backend에서 `exp` claim 만료 시 401 응답 코드 확인 (Spring 기본 동작 검증) — 등급: `planned`
- [ ] 트레이드오프 정리 노트: "stateless JWT vs 즉시 무효화" — 등급: `planned`
## 진행 중 메모
- **Refresh Token Rotation의 확인된 동작**: 사용된 refresh token을 무효화하고 새 refresh token을 발급한다. 예전 RT의 재등장은 탈취뿐 아니라 client race/retry일 수도 있으므로 원인을 단정하지 않는다.
- **Max Reuse 의미는 실험 대상**: 0과 1에서 같은 sequence를 실행하고 응답·후속 RT·session 상태를 비교한다. race가 결과를 섞지 않도록 시연 중 단일 refresh thread를 보장한다.
- **access token revoke 즉시 적용 안 되는 이유**: backend가 매 요청마다 Keycloak에 introspection 안 함 — JWT signature/iss/aud/exp만 검증. 그게 JWT의 본질적 트레이드오프.
- **짧은 access token 만료**: 5분으로 줄이면 revoke 후 최대 5분 노출 — 학습 단계 권장. prod는 115분 권장 (보안 vs Keycloak 부하 트레이드오프).
- **`id_token_hint`의 역할**: logout 시 어느 session을 끝낼지 식별. ID Token이 없으면 Keycloak이 logout 페이지에서 "정말 로그아웃?" 추가 확인 UI 표시.
- **`post_logout_redirect_uri`**: Keycloak client 설정에 `Valid post logout redirect URIs`로 사전 등록 필요 (현재 Keycloak 18+).
## 결정 사항 (decisions)
- 2026-07-18: 본 branch의 D1은 보안 정책 결정이 아니라 **실행 test profile**이다. [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1을 소비해 rotation ON에서 Max Reuse 0/1을 모두 관찰하며 값 의미를 미리 정하지 않는다.
- 2026-05-25: **Access Token Lifespan 5분.** 이유: revoke 함정을 짧은 대기 시간으로 검증 가능.
- 2026-05-25: **introspection 패턴은 out of scope.** 이유: JWT stateless를 포기하는 트레이드오프 — 학습 목적은 stateless의 한계를 인지하는 것.
- 2026-05-25: **명시적 revoke + logout 분리 학습.** 이유: 두 흐름이 다른 endpoint를 사용하는 것을 직접 확인.
## 결정-근거 매핑
> 2026-07-18 `/branch-spec` 자동조사 반영: RFC 7009(revoke) + Keycloak logout endpoint + Keycloak "Revoke Refresh Token" 설정 정의 + RFC 7519 `exp` + Spring RS + oidc-client-ts + OAuth 2.1 raw 를 Sources 에 연결. 결과 — **D3·D4 는 official 근거로 완전 해소**, D1·D2·D5 는 **부분 해소**(방향/메커니즘 확보, 잔여 `UNSUPPORTED`).
> 잔여 `UNSUPPORTED` 는 근거 부족이 아니라 *공식 문서에 없음을 전수 검색으로 확정한 gap*(Keycloak "Refresh Token Max Reuse" 설정명 + family-invalidate 자동 동작 = `KC-ROT-C6` negative finding) 또는 *어느 표준도 권고 안 하는 임의 수치*("5분")다. 둘 다 admin UI 실측/실험으로만 닫힌다 → `## Claims To Verify` 참조.
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지. `Supporting Claims` 는 `raw/<category>/<slug>.md#Cn` 형식.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | **실행 test profile** — concept owner [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1을 소비해 rotation ON + Max Reuse 0/1을 동일 조건에서 비교 | target-version 의미를 검증할 때 두 profile 모두 실행. 한 값을 보안상 우월하다고 사전 분류하지 않음 | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C1` + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` + `UNSUPPORTED_DECISION`(`KC-ROT-C6`) | `official-vendor-doc + official-standard + needs-confirmation (Max Reuse semantics)` | UI/export에서 필드 존재를 확인하고 실험 결과에 따라 concept owner를 갱신. 본 D1이 독립 정책 owner가 되지 않음 |
| D2 | Access Token Lifespan 5분 — revoke 함정 짧은 대기로 검증 | 학습 단계엔 5분(revoke 함정을 5분 대기로 관찰 가능). prod 는 보안 vs Keycloak 부하 균형으로 1~15분 구간에서 선택 | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C3` (Access Token Lifespan 설정 존재/역할) + `#KC-ROT-C5` (짧은 lifespan = 유출 완화 Keycloak 공식 원칙) + `raw/official-docs/oauth2-token-revocation-rfc-7009.md#RFC7009-C6` (short-lived AT = RFC 설계 대안) + `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C2` (`exp` 후 MUST NOT accept — 만료 대기가 검증 방법인 이유). **`UNSUPPORTED_IMPL_DECISION`**: "5분" 정확한 수치는 어느 표준도 분 단위 미권고 (trade-off: 짧을수록 revoke 노출창↓ but refresh 왕복↑·서버 부하↑; 5분은 학습 대기시간 편의로 임의 선택) | `official-vendor-doc (KC-ROT-C3/C5 방향) + official-standard (RFC7009-C6, JWT-RFC7519-C2) + UNSUPPORTED_IMPL_DECISION (5분 수치)` | OWASP/Keycloak 공식 권장 TTL 구간 raw 추가 시 수치 보강 |
| D3 | introspection 패턴은 out of scope — JWT stateless 트레이드오프 학습 목적 | stateless 한계 *인지*가 목표면 introspection out of scope. 진짜 즉시 무효화가 요구되면 대안: 매 요청 introspection 또는 opaque/reference token 채택(별도 branch, stateless 이점 포기) | `raw/official-docs/oauth2-token-revocation-rfc-7009.md#RFC7009-C5` (self-contained AT → RS 추가 상호작용 불필요 = revoke 즉시 반영 안 됨의 표준 원인) + `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C2` (`exp` 후 MUST NOT accept) + `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1` (Spring RS = signature/`iss`/JWKS 검증, 매 요청 introspection 안 함) | `official-standard (RFC7009-C5, JWT-RFC7519-C2) + official-vendor-doc (SSRS-JWT-C1/C2)` | Keycloak 이 기본 self-contained JWT access token 을 발급하는지 실측(token decode) — RFC/Spring 은 일반 아키텍처만 증명 |
| D4 | 명시적 revoke + logout 분리 학습 — 두 endpoint 의 다른 동작 직접 확인 | 특정 토큰만 즉시 폐기(SSO session 유지 가능)면 `/revoke`. 사용자 로그아웃(브라우저 SSO session 종료)까지면 `/logout`. 목적이 달라 분리 시연 | (revoke) `raw/official-docs/oauth2-token-revocation-rfc-7009.md#RFC7009-C1`~`C4` (endpoint·`token`·`token_type_hint`·refresh revoke SHOULD cascade AT) + (logout) `raw/official-docs/keycloak-oidc-logout-endpoint-official.md#KC-LOGOUT-C1`~`C6` (logout endpoint·RP-initiated redirect·`id_token_hint`·`post_logout_redirect_uri`·Valid Post Logout Redirect URIs 매칭) | `official-standard (RFC 7009: RFC7009-C1~C4) + official-vendor-doc (Keycloak logout: KC-LOGOUT-C1~C6)` | Keycloak 세션이 `/logout` 호출 후 실제로 종료되는지 실측(runtime) — 명세는 확보, 동작은 미검증 (Claims To Verify #3) |
| D5 | **실행 관찰 절차** — concept owner [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D4를 소비해 RT_1 사용→AT_2+RT_2 발급→RT_1 재사용 응답→RT_2 후속 사용→realm session 상태를 순서대로 기록 | D1의 0/1 profile 각각에 동일 절차 적용. family invalidation은 가능한 관찰 결과 중 하나일 뿐 expected result가 아님 | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C1`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3`, `#OA21-C6`, `raw/official-docs/oidc-client-ts-library.md#OIDCTS-C4`, `#OIDCTS-C5` + `UNSUPPORTED_DECISION`(`KC-ROT-C6`) | `official-vendor-doc + official-standard + needs-confirmation (reuse impact)` | status/body, RT_2 유효성, session 상태를 독립 증거로 남기고 concept owner D4에 결과 반영 |
## 구현 가이드
> *결정*이 "*무엇*"이라면 본 §는 "*어디에 어떻게*"의 사전 명세. 본 branch 는 코드베이스가 아니라 **Keycloak admin 설정 + curl/DevTools 실험** 이 "구현"이므로, sub-section 은 설정 카탈로그·endpoint 계약·실험 시퀀스로 구성한다. ⚠️ 실 구현 repo `/home/donghyeon/workspace/keycloak-patterns/` 는 현재 **미생성**(controller 확인) → 아래 모든 항목은 `planned`, 코드 존재 주장 없음.
### 1. Keycloak Realm Token/Session 설정 카탈로그 (D1·D2)
> **Trace**: D1(concept owner의 0/1 test profile) + D2(Access Token Lifespan 5분). 근거: `KC-ROT-C1`(Revoke Refresh Token 정의)·`KC-ROT-C3`(Access Token Lifespan 정의).
>
> - **UNSUPPORTED_IMPL_DECISION**:
> - `Refresh Token Max Reuse` 필드명과 0/1 의미 — `KC-ROT-C6`(공식 문서 부재). admin UI/export 및 runtime 비교 전까지 실험 변수로만 취급한다.
> - `Access Token Lifespan = 5분` 수치 — 표준 미권고(D2 trade-off). 학습 대기시간 편의로 임의 선택.
| 위치 (admin console) | 설정 | 값 | 근거 |
|---|---|---|---|
| Realm Settings → Sessions/Tokens | Revoke Refresh Token | `ON` | `KC-ROT-C1` (Enabled → 사용된 RT revoke + 새 토큰 발급) |
| Realm Settings → Sessions/Tokens | Refresh Token Max Reuse | `0`, `1` 두 profile | `UNSUPPORTED_IMPL_DECISION` (`KC-ROT-C6` 부재 — UI/export + runtime 비교) |
| Realm Settings → Sessions/Tokens | Access Token Lifespan | `5m` | `KC-ROT-C3` (설정 역할) + `UNSUPPORTED_IMPL_DECISION` (수치) |
| Client(`spa-client`) → Logout settings | Valid Post Logout Redirect URIs | `http://localhost/` 등록 | `KC-LOGOUT-C6` (매칭 필수) — §3 참조 |
### 2. Rotation reuse-detection 시연 시퀀스 (D5)
> **Trace**: D5(rotation flow). 근거: `KC-ROT-C1`(rotation 동작) + `OIDCTS-C4/C5`(SPA silent renew) + `OA21-C3/C6`(배경).
>
> - **관찰 경계**: RT_1 재사용 후 family 전체 invalidation은 문서로 증명되지 않았다(`KC-ROT-C6`). 따라서 시퀀스는 expected result가 아니라 응답·RT_2·session을 분리 측정하는 절차다.
```text
1) SPA login (oidc-client-ts UserManager.signinRedirect) → AT_1 + RT_1 발급 [OIDCTS-C3/C4]
2) silent renew 1회 (startSilentRenew) → /token grant_type=refresh_token(RT_1)
→ Keycloak: RT_1 revoke + AT_2 + RT_2 반환 [KC-ROT-C1]
3) stolen token 시뮬레이션: curl 로 RT_1 재사용 → /token(RT_1)
→ 관찰 A: HTTP status/body [UNSUPPORTED — KC-ROT-C6]
4) RT_2 로 다음 refresh 수행 → 관찰 B: 성공/실패와 응답
5) realm session 상태 확인 → 관찰 C: session 유지/종료
```
- DevTools Network 탭: 2)의 `/token` 응답 body 에서 `refresh_token` 값이 RT_1→RT_2 로 바뀌는지 캡처(rotation 증거).
- ⚠️ silent renew와 manual curl이 동시에 RT_1을 쓰면 어떤 호출이 먼저 소비했는지 불명확해진다. manual 시연 시 silent renew를 일시 중단하고 순서를 로그 timestamp로 고정한다.
### 3. 명시적 revoke + RP-Initiated Logout endpoint 계약 (D4)
> **Trace**: D4(revoke/logout 분리). 근거: revoke = `RFC7009-C1~C4`, logout = `KC-LOGOUT-C1~C6`. UNSUPPORTED 없음(양 endpoint 모두 official 근거 확보).
| 흐름 | 요청 | 파라미터 계약 | 근거 |
|---|---|---|---|
| refresh token revoke | `POST /realms/<realm>/protocol/openid-connect/revoke` | `token=<RT>` (REQUIRED) · `token_type_hint=refresh_token` (OPTIONAL) · `client_id=<spa-client>` | `RFC7009-C1/C2/C3` |
| revoke 부수효과 | (위 동일) | RT revoke 시 동일 grant 의 access token 도 **SHOULD** 무효화(AS 지원 시) — MUST 아님 | `RFC7009-C4` |
| RP-Initiated Logout | `GET /realms/<realm>/protocol/openid-connect/logout?id_token_hint=<id_token>&post_logout_redirect_uri=http://localhost/` | `id_token_hint` 없으면 confirm UI(`C3`) · `post_logout_redirect_uri` 쓰려면 `client_id` 또는 `id_token_hint` 동반(`C5`) · 값은 Valid Post Logout Redirect URIs 와 매칭(`C6`) | `KC-LOGOUT-C1~C6` |
| SPA 트리거 | `UserManager.signoutRedirect()` | 라이브러리가 위 logout URL 구성 | `OIDCTS` (API `needs-confirmation`) |
### 4. Stateless JWT trap 검증 절차 (D3)
> **Trace**: D3(stateless 한계 학습). 근거: `RFC7009-C5`(self-contained AT) + `JWT-RFC7519-C2`(`exp` 후 MUST NOT accept) + `SSRS-JWT-C1/C2`(Spring RS 검증 구성). UNSUPPORTED 없음.
```text
1) /protocol/openid-connect/revoke 로 RT revoke (또는 logout)
2) 동일 AT 로 backend GET /api/me 즉시 호출 → 기대: 200 OK
(AT 만료 전 · Spring RS 는 서명/iss/exp 만 검증, revoke 사실 모름) [RFC7009-C5, SSRS-JWT-C1]
3) Access Token Lifespan(5분) 경과 후 재호출 → 기대: 401
(exp 도달 → MUST NOT be accepted) [JWT-RFC7519-C2]
```
- backend 설정: `spring.security.oauth2.resourceserver.jwt.issuer-uri` 한 줄(= `KC_HOSTNAME` 기반 issuer 와 정확 일치, `SSRS-JWT-C1`). issuer 불일치 시 401 — §엣지·의존 참조([[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] 의존).
## 엣지·실패·의존
> R4 캡처용. 정상 경로 외에 *구현(시연) 중 부딪힐* 실패/엣지 + 다른 계약 의존.
- **실패·엣지 경로**:
- **SPA race로 관찰 오염**: silent renew와 manual `/token`이 동시에 RT_1을 제출하면 누가 토큰을 먼저 소비했는지 알 수 없다. 시연 시 silent renew를 끄거나 in-flight refresh를 단일 promise로 직렬화한다.
- **`post_logout_redirect_uri` 미등록**: client `Valid Post Logout Redirect URIs` 에 없으면 매칭 실패(`KC-LOGOUT-C6`) → logout 거부/에러. 기대: 사전 등록(§구현 가이드 1, [[raw/branch-notes/feature-keycloak-realm-client-export]] 의존).
- **`id_token_hint` 누락**: `post_logout_redirect_uri` 만 주고 `id_token_hint`/`client_id` 둘 다 없으면 confirm UI 노출(`KC-LOGOUT-C3/C5`) → 자동 redirect 안 됨. 기대: `id_token_hint` 동반.
- **revoke 후 만료 전 AT = 여전히 200** (함정 그 자체): `RFC7009-C5` 로 표준상 예상되는 결과. "버그"가 아니라 stateless 아키텍처의 정상 동작 — 학습 포인트.
- **Keycloak `iss`/`issuer-uri` 불일치**: `KC_HOSTNAME` 과 backend `issuer-uri` 가 다르면 JWT `iss` 검증 실패로 revoke/logout 시연 이전에 401(`SSRS-JWT-C1`).
- **다른 계약 의존** (sibling branch + 그 Decision ID — 소비하는 계약이 어느 결정에서 확정됐는지):
- [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] `#D1`·`#D3` — SPA 토큰 발급(`#D1` oidc-client-ts 채택) + silent renew(`#D3` `automaticSilentRenew: true`) 구현. 본 branch D5 시연이 이 SPA 흐름을 consume. 그 계약(로그인/갱신 방식)이 바뀌면 rotation 시연 절차 영향.
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] `#D1`·`#D6``KC_HOSTNAME=localhost`(`#D1`) + backend `iss`/`issuer-uri` 문자열 일치 + JWKS 도달 메커니즘(`#D6`, ⚠️ 그 branch 기준 현재 기본=(C)). 본 branch D3 의 backend 401 검증(`SSRS-JWT-C1`) 전제.
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] `#D3` — backend = stateless + JWT 만 사용(`#D3`)의 Resource Server 검증 경로. 본 branch D3 의 `exp`→401 이 이 검증 체인 위에서 동작.
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] `#D1`·`#D4` — Keycloak 26.x 실행(`#D1`) + realm JSON auto-import(`#D4`). 모든 실측 TODO 의 실행 환경 전제.
- [[raw/branch-notes/feature-keycloak-realm-client-export]] D5 — realm-export.json 저작·commit에 client `Valid Post Logout Redirect URIs` 등록 포함. 본 branch D4 logout의 전제. rotation 값·정책은 concept [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D2가 owner이고 본 branch D1/D5는 실행만 담당한다.
## 검증해야 할 주장
> 구현 전/중/후에 실제 검증해야 하는 주장. P3A는 실 구현 대상이며, D1/D5의 0/1 비교 결과를 concept owner에 환류한다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| `Max Reuse=0``1`에서 RT_1 재사용이 RT_2와 realm session에 미치는 영향 | 필드·값 의미와 family invalidation 동작의 verbatim 인용 없음(`KC-ROT-C6`); 버전별 차이 가능 | 두 profile에서 동일하게 RT_1 사용→RT_2 발급→RT_1 재사용→RT_2 사용→session 확인. 각 status/body를 독립 기록 | `planned` |
| `/protocol/openid-connect/revoke` 로 RT revoke 후 동일 access_token 으로 `/api/me` 호출 시 만료 전에는 200 응답 (stateless JWT 한계) | JWT stateless backend 동작의 표준 근거는 확보(`RFC7009-C5`)나 Keycloak+Spring 실동작 미검증 | Access Token Lifespan 5분 설정 → revoke 직후 호출 (200 예상) → 5분 후 호출 (401 예상) | `planned` |
| Keycloak `/protocol/openid-connect/logout?id_token_hint=...&post_logout_redirect_uri=...` 흐름이 OIDC RP-Initiated Logout 1.0 을 준수 | spec 근거는 확보(`KC-LOGOUT-C1~C6`)나 Keycloak 세션이 실제 종료되는지 runtime 미검증 | Keycloak admin UI 의 logout endpoint 동작 캡처 + logout 후 SSO session 없어 재로그인 필요 확인 | `needs-confirmation` |
| `post_logout_redirect_uri` 가 Keycloak client `Valid post logout redirect URIs` 에 사전 등록 필요 | 근거 확보(`KC-LOGOUT-C6`); 내 client 설정에서 실제 매칭·거부 미확인 | Keycloak admin UI 에서 client 설정 캡처 + 미등록 URI 로 logout 시 거부 확인 | `needs-confirmation` |
| `oidc-client-ts` silent renew와 manual `/token` 호출이 겹칠 때 실험 순서가 오염되는지 | silent renew 존재는 확보했지만 동시 호출 순서와 target Keycloak 결과는 미검증 | timestamp와 Keycloak log로 두 요청 순서를 기록하고, 정식 0/1 비교 실험은 silent renew OFF로 재실행 | `planned` |
| Spring Security Resource Server 가 `exp` claim 만료 시 401 응답 (basic JWT 검증 동작) | 표준(`JWT-RFC7519-C2`)+Spring 구성(`SSRS-JWT-C1/C2`) 근거 확보나 실제 401 응답 미확인 | 5분 후 호출 시 401 응답 캡처 | `planned` |
## 관심사 커버리지
> **EXEMPT** — `rules/coverage-gate.md` §7: `governing_docs` 미지정 + `related_projects` 에 ca-skeleton/ca-tmpl 없음(= `[keycloak-patterns]` 학습 노트) → coverage 게이트 면제. 완전성 기준(governing canonical 문서)이 존재하지 않으므로 관심사 매트릭스를 생성하지 않는다. 깊이(depth R1~R4)만 게이트 대상.
## 마주친 문제
- (구현 시작 후 추가) `post_logout_redirect_uri`가 client에 등록 안 되어 logout 실패 예상 — sub-5-2에서 추가 등록 필요.
- (구현 시작 후 추가) `oidc-client-ts` silent renew와 manual refresh token 호출이 충돌 가능 — manual 시연 시 silent renew 일시 OFF.
- (구현 시작 후 추가) refresh token 요청 race가 0/1 비교 결과를 오염할 가능성 — DevTools와 Keycloak log에서 호출 순서를 확인.
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official]]
- [[raw/official-docs/oauth2-token-revocation-rfc-7009]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: branches:start -->
- [[raw/branch-notes/feature-keycloak-refresh-token-rotation]]
<!-- GENERATED: branches:end -->
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
- (없음 — 현재 documented-only 단계)
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- (없음 — Phase 3 실 구현 단계에 누적)
## 관련 일일 노트
## 완료 후 정리
> rotation 재사용 시연, revoke 함정 시연, logout 전체 흐름이 로그/캡처로 증명되면 `planned` → `actually-implemented`/`locally-verified` 승급. 트레이드오프 노트는 향후 `wiki/concepts/`로 ingest 후보.
- PR 링크: (별도 keycloak-patterns repo)
- 리뷰 메모:
- 머지 결과 / 배포 환경: 로컬 docker-compose / 단일 EC2 시뮬레이션
- **wiki 추출 대상**:
- `actually-implemented` 항목: (구현 후 채움)
- `locally-verified` 항목: (구현 후 채움)
- `prod-verified` 항목: (없음)
- **추출하지 않을 항목**: 현재 전부 `planned`.