- 계약 채택 — 독자 질문, 후보 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>
7.2 KiB
id, kind, slug, title, topic, topicName, project, status, version, basisVersion, studio, sourceRevision, source
| id | kind | slug | title | topic | topicName | project | status | version | basisVersion | studio | sourceRevision | source | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 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 | keycloak-patterns-lab@2026-08 |
|
Bearer JWT가 인증된 principal이 되기까지
Resource Server가 받는 입력은 Authorization 헤더에 실려 온 문자열 하나다. 이 문자열은 서명 검증, issuer와 시간 검증, audience 검증, 역할 변환을 차례로 지나야 인증된 요청 주체(principal)가 된다. OAuth 2.0과 JWT를 한 번이라도 다뤄 본 사람을 대상으로, 먼저 Resource Server가 받는 입력부터 이 사슬이 단계마다 무엇을 확인하고 무엇을 다음 단계로 넘기는지 따라간다. 서명 검증을 통과했다고 이 API를 위해 발급된 토큰인 것은 아니고, 그 확인은 audience 검증이 따로 맡는다.
관계
- OAuth Token과 Application Session을 구분하는 기준 이 검증을 통과한 JWT와 애플리케이션 세션은 다른 상태다.
- Authorization Code Flow의 Endpoint와 Credential 이동 기준 이 JWT가 어느 엔드포인트에서 발급되는지 정리한 기록이다.
- SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계 브라우저가 이 헤더를 직접 만든 구성이다.
본문
Resource Server가 받는 입력
브라우저가 보내든 BFF(Backend For Frontend, 프런트엔드 전용 백엔드)가 보내든 요청의 모양은 같다.
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
Spring이 받는 것은 이 헤더에 실려 온 문자열 그대로다. 요청마다 JWT로 인증하고 애플리케이션 세션을 만들지 않으려고 SecurityConfig.apiSecurity()는 CORS를 켜고 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 보호를 끄며 SessionCreationPolicy.STATELESS를 선택한다.
세션을 만들지 않으니 로그아웃하는 순간에 서버에서 지울 상태도 없다. 이미 발급된 JWT는 필요한 값을 자기 안에 담고 있어서 만료 전까지 유효하고, 짧은 TTL(Time To Live, 유효 시간)과 검증기가 그 범위를 좁힌다.
변환 순서
직접 만든 코드가 지나는 순서는 다음과 같다.
raw Bearer JWT
→ NimbusJwtDecoder(JWK signature)
→ default issuer + timestamp validators
→ AudienceValidator("keycloak-pattern-api")
→ validated Jwt
→ KeycloakRealmRoleConverter
→ authenticated principal + ROLE_* authorities
헤더를 꺼내 JWT 인증 제공자에 넘기고 설정된 디코더를 부르는 일은 Spring OAuth2 Resource Server가 한다. 저장소의 코드는 Spring 내부 필터를 직접 만들지 않으므로, 이 순서에는 설정 DSL(Domain Specific Language, 설정 전용 문법)이 붙여 주는 부분과 직접 만들어 끼운 빈이 함께 들어 있다.
서명 확인 하나로 끝내지 않는 이유
서명이 맞다는 것은 그 IdP(Identity Provider, 인증 제공자)가 발급했다는 뜻이다. 서명 검증에 쓰는 JWK(JSON Web Key, 공개키를 JSON으로 표현한 형식)는 그 IdP의 공개키를 담고 있을 뿐이어서, 같은 IdP가 다른 API를 위해 발급한 토큰도 서명은 똑같이 맞다. 그래서 서명만 확인하고 끝내지 않는다.
| 확인 단계 | 확인하는 것 | 이 단계가 답하지 못하는 것 |
|---|---|---|
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 토큰인가 |
| issuer | 기대한 realm인가 | 아직 유효한가 |
| timestamp | 만료 전인가 | 이 API가 대상인가 |
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
| 역할 변환 | 어떤 권한을 갖는가 | — |
expected issuer와 JWK URL이 다른 이유
두 값은 같은 realm을 가리키지만 쓰임이 다르다.
expected issuer = http://localhost:8080/realms/keycloak-patterns
JWK URL = http://keycloak:8080/.../certs
expected issuer는 토큰 안에 적혀 브라우저까지 보이는 값이다. 브라우저가 도달하는 주소로 발급됐으니 클레임을 검증하는 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 컨테이너 네트워크 경로이고, Resource Server는 같은 Docker 네트워크 안에서 서비스 이름으로 Keycloak에 도달한다.
두 값을 같게 맞추려고 expected issuer를 컨테이너 주소로 바꾸면 브라우저가 받은 토큰의 iss와 어긋난다.
audience 검증
audience는 이 토큰이 어느 API를 위해 발급됐는지 담는 값이다. AudienceValidator는 jwt.getAudience()에 keycloak-pattern-api가 들어 있는지 확인하고, 없으면 invalid_token 결과를 만든다.
정상적으로 발급받은 같은 JWT를 expected audience가 다른 진단용 Resource Server에 그대로 제출하면 401이 돌아온다. expected issuer가 다른 서버도 마찬가지로 401이다.
다만 이 두 서버가 재 준 범위는 audience 검증과 issuer 검증까지다. 서명이 틀린 JWT나 만료된 JWT를 넣는 전용 E2E 계약은 만들지 않았다. 단위 테스트에 합성 JWT를 주입해 컨트롤러가 200을 반환하는 것은 확인할 수 있지만, 그 200은 그 요청이 실제 NimbusJwtDecoder의 서명 검증과 issuer 검증을 지났다는 증거가 아니다. 사슬의 앞 두 단계는 이 계약 안에서 부정 입력으로 확인하지 않았다.
realm role이 authority가 되는 변환
KeycloakRealmRoleConverter는 realm_access.roles에 들어 있는 문자열을 골라 앞에 ROLE_을 붙인다.
realm_access.roles: ["user-role"]
→ ROLE_user-role
Spring Security의 hasRole("user-role")이 ROLE_user-role이라는 이름의 권한을 찾기 때문에 이 접두사가 필요하다.
인증과 인가는 다른 endpoint에서 갈린다
/api/me는 특정 역할을 요구하지 않고 .authenticated()만 요구한다. 그래서 역할 클레임이 없어 변환 결과가 빈 목록이어도, JWT가 유효하기만 하면 /api/me는 통과한다.
admin-role의 효과는 /api/admin에서 나타난다. 일반 사용자는 403, 관리자 사용자는 200이다.
ApiController는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
{
"subject": "<keycloak-user-sub>",
"username": "regular-user",
"issuer": "http://localhost:8080/realms/keycloak-patterns",
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
}
이 JSON의 regular-user는 프록시가 만들어 붙이는 identity header에도 똑같이 들어갈 수 있는 값이다. 화면에 보이는 이름은 같아도 그 값을 무엇이 보증했는지는 다르다. 여기서는 서명과 issuer, audience를 확인한 JWT에서 꺼냈다.