Files
document-haness/docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

117 lines
5.3 KiB
Markdown

---
id: 5c8f12d5-1ead-469b-8e91-2de69401df48
kind: CONCEPT
slug: cookie-auth-csrf
title: Cookie로 인증하는 요청에서 CSRF token이 하는 일
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
---
# Cookie로 인증하는 요청에서 CSRF token이 하는 일
session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다.
## 관계
- **BFF 인증 구조 설계 기준**
이 확인이 필요한 구조의 설계 항목이다.
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
이 동작을 실제로 재현한 기록이다.
- **OAuth Token과 Application Session을 구분하는 기준**
session cookie와 CSRF token은 서로 다른 값이다.
## 본문
<!-- body:start -->
## cookie가 credential이 되면 생기는 일
브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다.
이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. `GET /bff/api/me`만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다.
## token을 받아 오는 요청
브라우저가 먼저 CSRF material을 요청한다.
```http label="CSRF token 요청"
GET http://localhost:8083/bff/csrf
Accept: application/json
Cookie: AP3_SESSION=<opaque-session-id>
```
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` cookie를 path `/`에 만든다. controller는 다음 JSON을 반환한다.
```json label="CsrfController가 반환하는 JSON"
{
"headerName": "X-XSRF-TOKEN",
"parameterName": "_csrf",
"token": "<xor-masked-csrf-token>"
}
```
## body의 token과 cookie의 값은 다르다
같은 CSRF material이 세 자리에 서로 다른 형태로 놓인다.
| 위치 | 값 |
|---|---|
| 응답 body의 `token` | XOR와 Base64로 mask된 값 |
| `XSRF-TOKEN` cookie | raw 값 |
| POST의 `X-XSRF-TOKEN` 헤더 | cookie와 같은 raw 값 |
`XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 `headerName`만 읽고, 실제 값은 `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 쓴다.
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다.
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다.
## 검증이 controller보다 먼저 일어난다
정상 상태 변경 요청은 다음과 같다.
```http label="CSRF 검증을 통과하는 POST"
POST http://localhost:8083/bff/api/preferences
Content-Type: application/x-www-form-urlencoded
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
X-XSRF-TOKEN: <same-raw-csrf-token>
theme=dark
```
Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다.
## SameSite가 정하는 것과 CSRF token이 정하는 것
| | SameSite | CSRF token |
|---|---|---|
| 누가 판단하나 | 브라우저 | 서버 |
| 무엇을 정하나 | cookie를 보낼지 | 요청을 받아들일지 |
| 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 |
port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 cookie를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
네 가지 입력에서 cookie와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
| 입력 | cookie 동작 | 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가 빠졌는지다.
## CSRF가 XSS를 대신하지 않는다
브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 같이 읽을 수 있다.
이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다.
<!-- body:end -->