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>
345 lines
17 KiB
Markdown
345 lines
17 KiB
Markdown
---
|
|
id: d85bd6af-7599-4ef7-9407-6609927d5b5c
|
|
kind: CASE
|
|
slug: bff-session-csrf-responsibility
|
|
title: BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식
|
|
topic: oauth-oidc-auth-boundary
|
|
topicName: OAuth/OIDC 인증 경계
|
|
project: KeyCloak Patterns
|
|
status: 게시 중
|
|
version: 30
|
|
verifiedOn: 2026-08-25
|
|
studio: "https://hyeonworks.com/studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit"
|
|
public: "https://hyeonworks.com/cases/bff-session-csrf-responsibility"
|
|
assets:
|
|
- key: ap3-bff-custody-82fa18bd
|
|
file: ../../../final/assets/tech-log-studio/ap3-bff-custody.svg
|
|
- key: ap3-csrf-split-501dd1f7
|
|
file: ../../../final/assets/tech-log-studio/ap3-csrf-split.svg
|
|
---
|
|
|
|
# BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식
|
|
|
|
BFF 구조에서는 브라우저가 access token이나 refresh token을 받지 않는다. 로그인 이후 브라우저는 `AP3_SESSION`으로 BFF를 호출하고, access token과 refresh token은 BFF의 authorized client에 저장된다.
|
|
|
|
상태를 변경하는 요청도 session cookie를 사용하게 되면서 CSRF 검증이 추가됐다. 이때 브라우저에는 `XSRF-TOKEN`도 함께 사용된다. 현재 구현에서는 여기까지 확인했고, 재시작이나 여러 replica에서 session을 공유하는 부분은 아직 구현하지 않았다.
|
|
|
|
## 관계
|
|
|
|
- **BFF 인증 구조 설계 기준**
|
|
BFF 구조에서 필요한 항목 중 현재 구현된 부분과 아직 구현하지 않은 부분을 확인한다.
|
|
- **OAuth Token과 Application Session을 구분하는 기준**
|
|
`AP3_SESSION`, `XSRF-TOKEN`, server-side access token과 refresh token이 각각 다른 위치에서 사용된다.
|
|
- **OAuth/OIDC 인증 패턴 선택 기준**
|
|
브라우저에 OAuth token을 전달하지 않는 대신 BFF가 session과 token을 관리하고, 상태 변경 요청에는 CSRF 검증이 필요하다.
|
|
- **BFF가 OAuth Token을 관리하는 조건**
|
|
access token과 refresh token을 BFF가 보관하고 Resource Server 호출도 BFF가 수행하는 구조를 실제로 확인한다.
|
|
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
|
|
현재 session과 authorized client가 모두 process-local memory에 있다는 점에서 시작한다.
|
|
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
|
|
HttpSession과 authorized client가 서로 다른 방식으로 조회되고 저장된다.
|
|
|
|
## 문제
|
|
|
|
BFF에서는 confidential client인 BFF 서버가 code를 교환하고 access token과 refresh token을 server-side authorized client에 저장한다. 브라우저에는 HttpOnly AP3_SESSION이 전달된다.
|
|
|
|
브라우저는 이후 요청마다 이 session cookie를 BFF로 보낸다. 상태를 변경하는 요청에서도 cookie는 자동으로 전송되기 때문에 session cookie만 확인해서는 해당 요청이 원래 페이지에서 보낸 요청인지 구분할 수 없다. 그래서 상태 변경 요청에는 CSRF 검증이 추가된다.
|
|
|
|
현재 session과 authorized client는 memory에 저장되어 있다. BFF를 재시작하거나 요청이 다른 replica로 이동하는 경우까지 처리하려면 이 상태를 어디에 저장할지도 따로 정해야 한다.
|
|
|
|
브라우저에서 OAuth token을 제거한 뒤 실제로 브라우저에 무엇이 남고 BFF에서 추가로 처리해야 하는 부분이 무엇인지 확인했다.
|
|
|
|
## 결론
|
|
|
|
브라우저에서는 두 개의 cookie를 사용한다.
|
|
|
|
AP3_SESSION : HttpOnly, JavaScript 읽기 x
|
|
XSRF-TOKEN : JavaScript 읽기 o
|
|
|
|
AP3_SESSION은 브라우저가 요청을 보낼 때 자동으로 포함된다. 상태 변경 요청에서는 XSRF-TOKEN의 값을 X-XSRF-TOKEN 헤더에도 넣고 BFF가 이를 확인한다. JavaScript에서 값을 읽어 헤더에 넣어야 하기 때문에 XSRF-TOKEN은 HttpOnly가 아니다.
|
|
|
|
XSS가 없어지는 것은 아니다. same-origin의 악성 script는 AP3_SESSION을 직접 읽을 수는 없지만, 브라우저가 session cookie를 붙인 상태로 BFF를 호출하게 할 수 있다. XSRF-TOKEN은 JavaScript에서 읽을 수도 있다.
|
|
|
|
이 구조에서 브라우저에 전달되지 않는 것은 access token과 refresh token 원문이다. 그래서 브라우저에서 유출된 OAuth token을 다른 client에서 사용하거나 Resource Server에 직접 보내는 형태의 재사용은 줄어든다.
|
|
|
|
현재 추가로 구현된 부분은 CSRF 검증이다.
|
|
|
|
재시작 뒤 로그인 유지, replica 공유 session, 저장 token 암호화, logout, downstream 오류 변환, timeout, 경로별 인가 : x
|
|
|
|
## 검증 환경
|
|
|
|
Keycloak 26.7.0
|
|
|
|
realms
|
|
confidential, client_secret_basic
|
|
PKCE S256 : o
|
|
provider : authorization-code, refresh-token
|
|
|
|
store : memory o
|
|
CSRF : o
|
|
HTTP : o
|
|
|
|
## 재현 조건
|
|
|
|
1. UI에서 로그인하고 authorization request를 확인한다.
|
|
|
|
client_id : bff-confidential
|
|
code_challenge_method : S256
|
|
|
|
2. 브라우저 요청 목록에 Keycloak token endpoint와 8081 직접 호출이 없는지 확인한다.
|
|
|
|
3. cookie가 AP3_SESSION이며 HttpOnly와 SameSite=Lax인지 확인하고 Web Storage가 비어 있는지 확인한다.
|
|
|
|
4. /bff/token-boundary를 호출한다.
|
|
|
|
accessTokenStoredOnServer : true
|
|
refreshTokenStoredOnServer : true
|
|
browserTokenCount : 0
|
|
csrfProtectionEnabled : true
|
|
|
|
5. /bff/api/me가 200이고 downstream 응답에 username과 audience가 있는지 확인한다.
|
|
|
|
6. GET /bff/csrf를 호출해 XSRF-TOKEN cookie와 token metadata를 받는지 확인한다. 응답 본문의 token과 cookie 값이 같은 문자열이 아닌지도 확인한다.
|
|
|
|
7. session cookie는 있지만 CSRF 헤더가 없는 POST /bff/api/preferences가 403인지 확인한다.
|
|
|
|
8. cookie의 raw 값을 X-XSRF-TOKEN에 넣은 같은 POST가 200이고 theme이 dark인지 확인한다.
|
|
|
|
9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 AP3_SESSION이 요청에 실리지 않는지 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## BFF가 Token을 보관하고 Resource Server를 호출하는 방식
|
|
|
|
:::evidence key="ap3-bff-custody-82fa18bd" alt="브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true"
|
|
:::
|
|
|
|
브라우저는 OAuth token으로 Resource Server를 호출하지 않는다. access token과 refresh token은 BFF의 authorized client가 보관하고 Resource Server 호출도 BFF가 수행한다.
|
|
|
|
## 브라우저에서 사용하는 값
|
|
|
|
| 무엇 | 브라우저에 있나 | JavaScript가 읽나 |
|
|
|---|---|---|
|
|
| AP3_SESSION | o | x |
|
|
| XSRF-TOKEN | o | o |
|
|
| access token | x | x |
|
|
| refresh token | x | x |
|
|
|
|
`AP3_SESSION`은 HttpOnly이기 때문에 JavaScript에서 직접 읽을 수 없다. 하지만 BFF로 요청을 보내면 브라우저가 cookie를 자동으로 포함한다.
|
|
|
|
`XSRF-TOKEN`은 JavaScript가 읽은 값을 요청 헤더에도 넣어야 하기 때문에 HttpOnly가 아니다.
|
|
|
|
same-origin의 악성 script도 같은 방식으로 BFF를 호출할 수 있다. `AP3_SESSION`을 직접 읽지는 못해도 브라우저가 cookie를 요청에 붙이고, JavaScript에서 읽을 수 있는 `XSRF-TOKEN`에도 접근할 수 있다.
|
|
|
|
브라우저에 access token과 refresh token 원문을 전달하지 않는 것과 XSS를 막는 것은 별개의 문제다.
|
|
|
|
## AP3_SESSION으로 Access Token을 찾는 과정
|
|
|
|
브라우저가 `/bff/api/me`를 호출할 때는 `Authorization` 헤더가 없고 JavaScript에서도 access token을 다루지 않는다.
|
|
|
|
```http label="브라우저 입력 — cookie 하나"
|
|
GET http://localhost:8083/bff/api/me
|
|
Accept: application/json
|
|
Cookie: AP3_SESSION=<opaque-session-id>
|
|
```
|
|
|
|
`AP3_SESSION` 안에 token이 들어 있는 것은 아니다. 이 cookie로 HttpSession을 찾고, HttpSession에 저장된 `SecurityContext`에서 현재 사용자의 `Authentication`을 확인한다.
|
|
|
|
```text label="cookie에서 Bearer까지"
|
|
AP3_SESSION
|
|
→ HttpSession
|
|
→ SecurityContext
|
|
→ Authentication.getName()
|
|
→ ("keycloak", principal name)
|
|
→ OAuth2AuthorizedClientService
|
|
→ access token + refresh token
|
|
```
|
|
|
|
`BffController.currentUser(Authentication)`는 `OAuth2AuthorizeRequest`를 만들고 `OAuth2AuthorizedClientManager.authorize()`를 호출한다.
|
|
|
|
manager bean은 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고 authorization-code provider와 refresh-token provider가 함께 구성되어 있다.
|
|
|
|
authorized client나 필요한 access token을 찾을 수 없으면 401이 된다.
|
|
|
|
access token을 찾으면 BFF의 `RestClient`가 Resource Server 요청을 만든다.
|
|
|
|
```http label="cookie로 조회된 토큰을 넣어서 조립"
|
|
GET http://app:8081/api/me
|
|
Authorization: Bearer <server-held-access-token>
|
|
```
|
|
|
|
브라우저에서 받은 `AP3_SESSION`을 Resource Server에 전달하는 것은 아니다. BFF가 authorized client에서 access token을 찾은 다음 `Authorization: Bearer` 헤더를 새로 만들어 Resource Server에 보낸다.
|
|
|
|
`AP3_SESSION`은 브라우저와 BFF 사이에서 사용하고, Bearer access token은 BFF와 Resource Server 사이에서 사용한다.
|
|
|
|
:::warning
|
|
|
|
Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인.
|
|
|
|
:::
|
|
|
|
## `browserTokenCount: 0`만으로 확인할 수 없는 부분
|
|
|
|
`/bff/token-boundary`는 server-side token 저장 상태를 다음과 같이 반환한다.
|
|
|
|
```json label="/bff/token-boundary 응답"
|
|
{
|
|
"pattern": "AP3-backend-for-frontend",
|
|
"principal": "regular-user",
|
|
"accessTokenStoredOnServer": true,
|
|
"refreshTokenStoredOnServer": true,
|
|
"browserTokenCount": 0,
|
|
"csrfProtectionEnabled": true
|
|
}
|
|
```
|
|
|
|
여기서 `browserTokenCount: 0`은 브라우저를 직접 검사해서 나온 값이 아니다. controller에 들어 있는 literal 값이다.
|
|
|
|
그래서 이 값과 별도로 브라우저를 확인했다. 로그인 이후 개발자 도구에서 network 요청을 확인했을 때 Keycloak token endpoint 호출이 없었고 Resource Server의 8081을 직접 호출하는 요청도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다.
|
|
|
|
```text label="같은 주장에 대한 두 종류의 근거"
|
|
self-report /bff/token-boundary → browserTokenCount: 0
|
|
external observation 브라우저 network → token endpoint 없음
|
|
Web Storage → token 문자열 없음
|
|
```
|
|
|
|
`browserTokenCount: 0` 응답과 실제 브라우저에서 확인한 결과는 따로 기록한다.
|
|
|
|
이 endpoint는 `OAuth2AuthorizedClientManager.authorize()`를 호출하지 않고 `OAuth2AuthorizedClientService`에서 authorized client를 직접 조회한다. 따라서 이 endpoint를 호출하는 과정에서 refresh를 수행하지 않는다.
|
|
|
|
## 상태 변경 요청에서 CSRF를 확인하는 방식
|
|
|
|
브라우저는 session cookie를 요청마다 자동으로 전송한다. 상태를 변경하는 POST 요청에서도 동일하게 cookie가 포함된다.
|
|
|
|
POST를 보내기 전에 `/bff/csrf`를 호출하면 다음 `XSRF-TOKEN` cookie를 받는다.
|
|
|
|
```http label="응답 헤더 — cookie에는 raw 값이 들어간다"
|
|
HTTP/1.1 200 OK
|
|
Cache-Control: no-store
|
|
Pragma: no-cache
|
|
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
|
|
```
|
|
|
|
응답 본문에도 CSRF 관련 정보가 들어간다.
|
|
|
|
```json label="응답 본문 — 여기 token은 가려진 값이다"
|
|
{
|
|
"headerName": "X-XSRF-TOKEN",
|
|
"parameterName": "_csrf",
|
|
"token": "<xor-masked-csrf-token>"
|
|
}
|
|
```
|
|
|
|
cookie의 `XSRF-TOKEN`과 응답 본문의 `token`은 같은 문자열이 아니다.
|
|
|
|
:::evidence key="ap3-csrf-split-501dd1f7" alt="BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다." caption="" zoom="true"
|
|
:::
|
|
|
|
`CookieCsrfTokenRepository.withHttpOnlyFalse()`가 cookie에 raw 값을 넣는다. `XorCsrfTokenRequestAttributeHandler`가 request attribute로 노출되는 token을 XOR와 Base64로 가리기 때문에 응답 본문에서는 다른 문자열이 보인다.
|
|
|
|
SPA에서는 응답 본문의 `token`을 요청 헤더 값으로 사용하지 않는다. 본문에서는 `headerName`을 확인하고 `document.cookie`에서 raw `XSRF-TOKEN` 값을 읽어 해당 헤더에 넣는다.
|
|
|
|
```text label="세 자리의 값이 서로 다르다"
|
|
body.token masked token
|
|
cookie XSRF-TOKEN raw token
|
|
X-XSRF-TOKEN raw token
|
|
```
|
|
|
|
```http label="다음 요청 헤더에 X-XSRF-TOKEN가 들어간다"
|
|
POST /bff/theme HTTP/1.1
|
|
Host: localhost:8083
|
|
Content-Type: application/json
|
|
|
|
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
|
|
X-XSRF-TOKEN: <raw-csrf-token>
|
|
```
|
|
|
|
```json label="요청 본문"
|
|
{
|
|
"theme":"dark"
|
|
}
|
|
```
|
|
|
|
`SpaCsrfTokenRequestHandler`는 응답으로 노출하는 token 형태와 요청에서 확인하는 token 형태를 나눠 처리한다. 요청에서는 `X-XSRF-TOKEN` 헤더로 전달된 raw 값을 확인한다.
|
|
|
|
:::note
|
|
|
|
응답 본문의 token을 가리는 것은 BREACH 완화를 위한 처리다. HTTP 응답 압축 크기의 차이를 이용해 응답 안의 비밀값을 추측하는 것을 어렵게 하기 위해 응답에 노출되는 token 형태를 매번 다르게 만든다.
|
|
|
|
:::
|
|
|
|
## SameSite와 CSRF Token을 각각 확인한 경우
|
|
|
|
네 가지 요청으로 동작을 확인했다.
|
|
|
|
| 입력 | 막는 것 | 응답 |
|
|
|---|---|---|
|
|
| same-origin, 헤더 없음 | CSRF token | 403 |
|
|
| same-site 다른 port, 헤더 없음 | CSRF token | 403 |
|
|
| cross-site POST | SameSite | cookie 누락 |
|
|
| same-origin, 값 일치 | 통과 | 200 |
|
|
|
|
same-origin과 same-site 다른 port 요청에는 session cookie가 포함됐다. CSRF 헤더가 없었기 때문에 두 요청은 403이 됐다.
|
|
|
|
cross-site POST에서는 `AP3_SESSION` 자체가 요청에 포함되지 않았다.
|
|
|
|
port가 다르더라도 site 기준으로는 같은 site가 될 수 있기 때문에 SameSite만으로 same-site 요청까지 막는 것은 아니다.
|
|
|
|
cross-site POST에서는 최종 status보다 `AP3_SESSION` cookie가 요청에 포함되지 않았다는 부분을 확인했다.
|
|
|
|
## BFF에서 추가로 처리해야 하는 항목
|
|
|
|
현재 구조에서 확인한 항목은 다음과 같다.
|
|
|
|
| 새로 생긴 책임 | 현재 구현에 있나 |
|
|
|---|---|
|
|
| 상태 변경 요청의 CSRF 검증 | o |
|
|
| 재시작 뒤 로그인 유지 | x |
|
|
| replica가 함께 쓰는 session | x |
|
|
| 저장 token 암호화 | x |
|
|
| logout 때 session과 authorized client 삭제 | x |
|
|
| downstream 오류를 화면 오류로 변환 | x |
|
|
| timeout · retry · circuit breaker | x |
|
|
| 경로별 인가 | x |
|
|
|
|
현재 구현된 것은 CSRF 검증이다. 나머지 항목은 아직 구현하지 않았다.
|
|
|
|
현재 HttpSession과 `OAuth2AuthorizedClientService`는 process-local memory를 사용한다.
|
|
|
|
authorized client는 session ID로 찾는 것이 아니라 client registration 이름과 principal name으로 찾는다. 따라서 같은 principal이 여러 브라우저 session에서 로그인한 경우 같은 authorized client 항목을 공유하거나 덮어쓸 수 있다.
|
|
|
|
## 자동 테스트에서 확인한 범위
|
|
|
|
아래 항목은 커밋된 자동 테스트에서 확인하도록 정의한 내용이다.
|
|
|
|
| 항목 | 확인했나 |
|
|
|---|---|
|
|
| `bff-confidential` + S256 challenge | o |
|
|
| 브라우저 요청에 token endpoint 없음 | o |
|
|
| 브라우저 요청에 8081 직접 호출 없음 | o |
|
|
| `AP3_SESSION` HttpOnly · SameSite=Lax | o |
|
|
| Web Storage 비어 있음 | o |
|
|
| server access·refresh boolean이 true | o |
|
|
| `/bff/api/me` 200 · username · audience | o |
|
|
| CSRF 헤더 없는 POST 403 | o |
|
|
| raw 값을 헤더에 넣은 POST 200 | o |
|
|
| cross-site POST에서 cookie 누락 | o |
|
|
| preference의 사용자별 격리 | x |
|
|
| preference 영속성 | x |
|
|
| 공유 session store | x |
|
|
| 저장 token 암호화 | x |
|
|
| logout | x |
|
|
| downstream 401의 전달 모양 | x |
|
|
| timeout · 경로별 인가 | x |
|
|
|
|
## 이번 구현에서 확인한 결과
|
|
|
|
로그인 이후 브라우저 network에는 Keycloak token endpoint 호출이 없었고 `/bff/api/me` 요청에도 `Authorization: Bearer`가 없었다. 브라우저는 `AP3_SESSION`으로 BFF를 호출하고, Resource Server에 보낼 access token은 BFF가 authorized client에서 찾아 사용했다.
|
|
|
|
상태 변경 요청에서는 session cookie가 자동으로 포함되기 때문에 CSRF token을 추가로 확인했다. 현재 session과 authorized client는 모두 BFF process memory에 저장된다.
|
|
|
|
브라우저가 OAuth token을 받으면 안 되고 backend가 화면에 필요한 여러 API를 조합해야 한다면 이 구조를 고른다. OAuth 흐름을 브라우저에서 직접 확인하는 것이 목적이면 SPA 구조가, 브라우저의 Resource Server 직접 호출을 유지해야 한다면 Mediator가 맞는다.
|
|
|
|
<!-- body:end --> |