docs(keycloak): adopt the decomposition contract, fix the redirect URI, strip evaluative prose

- 계약 채택 — 독자 질문, 후보 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>
This commit is contained in:
DongHyeonka
2026-09-07 12:39:20 +09:00
co-authored by Claude Fable 5.1
parent 62520a4dce
commit 4d50bb939a
26 changed files with 1947 additions and 1225 deletions
@@ -10,18 +10,21 @@ status: 게시 전
version: 4
basisVersion: Keycloak 26.7.0 · oidc-client-ts
studio: "https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#문제를-어렵게-만든-제약-로그인-흐름과-api-흐름
---
# Authorization Code와 PKCE가 보호하는 구간
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 access token으로 바꾸는 구간을 PKCE가 보호한다. authorization request에 넣은 code_challenge token request에 넣은 code_verifier 맞아야 교환이 끝난다.
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 액세스 토큰으로 바꾸는 구간을 PKCE가 보호한다. authorization request에 code_challenge를 담아 보내고, token request에는 그 원본인 code_verifier를 보내 두 값이 대응하는지 확인한다. 두 값이 맞아야 교환이 끝난다.
## 관계
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
이 개념을 endpoint별 기준으로 정리한 기록이다.
- **Public Client와 Confidential Client 구분 기준**
client 종류에 따라 token endpoint의 인증 방식이 달라진다.
클라이언트 종류에 따라 token endpoint의 인증 방식이 달라진다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code를 직접 교환한 구성이다.
@@ -31,9 +34,11 @@ authorization code는 로그인을 마친 사용자가 애플리케이션으로
## code를 한 번 더 교환하는 이유
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth client가 code를 token endpoint에 제출해야 access token을 받는다.
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth 클라이언트가 code를 token endpoint에 제출해야 액세스 토큰을 받는다.
교환을 나눈 덕분에 access token이 브라우저 주소창을 지나지 않는다. authorization request는 full-page navigation이라 URL이 주소창과 히스토리, Authorization Server 접근 로그에 남는다. 여기 남아도 되는 값만 code로 고, token은 별도 요청의 body로 받는다.
authorization request는 브라우저 전체가 옮겨 가는 요청(full-page navigation)이어서 그 URL이 주소창과 히스토리 Authorization Server 접근 로그에 남는다. 그래서 이 요청에는 그런 곳에 남아도 되는 값만 code로 고, 액세스 토큰은 별도 요청의 body로 받는다. 교환을 둘로 나눈 덕분에 액세스 토큰이 브라우저 주소창을 지나지 않는다.
PKCE 값이 어디서 만들어져 어디서 확인되는지, 두 요청을 순서대로 본다.
## authorization request에 들어가는 challenge
@@ -50,9 +55,9 @@ GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
&code_challenge_method=S256
```
`response_type=code` Authorization Code Flow를 쓴다는 표시이고, `code_challenge`와 `code_challenge_method=S256` PKCE 사용을 나타낸다. `state`와 challenge 값은 요청마다 달라진다.
`response_type=code` Authorization Code Flow를 쓴다는 이고, `code_challenge`와 `code_challenge_method=S256` PKCE를 쓴다는 뜻이다. `state`와 challenge 값은 요청마다 달라진다.
`state`와 PKCE verifier는 redirect를 건너야 하므로 브라우저에 남는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
`state`와 PKCE verifier는 Keycloak에 갔다가 돌아오는 사이에도 있어야 해서 브라우저가 들고 있는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
## token request가 제출하는 verifier
@@ -69,31 +74,33 @@ grant_type=authorization_code
&code_verifier=<original-verifier>
```
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 verifier가 대응하는지 확인하고 교환을 끝낸다. 이 대응이 authorization request를 시작한 client와 code를 교환하는 주체를 연결한다.
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 verifier가 대응하는지 확인한 뒤에 교환을 끝낸다. 이 확인이 authorization request를 시작한 클라이언트와 code를 교환하는 주체를 이어 준다.
다만 커밋된 AP1 브라우저 테스트가 직접 보는 것은 authorization request의 challenge와 token request의 endpoint, `grant_type=authorization_code`까지다. body에 실린 `code_verifier`와 `client_id`, `redirect_uri`, code 값을 하나씩 비교하지는 않는다.
## S256과 plain의 차이
verifier에서 challenge를 만드는 방법 두 가지다.
verifier에서 challenge를 만드는 방법 두 가지다. `plain`은 verifier를 그대로 challenge로 보내는데, 중간에서 challenge를 본 쪽은 그 값을 그대로 verifier로 쓸 수 있다. `S256`은 verifier에 SHA-256을 적용한 값을 challenge로 보내기 때문에 challenge만으로는 verifier를 되돌릴 수 없다.
| method | challenge 값 | 중간에서 challenge를 본 경우 |
|---|---|---|
| `plain` | verifier 그대로 | 그대로 verifier로 쓸 수 있다 |
| `S256` | verifier의 SHA-256 | verifier를 되돌릴 수 없다 |
AP1 realm은 S256을 요구한다. AP1 코드에는 `createPkcePair()`라는 수동 helper도 있어서 32 random bytes를 padding 없는 Base64URL verifier로 바꾸고 SHA-256 challenge와 `"S256"`을 반환한다. 다만 실제 `signinRedirect()`는 이 helper를 호출하지 않는다. helper는 UI의 PKCE demo button용이고 로그인은 pinned oidc-client-ts가 수행한다.
AP1 realm은 S256을 요구한다. AP1 코드에는 `createPkcePair()`라는 손으로 만든 보조 함수도 있다. 이 함수는 무작위 32바이트를 패딩 없는 Base64URL verifier로 바꾸고, 거기에 SHA-256을 적용한 challenge와 `"S256"`을 함께 반환한다. 다만 로그인을 시작하는 `signinRedirect()`는 이 함수를 부르지 않는다. 화면의 PKCE 데모 버튼이 쓰는 코드이고, 실제 로그인은 버전을 고정한 oidc-client-ts가 수행한다.
## PKCE가 막지 않는 것
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 access token을 숨기지는 않는다. 브라우저가 token을 직접 다루는 구성에서 실행 중 악성 script가 Bearer token거나 사용자 권한으로 API를 부르는 문제는 PKCE 밖이다.
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 액세스 토큰을 숨겨 주지는 않는다. 브라우저가 토큰을 직접 다루는 구성에서 실행 중 악성 스크립트가 Bearer 토큰거나 사용자 권한으로 API를 부르는 것은 PKCE가 막는 문제가 아니다.
`state` PKCE와 다른 값이다. `state`는 callback이 원래 시작한 transaction의 것인지 대조하는 값이고, verifier는 code 교환 주체를 묶는 값이다.
`state` PKCE 값과 하는 일이 다르다. `state`는 돌아온 callback이 브라우저가 처음 시작한 트랜잭션의 것인지 대조하고, verifier는 code 교환하는 주체를 authorization request를 시작한 클라이언트에 묶는다.
## client 종류에 따라 달라지는 부분
## 클라이언트 종류에 따라 달라지는 인증
`spa-public`은 secret이 없는 public client다. token endpoint에서 client 인증을 하지 않고 PKCE만 사용한다.
`spa-public`은 secret이 없는 public client다. token endpoint에서 클라이언트 인증을 하지 않고 PKCE만 사용한다.
confidential client는 여기에 client 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 client를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 authorization request resolver에 장착해 framework가 state와 verifier를 만든다.
confidential client는 여기에 클라이언트 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 클라이언트를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 authorization request를 만드는 resolver에 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 장착한다. 그러면 프레임워크가 `state`와 verifier를 만든다.
AP2 client 설정에 S256을 강제하는 속성이 없고, AP2 테스트도 authorization request의 challenge를 검사하지 않는다. AP2에서 확인한 것은 Authorization Code Flow를 쓴다는 까지고, PKCE S256이 고정됐는지는 확인하지 않았다.
클라이언트 설정에 S256을 강제하는 속성이 없고, 테스트도 authorization request의 challenge를 검사하지 않는다. Authorization Code Flow를 쓴다는 까지는 확인했고, PKCE S256이 고정됐는지는 확인하지 않았다.
<!-- body:end -->
@@ -10,18 +10,21 @@ status: 게시 전
version: 4
basisVersion: Keycloak 26.7.0 · Spring Security OAuth2 Resource Server
studio: "https://hyeonworks.com/studio/documents/87000d59-b69f-4010-9481-0b71c8bde32d/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap1
---
# Bearer JWT가 인증된 principal이 되기까지
Resource Server가 받는 입력은 Authorization 헤더 문자열 하나다. 이 문자열 서명 검증, issuer와 시간 검증, audience 검증, role 변환을 차례로 지나 authenticated principal이 된다. 서명 검증을 통과해도 이 API를 위해 발급된 token인지는 audience 검증에서 따로 다.
Resource Server가 받는 입력은 `Authorization` 헤더에 실려 온 문자열 하나다. 이 문자열 서명 검증, issuer와 시간 검증, audience 검증, 역할 변환을 차례로 지나야 인증된 요청 주체(principal)가 된다. OAuth 2.0과 JWT를 한 번이라도 다뤄 본 사람을 대상으로, 먼저 Resource Server가 받는 입력부터 이 사슬이 단계마다 무엇을 확인하고 무엇을 다음 단계로 넘기는지 따라간다. 서명 검증을 통과했다고 이 API를 위해 발급된 토큰인 것은 아니고, 그 확인은 audience 검증 따로 맡는다.
## 관계
- **OAuth Token과 Application Session을 구분하는 기준**
이 검증을 통과한 JWT와 애플리케이션 session은 다른 상태다.
이 검증을 통과한 JWT와 애플리케이션 세션은 다른 상태다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
이 JWT가 어느 endpoint에서 발급되는지 정리한 기록이다.
이 JWT가 어느 엔드포인트에서 발급되는지 정리한 기록이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 이 헤더를 직접 만든 구성이다.
@@ -31,20 +34,20 @@ Resource Server가 받는 입력은 Authorization 헤더의 문자열 하나다.
## Resource Server가 받는 입력
브라우저나 BFF가 보내 요청의 모양은 같다.
브라우저가 보내든 BFF(Backend For Frontend, 프런트엔드 전용 백엔드)가 보내 요청의 모양은 같다.
```http label="Resource Server 입력"
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
```
Spring 쪽 입력은 raw Bearer string이다. 요청마다 JWT로 인증하고 application session을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다.
Spring이 받는 것은 이 헤더에 실려 온 문자열 그대로다. 요청마다 JWT로 인증하고 애플리케이션 세션을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 보호를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다.
session을 만들지 않으므로 logout 순간에 지울 server 상태 없다. 이미 발급된 self-contained JWT는 만료 전까지 유효하고, 짧은 TTL과 validator가 그 범위를 좁힌다.
세션을 만들지 않으니 로그아웃하는 순간에 서버에서 지울 상태 없다. 이미 발급된 JWT는 필요한 값을 자기 안에 담고 있어서 만료 전까지 유효하고, 짧은 TTL(Time To Live, 유효 시간)과 검증기가 그 범위를 좁힌다.
## 변환 순서
custom code가 지나는 순서는 다음과 같다.
직접 만든 코드가 지나는 순서는 다음과 같다.
```text label="raw Bearer JWT가 principal이 되기까지"
raw Bearer JWT
@@ -56,19 +59,19 @@ raw Bearer JWT
→ authenticated principal + ROLE_* authorities
```
Spring OAuth2 Resource Server가 헤더를 추출하고 JWT authentication provider를 거쳐 configured decoder를 호출한다. repository code가 Spring 내부 filter를 직접 만들지 않으므로, DSL이 설치하는 framework integration과 custom bean 경계를 나눠 읽어야 한다.
헤더를 꺼내 JWT 인증 제공자에 넘기고 설정된 디코더를 부르는 일은 Spring OAuth2 Resource Server가 한다. 저장소의 코드는 Spring 내부 필터를 직접 만들지 않으므로, 이 순서에는 설정 DSL(Domain Specific Language, 설정 전용 문법)이 붙여 주는 부분과 직접 만들어 끼운 빈이 함께 들어 있다.
## 서명을 통과한 뒤에 남는 확인
## 서명 확인 하나로 끝내지 않는 이유
서명이 맞다는 것은 그 IdP가 발급했다는 뜻이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다. 그래서 서명만 확인하고 끝내지 않는다.
서명이 맞다는 것은 그 IdP(Identity Provider, 인증 제공자)가 발급했다는 뜻이다. 서명 검증에 쓰는 JWK(JSON Web Key, 공개키를 JSON으로 표현한 형식)는 그 IdP의 공개키를 담고 있을 뿐이어서, 같은 IdP가 다른 API를 위해 발급한 토큰도 서명은 똑같이 맞다. 그래서 서명만 확인하고 끝내지 않는다.
| 확인 단계 | 확인하는 것 | 통과해도 남는 질문 |
| 확인 단계 | 확인하는 것 | 이 단계가 답하지 못하는 것 |
|---|---|---|
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 token인가 |
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 토큰인가 |
| issuer | 기대한 realm인가 | 아직 유효한가 |
| timestamp | 만료 전인가 | 이 API가 대상인가 |
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
| role converter | 어떤 권한을 갖는가 | — |
| 역할 변환 | 어떤 권한을 갖는가 | — |
## expected issuer와 JWK URL이 다른 이유
@@ -79,34 +82,36 @@ 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에 도달한다.
expected issuer는 토큰 안에 적혀 브라우저까지 보이는 값이다. 브라우저가 도달하는 주소로 발급됐으니 클레임을 검증하는 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 컨테이너 네트워크 경로이고, Resource Server 같은 Docker 네트워크 안에서 서비스 이름으로 Keycloak에 도달한다.
하나는 claim 검증 기준이고 하나는 network access 경로다. 두 값을 같게 맞추려다 issuer를 container 주소로 바꾸면 브라우저가 받은 token의 `iss`와 어긋난다.
두 값을 같게 맞추려고 expected issuer를 컨테이너 주소로 바꾸면 브라우저가 받은 토큰의 `iss`와 어긋난다.
## audience 검증
`AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 포함됐는지 확인한다. 누락되면 `invalid_token` 결과를 만든다.
audience는 이 토큰이 어느 API를 위해 발급됐는지 담는 값이다. `AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 들어 있는지 확인하고, 없으면 `invalid_token` 결과를 만든다.
같은 정상 JWT를 expected audience가 다른 진단용 Resource Server에 제출하면 401이 된다. issuer가 다른 서버도 마찬가지다. 두 서버가 같은 token에 401을 돌려준 것이 audience 검증과 issuer 검증이 실제로 걸린다는 관측이다.
정상적으로 발급받은 같은 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`의 string을 골라 `ROLE_` prefix를 붙인다.
`KeycloakRealmRoleConverter`는 `realm_access.roles`에 들어 있는 문자열을 골라 앞에 `ROLE_` 붙인다.
```text label="role 변환"
realm_access.roles: ["user-role"]
→ ROLE_user-role
```
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role` authority를 찾기 때문에 prefix가 필요하다.
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role`이라는 이름의 권한을 찾기 때문에 이 접두사가 필요하다.
## 인증과 인가는 다른 endpoint에서 갈린다
`/api/me`는 특정 role을 요구하지 않고 `.authenticated()`만 요구한다. role claim이 없어서 converter 결과가 빈 list여도 JWT가 유효하면 `/api/me`는 통과한다.
`/api/me`는 특정 역할을 요구하지 않고 `.authenticated()`만 요구한다. 그래서 역할 클레임이 없어 변환 결과가 빈 목록이어도, JWT가 유효하기만 하면 `/api/me`는 통과한다.
`admin-role`의 효과는 `/api/admin`에서 나타난다. regular user는 403, admin user는 200이다. 로그인 성공과 role 인가를 같은 테스트로 확인하면 이 차이가 가려진다.
`admin-role`의 효과는 `/api/admin`에서 나타난다. 일반 사용자는 403, 관리자 사용자는 200이다.
controller는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
`ApiController`는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
```json label="ApiController가 반환하는 JSON"
{
@@ -117,4 +122,6 @@ controller는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든
}
```
이 JSON의 `regular-user`는 프록시가 만들어 붙이는 identity header에도 똑같이 들어갈 수 있는 값이다. 화면에 보이는 이름은 같아도 그 값을 무엇이 보증했는지는 다르다. 여기서는 서명과 issuer, audience를 확인한 JWT에서 꺼냈다.
<!-- body:end -->
@@ -10,20 +10,23 @@ status: 게시 전
version: 4
basisVersion: Keycloak 26.7.0 · oidc-client-ts · oauth2-proxy 7.15.2
studio: "https://hyeonworks.com/studio/documents/bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#문제를-어렵게-만든-제약-브라우저에-없다
---
# 브라우저가 credential을 보관하는 위치와 그 성질
브라우저에는 JavaScript memory, Session Storage, Local Storage, cookie가 있고 각각 수명과 접근 경로가 다르다. 어떤 credential 어디에 있는지에 따라 새로고침 뒤 남는 것, JavaScript가 읽을 수 있는 것, 요청에 자동으로 붙는 것이 갈린다.
브라우저에는 JavaScript 메모리, Session Storage, Local Storage, 쿠키가 있고 각각 수명과 접근 경로가 다르다. 자격 증명(credential)을 어디에 두느냐에 따라 새로고침 뒤에 무엇이 유지되는지, JavaScript가 무엇을 읽을 수 있는지, 어떤 값이 요청에 자동으로 실리는지가 갈린다.
## 관계
- **OAuth Token과 Application Session을 구분하는 기준**
여기 있는 값들에 각각 다른 이름을 쓰는이다.
여기 있는 값들을 어떤 이름으로 갈라 부르는지 정한이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
memory-only 구성을 실제로 확인한 기록이다.
- **BFF 인증 구조 설계 기준**
브라우저에 session cookie만 두는 구조의 설계 항목이다.
브라우저에 세션 쿠키만 두는 구조의 설계 항목이다.
## 본문
@@ -31,72 +34,72 @@ studio: "https://hyeonworks.com/studio/documents/bb5c37ae-2d94-48f7-ad4e-a37c61c
## 네 위치의 성질
네 곳을 수명, JavaScript의 접근 여부, 요청 자동 첨부 세 가지로 갈라 보면 이렇다. 표의 `HttpOnly`는 JavaScript가 쿠키 값을 직접 읽지 못하게 하는 쿠키 속성인데, 나머지 성질은 뒤에서 따로 살펴보겠다.
| 위치 | 새로고침 뒤 | JavaScript가 읽나 | 요청에 자동으로 붙나 |
|---|---|---|---|
| JavaScript memory | 초기화 | 읽는다 | 붙지 않는다 |
| JavaScript 메모리 | 초기화 | 읽는다 | 붙지 않는다 |
| Session Storage | 탭이 살아 있으면 유지 | 읽는다 | 붙지 않는다 |
| Local Storage | 유지 | 읽는다 | 붙지 않는다 |
| HttpOnly cookie | 만료까지 유지 | 읽지 못한다 | 붙는다 |
| HttpOnly 쿠키 | 만료까지 유지 | 읽지 못한다 | 붙는다 |
자동으로 붙는다는 성질이 cookie를 credential로 쓸 때 CSRF 검증이 필요해지는 이유다.
네 곳 가운데 쿠키만 요청에 자동으로 붙기 때문에, 쿠키를 자격 증명으로 쓰면 그 요청이 사용자가 의도한 것인지 확인하는 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 검증이 따라붙는다.
## userStore와 stateStore를 나눈다
oidc-client-ts의 `UserManager` 저장소를 따로 받는다.
oidc-client-ts의 `UserManager`는 저장소를 두 개 따로 받는다. AP1은 이렇게 설정했다.
```text label="AP1의 UserManager 저장소 설정"
userStore = InMemoryWebStorage
stateStore = sessionStorage
```
`userStore`는 로그인 뒤 `User`와 token set을 보관한다. `stateStore`는 redirect를 건너야 하는 authorization transaction을 보관한다.
두 저장소의 내용도 성격이 다르다.
`userStore`는 로그인 뒤 `User`와 토큰 묶음을 보관하고, `stateStore`는 리다이렉트를 건너야 하는 authorization transaction을 보관한다.
| 저장소 | 들어가는 것 | 언제까지 필요한가 |
|---|---|---|
| userStore | `User`, access·refresh·ID token, expiry, profile | 로그인 상태가 유지되는 동안 |
| stateStore | `state`, PKCE verifier | callback 처리가 끝날 때까지 |
| userStore | `User`, access·refresh·ID token, 만료 시각, 프로필 | 로그인 상태가 유지되는 동안 |
| stateStore | `state`, PKCE verifier | 콜백 처리가 끝날 때까지 |
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 memory에 둘 수 없다. 이 값이 Session Storage에 있는 것과 token이 Web Storage에 있는 것은 다른 설정이다.
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 메모리에 둘 수 없다. 이 값이 Session Storage에 있는 것과 토큰이 Web Storage에 있는 것은 서로 다른 설정이다.
## memory-only가 뜻하는 범위
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다. 새로고침하면 `User`와 token이 초기화되고, Local Storage와 Session Storage에는 token 복사본이 지 않는다.
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 `User`와 토큰이 초기화되고, Local Storage와 Session Storage에도 토큰 복사본이 만들어지지 않는다.
memory-only는 persistent storage에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저 fetch를 hook하면 API 호출의 Bearer access token을 관측할 수 있다.
이 설정은 저장 위치를 견준 뒤에 고른 것이다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침은 편해지지만 노출 시간도 길어진다. HttpOnly 쿠키로 옮기는 것은 저장 위치만 바꾸면 끝나는 작업이 아니라 서버가 세션이나 토큰 중계를 맡는 AP3 계열 구조가 있어야 한다. refresh token만 mediator로 옮기는 AP2도 함께 검토했지만, 두 구조 모두 브라우저가 code를 교환하고 토큰 수명을 관리하는 모습을 가린다. AP1은 그 과정을 보여 주려고 access·refresh·ID token을 JavaScript 메모리에 두었고, 새로고침 뒤에 인증 상태를 복구하지 못하는 것을 감수했다.
이 구성에서 관측한 두 결과는 다음과 같다.
memory-only는 영구 저장소에 쓰지 않는다는 뜻이지, 실행 중인 스크립트가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저의 `fetch`를 가로채면 API 호출에 실린 Bearer access token을 그대로 관측할 수 있다.
```text label="함께 읽어야 하는 두 결과"
```text label="관측한 두 결과"
Local Storage · Session Storage → access token 문자열 없음
실행 중 fetch hook → Authorization: Bearer 관측됨
```
AP2도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 된다. 세 경계를 지나는 동안 persistent storage에는 쓰이지 않는다.
AP2도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 되고, 세 경계를 지나는 동안 영구 저장소에는 쓰이지 않는다.
## HttpOnly cookie
HttpOnly는 JavaScript가 cookie 값을 직접 읽지 못하게 하는 속성이다. `document.cookie`로 조회되지 않지만 브라우저는 요청마다 붙여 보낸다.
`HttpOnly` 쿠키는 `document.cookie`로 조회되지 않지만, 브라우저는 요청마다 이 값을 자동으로 붙여 보낸다.
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. 브라우저 JavaScript에 OAuth token을 전달하지 않는 구조에서도 이 cookie는 남는다. 브라우저에 없는 은 애플리케이션이 쓰는 OAuth token이고, 인증 상태 자체는 이 cookie로 남아 있다.
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. AP3와 AP4는 브라우저 JavaScript에 OAuth 토큰을 넘기지 않는다. 브라우저를 확인하면 HttpOnly 세션 쿠키가 남아 있고 요청할 때마다 자동으로 붙는다. 그래서 이 기록에서 「브라우저에 없다」은 애플리케이션이 쓰는 OAuth 토큰에만 쓴다. 인증 상태 자체는 이 쿠키가 들고 있다.
Keycloak 도메인의 SSO cookie도 별도로 존재할 수 있다. 애플리케이션 memory의 `User`가 사라진 것과 IdP session이 끝난 것은 다른 사건이다.
Keycloak 도메인의 SSO 쿠키도 별도로 존재할 수 있다. 애플리케이션 메모리의 `User`가 사라진 것과 IdP(Identity Provider) 세션이 끝난 것은 다른 사건이다.
## opaque cookie
opaque는 내부 값을 브라우저가 해석하지 않고 그대로 돌려준다는 뜻이다.
AP2와 AP3의 session cookie는 server-side 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 cookie 안에는 없다. cookie가 token map을 직렬화한다고 설명하면 구현이 틀리게 된다.
AP2와 AP3의 세션 쿠키는 서버 쪽 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 쿠키 안에는 없다.
AP4에서 `session-cookie-minimal=true`를 쓰면 server-side session store 없이 edge가 필요한 최소 정보만 cookie 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속 보관한다고 말할 수 없다.
AP4에서 `session-cookie-minimal=true`를 쓰면 서버 쪽 세션 저장소 없이 엣지가 필요로 하는 최소 정보만 쿠키 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속해서 보관한다고 말할 수 없다.
AP2와 AP3의 cookie는 server-side 상태를 찾는 열쇠이고, AP4의 cookie는 최소 상태를 담은 값이다. 두 cookie를 같은 문장으로 설명하지 않는다.
AP2와 AP3의 쿠키는 서버 쪽 상태를 찾는 열쇠이고, AP4의 쿠키는 최소 상태를 담은 값이다.
## 학습 환경의 cookie 속성을 일반화하지 않는다
지금 구성은 cookie 속성과 redirect를 눈으로 확인하려고 HTTPS가 아 HTTP를 쓴다. 그래서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
지금 구성은 쿠키 속성과 리다이렉트를 눈으로 확인하려고 HTTPS가 아니라 HTTP를 쓰고 있어서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다. 여기서 관측한 값을 운영 cookie 기본값으로 옮겨 적지 않는다.
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다.
<!-- body:end -->
@@ -10,11 +10,14 @@ status: 게시 전
version: 4
basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap3
---
# Cookie로 인증하는 요청에서 CSRF token이 하는 일
session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다.
세션 쿠키는 브라우저가 요청마다 자동으로 붙이기 때문에, 상태를 바꾸는 요청이 사용자 의도한 것인지 서버가 따로 확인해야 한다. CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 토큰이 그 확인을 맡고, SameSite는 브라우저가 쿠키를 언제 보낼지 정하는 별도의 정책이다.
## 관계
@@ -23,7 +26,7 @@ session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
이 동작을 실제로 재현한 기록이다.
- **OAuth Token과 Application Session을 구분하는 기준**
session cookie와 CSRF token은 서로 다른 값이다.
세션 쿠키와 CSRF 토큰은 서로 다른 값이다.
## 본문
@@ -31,13 +34,15 @@ session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서
## cookie가 credential이 되면 생기는 일
브라우저 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다.
BFF(Backend for Frontend)는 브라우저 OAuth 토큰을 내려보내지 않는다. 대신 JavaScript가 읽을 수 없는 HttpOnly 세션 쿠키 하나로 로그인한 사용자를 알아본다.
cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. `GET /bff/api/me`만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다.
쿠키를 요청에 붙이는 쪽은 애플리케이션 코드가 아니라 브라우저다. 다른 사이트가 만든 요청에도 같은 쿠키가 실릴 수 있다는 뜻이다. `GET /bff/api/me`처럼 읽기만 하는 요청에서는 이것이 문제로 보이지 않으므로, 값을 바꾸는 요청을 따로 봐야 한다.
브라우저에 OAuth 토큰을 내려보내지 않기로 하면서 로그인 상태가 서버로 옮겨 왔고, 값을 바꾸는 요청을 가려내는 일도 그때 BFF가 맡게 됐다. 먼저 브라우저가 토큰을 받아 오는 요청부터 본다.
## token을 받아 오는 요청
브라우저가 먼저 CSRF material을 요청한다.
브라우저는 상태를 바꾸는 요청을 보내기 전에 CSRF 값부터 받아 온다.
```http label="CSRF token 요청"
GET http://localhost:8083/bff/csrf
@@ -45,7 +50,7 @@ Accept: application/json
Cookie: AP3_SESSION=<opaque-session-id>
```
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` cookie를 path `/`에 만든다. controller는 다음 JSON을 반환한다.
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` 쿠키를 경로 `/`에 만든다. 컨트롤러는 다음 JSON을 반환한다.
```json label="CsrfController가 반환하는 JSON"
{
@@ -57,23 +62,23 @@ Cookie: AP3_SESSION=<opaque-session-id>
## body의 token과 cookie의 값은 다르다
같은 CSRF material이 세 자리에 서로 다른 형태로 놓인다.
같은 CSRF 값이 응답 본문, 쿠키, 요청 헤더 세 곳에 서로 다른 형태로 놓인다.
`XorCsrfTokenRequestAttributeHandler`가 요청 속성에 넣는 토큰을 XOR와 Base64로 가리기 때문에, 위 JSON에는 가려진 값이 담긴다. 쿠키 쪽은 다르다. `XSRF-TOKEN`에는 가리지 않은 원본 값이 들어간다. 그래서 SPA는 JSON에서 `headerName`만 읽고, 실제로 보낼 값은 `document.cookie`에서 `XSRF-TOKEN`을 찾아 쓴다.
| 위치 | 값 |
|---|---|
| 응답 body의 `token` | XOR와 Base64로 mask된 값 |
| `XSRF-TOKEN` cookie | raw 값 |
| POST의 `X-XSRF-TOKEN` 헤더 | cookie와 같은 raw 값 |
| 응답 본문의 `token` | XOR와 Base64로 가린 값 |
| `XSRF-TOKEN` 쿠키 | 원본 값 |
| POST의 `X-XSRF-TOKEN` 헤더 | 쿠키와 같은 원본 값 |
`XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 `headerName`만 읽고, 실제 값은 `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 쓴다.
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. 서버가 기대하는 헤더가 요청에 있으면 제출된 원본 토큰을 그대로 읽고, 없으면 XOR resolver 경로를 쓴다.
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다.
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다.
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다.
## 검증이 controller보다 먼저 일어난다
정상 상태 변경 요청은 다음과 같다.
검증을 통과하는 상태 변경 요청은 다음과 같다.
```http label="CSRF 검증을 통과하는 POST"
POST http://localhost:8083/bff/api/preferences
@@ -84,33 +89,35 @@ X-XSRF-TOKEN: <same-raw-csrf-token>
theme=dark
```
Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다.
컨트롤러에 닿기 전에 Spring CSRF 필터가 저장소에 있는 기대값과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 컨트롤러는 실행되지 않고 403이 된다. 검증하는 곳이 컨트롤러보다 앞이라, 엔드포인트를 새로 추가해도 같은 필터를 지난다.
다만 이 필터가 보는 것은 CSRF 토큰뿐이다. 경로별 인가는 이 예제의 자동 테스트가 확인하는 범위 밖이라, 새 엔드포인트가 같은 필터를 지났다는 것으로 그 엔드포인트의 인가까지 확인되지는 않는다.
## SameSite가 정하는 것과 CSRF token이 정하는 것
| | SameSite | CSRF token |
SameSite는 쿠키를 다른 사이트로 보낼지 브라우저가 정하는 정책이고, CSRF 토큰은 상태를 바꾸는 요청이 사용자의 의도인지 서버가 검증하는 애플리케이션 규약이다.
| | SameSite | CSRF 토큰 |
|---|---|---|
| 누가 판단하나 | 브라우저 | 서버 |
| 무엇을 정하나 | cookie를 보낼지 | 요청을 받아들일지 |
| 무엇을 정하나 | 쿠키를 보낼지 | 요청을 받아들일지 |
| 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 |
port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 cookie를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
포트가 달라도 사이트 계산상 같은 사이트로 잡히는 경우가 있어서 둘은 서로를 대신하지 못한다. SameSite가 쿠키를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
네 가지 입력에서 cookie와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
네 가지 입력에서 쿠키와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
| 입력 | cookie 동작 | CSRF 동작 | 결과 |
| 입력 | 쿠키 동작 | CSRF 동작 | 결과 |
|---|---|---|---|
| same-origin, CSRF 헤더 없음 | session cookie 붙음 | token 부재로 거부 | 403 |
| same-origin, raw cookie와 헤더 일치 | session cookie 붙음 | token 일치 | 200 |
| 다른 port지만 same-site, 헤더 없음 | cookie가 붙을 수 있음 | token 부재로 거부 | 403 |
| cross-site POST | SameSite=Lax로 cookie 제외 | 이 지점 이후는 고정하지 않음 | cookie omission이 확인 지점 |
마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다.
| same-origin, CSRF 헤더 없음 | 세션 쿠키 붙음 | 토큰이 없어 거부 | 403 |
| same-origin, 원본 쿠키와 헤더 일치 | 세션 쿠키 붙음 | 토큰 일치 | 200 |
| 다른 포트지만 same-site, 헤더 없음 | 쿠키가 붙을 수 있음 | 토큰이 없어 거부 | 403 |
| cross-site POST | SameSite=Lax로 쿠키 제외 | 이후 서버 처리는 고정하지 않음 | 쿠키가 빠졌는지가 확인 지점 |
## CSRF가 XSS를 대신하지 않는다
브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 같이 읽을 수 있다.
브라우저에 OAuth 토큰을 주지 않아도 XSS가 무해해지지는 않는다. 같은 출처에서 실행되는 악성 스크립트는 피해자의 세션으로 BFF 엔드포인트를 그대로 부를 수 있고, JavaScript가 읽으라고 열어 둔 `XSRF-TOKEN`도 함께 읽을 수 있기 때문이다.
이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다.
이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰의 원문이 스크립트로 새어 나가 다른 클라이언트나 직접 API 호출에 다시 쓰이는 범위다. CSP, 출력 인코딩, 의존성 무결성, 애플리케이션 인가는 이 구조가 대신 막아 주지 않으므로 각각 따로 세워야 한다.
<!-- body:end -->
@@ -10,20 +10,23 @@ status: 게시 전
version: 4
basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request module
studio: "https://hyeonworks.com/studio/documents/a3493786-d3fb-4b01-b1c5-ecb23c3d5497/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap4
---
# Forward-Auth와 Nginx auth_request의 동작
forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 endpoint에 허용 여부를 묻는 방식이다. Nginx에서는 auth_request directive가 그 질문을 subrequest로 만든다. 인증 결과는 upstream 요청의 헤더로 바뀌고, upstream은 JWT 대신 그 헤더를 입력으로 받는다.
forward-auth는 실제 요청을 업스트림으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 먼저 묻는 방식이다. Nginx에서는 `auth_request` 지시어가 그 질문을 하위 요청(subrequest)으로 만든다. 인증 결과는 업스트림으로 나가는 요청의 헤더로 바뀌기 때문에, 업스트림은 JWT 대신 그 헤더를 입력으로 받는다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
이 동작을 운영에서 신뢰하려면 무엇이 필요한지 정리한이다.
이 동작을 운영에서 신뢰하려면 무엇이 갖춰져야 하는지 적은이다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
헤더 위조를 실제로 재현한 기록이다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 구조를 언제 고르는지 비교한 기이다.
이 구조를 언제 고르는지 다른 패턴과 비교한 기이다.
## 본문
@@ -31,53 +34,53 @@ forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인
## 요청 하나가 두 번 평가된다
브라우저 요청이 들어오면 Nginx는 바로 upstream을 호출하지 않는다. `location /`에 다음 directive가 있다.
브라우저 요청이 들어와도 Nginx는 업스트림을 바로 호출하지 않는다. 업스트림은 Nginx가 요청을 최종으로 넘기는 뒤쪽 서버이고, 이 구성에서는 `app:8081`의 Spring 애플리케이션이다. 바로 넘기지 않는 것은 `location /`에 다음 지시어가 있기 때문이다.
```nginx label="general location의 auth_request"
auth_request /oauth2/auth;
```
Nginx는 먼저 `/oauth2/auth`로 subrequest를 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 한 번의 외부 요청이 인증 판단과 upstream 전달 두 단계로 나뉜다.
`auth_request`는 원래 요청을 처리하기 전에 지정한 경로로 인증용 하위 요청을 한 번 보내는 지시어다. 하위 요청은 Nginx가 원래 요청과 별개로 내부에서 만들어 보내는 요청을 말한다. 그래서 Nginx는 먼저 `/oauth2/auth`로 하위 요청을 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 밖에서 들어온 요청 한 번이 인증 판단과 업스트림 전달 두 단계로 나뉜다.
`location = /oauth2/auth`는 `internal`로 선언한다. Nginx가 만드는 subrequest만 들어갈 수 있 브라우저가 같은 URL을 직접 호출하면 정상 auth endpoint로 쓸 수 없다. 외부에서 이 경로를 부르면 404가 된다.
`location = /oauth2/auth`는 `internal`로 선언한다. Nginx가 만든 하위 요청만 들어갈 수 있어서 브라우저가 같은 URL을 직접 호출하면 정상 인증 엔드포인트로 쓸 수 없고, 밖에서 이 경로를 부르면 404가 된다.
## subrequest가 실어 보내는 것
subrequest는 body를 보내지 않고 `Content-Length`를 비운다. 원래 요청의 문맥은 헤더로 바다.
하위 요청은 본문을 보내지 않고 `Content-Length`를 비우기 때문에, 원래 요청의 문맥은 헤더로 바뀌어 실린다.
| subrequest 헤더 | 값의 출처 |
|---|---|
| `X-Original-URL` | scheme, host와 original request URI |
| `X-Real-IP` | client address |
| `X-Forwarded-For` | proxy chain |
| `X-Forwarded-Host` | original host |
| `X-Forwarded-Proto` | original scheme |
| `X-Forwarded-Uri` | original request URI |
| `Cookie` | 브라우저에 cookie가 있을 때 원래 요청의 값 |
| `X-Original-URL` | 스킴과 호스트, 원래 요청의 URI |
| `X-Real-IP` | 클라이언트 주소 |
| `X-Forwarded-For` | 프록시 체인 |
| `X-Forwarded-Host` | 원래 요청의 호스트 |
| `X-Forwarded-Proto` | 원래 요청의 스킴 |
| `X-Forwarded-Uri` | 원래 요청의 URI |
| `Cookie` | 브라우저에 쿠키가 있을 때 원래 요청의 값 |
oauth2-proxy는 이 정보로 session이 유효한지 판단한다.
oauth2-proxy는 이렇게 넘어온 헤더를 보고 세션이 유효한지 판단한다.
## 미인증 401의 응답이 경로마다 다르다
인증 결과가 401일 때 무엇을 돌려지는 location마다 다르다.
인증 결과가 401일 때 그다음에 무엇을 돌려주는지는 요청이 걸린 `location`마다 다르다.
| 외부 입력 | 인증 상태 | 결과 |
|---|---|---|
| `GET /` | 미인증 | `/oauth2/start`로 302 |
| `GET /api/edge` | 미인증 | `Location` 없는 401 JSON |
general location은 `@oauth2_signin`으로 이동해 로그인을 시작한다.
`location /`으로 들어온 요청은 `@oauth2_signin`으로 이동해 로그인을 시작한다.
```http label="미인증 navigation의 응답"
HTTP/1.1 302 Found
Location: http://localhost:8088/oauth2/start?rd=http://localhost:8088/
```
exact API location은 redirect 없이 401을 만든다. 브라우저 UX와 프로그램이 부르는 API UX를 나눈 구성이다. 이 분리는 해당 path에만 구성돼 있 다른 path는 general location 규칙을 따른다.
`/api/edge`를 정확히 일치로 잡은 `location`은 리다이렉트 없이 401을 만든다. 브라우저로 화면을 여는 쪽과 프로그램이 API를 부르는 쪽에 서로 다른 응답을 주려고 나눈 구성이다. 다만 이 분리는 `/api/edge` 경로에만 구성돼 있어서, 다른 경로로 들어온 미인증 요청은 `location /`의 로그인 리다이렉트 규칙을 따른다.
## 인증 결과를 변수로 옮긴다
oauth2-proxy가 session을 유효하다고 판단하면 auth 응답에 사용자와 이메일이 들어 있다. Nginx는 `auth_request_set`으로 그 값을 local 변수에 복사한다.
oauth2-proxy가 세션을 유효하다고 판단하면 인증 응답에 사용자와 이메일이 담겨 온다. Nginx는 `auth_request_set`으로 그 값을 지역 변수에 복사한다. `auth_request_set`은 인증 하위 요청의 응답에서 값을 꺼내 변수에 담아 두는 지시어다.
```text label="auth_request_set 변수"
$auth_user ← oauth2-proxy X-Auth-Request-User
@@ -85,9 +88,11 @@ $auth_email ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie
```
여기 복사한 사용자와 이메일이 다음 절에서 업스트림 요청의 헤더가 된다.
## upstream 요청을 새로 만든다
원래 요청을 그대로 전달하지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고 헤더는 Nginx가 만든 값으로 채워진다.
Nginx는 원래 요청을 그대로 전달하지 않는다. 밖에서 들어온 `/api/edge`는 안쪽 `/edge/me`로 다시 매핑되고, 헤더는 앞에서 변수에 담아 둔 값으로 채워진다.
```http label="Nginx가 만드는 upstream 요청"
GET http://app:8081/edge/me
@@ -96,13 +101,13 @@ X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
X-Internal-Auth-Token: <nginx-environment-secret>
```
client가 보낸 같은 이름의 헤더를 merge하지 않고 덮어쓴다. 공격자가 `X-Auth-Request-User: spoofed-admin`을 보내도 upstream 입력은 oauth2-proxy가 확인한 실제 user가 된다.
클라이언트가 보낸 같은 이름의 헤더는 합치지 않고 덮어쓴다. 그래서 공격자가 `X-Auth-Request-User: spoofed-admin`을 보내도 업스트림이 받는 값은 oauth2-proxy가 확인한 실제 사용자가 된다.
upstream이 받는 요청에서 브라우저가 보낸 헤더와 edge가 만든 헤더 구분되지 않는다. 그래서 이 덮어쓰기가 edge에서 끝다.
업스트림에 도착한 요청만 봐서는 브라우저가 보낸 헤더와 바깥과 맞닿은 Nginx, 즉 엣지가 만든 헤더 구분할 수 없다. 덮어쓰기를 엣지에서 끝하는 이유가 여기에 있다.
## upstream은 두 겹을 확인한다
Spring controller는 헤더 두 개를 함께 본다.
Spring 컨트롤러는 헤더 두 개를 함께 본다.
```text label="/edge/me의 확인 순서"
1. X-Auth-Request-User가 blank인지 확인
@@ -111,9 +116,9 @@ Spring controller는 헤더 두 개를 함께 본다.
4. 둘 다 유효하면 allowlist된 identity field만 응답에 넣는다
```
`MessageDigest.isEqual`은 입력값의 일치 길이에 따라 실행 시간이 크게 달라지지 않는 비교다.
`MessageDigest.isEqual`은 두 값이 앞에서부터 얼마나 일치하는지에 따라 실행 시간이 크게 달라지지 않는 비교 메서드다.
user 헤더가 없거나 internal token이 틀리면 401다.
사용자 헤더가 없거나 내부 토큰이 틀리면 401을 돌려준다.
```json label="신뢰 조건을 만족하지 못한 응답"
{
@@ -121,7 +126,7 @@ user 헤더가 없거나 internal token이 틀리면 401이다.
}
```
이 검사는 Spring Security의 `/edge/**` rule이 아니라 controller가 직접 한다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 있어서, 새 edge endpoint를 추가하면서 같은 검사를 부르지 않으면 보호가 자동으로 따라오지 않는다.
이 검사는 Spring Security의 `/edge/**` 규칙이 아니라 컨트롤러가 직접 한다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 있어서, 엣지 엔드포인트를 새로 만드는 사람이 같은 검사를 직접 불러 주지 않으면 그 엔드포인트에는 검사가 붙지 않는다.
## 세 방어선이 각각 막는 것
@@ -131,12 +136,14 @@ header overwrite : client가 보낸 동명 헤더를 Nginx 값으로 덮어쓴
internal token : upstream이 edge를 거쳤다는 추가 신호를 확인한다
```
controller의 shared token만으로는 외부에서 app과 oauth2-proxy에 직접 닿지 못하게 할 수 없다. network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 경우를 걸러 내지 못한다.
컨트롤러가 확인하는 공유 토큰만으로는 에서 app의 8081과 oauth2-proxy의 4180에 직접 닿는 것을 막지 못한다. 두 포트를 호스트에 열지 않는 것만으로는 안쪽 워크로드가 보냈거나 잘못 붙은 프록시 헤더를 걸러 내지 못한다.
세 방어선을 함께 두어도 신뢰할 프록시 범위를 넓게 잡거나 공유 시크릿이 노출되면 다른 사용자를 흉내 낼 수 있다.
## 지금 구성이 보여 주지 않는 것
general `location /`도 `proxy_pass http://app:8081/edge/me`를 쓴다. `/orders/123` 같은 임의 upstream path를 보존하는 범용 reverse proxy가 아니다. auth-request와 header trust를 관찰하는 fixture다.
`location /`도 `proxy_pass http://app:8081/edge/me`를 쓰기 때문에, `/orders/123` 같은 임의 경로를 그대로 뒤로 넘기는 범용 리버스 프록시가 아니다. `/`와 `/api/edge`를 같은 사용자 응답으로 이어 붙여 `auth_request`와 헤더 신뢰만 관찰하려고 만든 실험 구성이다.
실제 upstream을 붙이면 URI rewrite, request body, timeout, retry, response header, logout, 상태 변경 요청 보호를 따로 설계해야 한다. 현재 edge 응답은 user와 email만 전달하고 role, groups, tenant, token expiry는 전달하지 않는다.
실제 업스트림을 붙이면 URI 재작성, 요청 본문, 타임아웃, 재시도, 응답 헤더, 로그아웃, 상태를 바꾸는 요청 보호를 하나씩 따로 설계해야 한다. 지금 엣지 응답이 넘기는 값은 사용자와 이메일 둘뿐이고 역할·그룹·테넌트·토큰 만료는 넘기지 않는다. 이 값이 필요한 업스트림을 붙이려면 무엇을 어디서 읽어 어떤 헤더로 넘길지 먼저 정해야 한다. 지금 `/edge/me`는 역할 판단을 하지 않는다.
<!-- body:end -->
@@ -10,20 +10,23 @@ status: 게시 전
version: 4
basisVersion: Keycloak 26.7.0 identity brokering
studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-google-login
---
# 외부 IdP Brokering의 동작
브로커는 외부 IdP의 응답을 검증해 자기 realm의 identity로 연결한 뒤, 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 code와 token은 언제나 브로커가 발급한 것이므로, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer는 바뀌지 않는다.
브로커링(brokering)은 브로커가 외부 IdP(Identity Provider)의 인증 결과를 대신 받아 검증하고, 자기 realm 안의 사용자와 연결하는 동작이다. 연결이 끝나면 브로커는 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 코드와 토큰은 언제나 브로커가 발급한 것이, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer, 곧 그 토큰을 발급한 주체는 바뀌지 않는다.
## 관계
- **외부 IdP 연동과 Application 인증 구조의 경계**
이 동작을 경계 기준으로 정리한 기록이다.
- **OAuth Token과 Application Session을 구분하는 기준**
upstream IdP session과 애플리케이션 상태를 구분하는 기준이다.
외부 IdP 세션과 애플리케이션 상태를 구분하는 기준이다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
브로커가 발급하는 code가 지나는 endpoint다.
브로커가 발급하는 코드가 지나는 endpoint다.
## 본문
@@ -31,7 +34,9 @@ studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9e
## 두 개의 OAuth 왕복이 이어진다
사용자가 브로커 로그인 화면에서 외부 IdP 고르면 인증이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이이고, 뒤의 왕복은 애플리케이션과 브로커 사이다.
사용자가 브로커 로그인 화면에서 어느 외부 IdP로 로그인할지 고르면 인증 왕복이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이에서, 뒤의 왕복은 애플리케이션과 브로커 사이에서 일어난다. 브로커보다 앞에 있는 외부 IdP 쪽을 upstream이라고 부른다.
먼저 브라우저가 외부 IdP에 authorization 요청을 보내고, 브로커는 돌아온 응답을 검증해 자기 realm의 사용자와 연결한다. 그다음 애플리케이션으로 나가는 값은 브로커가 다시 만든다. 무엇이 무엇으로 바뀌는지 순서로 적으면 이렇다.
```text label="brokering 변환 순서"
Google identity assertion
@@ -42,39 +47,35 @@ Google identity assertion
→ AP1·AP2·AP3·AP4 중 선택한 downstream 경계
```
브라우저가 upstream authorization을 수행하고, 브로커가 그 응답을 검증해 local identity와 연결한다. 그다음 애플리케이션으로 나가는 데이터는 다시 브로커가 만든다.
외부 IdP가 보낸 것은 첫 줄의 `Google identity assertion` 하나이고, 그 아래 `Keycloak local user/session`과 `Keycloak authorization code`는 브로커가 만든다.
## 애플리케이션이 상대하는 issuer는 그대로다
AP1의 Resource Server가 검증하는 issuer도 브로커이고, AP2와 AP3가 주고받는 authorization code의 issuer도 브로커이며, AP4의 oauth2-proxy가 OIDC(OpenID Connect) 공급자로 연결하는 곳도 브로커다. 외부 IdP가 발급한 토큰은 애플리케이션까지 내려가지 않는다.
| 계층 | 무엇을 발급하나 | 누가 검증하나 |
|---|---|---|
| 외부 IdP | upstream identity assertion | 브로커 |
| 브로커 | authorization code, access·ID token | 애플리케이션과 Resource Server |
AP1 Resource Server가 검증하는 issuer도 브로커이고, AP2와 AP3가 교환하는 code의 issuer도 브로커이며, AP4의 oauth2-proxy가 연결하는 OIDC provider도 브로커다. 애플리케이션은 외부 IdP의 token을 받지 않는다.
그래서 소셜 로그인을 붙여도 브라우저가 token을 받는지, 어느 계층이 API를 부르는지는 바뀌지 않는다. 그 선택은 네 패턴 중 무엇을 골랐는지가 정한다.
그래서 소셜 로그인을 붙여도 브라우저가 토큰을 받는지, 어느 계층이 API를 부르는지는 달라지지 않는다. 그 둘은 AP1부터 AP4까지 네 패턴 중 무엇을 골랐는지가 정한다.
## account identity를 정하는 key
브로커가 upstream 사용자를 local user와 연결할 때 쓰는 안정적인 key는 provider alias와 upstream `sub`의 조합이다.
브로커가 외부 IdP의 사용자를 자기 realm의 사용자와 연결할 때 쓰는 안정적인 는 provider alias와 upstream `sub`를 묶은 값이다. alias는 브로커에 등록한 외부 IdP마다 붙인 이름이고, `sub`는 그 IdP가 사용자 한 명에게 부여하는 고유 식별자다.
email은 key가 아니다. upstream email이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 email의 소유권을 증명하지 않은 상태에서 계정이 합쳐다. 계정 연결은 인증 구조와 분리된 별도 설계 항목이다.
이메일은 이 키가 아니다. 외부 IdP가 보낸 이메일이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 이메일의 소유권을 증명하지 않은 채로 계정이 합쳐지기 때문이다. 계정 연결은 인증 구조와 떼어서 따로 설계 항목이다.
## 경계를 섞으면 생기는 일
외부 IdP 애플리케이션 인증 구조 하나로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 된다. 두 경계는 검증 방법이 다르다.
외부 IdP 연동을 다섯 번째 애플리케이션 인증 구조로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 다르다. 그러면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
경계가 새는지는 다음 지점에서 본다. UI에서 provider를 고르게 하거나 provider별 계정 연결을 다루는 것은 자연스럽다. Resource Server의 token 검증이나 애플리케이션 인가가 upstream IdP별로 갈리기 시작하면 브로커 경계가 애플리케이션까지 새고 있는지 본다.
외부 IdP의 token을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단이 함께 빠진다.
화면에서 어느 외부 IdP로 로그인할지 고르게 하거나 IdP마다 계정 연결을 다루는 것은 브로커가 하는 일이라 경계를 넘지 않는다. Resource Server의 토큰 검증이나 애플리케이션 인가가 외부 IdP별로 갈리기 시작하면, 브로커 경계가 애플리케이션까지 샜는지 확인한다. 외부 IdP의 토큰을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단도 함께 빠진다.
## 현재 검증한 범위
지금 자동화는 controllable mock OIDC provider로 broker와 claim mapping 계약을 확인다. 실제 Google 계정, public HTTPS callback, consent와 production domain policy를 통과했다는 뜻 아니다.
지금 자동화는 실제 Google 대신 controllable mock OIDC provider를 세워 브로커와 claim mapping 계약을 확인하도록 작성되어 있다. 실제 Google 계정과 공개 HTTPS redirect가 성공하는지는 증명하지 않았다. 사용자 동의와 운영 도메인 정책을 통과했다는 뜻 아니다.
upstream IdP 검증 범위와 애플리케이션 credential 경계를 분리해서 적어야 이 사실 경계가 유지된다.
그래서 upstream IdP를 어디까지 검증했는지와 애플리케이션이 다루는 자격 증명 경계는 따로 적는다.
<!-- body:end -->