303 lines
30 KiB
Markdown
303 lines
30 KiB
Markdown
---
|
||
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는 1–15분 권장 (보안 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`.
|