The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.6 KiB
id, kind, slug, title, topic, topicName, project, status, version, basisVersion, studio
| id | kind | slug | title | topic | topicName | project | status | version | basisVersion | studio |
|---|---|---|---|---|---|---|---|---|---|---|
| 87000d59-b69f-4010-9481-0b71c8bde32d | CONCEPT | bearer-jwt-validation-chain | Bearer JWT가 인증된 principal이 되기까지 | oauth-oidc-auth-boundary | OAuth/OIDC 인증 경계 | KeyCloak Patterns | 게시 전 | 4 | Keycloak 26.7.0 · Spring Security OAuth2 Resource Server | https://hyeonworks.com/studio/documents/87000d59-b69f-4010-9481-0b71c8bde32d/edit |
Bearer JWT가 인증된 principal이 되기까지
Resource Server가 받는 입력은 Authorization 헤더의 문자열 하나다. 이 문자열이 서명 검증, issuer와 시간 검증, audience 검증, role 변환을 차례로 지나 authenticated principal이 된다. 서명 검증을 통과해도 이 API를 위해 발급된 token인지는 audience 검증에서 따로 본다.
관계
- OAuth Token과 Application Session을 구분하는 기준 이 검증을 통과한 JWT와 애플리케이션 session은 다른 상태다.
- Authorization Code Flow의 Endpoint와 Credential 이동 기준 이 JWT가 어느 endpoint에서 발급되는지 정리한 기록이다.
- SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계 브라우저가 이 헤더를 직접 만든 구성이다.
본문
Resource Server가 받는 입력
브라우저나 BFF가 보내는 요청의 모양은 같다.
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
Spring 쪽 입력은 raw Bearer string이다. 요청마다 JWT로 인증하고 application session을 만들지 않으려고 SecurityConfig.apiSecurity()는 CORS를 켜고 CSRF를 끄며 SessionCreationPolicy.STATELESS를 선택한다.
session을 만들지 않으므로 logout 순간에 지울 server 상태가 없다. 이미 발급된 self-contained JWT는 만료 전까지 유효하고, 짧은 TTL과 validator가 그 범위를 좁힌다.
변환 순서
custom code가 지나는 순서는 다음과 같다.
raw Bearer JWT
→ NimbusJwtDecoder(JWK signature)
→ default issuer + timestamp validators
→ AudienceValidator("keycloak-pattern-api")
→ validated Jwt
→ KeycloakRealmRoleConverter
→ authenticated principal + ROLE_* authorities
Spring OAuth2 Resource Server가 헤더를 추출하고 JWT authentication provider를 거쳐 configured decoder를 호출한다. repository code가 Spring 내부 filter를 직접 만들지는 않으므로, DSL이 설치하는 framework integration과 custom bean 경계를 나눠 읽어야 한다.
서명을 통과한 뒤에 남는 확인
서명이 맞다는 것은 그 IdP가 발급했다는 뜻이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다. 그래서 서명만 확인하고 끝내지 않는다.
| 확인 단계 | 확인하는 것 | 통과해도 남는 질문 |
|---|---|---|
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 token인가 |
| issuer | 기대한 realm인가 | 아직 유효한가 |
| timestamp | 만료 전인가 | 이 API가 대상인가 |
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
| role converter | 어떤 권한을 갖는가 | — |
expected issuer와 JWK URL이 다른 이유
두 값은 같은 realm을 가리키지만 쓰임이 다르다.
expected issuer = http://localhost:8080/realms/keycloak-patterns
JWK URL = http://keycloak:8080/.../certs
expected issuer는 token 안의 browser-visible 값이다. 브라우저가 도달하는 주소로 발급됐으므로 claim 검증 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 container network 경로다. Resource Server가 같은 Docker network 안에서 service name으로 Keycloak에 도달한다.
하나는 claim 검증 기준이고 하나는 network access 경로다. 두 값을 같게 맞추려다 issuer를 container 주소로 바꾸면 브라우저가 받은 token의 iss와 어긋난다.
audience 검증
AudienceValidator는 jwt.getAudience()에 keycloak-pattern-api가 포함됐는지 확인한다. 누락되면 invalid_token 결과를 만든다.
같은 정상 JWT를 expected audience가 다른 진단용 Resource Server에 제출하면 401이 된다. issuer가 다른 서버도 마찬가지다. 두 서버가 같은 token에 401을 돌려준 것이 audience 검증과 issuer 검증이 실제로 걸린다는 관측이다.
realm role이 authority가 되는 변환
KeycloakRealmRoleConverter는 realm_access.roles의 string을 골라 ROLE_ prefix를 붙인다.
realm_access.roles: ["user-role"]
→ ROLE_user-role
Spring Security의 hasRole("user-role")이 ROLE_user-role authority를 찾기 때문에 prefix가 필요하다.
인증과 인가는 다른 endpoint에서 갈린다
/api/me는 특정 role을 요구하지 않고 .authenticated()만 요구한다. role claim이 없어서 converter 결과가 빈 list여도 JWT가 유효하면 /api/me는 통과한다.
admin-role의 효과는 /api/admin에서 나타난다. regular user는 403, admin user는 200이다. 로그인 성공과 role 인가를 같은 테스트로 확인하면 이 차이가 가려진다.
controller는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
{
"subject": "<keycloak-user-sub>",
"username": "regular-user",
"issuer": "http://localhost:8080/realms/keycloak-patterns",
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
}