feat: 문서 구조 변경 및 tech-visual 스킬 추가

This commit is contained in:
DongHyeonka
2026-09-04 18:20:00 +09:00
parent 43901f0abf
commit 2efb7ee1f2
683 changed files with 61180 additions and 10479 deletions
@@ -0,0 +1,266 @@
---
id: 488ce49b-afa4-42a5-a2ce-de2e0653cd82
kind: CASE
slug: split-custody-access-token
title: Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 22
verifiedOn: 2026-08-24
studio: "https://hyeonworks.com/studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit"
public: "https://hyeonworks.com/cases/split-custody-access-token"
assets:
- key: ap2-split-custody-779cb791
file: ../../../final/assets/tech-log-studio/ap2-split-custody.svg
---
# Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
confidential client인 mediator가 code를 교환하고 refresh token을 server-side authorized client에 보관한다. 브라우저는 Resource Server를 직접 호출하기 때문에 access token이 필요하고, mediator는 JSON 응답으로 access token을 반환한다. refresh token은 서버에 남아 있지만 access token은 브라우저까지 전달된다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
SPA에서는 브라우저가 code 교환과 token 보관을 모두 맡는다. 여기서는 그중 refresh token 관리를 서버로 옮긴다.
- **Public Client와 Confidential Client 구분 기준**
Mediator는 confidential client지만 access token을 브라우저 응답으로 반환한다. client 종류와 token 노출 위치가 같은 기준이 아니라는 사례다.
- **OAuth Token과 Application Session을 구분하는 기준**
access token은 응답 본문, JavaScript 지역 변수, Authorization 헤더를 지나고 application session은 별도로 관리된다. 어떤 상태를 말하는지 이름을 나눠야 하는 이유다.
- **OAuth/OIDC 인증 패턴 선택 기준**
refresh token은 서버에 두지만 access token은 브라우저에 전달되고 mediator의 server state도 함께 관리해야 하는 구조다.
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
여기서는 refresh token rotation과 재사용 0회 구성을 사용한다. 여러 replica에서 refresh가 겹치는 문제는 별도로 남아 있다.
## 문제
Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고 access token과 refresh token을 server-side authorized-client service에 저장한다. 브라우저에는 HttpOnly `AP2_SESSION`만 관리하게 된다.
서버에서 token을 보관한다는 점만 보면 BFF와 비슷하다. 하지만 Mediator에서는 브라우저가 Resource Server를 직접 호출한다. Resource Server를 호출하려면 access token이 필요하기 때문에 mediator가 access token을 응답으로 다시 반환한다.
처음에는 refresh token을 서버로 옮기면 브라우저가 credential을 직접 다뤄야 하는 범위도 대부분 줄어든다고 봤다. `/token/access` 응답부터 Resource Server 요청까지 따라가 보니 refresh token은 서버에 남지만 access token은 계속 브라우저에서 사용되고 있었다.
## 결론
서버로 옮긴 것은 client secret과 refresh token이다. access token은 브라우저에서 다음 세 곳에 나타난다.
access token이 사용되는 위치
/token/access 응답 본문 : o
JavaScript 지역 변수 : o
/api/me Authorization 헤더 : o
server state : mediator의 session과 authorized-client 저장소를 운영해야 한다.
browser 노출 : access token은 브라우저 실행 영역 안에 그대로 있다.
## 검증 환경
Keycloak 26.7.0
realms
client-confidential : o
implicit flow, direct grant : x
client_authentication : client_secret_basic
grant_type : authorization_code
scopes : openid profile email
callback : http://localhost:8082/login/oauth2/
code/keycloak
principal claim : preferred_username
OAuth2AuthorizedClientService : Spring Boot의 in-memory
Spring Session, Redis, JDBC token store 의존성 : x
Resource Server CORS allowlist
origin : http://localhost:8082
method : GET, OPTIONS
header : Authorization, Content-Type
HTTPS : x
HTTP : o
## 재현 조건
1. Mediator UI에서 로그인한 뒤 `/token/boundary`를 호출한다.
accessTokenStored : true
refreshTokenStored : true
browserReceivesRefreshToken : false
2. `/token/access` 응답에 다음 세 key만 있는지 확인한다.
access_token, token_type, expires_at
3. 같은 응답의 `Cache-Control``no-store`가 있는지 확인한다.
4. 반환된 access JWT를 decode해 audience에 `keycloak-pattern-api`가 있는지 확인한다.
5. 브라우저가 해당 token으로 Resource Server를 직접 호출했을 때 200을 받는지 확인한다.
6. cookie가 `AP2_SESSION`이며 HttpOnly와 SameSite=Lax인지 확인한다.
7. Local Storage와 Session Storage에 access token 원문이나 `refresh_token` 문자열이 없는지 확인한다.
## 본문
<!-- body:start -->
## Mediator에서 Access Token과 Refresh Token을 관리하는 위치
:::evidence key="ap2-split-custody-779cb791" alt="Spring mediator의 authorized client 안에 access token과 refresh token이 함께 있고, 그중 access token만 브라우저 실행 영역으로 돌아오는 그림. 브라우저에서 Resource Server로 가는 Authorization Bearer 화살표는 mediator를 지나지 않는다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true"
:::
mediator는 access token과 refresh token을 모두 보관한다. 브라우저가 Resource Server를 직접 호출해야 하기 때문에 access token은 `/token/access`를 통해 다시 브라우저로 전달된다.
## Mediator에서 서버에 보관하는 값
SPA 구조에서는 브라우저가 code를 직접 교환하고 받은 token도 브라우저에서 관리했다. Mediator에서는 Spring mediator가 confidential client가 되어 code 교환을 맡고 token을 server-side authorized client에 저장한다.
각 값의 위치는 다음과 같다.
| 무엇 | 브라우저에 있나 | 서버에 있나 |
|---|---|---|
| client secret | x | o |
| refresh token | x | o |
| access token | o | o |
| 로그인 상태 | AP2_SESSION | HttpSession |
access token은 서버에도 저장되지만 브라우저에도 전달된다.
## AP2_SESSION이 생성되는 시점
`AP2_SESSION`은 token 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
Spring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장한다. Browser가 KeyCloak으로 이동했다가 callback으로 다시 Spring에 돌아왔을 때 앞에서 시작한 로그인 요청을 찾을 수 있어야 하기 때문에 이 시점에 session cookie가 먼저 만들어진다.
```text label="callback 하나가 두 개의 상태로 나뉜다"
AP2_SESSION
→ servlet HttpSession의 login SecurityContext
→ Authentication(principal name = preferred_username)
("keycloak", principal name)
→ OAuth2AuthorizedClientService
→ access token + refresh token
```
`AP2_SESSION` 안에 token이 들어 있는 것은 아니다. 이 cookie는 HttpSession을 찾기 위한 session ID이고, HttpSession에는 로그인 SecurityContext가 저장되어 있다. token은 여기서 확인한 principal을 이용해 별도의 store에 저장된 authorized client에서 찾는다.
:::warning
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 token 상태가 모두 현재 process의 memory에 있다.
:::
## /token/access가 반환하는 세 가지 값
브라우저가 Resource Server를 직접 호출하려면 access token이 필요하다. mediator는 `/token/access`를 통해 현재 access token을 반환한다.
```http label="브라우저 입력 — cookie 한 개"
GET http://localhost:8082/token/access
Accept: application/json
Cookie: AP2_SESSION=<opaque-session-id>
```
controller는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣어 `OAuth2AuthorizedClientManager.authorize()`를 호출한다. 반환된 authorized client에서 access token을 꺼내 다음 세 값을 응답한다.
```http label="응답 헤더"
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json
```
```json label="응답 본문 — refresh_token은 없음"
{
"access_token": "<raw-keycloak-jwt>",
"token_type": "Bearer",
"expires_at": "<ISO-8601-instant>"
}
```
HTTP 응답 본문에는 access token만 포함되고 refresh token은 포함되지 않는다.
authorized client나 access token이 없으면 401을 반환한다.
## Access Token이 브라우저에서 사용되는 위치
브라우저 JavaScript는 `/token/access` 응답에서 access token을 읽어 지역 변수에 넣는다.
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
const {
access_token: accessToken,
expires_at: expiresAt
} = await tokenResponse.json();
```
이 값은 바로 다음 Resource Server 요청의 `Authorization` 헤더에 사용된다.
```http label="mediator를 지나지 않는 경로"
GET http://localhost:8081/api/me
Accept: application/json
Authorization: Bearer <raw-keycloak-jwt>
Origin: http://localhost:8082
```
access token은 다음 세 곳에서 사용된다.
```text
/token/access response body
→ JavaScript local variable
→ /api/me Authorization header
```
세 곳 모두 브라우저에서 요청을 처리하는 동안의 흐름 안에 있다.
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 token을 쓰지 않는다는 뜻이다. 실행 중인 script가 응답이나 지역 변수의 token에 접근할 수 없다는 뜻은 아니다.
## /token/access는 일회성 전달이 아니다
`/token/access`가 access token을 한 번만 전달하고 이후에는 다시 받을 수 없는 방식인지 확인했다.
| one-time handoff 요건 | 있나 |
|---|---|
| handoff ID | x |
| nonce | x |
| 사용 표시(consume flag) | x |
| 건넨 뒤 삭제 | x |
| 재호출 거부 | x |
현재 구현에는 한 번 전달한 token을 사용 처리하거나 이후 호출을 거부하는 동작이 없다. 같은 인증된 session에서는 현재 access token을 다시 요청할 수 있다.
```text
repeatable GET
→ current authorized client lookup/refresh opportunity
→ current raw access token response
```
브라우저에 반환하는 값은 access token뿐이고 refresh token은 응답에 넣지 않는다.
## Mediator에서 서버 상태와 브라우저 노출
- server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
- browser 노출 : access token은 여전히 응답 본문과 헤더에 있다
브라우저가 Resource Server를 직접 호출해야 한다면 이 구조를 사용할 수 있다. 브라우저에서 access token까지 없애야 한다면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
## 현재 자동 테스트로 확인한 범위
아래 항목은 커밋된 자동 테스트에서 확인하도록 정의한 내용이다.
| 항목 | 확인했나? |
|---|---|
| server access·refresh boolean이 true | o |
| `browserReceivesRefreshToken`이 false | o |
| 응답이 세 개 | o |
| `Cache-Control`에 `no-store` | o |
| audience에 `keycloak-pattern-api` 포함 | o |
| Resource Server 직접 호출 200 | o |
| cookie HttpOnly · SameSite=Lax | o |
| Web Storage에 token 문자열 없음 | o |
| 두 번째 `/token/access` 거부 | x |
| 만료 뒤 실제 refresh | x |
| logout 때 두 상태 삭제 | x |
| 재시작·replica 이동 뒤 복구 | x |
| 허용 밖 origin의 CORS 거부 | x |
manager에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 만료된 token을 갱신할 수 있는 구성은 들어가 있지만, 실제로 만료를 기다린 뒤 refresh가 성공하는지와 rotation된 token이 저장되는지는 아직 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,344 @@
---
id: d85bd6af-7599-4ef7-9407-6609927d5b5c
kind: CASE
slug: bff-session-csrf-responsibility
title: BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식
topic: 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 -->
@@ -0,0 +1,332 @@
---
id: a0e1cc05-92b3-4dac-bce1-513ab8cd862b
kind: CASE
slug: identity-header-trust
title: Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 39
verifiedOn: 2026-08-25
studio: "https://hyeonworks.com/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit"
public: "https://hyeonworks.com/cases/identity-header-trust"
assets:
- key: ap4-edge-trust-1cff2399
file: ../../../final/assets/tech-log-studio/ap4-edge-trust.svg
---
# Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
X-Auth-Request-User는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.
upstream이 받는 요청에서는 두 경우의 모양이 같다.
그래서 header overwrite, backend direct path 차단, internal credential 검증을 서로 독립된 세 곳에 둔다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
이 기준의 다섯 조건이 실제로 어떻게 구성되는지 코드와 설정으로 확인한 자리다.
- **OAuth Token과 Application Session을 구분하는 기준**
proxy session cookie와 identity 헤더를 JWT와 구분해야 하는 실례다.
- **OAuth/OIDC 인증 패턴 선택 기준**
OAuth를 모르는 upstream 앞의 공통 관문을 얻고 network·헤더 신뢰 계약을 내주는 경우다.
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
edge가 user와 email만 전달한다는 사실이 이 질문의 출발점이다.
## 문제
앞단 proxy가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다.
대신 upstream은 X-Auth-Request-User 하나로 사용자를 판단하게 된다.
이 헤더는 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다.
upstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문제가 된다.
backend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면
공격자가 인증된 사용자처럼 보낼 수 있다.
그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다.
## 결론
헤더를 믿으려면 서로 독립된 세 곳에서 막아야 한다.
host port 닫힘 : 외부에서 upstream·proxy로 바로 가는 경로를 막는다
Nginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다
upstream internal token : edge를 거치지 않은 내부 요청을 막는다
network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 문제를 막지 못한다.
controller의 공유 token만으로는 외부 직접 접근이 어려워지는 network 속성을 대신할 수 없다.
## 검증 환경
Keycloak 26.7.0, oauth2-proxy 7.15.2
client : edge-proxy
confidential, PKCE S256 : o
외부 공개
Nginx : 8088
app 8081, oauth2-proxy 4180 : Compose network에 expose만, host publish x
Nginx
auth_request /oauth2/auth
location = /oauth2/auth : internal
auth_request_set으로 user, email, Set-Cookie 복사
client 제공 동명 헤더 : 덮어쓰기
trusted proxy : 단일 IP
upstream
EdgeIdentityController.currentUser(HttpServletRequest)
X-Internal-Auth-Token 비교 : MessageDigest.isEqual
SecurityConfig의 /edge/** : permitAll
AP4_SESSION
HttpOnly : true
SameSite : Lax
Secure : false in local HTTP fixture
expire : 1 hour in proxy configuration
session-cookie-minimal : true
server-side session store : x
automatic discovery : x
login, token, JWKS, userinfo URL을 각각 관리.
HTTP : o
## 재현 조건
1. cookie 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인.
2. cookie 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인.
3. authorization request에 client_id=edge-proxy와 code_challenge_method=S256이 있는지 확인.
4. 로그인 뒤 cookie가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인.
브라우저 요청 목록에 Keycloak token endpoint가 없어야 함.
Web Storage가 비어 있고 document.cookie로 session cookie를 읽을 수 없어야 함.
5. 정상 session에 다음 헤더를 얹어 GET /api/edge를 보냄.
X-Auth-Request-User : spoofed-admin
X-Auth-Request-Email : spoofed-admin@example.test
X-Internal-Auth-Token : attacker-controlled-token
응답은 200이고 user는 spoofed-admin이 아니라 실제 authenticated user여야 함.
6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인.
7. host의 4180과 8081에 접근할 수 없는지 확인.
8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고,
둘 다 맞으면 200인지 확인.
## 본문
<!-- body:start -->
## 같은 이름의 헤더
:::evidence key="ap4-edge-trust-1cff2399" alt="왼쪽 외부 영역의 브라우저에 AP4_SESSION과 점선으로 표시된 client 제공 header가 있다. 가운데 Nginx는 8088만 공개하고 header 덮어쓰기를 맡는다. 오른쪽 점선 영역은 host port가 닫혀 있고 oauth2-proxy와 Spring upstream이 들어 있다. Nginx가 oauth2-proxy에 auth_request를 보내 user와 email을 받고, nginx-owned header와 internal token으로 upstream 요청을 만든다." caption="" zoom="true"
:::
`X-Auth-Request-User`는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.
그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다.
## 위조 요청의 모양
로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자.
```http label="공격자가 보낸 요청"
GET http://localhost:8088/api/edge
Cookie: AP4_SESSION=<opaque-session>
X-Auth-Request-User: spoofed-admin
X-Auth-Request-Email: spoofed-admin@example.test
X-Internal-Auth-Token: attacker-controlled-token
```
이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다.
## 세 개의 독립된 경계
현재 OAuth2-Proxy 구조에서는 이 문제를 서로 독립된 세 곳에서 막는다.
| 위치 | 막는 것 |
|---|---|
| host port 닫힘 | 외부에서 upstream·proxy로 가는 직접 경로 |
| Nginx header 덮어쓰기 | client가 보낸 동명 헤더 |
| upstream internal token | edge를 거치지 않은 내부 요청 |
세 곳 중 하나가 빠지면 나머지 둘이 그 자리를 메우지 못한다. host port가 열려 있으면 헤더 검사만으로 막을 수 없고, 덮어쓰기가 없으면 인증을 안 거친 헤더가 그대로 upstream에 들어가고, internal token이 없으면 내부 workload가 edge처럼 동작할 수 있는 여지가 생긴다.
**network isolation만으로는 내부 위조를 막지 못한다. controller의 공유 token만으로는 외부 직접 접근을 막지 못한다.**
## Nginx가 헤더를 만드는 경계
Nginx는 먼저 internal subrequest를 만든다.
`location = /oauth2/auth`는 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있다.
```nginx label="upstream을 부르기 전에 먼저 물어본다"
auth_request /oauth2/auth;
```
oauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다.
```text label="auth_request_set — 값의 출처가 여기서 고정"
$auth_user ← oauth2-proxy X-Auth-Request-User
$auth_email ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie
```
그 다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 세 헤더는 **merge가 아니라 덮어쓰기**로 채워진다.
```http label="upstream이 실제로 받는 요청"
GET http://app:8081/edge/me
X-Auth-Request-User: <oauth2-proxy-authenticated-user>
X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
X-Internal-Auth-Token: <nginx-environment-secret>
```
그래서 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다.
## upstream이 확인하는 두 값
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는다.
1. `X-Auth-Request-User`를 읽고 비어 있는지 확인한다.
2. `X-Internal-Auth-Token`을 읽어 설정값과 `MessageDigest.isEqual`로 비교한다.
두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다.
```json label="정상 응답 — 4가지 필드"
{
"pattern": "AP4-edge-forward-auth",
"user": "regular-user",
"email": "regular-user@example.test",
"identityHeader": "X-Auth-Request-User"
}
```
하나라도 다르면 401이 된다.
```json label="user 헤더가 없거나 internal token이 틀릴 때"
{
"error": "trusted edge authentication is required"
}
```
internal token 비교에는 일반 문자열 비교 대신 `MessageDigest.isEqual`을 썼다. 비교 시간 차이로 값이 어디까지 맞았는지 새어 나가는 것을 줄이려는 선택이다.
:::danger
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 그 endpoint는 보호되지 않는다.
:::
운영으로 넘어갈 때는 이 검사를 filter나 interceptor, security chain처럼 **대상 endpoint 전체에 걸리는 공통 경계**로 옮겨야 한다.
## 경로마다 달라지는 결과
같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다.
| 외부 입력 | 인증 상태 | 결과 |
|---|---|---|
| `GET /` | 미인증 | `/oauth2/start` 302 |
| `GET /api/edge` | 미인증 | redirect 없는 401 |
| `GET /oauth2/auth` | 무관 | 404 |
| `GET /` + 위조 헤더 | 정상 session | 실제 user 200 |
| `/edge/me` + user 헤더만 | edge token 없음 | 401 |
| `/edge/me` + 틀린 token | token 불일치 | 401 |
아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 `Location` 없는 401을 받아야 한다.
**redirect 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.**
다른 경로는 로그인 redirect 규칙을 따른다.
셋째 줄은 auth endpoint다. 외부에서 `/oauth2/auth`를 직접 부르면 404다. `internal` 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다.
## 브라우저가 가지고 있는 것
OAuth2-Proxy 구조는 server-side session store를 두지 않는다.
```text label="AP4_SESSION cookie 설정"
name = AP4_SESSION
HttpOnly = true
SameSite = Lax
Secure = false in local HTTP fixture
expire = 1 hour in proxy configuration
```
`session-cookie-minimal=true`를 쓰면 cookie에는 access·refresh·ID token 대신 edge가 필요한 최소 정보만 남는다. 브라우저에 남는 것은 JavaScript로 읽을 수 없고 다음 요청에 자동으로 붙는 opaque cookie 하나뿐이다.
지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다.
## endpoint를 외부용과 내부용으로 나눈 이유
브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다.
그래서 자동 discovery를 끄고 네 주소를 각각 관리한다.
```text label="issuer는 브라우저가 접속하는 부분"
issuer expected value = http://localhost:8080/realms/keycloak-patterns
login URL = http://localhost:8080/.../auth
redeem/token URL = http://keycloak:8080/.../token
JWKS/userinfo URL = http://keycloak:8080/...
```
issuer는 요청을 보내기 위한 주소가 아니라, Keycloak이 발급한 토큰의 `iss` claim이 기대한 값과 같은지 검증하는 기준값이다. token URL과 userinfo URL은 oauth2-proxy가 내부에서 실제로 요청을 보내는 network 주소다.
둘 다 같은 Keycloak realm을 가리키지만 쓰임이 다르다. 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없어서 로그인에는 `localhost:8080`을 쓴다. 컨테이너 안에서는 자기 `localhost:8080`이 Keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 쓴다.
## upstream이 JWT를 받지 않는다
앞의 세 구조에서는 Resource Server가 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 `/edge/me`는 **JWT를 입력으로 받지 않는다.**
| 신뢰하는 입력 | AP1~AP3 | AP4 |
|---|---|---|
| 서명된 JWT | o | x |
| network topology | x | o |
| internal token | x | o |
| edge의 user·email | x | o |
오른쪽 열이 AP4가 신뢰하는 입력이다. edge가 인증 경계가 되므로, backend 직접 경로나 사용자 제공 헤더를 허용하면 다른 사용자처럼 요청을 보낼 수 있게 된다.
## 헤더를 늘릴 때 정해야 하는 것
현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다.
- claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가
- allowlist : Nginx가 어느 응답 헤더만 복사하는가
- 덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가
- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지
- 갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분** 이다.
| 항목 | 확인한 부분 |
|---|---|
| cookie 없는 root의 302 | o |
| cookie 없는 `/api/edge`의 401 | o |
| `edge-proxy` + S256 challenge | o |
| `AP4_SESSION` HttpOnly · SameSite=Lax | o |
| 브라우저 요청에 token endpoint 없음 | o |
| Web Storage 비어 있고 cookie 읽기 불가 | o |
| 위조 헤더를 보내도 실제 user로 200 | o |
| 외부 `/oauth2/auth` 404 | o |
| host의 4180 · 8081 접근 불가 | o |
| user 헤더 없음 · token 없음 · token 불일치 401 | o |
| role 전달 | x |
| 새 endpoint의 공통 강제 | x |
| 상태 변경 요청의 CSRF | x |
| session 갱신 | x |
| replica 간 secret 공유 | x |
| internal secret 교체 | x |
일곱째 줄의 assertion은 요청이 실패하는지가 아니다. **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다.
## 증명하지 않는 것
현재 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾼다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy가 아니다. 그래서 path, method, body, streaming, websocket 같은 큰 헤더 동작은 입증하지 못했다.
<!-- body:end -->
@@ -0,0 +1,220 @@
---
id: bf675775-4f3e-4744-8014-f0efff51422a
kind: CASE
slug: spa-browser-credential-boundary
title: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 31
verifiedOn: 2026-08-22
studio: "https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit"
public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
---
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
AP1에서는 SPA를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 access token, refresh token, ID token은 Web Storage에 저장하지 않고 JavaScript memory에만 둔다.
이렇게 하면 새로고침 뒤에는 token이 남지 않는다. 하지만 페이지가 열려 있는 동안에는 JavaScript에서 token을 사용하고 있고, Resource Server를 호출할 때도 access token을 `Authorization` 헤더에 넣는다. 실행 중 XSS가 발생했을 때 영향을 받는 부분은 그대로 남아 있다.
## 관계
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
SPA에서 authorization request를 보내고 authorization code를 받은 뒤 token을 교환하는 과정을 직접 확인한 내용이다.
- **Public Client와 Confidential Client 구분 기준**
SPA는 client secret을 안전하게 숨길 수 없기 때문에 public client로 구성했고 PKCE S256을 사용했다.
- **OAuth Token과 Application Session을 구분하는 기준**
JavaScript memory에 있는 token과 Keycloak의 SSO cookie는 서로 다른 상태다. 새로고침 뒤 SPA의 token이 없어져도 Keycloak의 SSO 상태는 남아 있을 수 있다.
## 문제
AP1에서는 token을 Local Storage나 Session Storage에 저장하지 않고 JavaScript memory에만 둔다.
확인하고 싶었던 부분은 token을 Web Storage에 저장하지 않는 것만으로 실행 중 XSS까지 막을 수 있는지였다.
SPA가 authorization code를 직접 교환한 뒤 token을 어디에 가지고 있는지, API를 호출할 때 access token이 어디를 지나는지 확인했다. PKCE도 실제로 어느 구간에 적용되는지 같이 봤다.
## 결론
memory-only로 보관하면 새로고침 뒤에는 access token, refresh token, ID token이 남지 않는다.
하지만 페이지가 실행 중일 때는 JavaScript에서 token을 사용한다. 악성 script가 같은 페이지에서 실행되면 `fetch`를 가로채거나 사용자를 대신해서 API를 호출할 수 있다. access token도 JavaScript memory에만 있는 것이 아니라 Resource Server 요청의 `Authorization` 헤더에 들어간다.
Resource Server는 `SessionCreationPolicy.STATELESS`로 동작한다. 서버에서 삭제할 application session이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 없다.
현재 구성에서는 access token 수명을 300초로 두고 refresh token rotation을 사용한다. Resource Server에서는 issuer와 audience도 확인한다.
PKCE는 authorization code를 token으로 교환하는 구간에 사용한다. 이미 발급된 access token을 브라우저에서 숨겨주는 기능은 아니다.
## 검증 환경
Keycloak 26.7.0
realms 설정
public-client, standard flow : o
implicit flow, direct grant : x
authority : http://localhost:8080/realms/keycloak-patterns
redirect_uri : http://localhost:8088/OAuth2callback.html
scope : openid profile email
userStore : InMemoryWebStorage
stateStore : sessionStorage
automaticSilentRenew : true
Resource Server
SessionCreationPolicy.STATELESS
CSRF x
CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type
HTTPS : x
HTTP : o
## 재현 조건
1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 `response_type=code`, `code_challenge_method=S256`, 비어 있지 않은 `code_challenge`를 확인한다.
2. token 응답의 access token, refresh token, ID token이 비어 있지 않은지 확인한다.
3. 브라우저 `fetch`를 hook하고 `/api/me` 요청의 `Authorization` 헤더에서 Bearer access token을 확인한다.
4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인한다.
5. 같은 정상 JWT를 expected issuer와 audience가 다른 diagnostic server 두 곳에 보내고 401이 반환되는지 확인한다.
6. refresh token으로 새 token을 받은 뒤 이전 refresh token이 거부되는지 확인한다. revocation 뒤에는 refresh가 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
## 본문
<!-- body:start -->
## SPA에서 Token을 처리하는 위치
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true"
:::
authorization code 교환, token 보관, `Authorization` 헤더 생성까지 모두 브라우저에서 처리한다.
access token, refresh token, ID token도 JavaScript memory에 있고 Resource Server를 호출할 때 사용할 `Authorization` 헤더도 같은 페이지에서 만든다.
그래서 이 페이지에서 악성 script가 실행되면 JavaScript가 token을 사용하는 부분에도 접근할 수 있다.
## 새로고침 전후에 브라우저에 남는 값
`oidc-client-ts``InMemoryWebStorage`를 사용해서 로그인 결과를 Local Storage나 Session Storage에 저장하지 않고 실행 중 memory에만 둔다.
새로고침하면 memory에 있던 로그인 정보와 token은 사라진다.
| 위치 | reload 전 | reload 후 |
|---|---|---|
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
| Local Storage | 해당 없음 | 해당 없음 |
| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |
JavaScript memory에 있던 `User`가 사라지는 것과 Keycloak의 SSO session이 끝나는 것은 별개다.
SPA에서 가지고 있던 token이 사라져도 Keycloak SSO cookie가 남아 있으면 이후 authorization request에서 기존 로그인 상태가 다시 사용될 수 있다.
## Memory-only로 막을 수 있는 범위
memory-only로 바꾼 뒤 어떤 값이 없어지고 어떤 부분은 그대로 남는지 확인했다.
| 위협 | memory-only가 막아주나 |
|---|---|
| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |
| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |
| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |
| network 요청 헤더에 실린 access token | 막아주지 않는다 |
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
Resource Server를 호출할 때 SPA에서 access token을 `Authorization` 헤더에 넣는다.
```http label="브라우저가 Resource Server를 직접 부를 때"
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
```
그래서 access token은 JavaScript memory에만 존재하는 값은 아니다. API를 호출하는 동안에는 network 요청의 `Authorization` 헤더에도 들어간다.
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어서 서버에서 삭제할 application session이 없다.
이미 발급된 self-contained JWT를 logout 시점에 바로 무효화하는 처리도 넣지 않았다. logout에서는 Keycloak SSO 종료와 SPA의 user 제거를 처리하고, 발급된 access JWT를 deny-list로 따로 관리하지 않는다.
현재 access token 수명은 300초다.
access token : 300초
refresh token rotation, 재사용 허용 : x
issuer·audience : 검증
Local Storage나 Session Storage에 token을 저장하면 새로고침 이후에도 값을 다시 읽을 수 있지만, 브라우저 저장소에도 token이 남게 된다.
HttpOnly cookie를 사용하려면 현재 SPA처럼 브라우저에서 access token을 꺼내 Resource Server로 직접 보내는 방식과는 달라진다. server가 session이나 token 전달을 맡는 구조가 필요하다.
## PKCE가 적용되는 구간
PKCE(Proof Key for Code Exchange)를 사용할 때 authorization request에는 `code_challenge`가 들어가고, authorization code를 token으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
response_type=code
client_id=spa-public
redirect_uri=http://localhost:8088/OAuth2callback.html
scope=openid profile email
state=<opaque-state>
code_challenge=<opaque-challenge>
code_challenge_method=S256
```
이번 설정에서는 `response_type=code`를 사용하고 `code_challenge_method=S256`과 비어 있지 않은 `code_challenge`가 authorization request에 들어가는 것을 확인했다.
PKCE가 적용되는 곳은 authorization code를 token으로 교환하는 구간이다. token이 발급된 이후 access token을 브라우저에서 사용하지 못하게 하는 기능은 아니다.
## 테스트에서 확인한 범위
커밋된 테스트에서 확인하도록 만들어 둔 항목은 다음과 같다.
| 정의 여부 | 정의 내용 |
|---|---|
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
| o | token 응답에 비어 있지 않은 access·refresh·ID token |
| o | `/api/me` 200과 decoded access token의 audience 포함 |
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측 |
| o | Local Storage와 Session Storage에 access token substring 없음 |
| o | refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패 |
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
| x | 서명이 깨진 JWT, 만료된 JWT |
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
| x | callback에 error가 실려 돌아왔을 때의 화면 |
| x | `automaticSilentRenew`의 실제 갱신 경로 |
authorization request에서는 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 확인했다.
하지만 token request body에서 실제 `code_verifier`, `client_id`, `redirect_uri`, code 값이 어떻게 전달됐고 서로 대조됐는지는 아직 확인하지 않았다.
:::warning
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지 대신 JSON parse error가 먼저 노출되게 된다.
:::
## Redirect URI와 CORS에서 아직 확인하지 않은 부분
local realm의 redirect allowlist는 다음과 같이 wildcard로 설정되어 있다.
```text
http://localhost:8088/*
http://127.0.0.1:8088/*
```
SPA에서 실제 사용하는 callback은 `/OAuth2callback.html`이다.
SPA : `/OAuth2callback.html`만 o
exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
현재 설정에서는 wildcard가 허용되어 있기 때문에 exact callback만 허용했을 때 잘못된 redirect가 거부되는지는 아직 확인하지 않았다.
frontend Nginx에도 `/api/` proxy가 있지만 SPA에서는 상대 URL을 사용하지 않고 absolute URL인 `http://localhost:8081/api/me`를 호출한다.
그래서 현재 요청은 브라우저에서 Resource Server로 직접 나가고 CORS allowlist를 거친다. 상대 URL을 사용해서 Nginx를 통해 호출했다면 현재와 같은 CORS 경로는 지나지 않았을 것이다.
<!-- body:end -->