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,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 -->