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 -->
@@ -0,0 +1,98 @@
---
id: 75c6c657-3e03-47a0-a9d0-5637fce9dd3f
kind: CONCEPT
slug: authorization-code-and-pkce
title: Authorization Code와 PKCE가 보호하는 구간
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
basisVersion: Keycloak 26.7.0 · oidc-client-ts
studio: "https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit"
---
# Authorization Code와 PKCE가 보호하는 구간
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 access token으로 바꾸는 구간을 PKCE가 보호한다. authorization request에 넣은 code_challenge와 token request에 넣은 code_verifier가 맞아야 교환이 끝난다.
## 관계
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
이 개념을 endpoint별 기준으로 정리한 기록이다.
- **Public Client와 Confidential Client 구분 기준**
client 종류에 따라 token endpoint의 인증 방식이 달라진다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code를 직접 교환한 구성이다.
## 본문
<!-- body:start -->
## code를 한 번 더 교환하는 이유
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth client가 code를 token endpoint에 제출해야 access token을 받는다.
교환을 나눈 덕분에 access token이 브라우저 주소창을 지나지 않는다. authorization request는 full-page navigation이라 URL이 주소창과 히스토리, Authorization Server 접근 로그에 남는다. 여기 남아도 되는 값만 code로 두고, token은 별도 요청의 body로 받는다.
## authorization request에 들어가는 challenge
oidc-client-ts가 만드는 요청의 핵심 모양은 다음과 같다.
```http label="authorization request"
GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
?client_id=spa-public
&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html
&response_type=code
&scope=openid%20profile%20email
&state=<opaque-state>
&code_challenge=<opaque-challenge>
&code_challenge_method=S256
```
`response_type=code`가 Authorization Code Flow를 쓴다는 표시이고, `code_challenge`와 `code_challenge_method=S256`이 PKCE 사용을 나타낸다. `state`와 challenge 값은 요청마다 달라진다.
`state`와 PKCE verifier는 redirect를 건너야 하므로 브라우저에 남는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
## token request가 제출하는 verifier
callback으로 돌아온 code는 다음 요청으로 교환된다.
```http label="token request"
POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=spa-public
&code=<authorization-code>
&redirect_uri=http://localhost:8088/callback.html
&code_verifier=<original-verifier>
```
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 verifier가 대응하는지 확인하고 교환을 끝낸다. 이 대응이 authorization request를 시작한 client와 code를 교환하는 주체를 연결한다.
## S256과 plain의 차이
verifier에서 challenge를 만드는 방법이 두 가지다.
| 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가 수행한다.
## PKCE가 막지 않는 것
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 access token을 숨기지는 않는다. 브라우저가 token을 직접 다루는 구성에서 실행 중 악성 script가 Bearer token을 보거나 사용자 권한으로 API를 부르는 문제는 PKCE 밖이다.
`state`도 PKCE와 다른 값이다. `state`는 callback이 원래 시작한 transaction의 것인지 대조하는 값이고, verifier는 code 교환 주체를 묶는 값이다.
## client 종류에 따라 달라지는 부분
`spa-public`은 secret이 없는 public client다. token endpoint에서 client 인증을 하지 않고 PKCE만 사용한다.
confidential client는 여기에 client 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 client를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 authorization request resolver에 장착해 framework가 state와 verifier를 만든다.
AP2 client 설정에는 S256을 강제하는 속성이 없고, AP2 테스트도 authorization request의 challenge를 검사하지 않는다. AP2에서 확인한 것은 Authorization Code Flow를 쓴다는 데까지이고, PKCE S256이 고정됐는지는 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,119 @@
---
id: 87000d59-b69f-4010-9481-0b71c8bde32d
kind: CONCEPT
slug: bearer-jwt-validation-chain
title: Bearer JWT가 인증된 principal이 되기까지
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
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"
---
# Bearer JWT가 인증된 principal이 되기까지
Resource Server가 받는 입력은 Authorization 헤더의 문자열 하나다. 이 문자열이 서명 검증, issuer와 시간 검증, audience 검증, role 변환을 차례로 지나 authenticated principal이 된다. 서명 검증을 통과해도 이 API를 위해 발급된 token인지는 audience 검증에서 따로 본다.
## 관계
- **OAuth Token과 Application Session을 구분하는 기준**
이 검증을 통과한 JWT와 애플리케이션 session은 다른 상태다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
이 JWT가 어느 endpoint에서 발급되는지 정리한 기록이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 이 헤더를 직접 만든 구성이다.
## 본문
<!-- body:start -->
## Resource Server가 받는 입력
브라우저나 BFF가 보내는 요청의 모양은 같다.
```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`를 선택한다.
session을 만들지 않으므로 logout 순간에 지울 server 상태가 없다. 이미 발급된 self-contained JWT는 만료 전까지 유효하고, 짧은 TTL과 validator가 그 범위를 좁힌다.
## 변환 순서
custom code가 지나는 순서는 다음과 같다.
```text label="raw Bearer JWT가 principal이 되기까지"
raw Bearer JWT
→ NimbusJwtDecoder(JWK signature)
→ default issuer + timestamp validators
→ AudienceValidator("keycloak-pattern-api")
→ validated Jwt
→ KeycloakRealmRoleConverter
→ authenticated principal + ROLE_* authorities
```
Spring OAuth2 Resource Server가 헤더를 추출하고 JWT authentication provider를 거쳐 configured decoder를 호출한다. repository code가 Spring 내부 filter를 직접 만들지는 않으므로, DSL이 설치하는 framework integration과 custom bean 경계를 나눠 읽어야 한다.
## 서명을 통과한 뒤에 남는 확인
서명이 맞다는 것은 그 IdP가 발급했다는 뜻이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다. 그래서 서명만 확인하고 끝내지 않는다.
| 확인 단계 | 확인하는 것 | 통과해도 남는 질문 |
|---|---|---|
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 token인가 |
| issuer | 기대한 realm인가 | 아직 유효한가 |
| timestamp | 만료 전인가 | 이 API가 대상인가 |
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
| role converter | 어떤 권한을 갖는가 | — |
## expected issuer와 JWK URL이 다른 이유
두 값은 같은 realm을 가리키지만 쓰임이 다르다.
```text label="issuer와 JWK URL"
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에 도달한다.
하나는 claim 검증 기준이고 하나는 network access 경로다. 두 값을 같게 맞추려다 issuer를 container 주소로 바꾸면 브라우저가 받은 token의 `iss`와 어긋난다.
## audience 검증
`AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 포함됐는지 확인한다. 누락되면 `invalid_token` 결과를 만든다.
같은 정상 JWT를 expected audience가 다른 진단용 Resource Server에 제출하면 401이 된다. issuer가 다른 서버도 마찬가지다. 두 서버가 같은 token에 401을 돌려준 것이 audience 검증과 issuer 검증이 실제로 걸린다는 관측이다.
## realm role이 authority가 되는 변환
`KeycloakRealmRoleConverter`는 `realm_access.roles`의 string을 골라 `ROLE_` prefix를 붙인다.
```text label="role 변환"
realm_access.roles: ["user-role"]
→ ROLE_user-role
```
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role` authority를 찾기 때문에 prefix가 필요하다.
## 인증과 인가는 다른 endpoint에서 갈린다
`/api/me`는 특정 role을 요구하지 않고 `.authenticated()`만 요구한다. role claim이 없어서 converter 결과가 빈 list여도 JWT가 유효하면 `/api/me`는 통과한다.
`admin-role`의 효과는 `/api/admin`에서 나타난다. regular user는 403, admin user는 200이다. 로그인 성공과 role 인가를 같은 테스트로 확인하면 이 차이가 가려진다.
controller는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
```json label="ApiController가 반환하는 JSON"
{
"subject": "<keycloak-user-sub>",
"username": "regular-user",
"issuer": "http://localhost:8080/realms/keycloak-patterns",
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
}
```
<!-- body:end -->
@@ -0,0 +1,101 @@
---
id: bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07
kind: CONCEPT
slug: browser-credential-storage
title: 브라우저가 credential을 보관하는 위치와 그 성질
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
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"
---
# 브라우저가 credential을 보관하는 위치와 그 성질
브라우저에는 JavaScript memory, Session Storage, Local Storage, cookie가 있고 각각 수명과 접근 경로가 다르다. 어떤 credential이 어디에 있는지에 따라 새로고침 뒤 남는 것, JavaScript가 읽을 수 있는 것, 요청에 자동으로 붙는 것이 갈린다.
## 관계
- **OAuth Token과 Application Session을 구분하는 기준**
여기 있는 값들에 각각 다른 이름을 쓰는 기준이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
memory-only 구성을 실제로 확인한 기록이다.
- **BFF 인증 구조 설계 기준**
브라우저에 session cookie만 두는 구조의 설계 항목이다.
## 본문
<!-- body:start -->
## 네 위치의 성질
| 위치 | 새로고침 뒤 | JavaScript가 읽나 | 요청에 자동으로 붙나 |
|---|---|---|---|
| JavaScript memory | 초기화 | 읽는다 | 붙지 않는다 |
| Session Storage | 탭이 살아 있으면 유지 | 읽는다 | 붙지 않는다 |
| Local Storage | 유지 | 읽는다 | 붙지 않는다 |
| HttpOnly cookie | 만료까지 유지 | 읽지 못한다 | 붙는다 |
자동으로 붙는다는 성질이 cookie를 credential로 쓸 때 CSRF 검증이 필요해지는 이유다.
## userStore와 stateStore를 나눈다
oidc-client-ts의 `UserManager`는 두 저장소를 따로 받는다.
```text label="AP1의 UserManager 저장소 설정"
userStore = InMemoryWebStorage
stateStore = sessionStorage
```
`userStore`는 로그인 뒤 `User`와 token set을 보관한다. `stateStore`는 redirect를 건너야 하는 authorization transaction을 보관한다.
두 저장소의 내용도 성격이 다르다.
| 저장소 | 들어가는 것 | 언제까지 필요한가 |
|---|---|---|
| userStore | `User`, access·refresh·ID token, expiry, profile | 로그인 상태가 유지되는 동안 |
| stateStore | `state`, PKCE verifier | callback 처리가 끝날 때까지 |
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 memory에 둘 수 없다. 이 값이 Session Storage에 있는 것과 token이 Web Storage에 있는 것은 다른 설정이다.
## memory-only가 뜻하는 범위
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다. 새로고침하면 `User`와 token이 초기화되고, Local Storage와 Session Storage에는 token 복사본이 남지 않는다.
memory-only는 persistent storage에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저 fetch를 hook하면 API 호출의 Bearer access token을 관측할 수 있다.
이 구성에서 관측한 두 결과는 다음과 같다.
```text label="함께 읽어야 하는 두 결과"
Local Storage · Session Storage → access token 문자열 없음
실행 중 fetch hook → Authorization: Bearer 관측됨
```
AP2도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 된다. 세 경계를 지나는 동안 persistent storage에는 쓰이지 않는다.
## HttpOnly cookie
HttpOnly는 JavaScript가 cookie 값을 직접 읽지 못하게 하는 속성이다. `document.cookie`로 조회되지 않지만 브라우저는 요청마다 붙여 보낸다.
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. 브라우저 JavaScript에 OAuth token을 전달하지 않는 구조에서도 이 cookie는 남는다. 브라우저에 없는 것은 애플리케이션이 쓰는 OAuth token이고, 인증 상태 자체는 이 cookie로 남아 있다.
Keycloak 도메인의 SSO cookie도 별도로 존재할 수 있다. 애플리케이션 memory의 `User`가 사라진 것과 IdP session이 끝난 것은 다른 사건이다.
## opaque cookie
opaque는 내부 값을 브라우저가 해석하지 않고 그대로 돌려준다는 뜻이다.
AP2와 AP3의 session cookie는 server-side 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 cookie 안에는 없다. cookie가 token map을 직렬화한다고 설명하면 구현이 틀리게 된다.
AP4에서 `session-cookie-minimal=true`를 쓰면 server-side session store 없이 edge가 필요한 최소 정보만 cookie 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속 보관한다고 말할 수 없다.
AP2와 AP3의 cookie는 server-side 상태를 찾는 열쇠이고, AP4의 cookie는 최소 상태를 담은 값이다. 두 cookie를 같은 문장으로 설명하지 않는다.
## 학습 환경의 cookie 속성을 일반화하지 않는다
지금 구성은 cookie 속성과 redirect를 눈으로 확인하려고 HTTPS가 아닌 HTTP를 쓴다. 그래서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다. 여기서 관측한 값을 운영 cookie 기본값으로 옮겨 적지 않는다.
<!-- body:end -->
@@ -0,0 +1,115 @@
---
id: 5c8f12d5-1ead-469b-8e91-2de69401df48
kind: CONCEPT
slug: cookie-auth-csrf
title: Cookie로 인증하는 요청에서 CSRF token이 하는 일
topic: 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 -->
@@ -0,0 +1,141 @@
---
id: a3493786-d3fb-4b01-b1c5-ecb23c3d5497
kind: CONCEPT
slug: forward-auth-and-auth-request
title: Forward-Auth와 Nginx auth_request의 동작
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
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"
---
# Forward-Auth와 Nginx auth_request의 동작
forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 endpoint에 허용 여부를 묻는 방식이다. Nginx에서는 auth_request directive가 그 질문을 subrequest로 만든다. 인증 결과는 upstream 요청의 헤더로 바뀌고, upstream은 JWT 대신 그 헤더를 입력으로 받는다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
이 동작을 운영에서 신뢰하려면 무엇이 필요한지 정리한 기준이다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
헤더 위조를 실제로 재현한 기록이다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 구조를 언제 고르는지 비교한 기준이다.
## 본문
<!-- body:start -->
## 요청 하나가 두 번 평가된다
브라우저 요청이 들어오면 Nginx는 바로 upstream을 호출하지 않는다. `location /`에 다음 directive가 있다.
```nginx label="general location의 auth_request"
auth_request /oauth2/auth;
```
Nginx는 먼저 `/oauth2/auth`로 subrequest를 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 한 번의 외부 요청이 인증 판단과 upstream 전달 두 단계로 나뉜다.
`location = /oauth2/auth`는 `internal`로 선언한다. Nginx가 만드는 subrequest만 들어갈 수 있고 브라우저가 같은 URL을 직접 호출하면 정상 auth endpoint로 쓸 수 없다. 외부에서 이 경로를 부르면 404가 된다.
## subrequest가 실어 보내는 것
subrequest는 body를 보내지 않고 `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가 있을 때 원래 요청의 값 |
oauth2-proxy는 이 정보로 session이 유효한지 판단한다.
## 미인증 401의 응답이 경로마다 다르다
인증 결과가 401일 때 무엇을 돌려줄지는 location마다 다르다.
| 외부 입력 | 인증 상태 | 결과 |
|---|---|---|
| `GET /` | 미인증 | `/oauth2/start`로 302 |
| `GET /api/edge` | 미인증 | `Location` 없는 401 JSON |
general 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 규칙을 따른다.
## 인증 결과를 변수로 옮긴다
oauth2-proxy가 session을 유효하다고 판단하면 auth 응답에 사용자와 이메일이 들어 있다. Nginx는 `auth_request_set`으로 그 값을 local 변수에 복사한다.
```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
```
## upstream 요청을 새로 만든다
원래 요청을 그대로 전달하지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고 헤더는 Nginx가 만든 값으로 채워진다.
```http label="Nginx가 만드는 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가 보낸 같은 이름의 헤더를 merge하지 않고 덮어쓴다. 공격자가 `X-Auth-Request-User: spoofed-admin`을 보내도 upstream 입력은 oauth2-proxy가 확인한 실제 user가 된다.
upstream이 받는 요청에서 브라우저가 보낸 헤더와 edge가 만든 헤더는 구분되지 않는다. 그래서 이 덮어쓰기가 edge에서 끝나야 한다.
## upstream은 두 겹을 확인한다
Spring controller는 헤더 두 개를 함께 본다.
```text label="/edge/me의 확인 순서"
1. X-Auth-Request-User가 blank인지 확인
2. X-Internal-Auth-Token을 읽는다
3. 설정된 token과 MessageDigest.isEqual로 비교
4. 둘 다 유효하면 allowlist된 identity field만 응답에 넣는다
```
`MessageDigest.isEqual`은 입력값의 일치 길이에 따라 실행 시간이 크게 달라지지 않는 비교다.
user 헤더가 없거나 internal token이 틀리면 401이다.
```json label="신뢰 조건을 만족하지 못한 응답"
{
"error": "trusted edge authentication is required"
}
```
이 검사는 Spring Security의 `/edge/**` rule이 아니라 controller가 직접 한다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 있어서, 새 edge endpoint를 추가하면서 같은 검사를 부르지 않으면 보호가 자동으로 따라오지 않는다.
## 세 방어선이 각각 막는 것
```text label="AP4가 사용하는 세 방어선"
network isolation : app 8081과 oauth2-proxy 4180을 host에 publish하지 않는다
header overwrite : client가 보낸 동명 헤더를 Nginx 값으로 덮어쓴다
internal token : upstream이 edge를 거쳤다는 추가 신호를 확인한다
```
controller의 shared token만으로는 외부에서 app과 oauth2-proxy에 직접 닿지 못하게 할 수 없다. network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 경우를 걸러 내지 못한다.
## 지금 구성이 보여 주지 않는 것
general `location /`도 `proxy_pass http://app:8081/edge/me`를 쓴다. `/orders/123` 같은 임의 upstream path를 보존하는 범용 reverse proxy가 아니다. auth-request와 header trust를 관찰하는 fixture다.
실제 upstream을 붙이면 URI rewrite, request body, timeout, retry, response header, logout, 상태 변경 요청 보호를 따로 설계해야 한다. 현재 edge 응답은 user와 email만 전달하고 role, groups, tenant, token expiry는 전달하지 않는다.
<!-- body:end -->
@@ -0,0 +1,79 @@
---
id: d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719
kind: CONCEPT
slug: idp-brokering
title: 외부 IdP Brokering의 동작
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
basisVersion: Keycloak 26.7.0 identity brokering
studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit"
---
# 외부 IdP Brokering의 동작
브로커는 외부 IdP의 응답을 검증해 자기 realm의 identity로 연결한 뒤, 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 code와 token은 언제나 브로커가 발급한 것이므로, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer는 바뀌지 않는다.
## 관계
- **외부 IdP 연동과 Application 인증 구조의 경계**
이 동작을 경계 기준으로 정리한 기록이다.
- **OAuth Token과 Application Session을 구분하는 기준**
upstream IdP session과 애플리케이션 상태를 구분하는 기준이다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
브로커가 발급하는 code가 지나는 endpoint다.
## 본문
<!-- body:start -->
## 두 개의 OAuth 왕복이 이어진다
사용자가 브로커 로그인 화면에서 외부 IdP를 고르면 인증이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이이고, 뒤의 왕복은 애플리케이션과 브로커 사이다.
```text label="brokering 변환 순서"
Google identity assertion
→ Keycloak broker validation
→ provider alias + upstream sub로 account identity 결정
→ Keycloak local user/session
→ Keycloak authorization code
→ AP1·AP2·AP3·AP4 중 선택한 downstream 경계
```
브라우저가 upstream authorization을 수행하고, 브로커가 그 응답을 검증해 local identity와 연결한다. 그다음 애플리케이션으로 나가는 데이터는 다시 브로커가 만든다.
## 애플리케이션이 상대하는 issuer는 그대로다
| 계층 | 무엇을 발급하나 | 누가 검증하나 |
|---|---|---|
| 외부 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를 부르는지는 바뀌지 않는다. 그 선택은 네 패턴 중 무엇을 골랐는지가 정한다.
## account identity를 정하는 key
브로커가 upstream 사용자를 local user와 연결할 때 쓰는 안정적인 key는 provider alias와 upstream `sub`의 조합이다.
email은 key가 아니다. upstream email이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 email의 소유권을 증명하지 않은 상태에서 계정이 합쳐진다. 계정 연결은 인증 구조와 분리된 별도 설계 항목이다.
## 경계를 섞으면 생기는 일
외부 IdP를 애플리케이션 인증 구조 하나로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 된다. 두 경계는 검증 방법이 다르다.
비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
경계가 새는지는 다음 지점에서 본다. UI에서 provider를 고르게 하거나 provider별 계정 연결을 다루는 것은 자연스럽다. Resource Server의 token 검증이나 애플리케이션 인가가 upstream IdP별로 갈리기 시작하면 브로커 경계가 애플리케이션까지 새고 있는지 본다.
외부 IdP의 token을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단이 함께 빠진다.
## 현재 검증한 범위
지금 자동화는 controllable mock OIDC provider로 broker와 claim mapping 계약을 확인한다. 실제 Google 계정, public HTTPS callback, consent와 production domain policy를 통과했다는 뜻은 아니다.
upstream IdP 검증 범위와 애플리케이션 credential 경계를 분리해서 적어야 이 사실 경계가 유지된다.
<!-- body:end -->
@@ -0,0 +1,60 @@
---
id: 19b55c39-c583-4161-9775-df954280a568
kind: PROJECT_DECISION
slug: bff-owns-token-when-browser-must-not
title: BFF가 OAuth Token을 관리하는 조건
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 23
decisionStatus: PROPOSED
decidedOn: 2026-08-31
studio: "https://hyeonworks.com/studio/documents/19b55c39-c583-4161-9775-df954280a568/edit"
public: "https://hyeonworks.com/projects/keycloak-patterns/decisions/bff-owns-token-when-browser-must-not"
---
# BFF가 OAuth Token을 관리하는 조건
애플리케이션 계층에서 API 응답 조합과 인가를 처리하면서도 브라우저 JavaScript에는 OAuth Token을 노출하지 않아야 한다면 BFF 구조를 선택할 수 있다.
이 경우 BFF가 Authorization Code를 Token으로 교환하고, Access Token과 Refresh Token을 서버에 보관한다.
브라우저는 OAuth Token 대신 Application Session을 이용해 BFF를 호출하고,
BFF는 저장된 Access Token으로 Downstream Resource Server를 호출한다.
## 근거
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
이 결정이 가리키는 구조를 실제로 실행해 본 기록이다.
- **BFF 인증 구조 설계 기준**
이 결정이 PROPOSED인 동안의 실제 적용 기준이다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 결정을 적용할 조건과 피해야 할 조건이 여기 있다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
access token이 브라우저로 나가 이 요구를 만족하지 못한 경우다.
## 결정문
브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다.
브라우저에는 애플리케이션 session만 제공한다.
## 판단 이유
브라우저에 OAuth token을 전달하지 않으려면 server가 authorization code를 교환하고 access token을 사용해 downstream API를 호출해야 한다.
Mediator 구조에서는 브라우저가 Resource Server를 직접 호출하므로 access token을 `/token/access` 응답으로 전달한다.
그래서 브라우저에 OAuth token을 제공하지 않는다는 요구에는 맞지 않는다.
Forward-Auth 구조도 브라우저에 OAuth token을 전달하지 않을 수 있지만 upstream은 JWT를 직접 검증하지 않고 edge가 제공한 identity header를 사용한다.
애플리케이션이 access token으로 여러 Resource Server를 직접 호출하거나 사용자별 API 조합을 처리해야 한다면 BFF 쪽이 요구에 더 잘 맞는다.
그래서 이 결정을 적용할지는 브라우저에 OAuth token을 전달하지 않아야 하는지와 함께, 애플리케이션이 downstream API 호출과 조합을 직접 맡아야 하는지까지 보고 정한다.
## 영향
- BFF가 로그인 상태와 access token, refresh token을 보관하는 보안 구성요소가 된다. 요청을 그대로 넘기는 proxy와 같은 것으로 다루지 않는다.
- 상태 변경 요청마다 CSRF 검증이 필요해진다. 노출되는 값과 제출해야 하는 값이 다를 수 있어서 클라이언트 코드도 그 차이를 알고 있어야 한다.
- 재시작과 replica 이동을 견딜 공유 저장소와 저장 token 암호화, 암호화 key 교체를 설계해야 하는데 아직 정하지 않은 문제로 남아 있다.
- logout이 애플리케이션 session과 authorized client를 함께 지워야 하는데, 관리가 달라서 한 번의 삭제로 두 상태가 함께 지워지지 않는다.
- 모든 UI 요청이 BFF를 지나게 되어서 지연과 단일 장애 지점을 준비해야 한다.
- 브라우저에서 token을 없애도 XSS는 여전히 고려해야 된다.
@@ -0,0 +1,72 @@
---
id: 8c1ebea7-204e-445c-9812-0421d9eb0e9c
kind: PROJECT_DECISION
slug: federation-is-not-an-application-pattern
title: 외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 17
decisionStatus: ADOPTED
decidedOn: 2026-08-24
studio: "https://hyeonworks.com/studio/documents/8c1ebea7-204e-445c-9812-0421d9eb0e9c/edit"
public: "https://hyeonworks.com/projects/keycloak-patterns/decisions/federation-is-not-an-application-pattern"
---
# 외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.
Google, Keycloak, 애플리케이션 인증 구조는 각각 역할이 다르다.
Google은 실제 사용자 인증을 수행하는 외부 IDP이고, Keycloak은 Google의 인증 결과를 받아 애플리케이션이 사용할 토큰을 발급한다.
애플리케이션은 Google을 직접 신뢰하는 것이 아니라 Keycloak이 발급한 토큰을 기준으로 사용자를 인증한다.
SPA, BFF와 같은 구조는 로그인한 사용자의 토큰이나 세션을 어디에 관리할 것인지를 정한다.
따라서 외부 IDP가 붙더라도 인증 구조가 바뀌는 것은 아니다.
## 근거
- **외부 IdP 연동과 Application 인증 구조의 경계**
이 결정을 규칙으로 편 기준이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브로커가 발급한 code를 받는 애플리케이션 경계다.
- **OAuth Token과 Application Session을 구분하는 기준**
upstream IdP 상태와 애플리케이션 상태를 같은 이름으로 부르지 않는다.
## 결정문
외부 IdP 연동은 별도의 인증 구조가 아니다.
Google과 같은 외부 IDP는 사용자의 인증을 담당하고, Keycloak은 그 인증 결과를 받아 애플리케이션이 신뢰할 수 있는 토큰을 발급한다.
SPA, Mediator, BFF, OAuth2-proxy와 같은 4가지 구조는 이렇게 발급된 토큰이나 세션을 애플리케이션에서 어디까지 노출하고 관리할지를 구분한다.
## 판단 이유
사용자가 Keycloak 로그인 화면에서 Google 로그인을 선택하면 브라우저는 Google의 Authorization Endpoint로 이동한다.
Google에서 인증이 끝나면 그 결과는 Keycloak으로 돌아오고, Keycloak은 이 응답을 검증해 자신의 사용자 정보와 연결한다.
그러고 나서 애플리케이션 callback에는 Keycloak이 발급한 Authorization Code가 전달된다.
애플리케이션은 Google과 직접 토큰을 교환하지 않는다.
애플리케이션은 Keycloak이 발급한 Authorization Code를 Keycloak의 Token Endpoint에서 토큰으로 교환한다.
Resource Server가 검증하는 issuer도 Google이 아니라 Keycloak이고, 애플리케이션은 Google token을 받지 않는다.
그래서 Google 로그인을 추가해도 애플리케이션의 토큰 관리 구조는 달라지지 않는다.
SPA라면 여전히 브라우저에서 토큰을 관리하고, BFF라면 서버가 토큰을 관리하면서 API를 대신 호출해준다.
브라우저가 token을 받는지, 어느 계층이 API를 부르는지도 그대로다.
Google을 구조 하나로 세게 되면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 서로 다르다.
두 경계를 섞어 두면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
그래서 외부 IDP 연동과 애플리케이션 인증 구조는 별도의 경계로 나누어 설계하고 검증한다.
## 영향
- Google을 추가하더라도 애플리케이션이 신뢰하고 토큰을 검증하는 대상은 계속 Keycloak이다.
또한 토큰을 브라우저와 서버 중 어디에서 관리하고 어느 계층에서 API를 호출할지는 기존 4가지 구조가 정하는 그대로다.
- 외부 IDP의 계정을 기존 사용자와 어떻게 연결할지는 인증구조와 별개의 문제다.
외부 계정을 식별할 때는 Google과 같은 인증 제공자와 해당 제공자가 부여한 사용자 고유 식별자를 같이 사용한다.
이메일 주소는 변경될 수 있고 서로 다른 인증 제공자에서 같은 이메일을 사용할 수도 있기 때문에 이메일이 같다라는 이유로 기존 계정과 자동으로 연결하지 않는다.
- 외부 IDP 연동은 테스트 환경에서 확인할 부분과 실제 서비스 환경에서 확인할 부분을 나눠서 검증한다.
Mock Provider를 사용한 테스트에서는 KeyCloak이 외부 IDP의 인증 결과를 정상적으로 받아들이는지,
필요한 사용자 정보가 정상적으로 매핑되는지 확인한다.
실제 Google과 같은 외부 IDP를 연동할 때는 실제 계정으로 로그인이 가능한지, 공개 HTTPS Callback이 정상 작동 하는지, 사용자 동의 과정까지 진행되는지 확인해야 한다.
- Google과 같은 외부 IDP가 늘어나면 KeyCloak에서 관리해야 할 연동 설정도 많아진다.
이 연동 설정을 애플리케이션 팀이 관리할지 별도의 인프라 팀이 관리할지는 아직 정하지 않았다.
실무에서 어느 쪽이 맡는지도 확인하지 않았다.
@@ -0,0 +1,133 @@
---
id: 18a5cde2-dd1e-4bff-9f1c-997577ae438f
kind: QUESTION
slug: bff-session-authorized-client-store
title: BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 32
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/18a5cde2-dd1e-4bff-9f1c-997577ae438f/edit"
public: "https://hyeonworks.com/questions/bff-session-authorized-client-store"
---
# BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
Application Session과 Authorized Client는 저장하고 조회하는 기준이 서로 다르다.
Session은 `session ID`를 기준으로 조회하지만, Authorized Client는 `client registration 이름``principal name`을 기준으로 조회한다.
그래서 두 상태를 반드시 같은 저장소에 보관해야 하는 것은 아니며, 각각의 조회 방식과 운영 요구사항에 맞게 저장 구조를 결정해야 한다.
현재 Shared Store의 후보로는 Redis를 우선 생각하고 있지만 아직 최종 저장소로 결정한 것은 아니다.
특히 Access Token과 Refresh Token을 Redis에 저장할 경우 Token을 어떤 방식으로 암호화할지, Session과 Token의 만료 시간을 어떻게 맞출지, Logout할 때 Session과 Authorized Client가 모두 정상적으로 제거되는지까지는 확인하지 않았다.
따라서 현재 단계에서는 Redis를 저장소 후보로 작성하고, Token 보호와 만료 처리, Logout 시 상태 정리까지 검증한 뒤 실제 저장 구조를 결정한다.
## 관계
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
이 질문에서 저장소 부분만 떼어 낸 것이다.
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
session과 authorized client의 열쇠가 다르다는 사실의 출처다.
- **BFF 인증 구조 설계 기준**
이 기준의 저장소 항목이 이 질문의 답을 기다린다.
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
저장소를 공유한 뒤에야 replica 경쟁이 재현된다.
## 사실
- 현재 구성에 Spring Session과 Redis, JDBC repository, 암호화 token store가 없다.
- 현재 HttpSession은 servlet container의 in-memory 구현을 사용하므로 해당 process가 종료되면 session 데이터도 유지되지 않는다.
- OAuth2AuthorizedClientService도 자동구성이 고르는 in-memory 구현이다.
- session은 session ID로 조회하고 authorized client는 registration 이름과 principal name으로 조회한다.
두 저장 구조를 shared store로 전환할 때 각각 따로 설계해야 한다.
- authorized client manager에 authorization-code와 refresh-token provider가 함께 구성돼 있어서,
저장소를 공유하게 되면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있게 된다.
## 가정
- 두 상태를 같은 저장소에 둘 필요는 없다.
- 저장된 refresh token을 평문으로 두면 안 된다.
- session 만료와 token 만료 중 하나가 먼저 오게 되면 그 순간의 동작이 정의돼 있어야 한다.
예를 들면 token이 만료됐을 때 사용자의 로그인 상태가 여전히 유지되는지.
## 미지수
- Redis와 JDBC 중 무엇이 이 상태의 접근 패턴에 맞는가.
요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
- session과 authorized client를 같은 store에 둘지 나눌지.
- 암호화 key를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key로 저장된 값은 어떻게 읽는가.
- session TTL과 refresh token 수명 중 어느 것을 기준으로 만료를 맞추게 되는가.
- session과 Authorized Client 두 store를 logout에서 어떻게 한 번에 지우게 되는가.
- sticky session이 durable store의 대안이 되는가 보완이 되는가.
## 제약
- authorized client의 조회 시 session ID가 없어서 session만 공유해도 같은 사용자의 여러 session이 같은 token을 보게 된다.
- 현재 테스트에는 저장소 관련 계약이 없다.
- 모든 UI 요청이 BFF를 지나기 때문에 저장소 지연이 화면 지연으로 바로 드러나게 된다.
## 선택지
### 1. session과 authorized client를 모두 Redis에 둔다
Spring Session Redis와 Redis 기반 Authorized Client 저장소를 사용하면 여러 애플리케이션 인스턴스가 같은 Session과 Authorized Client 정보를 조회할 수 있다.
상태가 특정 인스턴스의 메모리에 묶이지 않으므로 서버가 재시작되거나 요청이 다른 Replica로 전달되는 환경에서도 인증 상태를 공유하기 쉬워진다.
Session과 Authorized Client에 설정된 유효 시간에 따라 Redis의 TTL을 이용해 저장된 상태를 만료시키는 구조도 구성할 수 있다.
다만 어떤 상태를 얼마 동안 유지할지는 애플리케이션의 Session 정책과 OAuth Token의 수명에 맞춰 별도로 정해야 한다.
이 구성에서는 인증 경로가 Redis의 가용성에 의존하게 된다.
Redis에 장애가 발생했을 때 기존 Session과 Authorized Client를 조회하지 못하는 상황을 어떻게 처리할지 정해야 하며,
장애 복구와 데이터 유지 방식도 함께 고려해야 한다.
또한 Access Token과 Refresh Token을 Redis에 저장한다면 저장된 Token을 어떤 방식으로 보호할지도 결정해야 한다.
Token을 암호화해서 저장할지, 암호화한다면 Key를 어디에 보관하고 어떻게 교체할지까지 저장소 설계에 포함한다.
### 2. session과 authorized client를 모두 JDBC에 둔다
JDBC 기반 저장소를 사용하면 이미 운영 중인 관계형 DB에 Session과 Authorized Client 정보를 저장할 수 있다.
인증 과정에서 Session이나 Authorized Client를 조회할 때마다 DB 접근이 발생하므로, 인증 요청이 기존 관계형 DB의 가용성과 성능에 영향을 받게 된다.
요청량이 증가했을 때 Session 조회가 지연되지 않는지 확인하고, 인증 관련 조회가 기존 애플리케이션 쿼리와 서로 영향을 주지 않는지도 확인해야 한다.
또한 만료된 Session과 Authorized Client 데이터가 계속 쌓이지 않도록 정리하는 방법과 주기를 정해야 한다.
JDBC를 고르면 기존 DB 운영 체계를 그대로 쓰면서 조회 지연과 DB 부하, 만료 데이터 정리까지 같이 관리하게 된다.
### 3. session만 공유하고 sticky session을 쓴다
Session Affinity를 사용하면 같은 사용자의 요청을 가능한 한 동일한 애플리케이션 인스턴스로 전달할 수 있으므로 기존 구조의 변경을 줄일 수 있다.
하지만 이것만으로 인증 상태가 여러 인스턴스에 공유되는 것은 아니다.
Authorized Client가 여전히 특정 애플리케이션 인스턴스의 메모리에 저장되어 있다면, 요청이 다른 인스턴스로 전달되거나 해당 인스턴스가 종료되었을 때 기존 Token 정보를 조회할 수 없다.
Session Affinity는 요청을 특정 인스턴스로 보내는 방법이다. Session과 Authorized Client를 공유하는 문제와 장애 이후에 인증 상태를 유지하는 문제는 그대로 남는다.
인스턴스 장애와 Replica 간 이동까지 고려한다면 Session과 Authorized Client의 저장 방식을 별도로 설계해야 한다.
### 4. session은 Redis, token은 암호화한 JDBC에 둔다
Session과 Authorized Client를 서로 다른 저장소에 보관하는 방법도 있다.
요청마다 자주 조회되는 Session은 Redis와 같이 빠르게 접근할 수 있는 저장소에 두고, Access Token과 Refresh Token은 암호화와 장기 보관 정책을 적용하기 쉬운 관계형 DB에 저장할 수 있다.
각 데이터의 접근 패턴과 보호 요구사항에 맞춰 저장소를 선택할 수 있다는 장점이 있다.
두 저장소의 상태는 함께 관리해야 한다.
Session이 만료되었는데 Authorized Client가 남거나, 반대로 Authorized Client가 먼저 제거되어 유효한 Session에서 Token을 찾지 못하는 상황이 발생할 수 있다.
따라서 각각의 만료 정책을 어떻게 맞출지 정해야 한다.
Logout에서도 Session과 Authorized Client가 서로 다른 저장소에 있으므로 두 상태를 모두 정리해야 한다.
한쪽을 제거하는 과정에서 실패했을 때 어떻게 처리할지도 함께 결정해야 한다.
또한 Redis와 관계형 DB를 모두 인증 경로에서 사용하게 되므로 모니터링, 장애 대응, 백업 등 운영해야 하는 저장소도 늘어난다.
저장소를 나눠서 얻는 것과 늘어나는 운영 대상을 같이 놓고 정한다.
## 다음 검증
후보마다 같은 입력으로 비교한다.
1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.
2. 저장소를 직접 열어 refresh token이 평문으로 남는지 확인한다.
3. session TTL과 token 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
4. logout 뒤 두 store에 잔여 항목이 없는지 확인한다.
5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.
암호화 key 교체 절차는 후보를 고른 뒤에 따로 설계한다.
@@ -0,0 +1,124 @@
---
id: 7ff40767-a00b-4db2-98f6-0cdfce8c8936
kind: QUESTION
slug: edge-authorization-scope
title: Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 36
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/7ff40767-a00b-4db2-98f6-0cdfce8c8936/edit"
public: "https://hyeonworks.com/questions/edge-authorization-scope"
---
# Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
현재 Edge는 인증된 사용자의 `user``email`만 Header로 전달하고 있으며, Upstream 애플리케이션에서는 Role을 이용한 인가 판단을 하지 않는다.
따라서 현재 구조만으로는 Role 기반 인가가 필요한 요구사항이 추가되었을 때 어떻게 처리할지 결정되어 있지 않다.
Edge가 사용자의 Role까지 확인해 Header로 전달할지, 아니면 애플리케이션이 Role과 권한을 확인하고 인가를 직접 판단하도록 할지 별도로 결정해야 한다.
Role이나 권한처럼 애플리케이션의 기능과 밀접한 정보가 계속 늘어난다면, 이러한 정보를 Edge Header에 계속 추가하기보다 인가 책임을 애플리케이션에서 처리하는 구조가 더 적절한지도 함께 검토한다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
edge가 user와 email만 전달한다는 사실의 출처다.
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
헤더 allowlist와 검증 조건이 이 기준에 있다.
- **BFF 인증 구조 설계 기준**
되돌리는 선택지의 기준이 이 문서다.
## 사실
- 지금 edge 응답은 user와 email만 전달한다. role과 groups, tenant, 인증 방식, token 만료는 전달하지 않는다.
- upstream의 identity endpoint는 role을 확인하지 않는다. 누가 왔는지만 응답한다.
- 현재 Internal Token 검증은 특정 Controller에서만 수행하고 있으며, Security 설정에서는 해당 경로를 `permitAll`로 허용하고 있다.
이 구조에서는 같은 내부 경로 아래에 새로운 Endpoint를 추가하더라도 Internal Token 검증이 자동으로 적용되지 않는다.
그래서 Filter, Interceptor, 또는 Spring Security의 인증 처리 단계처럼 공통 경계에서 검증하도록 옮겨야 한다.
- Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다. 추가적으로 늘어나는 헤더도 같은 처리를 받아야 한다.
- upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을 검증할 방법이 없다.
## 가정
- 헤더 종류가 늘어나면 정해야 할 계약도 늘어난다.
- role이 바뀌는 시점과 요청이 오는 시점이 달라서 그 사이에 들어온 요청은 바뀌기 전의 값을 볼 수도 있다.
## 미지수
- 다중 값 role을 어떤 구분자와 escaping으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
- 헤더 크기 상한을 넘으면 어떻게 되는지. proxy가 자르는지 요청 자체가 거부되는지.
- role이 바뀌었을 때 proxy session과 downstream 인가가 언제 반영되는지. 권한 변경이 몇 분 뒤에 반영되는지.
- upstream이 헤더 존재만 볼지 값과 service identity까지 볼지.
## 제약
- 전달할 헤더는 allowlist로 해야 하고 client가 보낸 동명 헤더는 항상 덮어써야 한다.
- internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮겨야 된다.
## 선택지
### 1. 인증만 edge에 둔다
Edge가 전달하는 Header를 `user``email` 정도로 제한하면 Edge와 Upstream 사이의 계약을 작게 유지할 수 있다.
Role이나 Permission 정보를 Header에 계속 추가하지 않으므로 Header 크기가 커지는 문제도 줄일 수 있다.
이 경우 인가 판단은 각 Upstream 애플리케이션이 직접 수행한다.
애플리케이션은 전달받은 사용자 식별 정보를 기준으로 자신의 저장소에서 Role이나 Permission을 조회하고,
해당 요청을 허용할지 결정해야 한다.
이 구조에서는 서비스마다 권한 조회와 인가 로직을 별도로 구성해야 한다.
따라서 Edge의 책임은 단순하게 유지할 수 있지만, 서비스 수가 늘어나면 각 서비스에서 동일하거나 유사한 권한 조회 체계를 반복해서 구현하고 운영해야 할 수 있다.
### 2. role 전달까지 edge에 둔다
Edge가 공통 Role 정보를 확인해 Upstream에 전달하면 각 서비스가 별도로 사용자 권한을 조회해야 하는 작업을 줄일 수 있다.
대신 Role을 Header로 전달하기 위한 계약을 먼저 정해야 한다.
사용자가 여러 Role을 가질 때 어떤 형식으로 직렬화할지, Header에 허용할 최대 크기를 어디까지로 할지, 사용자의 Role이 변경되었을 때 언제부터 새로운 값이 요청에 반영되는지도 명확하게 정의해야 한다.
또한 Upstream은 전달받은 Role이 원래 인증 시스템의 값과 일치하는지 확인할 수 있어야 하고, 그러지 못하면 Edge가 전달한 값을 그대로 신뢰하게 된다. 따라서 Edge에서 Role을 잘못 계산하거나 오래된 값을 전달하면 Upstream의 인가 판단도 그대로 잘못될 수 있다.
이 구조를 선택하게 되면 Edge가 Role 정보를 만드는 과정과 Header를 전달하는 경로를 신뢰 경계의 일부로 보고,
Role 갱신과 전달 오류를 어떻게 검증할지도 함께 설계해야 한다.
### 3. tenant와 인가 판단까지 edge에 둔다
Tenant 정보는 단순한 사용자 속성이 아니라 어떤 조직의 데이터에 접근할 수 있는지를 결정하는 값이다.
따라서 잘못된 Tenant 값 하나가 전달되면 다른 조직의 데이터에 접근하는 문제로 바로 이어질 수 있다.
이 때문에 Tenant를 Edge Header로 전달하려면 Upstream에서도 해당 사용자가 실제로 그 Tenant에 속하는지 다시 확인할 수 있는 방법이 필요하다.
현재 구성에는 이러한 재검증 수단이 없으므로 Tenant까지 Edge가 책임지는 구조로 확장하기에는 위험이 크다.
더 나아가 Tenant나 Role을 이용한 실제 인가 판단까지 Edge로 옮기면 Edge가 애플리케이션의 도메인 규칙을 알아야 한다.
어떤 사용자가 어떤 조직의 어떤 기능을 사용할 수 있는지 같은 정책이 바뀔 때마다 Edge의 로직도 함께 수정하고 배포해야 한다.
따라서 Edge는 인증된 사용자 정보를 전달하는 역할에 가깝게 유지하고, Tenant 소속 관계나 도메인에 종속된 인가 규칙은 Upstream 애플리케이션에서 검증하는 구조를 우선 검토한다.
### 4. 헤더 계약 대신 BFF가 인가와 API 호출을 맡는다
Role이나 Tenant 같은 정보를 Edge Header에 계속 추가하는 대신, BFF가 사용자에게 필요한 정보를 직접 조회하고 인가 판단과 API 호출을 처리하는 구조도 선택할 수 있다.
이 구조에서는 Edge가 애플리케이션의 Role, Tenant, 권한 정책까지 알 필요가 없다.
BFF가 필요한 사용자와 권한 정보를 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 호출해 결과를 조합할 수 있다. 따라서 애플리케이션 도메인에 가까운 책임을 Edge Header 계약에서 분리할 수 있다.
이 구조에서는 BFF를 도입하면서 서버가 다시 인증 상태를 관리해야 한다.
브라우저와 BFF 사이의 Application Session을 보호해야 하고, Cookie 기반 Session을 사용한다면 상태 변경 요청에 대한 CSRF 검증도 필요하다.
여러 BFF Replica에서 인증 상태를 유지해야 한다면 Session과 Authorized Client를 어떻게 공유할지 결정하고 Shared Store의 장애와 만료 처리도 운영해야 한다.
## 다음 검증
upstream이 실제로 요구하는 claim을 먼저 적는다.
1. 전달하려는 claim이 계속 늘어나는가.
2. role이나 tenant 변경이 즉시 반영돼야 하는가.
3. 정책이 애플리케이션 도메인을 알아야 하는가.
4. 헤더 값이 인가 판단의 근거가 되는가.
5. 서비스별 정책 차이가 커지는가.
2번부터 5번 중 하나라도 그렇다면 헤더를 늘리는 대신 BFF 구조를 검토한다.
role을 헤더에 담는 구성을 먼저 만들고, 다중 값과 크기 상한을 넣어 무엇이 먼저 잘못되는지 확인한다.
role을 바꾼 뒤 몇 번째 요청부터 반영되는지도 확인한다.
@@ -0,0 +1,131 @@
---
id: c72656b5-842d-45d9-b5f6-82b66b09d0b9
kind: QUESTION
slug: server-session-pattern-multi-instance
title: 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 39
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/c72656b5-842d-45d9-b5f6-82b66b09d0b9/edit"
public: "https://hyeonworks.com/questions/server-session-pattern-multi-instance"
---
# 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
Mediator와 BFF는 브라우저의 로그인 세션과 OAuth token을 서로 다른 저장소에서 관리한다.
현재 구현에서는 두 저장소 모두 애플리케이션 서버의 메모리에 있기 때문에, 서버 프로세스가 종료되면 저장된 상태도 같이 사라진다.
따라서 운영 환경에서 서버를 여러 인스턴스로 구성하게 될 경우 추가 설계가 필요한데, 사용자의 요청이 로그인할 때와 다른 인스턴스로 전달되어도 세션과 토큰을 찾을 수 있어야 하고, 서버가 재시작된 뒤에도 로그인 상태를 유지할 것인지 결정해야 한다.
또한 로그아웃할 때 여러 인스턴스에 걸쳐 저장된 세션과 토큰을 어떻게 같이 제거할지도 정해야 한다.
## 관계
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
두 상태가 모두 process-local memory에 있다는 사실의 출처다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
같은 저장소 구성을 쓰는 다른 패턴이다.
- **BFF 인증 구조 설계 기준**
이 질문의 답이 이 기준의 빈 항목을 채운다.
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
저장소 후보 비교로 독립시킨 질문이다.
## 사실
- Mediator와 BFF는 로그인 상태와 OAuth Token을 서로 다른 저장소에서 관리한다.
로그인 상태는 HttpSession에 저장하고 session ID로 조회한다.
반면 OAuth Token은 OAuth2AuthorizedClientService에 저장하며, client registration 이름과 principal name을 기준으로 조회한다.
- 현재 세션과 OAuth Token을 저장할 별도의 저장소를 직접 설정하지 않았다.
따라서 Spring Boot의 자동 구성이 고르는 메모리 기반 기본 구현이 사용된다.
다만 코드에 저장소를 직접 생성하는 Bean이 없기 때문에, 어떤 구현체가 실제로 사용되는지는 Spring Boot의 자동 구성 결과까지 확인해야 정확하게 알 수 있다.
- 현재는 Spring Session이나 Redis, JDBC 기반 Token Store와 같은 외부 저장소를 사용하지 않는다.
따라서 로그인 세션과 OAuth Token 정보는 모두 해당 애플리케이션 인스턴스의 메모리에 저장된다.
이 때문에 인스턴스가 종료되거나 재시작되면 해당 인스턴스가 가지고 있던 로그인 세션과 OAuth Token 정보도 같이 없어진다.
- Authorized Client는 세션별로 구분되지 않는다.
조회 기준에 session ID가 없기 때문에 같은 사용자가 여러 브라우저에서 로그인하면 동일한 Authorized Client 정보를 사용하게 된다.
- OAuth2-Proxy 구조에서는 로그인 상태를 별도의 서버 저장소에 보관하지 않고,
필요한 최소한의 정보를 브라우저의 세션 쿠키에 담아 관리한다.
현재 설정에서는 이 쿠키의 유효 시간을 1시간으로 두고 있다.
- 현재 테스트에는 서버가 재시작되거나 사용자의 요청이 다른 인스턴스로 전달된 뒤에도 로그인 상태와 OAuth Token을 정상적으로 사용할 수 있는지 확인하는 항목이 없다.
따라서 세션이나 Token 저장 방식을 변경하더라도 기존 동작이 그대로 유지되는지는 별도로 확인 및 검증이 필요하다.
## 가정
- 운영에서는 인스턴스가 둘 이상이다.
- 재시작과 배포가 로그인 상태를 끊어서는 안 되는데, 지금 구조에서는 끊기게 된다.
- 같은 사용자의 여러 브라우저 session이 서로의 token 항목을 덮어써서는 안 된다.
## 미지수
- 재시작 뒤 로그인이 유지되는가. 지금은 안 된다는 것까지 알지만 무엇을 바꿔야 되는지는 정하지 않았다.
- 인스턴스가 바뀌어도 같은 session을 찾게 되는가.
- 같은 사용자의 여러 session이 authorized client 항목을 공유하거나 덮어쓰게 되는가.
한쪽에서 로그아웃하면 다른 쪽도 끊기게 되는가.
- 저장된 refresh token이 암호화되는가.
저장소를 여는 사람이 그 값을 그대로 읽게 되는가.
- logout에서 HttpSession과 authorized client를 모두 정리하는가.
한쪽만 삭제했을 때 다음 요청이나 재로그인에서 어떤 상태가 복원되는가.
- session 만료와 token 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 어떻게 보이게 되는가.
- OAuth2-Proxy 구조의 replica들이 같은 cookie secret을 어떻게 공유하고 교체하게 되는가.
교체하는 동안 로그인해 있던 사람은 어떻게 되는가.
## 제약
- 현재 구조는 단일 인스턴스로 실행하고 있어 replica 간 session 조회와 failover 동작은 아직 구현되지 않았다.
- Authorized Client는 session ID를 기준으로 저장하거나 조회하지 않는다.
따라서 여러 인스턴스가 같은 세션을 사용할 수 있도록 Session Store를 공유 저장소로 변경하는 것만으로는 충분하지 않다.
로그인 세션을 여러 인스턴스에서 공유하는 방법과 OAuth Token이 저장된 Authorized Client를 어떻게 저장하고 공유할지는 각각 별도로 설계해야 한다.
- Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록 network에서 강제된 상태가 아니다.
## 선택지
### 1. 공유 저장소를 사용한다
HttpSession과 Authorized Client를 모두 외부의 공유 저장소에 보관하면 여러 애플리케이션 인스턴스가 동일한 로그인 세션과 OAuth Token 정보를 조회할 수 있다.
따라서 사용자의 요청이 다른 인스턴스로 전달되거나 특정 인스턴스가 재시작되더라도 기존 로그인 상태를 계속 사용할 수 있다.
다만 인증 과정이 외부 저장소에 의존하게 되므로 추가로 고려해야 할 사항이 생긴다.
저장소에 장애가 발생했을 때 인증 요청을 어떻게 처리할지 정해야 하고, 세션과 Token을 어떤 형식으로 저장할지와 저장된 Token을 어떻게 보호할지도 결정해야 한다.
또한 세션은 남아 있는데 Token은 이미 만료되는 것과 같은 불일치가 발생하지 않도록 두 상태의 만료 시간과 제거 시점도 함께 설계해야 한다.
### 2. session affinity로 같은 인스턴스에 붙인다
Sticky Session을 사용하면 같은 세션의 요청을 가능한 한 동일한 애플리케이션 인스턴스로 전달할 수 있다.
기존의 메모리 기반 세션과 Token 저장 방식을 그대로 사용할 수 있기 때문에 애플리케이션 코드의 변경이 적고 별도의 공유 저장소도 필요하지 않다.
하지만 해당 인스턴스가 종료되면 그 인스턴스의 메모리에 저장되어 있던 로그인 세션과 OAuth Token 정보도 함께 사라진다. 따라서 배포나 오토스케일링으로 인스턴스가 자주 교체되는 환경에서는 Sticky Session만으로 로그인 상태를 안정적으로 유지하기 어렵고, 인스턴스가 사라졌을 때 상태를 어떻게 복구할지 별도로 설계해야 한다.
### 3. 브라우저가 token을 들고 API를 직접 부른다
서버에 로그인 세션이나 OAuth Token 상태를 저장하지 않는 구조로 바꾸면, 여러 인스턴스가 공유해야 할 상태 자체가 없어지므로 별도의 공유 저장소나 Session Affinity가 필요하지 않다.
Resource Server는 각 요청에 포함된 Access Token을 검증하여 요청을 처리한다.
SPA처럼 브라우저가 OAuth Token을 직접 보관하고 API 요청에 사용하는 구조가 여기에 해당한다.
다만 브라우저에 OAuth Token을 노출하지 않아야 한다면 이 선택지는 제외한다.
### 4. 층이 다른 선택지 — 최소 정보만 담은 client-side cookie
이 방식은 기존 세션이나 Token 저장소를 다른 저장소로 교체하는 방법이 아니다.
서버에 인증 상태를 저장하는 구조 자체를 없애고, 필요한 인증 상태를 쿠키에 담아 전달하는 방식으로 변경하는 것이다.
따라서 공유 저장소나 Sticky Session처럼 기존 서버 상태를 어떻게 유지할지 결정하는 방법과 같이 비교하긴 어렵다.
Forward-Auth 구조로 전환하면 애플리케이션이 OAuth Token을 서버에 직접 저장하고 관리할 필요가 없어진다.
대신 여러 인스턴스가 동일한 인증 쿠키를 처리할 수 있도록 Cookie Secret을 공유해야 한다.
또한 인증 프록시가 전달하는 사용자 정보를 애플리케이션이 신뢰하게 되므로, 외부 요청이 해당 헤더를 위조할 수 없도록 네트워크 접근 경로와 전달 헤더를 함께 관리해야 한다.
## 다음 검증
인스턴스를 둘로 띄우고 순서대로 확인한다.
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 유지되는지 본다.
2. 한 인스턴스를 재시작하고 같은 session cookie로 로그인 상태가 남는지 본다.
3. 같은 사용자로 두 브라우저에서 로그인해 authorized client 항목이 서로를 덮어쓰는지 본다.
4. 한쪽에서 logout한 뒤 다른 쪽 요청이 어떻게 되는지 본다.
5. session 만료를 token 만료보다 짧게, 다시 길게 두고 각 경우의 응답과 화면을 기록한다.
여기서 확인한 결과로 선택지를 좁힌다.
@@ -0,0 +1,105 @@
---
id: 9ae4ec71-a32e-49a7-88c2-f7368541c28d
kind: QUESTION
slug: refresh-rotation-replica-contention
title: Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 33
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/9ae4ec71-a32e-49a7-88c2-f7368541c28d/edit"
public: "https://hyeonworks.com/questions/refresh-rotation-replica-contention"
---
# Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
realm이 refresh token rotation과 재사용 허용 0회를 쓰고 있다.
두 replica가 같은 refresh token으로 동시에 갱신할 수 있고, 그때 두 번째 사용이 거부될 가능성이 있다.
실제 Keycloak 응답과 session 영향은 아직 재현해 보지 않았다.
## 관계
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
저장소 결정이 이 질문보다 앞선다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
rotation과 재사용 0회를 쓰는 구성의 출처다.
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
다중 인스턴스 운영이 이 경쟁의 전제다.
- **BFF 인증 구조 설계 기준**
갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준의 항목이다.
## 사실
- realm은 refresh token rotation과 재사용 허용 0회를 쓴다. 한 번 갱신하면 이전 refresh token은 바로 무효가 된다.
- 커밋된 테스트는 새 refresh token 발급과 이전 token 거부, revocation 뒤 refresh 실패를 확인한다.
- authorized client manager에는 refresh-token provider가 구성되어 있어 access token 만료 시 refresh를 시도할 수 있다.
- 만료를 기다려 실제 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다.
- 현재 authorized client 저장소는 process-local이라 replica가 같은 refresh token 상태를 공유하지 않는다.
따라서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.
- 이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안은 화면이 정상으로 보이게 된다.
## 가정
- 운영에서는 replica가 둘 이상이고 저장소를 공유해 같은 authorized client 항목을 보게 된다.
- 두 replica가 비슷한 시각에 만료를 만나면 각각 갱신을 시도하게 된다.
## 미지수
- 같은 refresh token으로 두 replica가 동시에 갱신하면 각 replica가 어떻게 동작하게 되는지.
- 재사용 허용 0회에서 요청이 사용자 화면에 어떻게 보이게 되는지.
로그인 만료로 보이는가 일시적 오류로 보이는지.
- 저장소에서 새 token을 다시 읽어 재시도하면 성공하게 되는지, 아니면 재인증이 필요해지는지.
- 갱신을 한 곳에서만 할 것인지, 각자 하게 두고 실패는 재시도로 처리할 것인지.
- lock을 쓴다면 어디에 두고 얼마나 잡을지. 잡은 채로 프로세스가 내려가면 어떻게 푸는지.
- 갱신 실패를 로그인 만료와 구분해서 표시할 수 있는지.
## 제약
- rotation과 재사용 0회는 이미 realm에 설정한 상태다. 이 전제는 바꾸지 않고 답한다.
- 이미 발급된 access token은 만료 전까지 사용할 수 있으므로 refresh 실패는 즉시 보이지 않을 수 있다.
재현 테스트는 access token 만료 직후에 맞춰 실행한다.
- 이 경쟁은 저장소를 공유한 뒤에야 재현되므로 저장소를 정한 다음에 이어서 푼다.
## 선택지
### 1. 분산 lock으로 갱신을 직렬화한다
한 replica만 갱신하고 나머지는 끝나기를 기다렸다가 결과를 읽는 구성이다. 같은 refresh token을 두 replica가 동시에 쓰는 상황 자체를 만들지 않는 것이 목표다.
분산 lock을 사용하면 refresh 구간을 직렬화할 수 있다.
lock 저장소의 가용성, lock 만료, 재진입, lock 보유 process 종료 상황까지 함께 처리해야 한다.
### 2. 각자 갱신하고 실패는 재시도로 처리한다
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는 전제인데,
이 재시도가 성립하는지는 확인이 필요하다.
reuse detection 정책에 따라 같은 refresh token의 두 번째 사용이 token family 전체에 영향을 줄 수 있다.
이 경우 단순 retry로 끝나지 않고 재인증이 필요할 수 있다.
갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 다시 조회하는 순서도 별도로 확인해야 한다.
### 3. 갱신 전용 경로를 하나 둔다
refresh를 전담하는 구성요소 하나만 refresh token을 사용하고 다른 replica는 갱신 결과를 조회하도록 구성할 수 있다.
refresh를 전담하는 구성요소가 중단되면 access token 만료 이후 갱신을 수행할 주체가 없어지므로 해당 구성요소의 가용성과 복구 방식이 중요해진다.
### 4. 제약상 제외 — 재사용 허용을 늘린다
재사용을 짧게 허용하면 두 번째 사용이 거부되지 않고 코드도 고치지 않는다.
다만 rotation과 재사용 0회는 이미 realm에 설정한 상태이고,
훔친 refresh token을 그 시간 안에 쓸 수 있다는 문제도 남는다.
## 다음 검증
저장소를 공유한 뒤에 재현한다.
1. replica 두 대에서 같은 사용자로 access token 만료 직후 동시에 요청을 보낸다.
2. 이긴 쪽과 지는 쪽의 응답을 각각 기록한다.
3. 지는 쪽이 저장된 새 token으로 재시도해 성공하는지 본다.
4. 지는 쪽 사용자 화면에 무엇이 보이는지 기록한다.
5. lock을 넣은 구성과 안 넣은 구성을 같은 입력으로 비교해 실패율과 지연을 잰다.
실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
rotation과 재사용 0회를 바꾸는 선택지는 지금은 제외로 두고 나중에 다시 본다.
@@ -0,0 +1,111 @@
---
id: 39fdf472-82c4-43ed-abec-73de672f08ae
kind: REFERENCE
slug: authorization-code-endpoint-credential-movement
title: Authorization Code Flow의 Endpoint와 Credential 이동 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 34
verifiedOn: 2026-08-25
studio: "https://hyeonworks.com/studio/documents/39fdf472-82c4-43ed-abec-73de672f08ae/edit"
public: "https://hyeonworks.com/references/authorization-code-endpoint-credential-movement"
---
# Authorization Code Flow의 Endpoint와 Credential 이동 기준
Authorization Endpoint에서 Redirect, Token Endpoint, JWK 검증, Resource API까지 각 지점에서 무엇이 이동하고 무엇이 이동하지 않는지 확인한다. 먼저 client_secret이 가는 곳과 가지 않는 곳을 나눈다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code를 직접 교환하는 흐름에서 endpoint별 이동을 관측했다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
confidential client가 token endpoint에서 자기 client를 인증하는 실례다.
- **Public Client와 Confidential Client 구분 기준**
client 종류가 정해져야 PKCE와 client 인증의 자리가 정해진다.
## 목적
Authorization Endpoint와 Token Endpoint는 역할과 호출 방식이 다르다.
이 구분을 해야 SPA에서 client_secret이 어디로 갔는지, PKCE가 어느 구간을 지키는지 이해하기 쉽다.
하나는 브라우저의 full-page navigation이고 하나는 server-to-server 호출이 될 수도 있고 browser-to-server 호출이 될 수도 있다.
노출되는 것도, 인증하는 방법도 다르다.
Authorization Endpoint
경로 : 브라우저 주소창 남는 곳 : 히스토리·서버 로그·referrer client 인증 : x
Token Endpoint
경로 : body와 Authorization 헤더 보내는 쪽 : client 종류에 따라 server 또는 브라우저 client 인증 : o
## 규칙
### 1. Authorization Endpoint에는 client_secret을 보내지 않는다
이 요청은 브라우저 주소창을 통해 나간다. 그래서 URL이 주소창에도, 브라우저 히스토리에도, 서버 접근 로그에도, 그리고 링크를 타고 온 경우 referrer에도 남는다. 여기 실리는 값은 client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method다. secret이 필요한 인증은 아직 하지 않는다.
반대로 말하면 이 목록에 없는 값을 여기 넣으면 그 값도 같은 곳에 다 남는다.
### 2. Token Endpoint에서 비로소 client를 인증한다
code를 access token으로 바꾸는 요청은 credential을 URL query가 아니라 body와 Authorization 헤더에 싣는다. 그래서 client 인증을 여기서 한다. confidential client는 client_secret_basic처럼 secret을 함께 보낸다.
주소창과 히스토리에 남지 않는다는 뜻이지 어디에도 기록되지 않는다는 뜻은 아니다. 애플리케이션 debug 로그, reverse proxy 로그, tracing과 APM, packet capture에 남을 수 있어서 credential masking을 따로 둔다.
이 요청을 누가 보내는지는 client 종류에 따라 갈린다. server가 보내면 server-to-server이고, secret이 없는 SPA가 보내면 브라우저가 직접 보낸다. token endpoint를 server 안에서만 부르게 하려면 client 종류부터 confidential로 정해야 한다.
### 3. PKCE는 두 요청을 같은 주체에 묶는다
처음 요청에 code_challenge를 담아 보내고, 교환할 때 원본인 code_verifier를 보낸다.
Authorization Server가 이 둘이 대응하는지 확인하고, 대응해야 토큰 교환이 끝난다.
code를 누가 훔쳐 가도 verifier가 없으면 token으로 바꾸지 못한다.
### 4. issuer 검증값과 JWK 조회 주소를 같은 값으로 맞추려 하지 않는다
issuer는 요청을 보내는 주소가 아니라 token의 canonical issuer identifier다. 검증은 발급된 token의 iss claim이 그 값과 같은지를 본다.
JWK 조회 주소는 실제로 공개키를 가져오는 network 경로다. 이 예제에서는 브라우저가 보는 주소와 컨테이너 안에서 닿는 주소가 다르다. 컨테이너 안에서는 자기 localhost가 그 서버가 아니므로 service 이름을 써야 하고, 브라우저는 그 이름에 닿지 못한다.
issuer 검증값과 endpoint 연결 주소는 따로 구성한다. 둘을 하나로 맞추려 하면 로그인 redirect가 깨지거나 서버가 키를 못 가져온다.
### 5. Resource API는 서명만 보고 끝내지 않는다
서명이 맞다는 것은 그 IdP가 발급했다는 뜻일 뿐이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다.
그래서 issuer와 유효 시간, 그리고 이 API를 위해 발급됐다는 audience를 함께 본다. audience 검증이 빠지면 옆 서비스의 token으로 우리 API가 열린다.
### 6. redirect_uri는 exact match로 좁힌다
wildcard allowlist는 학습 환경에서 편하다. 다만 허용 범위가 넓으면 같은 호스트의 다른 경로로도 code가 갈 수 있다.
실제로 쓰는 callback 주소만 등록해 두면 code가 도착할 수 있는 곳이 그 하나로 줄어든다.
등록하지 않은 redirect_uri를 보냈을 때 거부하는지 확인하는 검사도 따로 둔다.
### 7. 로그인 구간과 API 호출 구간을 한 줄로 그리지 않는다
로그인 구간은 authorization request에서 시작해 callback과 code 교환을 지나 로그인 상태를 만드는 데까지다. API 호출 구간은 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답이다.
두 구간을 한 줄로 이어 그리면 누가 code를 바꾸고 누가 API를 부르는지가 겹쳐 보인다. 네 구조가 갈리는 자리가 여기라서 나눠서 그린다.
## 적용 조건
- Authorization Code Flow를 쓰는 client를 설정하거나 문서로 설명할 때
- 브라우저 요청과 server-to-server 요청이 한 흐름에 섞여 있을 때
- endpoint별로 무엇이 노출되는지 나눠야 할 때
- PKCE와 client 인증의 자리를 정할 때
## 예외
- Client Credentials처럼 사용자 없이 token을 받는 흐름은 Authorization Endpoint를 지나지 않는다.
- Device Authorization Grant는 브라우저 redirect 대신 별도의 사용자 code 단계를 쓴다. redirect_uri 항목이 그대로 적용되지 않는다.
## 예시
- authorization request에는 code_challenge_method=S256이 있고 client secret은 없다
- token request에는 code_verifier가 있다. secret을 가진 client는 이 요청에서 자기를 인증한다
- expected issuer는 http://localhost:8080/realms/keycloak-patterns 이고 JWK 조회는 컨테이너 network 주소를 쓴다
- audience에 keycloak-pattern-api 가 없으면 invalid_token 결과가 되어 401이 된다
- redirect allowlist에 wildcard가 있으면 등록한 host의 다른 경로로도 code가 갈 수 있다
@@ -0,0 +1,123 @@
---
id: 97eddd97-1096-426a-a2c6-a6c5bf1cd09f
kind: REFERENCE
slug: bff-authentication-design-criteria
title: BFF 인증 구조 설계 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 21
verifiedOn: 2026-08-30
studio: "https://hyeonworks.com/studio/documents/97eddd97-1096-426a-a2c6-a6c5bf1cd09f/edit"
public: "https://hyeonworks.com/references/bff-authentication-design-criteria"
---
# BFF 인증 구조 설계 기준
BFF 구조에서는 OAuth Token을 서버에서 관리하고, 브라우저는 Token 대신 Session Cookie를 사용해 BFF에 요청한다.
Cookie를 이용한 요청을 보호하기 위한 CSRF 검증, OAuth Token을 보관할 Authorized Client 저장소, 로그아웃할 때 Session과 Token을 함께 정리하는 방법, 그리고 BFF가 호출한 Resource Server에서 오류가 발생했을 때 이를 브라우저에 어떻게 전달할지를 같이 설계해야 한다.
## 관계
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
이 기준의 항목 중 실제로 구현된 것과 비어 있는 것을 센 기록이다.
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
저장소 항목이 아직 답이 없는 질문으로 남아 있다.
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
어느 저장소에 둘지가 이 기준의 미결 항목이다.
- **BFF가 OAuth Token을 관리하는 조건**
이 결정이 PROPOSED인 동안 실제 적용 기준은 이 문서다.
## 목적
BFF 구조에서는 BFF가 authorization code를 token으로 교환하고, access token을 사용해 Resource Server를 호출한다.
따라서 session과 authorized client를 함께 관리해야 한다.
cookie가 credential이 되면 브라우저가 요청마다 자동으로 붙여 보낸다. 그래서 값을 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다. 재시작과 replica 이동을 견딜 저장소도 같이 필요하다.
## 규칙
### 1. 브라우저에는 OAuth token을 전달하지 않는다
Access Token과 Refresh Token은 BFF 서버의 Authorized Client에 보관한다.
브라우저는 OAuth Token을 직접 사용하지 않고 Session Cookie를 이용해 BFF에 요청한다.
BFF는 이 Session을 확인한 뒤, 저장해 둔 Access Token으로 `Authorization: Bearer ...` 헤더를 새로 만들어 Resource Server를 호출한다. 브라우저가 보낸 Session Cookie는 Resource Server로 전달되지 않는다. Session Cookie는 브라우저와 BFF 사이의 Credential이고, Access Token은 BFF와 Resource Server 사이의 Credential이다.
### 2. cookie가 credential이면 상태 변경 요청에 CSRF 검증을 둔다
BFF 구조에서는 브라우저가 요청할 때 Session Cookie를 자동으로 전송한다.
그래서 데이터 생성, 수정, 삭제처럼 서버의 상태를 변경하는 요청에는 해당 요청이 실제 사용자의 의도에 의해 만들어졌는지 확인하기 위한 CSRF 검증이 필요하다.
현재 구성에서는 서버가 CSRF Token을 Cookie로 전달하고, JavaScript가 그 값을 읽어 요청 Header에 다시 담아 보낸다.
서버는 Cookie와 Header를 함께 확인해 요청을 검증한다.
이때 화면이나 응답 본문에 표시되는 CSRF Token과 실제 요청 Header에 넣어야 하는 값이 항상 같다고 생각하면 안 된다.
응답 본문에 노출된 값이 별도의 처리를 거친 값이라면, 클라이언트는 실제 CSRF Cookie에서 값을 읽어 Header에 넣어야 한다.
잘못된 값을 보내면 정상적인 요청이라도 CSRF 검증에 실패해 `403 Forbidden` 응답을 받게 된다.
`SameSite`와 CSRF Token도 서로 다른 역할을 한다.
`SameSite`는 브라우저가 Cross-Site 요청에 Cookie를 전송할지 제한하는 정책이고, CSRF Token은 Cookie가 포함되어 들어온 상태 변경 요청이 정상적인 클라이언트에서 만들어졌는지를 확인하기 위한 값이다.
또한 SameSite는 Origin이 아니라 Site를 기준으로 판단하므로, Origin은 다르지만 같은 Site에 속하는 요청도 존재할 수 있다.
### 3. session과 authorized client의 수명주기를 따로 설계한다
Application Session과 Authorized Client는 서로 다른 값을 저장하고 조회한다.
Session은 `session ID`를 기준으로 조회하지만, Authorized Client는 `client registration 이름``principal name`을 기준으로 조회한다.
따라서 여러 인스턴스에서 상태를 공유하기 위해 Shared Store를 도입할 때도 Session 저장소와 Authorized Client 저장소를 각각 어떻게 구성할지 확인해야 한다.
특히 Authorized Client의 조회 기준에는 `session ID`가 포함되지 않는다.
그래서 같은 사용자가 두 브라우저에서 동일한 Client로 로그인하면 두 Session이 같은 Authorized Client 정보를 사용하거나, 나중에 로그인하면서 저장된 Token 정보가 갱신될 수 있다.
브라우저나 Session마다 서로 다른 Token을 유지해야 한다면 `session ID`까지 포함해 Token을 구분할 수 있도록 별도의 저장 구조를 설계해야 한다.
운영 환경에서는 서버가 재시작되거나 요청이 다른 Replica로 전달되더라도 로그인 상태와 Token을 계속 사용할 수 있는지도 고려해야 한다. 이를 위해 Session과 Authorized Client를 공유 저장소에 보관할지, Session Affinity를 사용할지 등을 결정해야 한다.
Token을 외부 저장소에 보관한다면 Access Token과 Refresh Token을 어떻게 보호할지도 정해야 하며, 저장 시 암호화한다면 암호화 Key의 보관 위치와 교체 방법까지 함께 설계해야 한다.
Logout에서도 두 상태를 각각 정리해야 한다.
Application Session을 삭제하는 것만으로 Authorized Client에 저장된 OAuth Token까지 자동으로 삭제된다고 생각하면 안 된다.
Session과 Authorized Client는 조회 기준과 저장소가 다르므로, Logout 시 Session과 Authorized Client가 모두 제거되는지 각각 확인해야 한다.
### 4. Downstream 오류를 클라이언트 응답으로 변환한다
BFF가 Resource Server의 오류를 그대로 브라우저에 전달하면 화면에서는 오류의 원인을 일관되게 판단하기 힘들다.
예를 들어 Resource Server에서 `401 Unauthorized`가 발생했다면 Access Token이 만료되었거나 더 이상 유효하지 않은 상황인지 확인하고, 필요한 경우 Token 갱신이나 재로그인으로 연결해야 한다.
하지만 인증은 정상적으로 되었지만 해당 기능을 사용할 권한이 없어 `403 Forbidden`이 발생한 경우에는 권한 부족으로 처리해야 한다.
Resource Server가 응답하지 않거나 처리가 지연되는 경우도 별도의 규칙이 필요하다.
요청을 얼마 동안 기다릴지 Timeout을 정하고, 실패한 요청을 다시 시도할 수 있는 경우에는 Retry 정책을 적용한다.
반복적으로 장애가 발생하는 Resource Server에 계속 요청을 보내지 않도록 Circuit Breaker를 적용할지도 함께 결정한다.
모든 UI 요청이 BFF를 거치는 구조라면 이러한 오류 처리 규칙도 BFF에서 일관되게 적용하는 것이 좋다.
그렇지 않으면 같은 종류의 오류를 화면마다 서로 다른 방식으로 판단하고 처리하게 될 수 있다.
### 5. BFF에서도 XSS 방어는 별도로 필요하다
BFF 구조에서는 Access Token과 Refresh Token을 서버에 보관하므로 브라우저의 JavaScript가 OAuth Token 원문에 직접 접근하지 않도록 할 수 있다. 하지만 이것이 브라우저에서 실행되는 악성 JavaScript까지 막아 주는 것은 아니다.
같은 Origin에서 악성 Script가 실행되면 사용자의 Session을 이용해 BFF Endpoint를 호출할 수 있다.
현재처럼 JavaScript가 CSRF Cookie를 읽어 Header에 넣는 구조라면 악성 Script 역시 같은 방식으로 CSRF Token을 읽어 요청을 만들 수 있다.
BFF에서는 OAuth Token 원문이 브라우저 JavaScript에 직접 노출되지 않지만, XSS 자체를 방지하기 위한 CSP, Output Encoding 등의 보호 조치와 외부 Script 및 의존성을 안전하게 관리하는 방법은 별도로 적용해야 한다.
또한 악성 Script가 사용자의 Session을 이용해 BFF를 호출하더라도 허용된 작업만 수행할 수 있도록 애플리케이션의 인가 역시 각 요청에서 검증해야 한다.
## 적용 조건
- 브라우저가 OAuth token을 받아서는 안 될 때
- backend가 화면에 맞춰 여러 API를 조합해야 할 때
- 로그인 상태를 애플리케이션이 소유해야 할 때
- downstream API가 늘어나도 브라우저는 하나만 알게 하고 싶을 때
## 예외
- stateless 직접 API 호출과 독립 client가 핵심이면 BFF 구조로 설계하지 않는다.
server state와 단일 장애 지점만 늘어난다.
- 브라우저의 직접 API 호출을 남겨야 하면 refresh credential만 서버로 분리하는 구조가 맞다.
- server state를 둘 수 없는 환경이면 브라우저가 token을 직접 다루는 구조가 더 단순하다.
## 예시
- 브라우저 요청에는 Authorization 헤더가 없고 session cookie만 있다
- BFF가 authorized client에서 access token을 읽어 downstream Bearer 요청을 새로 만든다
- CSRF 헤더가 없는 POST는 403이 되고 cookie의 raw 값을 헤더에 넣은 POST는 200이 된다
- 응답 본문의 token은 가려진 값이고 헤더에 넣는 값은 cookie의 raw 값이다
@@ -0,0 +1,131 @@
---
id: 004dd0a2-5fb3-4f25-80c9-576f709de331
kind: REFERENCE
slug: forward-auth-identity-header-trust
title: Forward-Auth에서 Identity Header를 신뢰하기 위한 조건
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 29
verifiedOn: 2026-08-30
studio: "https://hyeonworks.com/studio/documents/004dd0a2-5fb3-4f25-80c9-576f709de331/edit"
public: "https://hyeonworks.com/references/forward-auth-identity-header-trust"
---
# Forward-Auth에서 Identity Header를 신뢰하기 위한 조건
애플리케이션이 프록시가 전달한 사용자 정보 헤더만으로 사용자를 판단하는 구조에서는, 해당 헤더가 실제로 신뢰할 수 있는 프록시에서 전달되었다는 것을 보장해야 한다.
이를 위해 외부 사용자가 애플리케이션에 직접 접근하지 못하도록 네트워크 경로를 제한하고,
사용자가 같은 이름의 헤더를 임의로 보내더라도 프록시가 이를 제거하거나 올바른 값으로 덮어써야 한다.
또한 필요한 경우 프록시에서 전달된 요청임을 확인할 수 있는 내부용 Credential도 함께 검증한다.
세 가지는 각각 다른 구간을 막으므로 하나만 적용하지 않고 같이 구성한다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
앞에서 정리한 다섯 가지 조건이 실제 설정에 적용되어 있는지 확인한 결과.
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
헤더를 어디까지 늘릴지가 이 기준의 미결 항목이다.
- **OAuth Token과 Application Session을 구분하는 기준**
identity 헤더를 JWT나 session과 같은 이름으로 부르지 않는다.
## 목적
외부 요청이 인증 프록시를 거쳐 애플리케이션으로 전달되는 구조에서는, 애플리케이션이 프록시가 추가한 사용자 정보 헤더를 기준으로 로그인한 사용자를 판단할 수 있다.
문제는 같은 이름의 헤더를 외부 사용자가 직접 만들어서 보낼 수도 있다는 점이다.
애플리케이션 입장에서는 전달받은 헤더만 보고 이것이 인증을 완료한 프록시가 추가한 값인지, 외부 사용자가 임의로 넣은 값인지 구분할 수 없다.
그래서 사용자 정보 헤더를 인증 근거로 사용하려면 먼저 외부 요청이 반드시 인증 프록시를 거쳐서만 애플리케이션에 도달하도록 구성해야 한다. 또한 애플리케이션이 받은 요청과 헤더가 신뢰할 수 있는 프록시를 통해 전달된 것인지 확인할 수 있는 방법도 같이 생각해야 한다.
## 규칙
### 1. 외부에서 애플리케이션과 인증 프록시에 직접 접근하지 못하게 한다
외부에서는 Edge에만 접근할 수 있도록 하고, 애플리케이션과 인증 프록시는 내부 네트워크에만 두어 Host Port로 직접 노출하지 않는다.
애플리케이션이 외부에 직접 노출되어 있으면 공격자가 Edge의 인증 과정을 거치지 않고 애플리케이션으로 요청을 보낼 수 있다.
이 경우 공격자가 사용자 정보 헤더까지 직접 만들어 보낼 수 있으므로, 애플리케이션은 해당 헤더가 인증을 거쳐 생성된 값인지 신뢰할 수 없게 된다.
그래서 사용자 정보 헤더를 인증 근거로 사용하려면 먼저 모든 외부 요청이 반드시 Edge를 거치도록 네트워크 경로부터 제한해야 한다.
### 2. client가 보낸 헤더를 항상 덮어쓴다
사용자 정보 헤더는 외부 요청에 들어 있던 값과 합치지 않고, 인증 프록시가 확인한 값으로 기존 헤더를 제거하거나 덮어쓴 뒤 애플리케이션에 전달한다.
기존 헤더와 인증 결과를 합쳐서 전달하면 공격자가 넣은 값과 프록시가 추가한 값이 하나의 헤더에 함께 포함될 수 있다.
이때 애플리케이션이 어떤 값을 사용자 정보로 사용할지는 헤더 처리 방식에 따라 달라질 수 있으므로, 인증된 값만 전달되도록 해야 한다.
또한 신뢰할 수 있는 프록시의 범위도 필요한 대상만 포함하도록 제한한다.
이 범위를 너무 넓게 설정하면 같은 내부 네트워크에 있는 다른 서비스가 신뢰받는 프록시처럼 요청을 보낼 수 있다.
특히 `Forwarded``X-Forwarded-*` 헤더를 신뢰하는 구조에서는 어떤 프록시의 요청까지 신뢰할지를 먼저 좁혀 둔다.
### 3. auth endpoint는 subrequest 전용으로 둔다
이 Endpoint는 외부 사용자가 직접 호출하는 API가 아니라, 인증 과정에서 Proxy가 내부적으로 호출하기 위한 Endpoint다.
따라서 외부 요청으로는 접근할 수 없게 하고 Proxy가 생성한 내부 요청만 허용해야 한다.
Nginx에서는 해당 Location에 `internal`을 설정해 외부에서 직접 호출하는 것을 차단할 수 있다.
### 4. upstream이 헤더 존재만 보지 않는다
요청이 신뢰할 수 있는 Proxy에서 전달된 것인지 확인하기 위해, 배포할 때 설정한 내부용 Credential과 요청에 포함된 Credential을 비교한다. 이때 Credential 값의 일부가 얼마나 일치하는지에 따라 비교 시간이 크게 달라지지 않는 안전한 비교 방식을 사용한다.
이 검증을 각 Controller에서 개별적으로 처리하면 새로운 Endpoint를 추가할 때 검증 로직을 빠뜨릴 수 있기 때문에 운영 환경에서는 `Filter`, `Interceptor`, `Security Chain`과 같은 공통 처리 지점에서 모든 대상 요청에 동일한 검증이 적용되도록 구성해야 한다.
### 5. Network 격리와 헤더 검증을 모두 적용한다
네트워크 격리는 외부 사용자가 인증 경로를 우회해 애플리케이션에 직접 접근하는 것을 막는다.
헤더 검증은 내부 네트워크에서 전달된 요청이라도 사용자 정보 헤더가 신뢰할 수 있는 값인지 확인한다.
두 방식은 보호하는 구간과 대상이 다르므로 둘 다 구성한다.
### 6. 인증된 사용자 정보 헤더만 전달한다
인증 프록시가 애플리케이션으로 전달할 사용자 정보 헤더를 미리 정해 두고, 허용하지 않은 헤더는 전달하지 않는다.
새로운 헤더를 추가할 때는 해당 값이 어떤 Claim에서 만들어지는지, 여러 값이 있을 때 어떤 형식으로 전달할지, 특수 문자를 어떻게 처리할지, 허용할 최대 크기는 얼마인지, 애플리케이션에서는 그 값을 어떻게 검증하고 사용할지를 함께 정해야 한다.
현재처럼 사용자 이름과 이메일만 전달하는 구조에서는 로그인한 사용자가 누구인지는 알 수 있지만, 해당 사용자가 어떤 권한을 가지고 있는지까지 알 수는 없다.
Role을 이용해 인가까지 처리하려면 Role 정보를 어떤 방식으로 전달할지뿐만 아니라, 사용자의 Role이 변경되었을 때 기존 Proxy Session과 애플리케이션의 인가 결과에 언제 반영할지도 별도로 정해야 한다.
### 7. 요청 성공 여부가 아니라 전달된 사용자 정보를 확인한다
정상적으로 로그인된 세션에서 사용자 정보 헤더만 위조해 요청했다면, 세션 자체는 유효하므로 요청이 `200 OK`로 처리되는 것은 정상이다.
테스트에서 확인해야 하는 것은 요청의 성공이나 실패가 아니라 애플리케이션이 어떤 사용자를 인증된 사용자로 인식했는지다.
공격자가 임의로 넣은 사용자 정보가 아니라, 인증 프록시가 확인한 실제 사용자 정보가 사용되어야 한다.
응답 코드만으로는 알 수 없으므로 실제 응답에 사용된 사용자 정보까지 확인한다.
### 8. 지금 확인한 것과 운영에서 더 필요한 것을 나눠 적는다
현재 테스트 환경에서는 외부에서 애플리케이션으로 직접 접근할 수 없는지,
외부 사용자가 넣은 사용자 정보 헤더를 인증된 값으로 덮어쓰는지,
인증 Endpoint를 내부 요청으로만 호출할 수 있는지,
그리고 애플리케이션이 내부 Credential을 검증하는지까지 확인했다.
다만 실제 운영 환경에서는 추가적인 보안 구성이 필요하다.
내부 Credential과 같은 Secret은 Secret Manager 등을 통해 안전하게 주입하고 주기적으로 교체할 수 있어야 한다.
또한 Network Policy 등을 이용해 모든 요청이 정해진 인증 경로를 거치도록 제한해야 한다.
더 강한 서비스 간 인증이 필요하다면 mTLS나 Workload Identity를 적용하는 방법도 고려할 수 있다.
## 적용 조건
- upstream에 OAuth client나 JWT 검증 코드를 넣기 어려울 때
- 여러 legacy service 앞에 같은 로그인 정책을 둘 때
- edge에서 정책을 강제할 수 있을 때
- 이미 forward-auth를 쓰고 있는 구조를 점검할 때
## 예외
- backend 직접 경로나 헤더 덮어쓰기를 닫을 수 없는 환경이면 이 구조를 쓰지 않는다.
- 애플리케이션이 사용자별 API 조합과 세밀한 인가를 직접 맡아야 하면 BFF 구조를 검토한다.
- 임의 경로와 body, streaming을 그대로 넘기는 범용 reverse proxy가 필요하면 URI rewrite와 timeout, 응답 헤더 처리를 따로 설계해야 한다.
## 예시
- 외부에는 edge만 공개하고 app과 auth proxy의 port는 host에 publish하지 않는다
- 정상 session에 위조 헤더를 얹은 요청은 200을 받지만 응답의 사용자는 실제 사용자다
- 외부에서 auth endpoint를 직접 부르면 404가 된다
- upstream은 user 헤더와 internal token을 같이 확인하고 하나라도 틀리면 401을 반환한다
- 내부 검사가 특정 controller에만 있으면 새 endpoint에는 보호되지 않는다
@@ -0,0 +1,98 @@
---
id: 1a00a640-8987-4075-a9e4-7ec023cdffbb
kind: REFERENCE
slug: external-idp-federation-application-boundary
title: 외부 IdP 연동과 Application 인증 구조의 경계
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 27
verifiedOn: 2026-08-30
studio: "https://hyeonworks.com/studio/documents/1a00a640-8987-4075-a9e4-7ec023cdffbb/edit"
public: "https://hyeonworks.com/references/external-idp-federation-application-boundary"
---
# 외부 IdP 연동과 Application 인증 구조의 경계
Google 로그인을 추가한다고 해서 새로운 다섯 번째 인증 구조가 생기는 것은 아니다.
사용자가 Google에서 인증을 마치면 Keycloak이 그 인증 결과를 받아 사용자를 확인하고, 애플리케이션에는 자신의 Authorization Code를 발급한다.
그 이후의 흐름은 기존과 같다. 애플리케이션은 여전히 Keycloak을 기준으로 인증을 처리하고, Token을 브라우저에서 관리할지 서버에서 관리할지에 따라 앞에서 구분한 네 가지 구조 중 하나를 사용한다.
## 관계
- **외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.**
이 기준을 프로젝트 결정으로 굳힌 기록이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브로커가 만든 authorization code를 애플리케이션이 받는 흐름이다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
외부 IdP가 있어도 애플리케이션 쪽 endpoint 이동은 그대로다.
## 목적
Google은 Keycloak 앞에서 실제 사용자 인증을 담당하는 외부 IdP다.
사용자가 Keycloak 로그인 화면에서 Google을 선택하면 브라우저는 Google로 이동해 인증을 진행한다.
인증이 완료되면 Keycloak이 그 결과를 확인하고 자신의 사용자 정보와 연결한 뒤, 애플리케이션에는 Keycloak이 발급한 Authorization Code를 전달한다.
따라서 Google과 같은 외부 IdP를 추가하더라도 애플리케이션의 인증 구조가 달라지는 것은 아니다.
Token을 브라우저가 직접 받을지 서버에서 관리할지, 그리고 브라우저와 서버 중 어느 계층이 Resource Server의 API를 호출할지는 기존 SPA, Mediator, BFF, OAuth2-Proxy 구조 중 어떤 방식을 선택했는지에 따라 결정된다.
## 규칙
### 1. 외부 IdP 인증과 애플리케이션 인증 구조를 나눈다
외부 IdP는 Keycloak 앞에서 사용자 인증을 담당한다. 애플리케이션이 선택하는 SPA, Mediator, BFF, OAuth2-Proxy 구조는 Keycloak에서 인증이 끝난 이후 Token과 API 호출을 어떻게 처리할지를 정한다.
Google에서 인증이 완료되면 그 결과는 먼저 Keycloak이 검증한다.
이후 애플리케이션은 Google이 아니라 Keycloak이 발급한 Authorization Code와 Token을 사용한다.
Resource Server 역시 Keycloak이 발급한 Token을 검증한다.
따라서 Google 로그인을 추가하더라도 애플리케이션의 OAuth 처리 방식은 기존 SPA, Mediator, BFF, OAuth2-Proxy 구조를 그대로 따른다.
로그인 화면에서 Google이나 다른 Provider를 선택하게 하거나, Provider별 계정을 Keycloak 사용자와 어떻게 연결할지를 별도로 처리하는 것은 자연스럽다.
하지만 Resource Server가 Google과 Keycloak의 Token을 각각 다르게 검증하거나, 애플리케이션의 인가 로직이 로그인에 사용한 Provider에 따라 달라지기 시작한다면 외부 IdP와 애플리케이션 사이를 분리하던 Keycloak의 역할이 제대로 유지되고 있는지 확인할 필요가 있다.
### 2. 외부 계정은 provider와 `subject` 조합으로 식별한다
이메일 주소는 변경될 수 있고 다른 계정과 중복될 가능성도 있기 때문에 외부 계정을 식별하고 연결하는 기준으로 사용하기에는 적절하지 않다.
대신 어떤 Provider에서 인증했는지와 해당 Provider가 사용자에게 부여한 고유 식별자(`subject`)를 함께 사용해 외부 계정을 식별한다.
예를 들어 Google 사용자는 `Google + subject`의 조합으로 구분한다.
이메일만을 기준으로 계정을 연결하면 사용자가 이메일 주소를 변경했을 때 기존 계정과의 연결을 찾지 못하거나, 동일한 이메일을 가진 다른 계정을 잘못 연결할 수 있다.
### 3. email 충돌은 별도의 계정 연결 문제로 다룬다
외부 IdP에서 전달받은 이메일 주소가 기존 계정의 이메일과 같더라도 두 계정을 자동으로 연결하지 않는다.
이메일이 같다는 사실만으로 두 계정이 같은 사용자의 것이라고 확신할 수 없기 때문이다.
계정을 연결해야 한다면 기존 계정으로 다시 로그인하거나 추가 인증을 요구하는 등, 사용자가 해당 계정의 실제 소유자임을 확인하는 별도의 절차를 거친다.
### 4. mock provider 테스트와 실제 IdP 검증을 구분한다
현재는 Mock Provider를 사용해 Keycloak이 외부 IdP의 인증 결과를 정상적으로 받아들이는지와 필요한 사용자 정보가 올바르게 매핑되는지까지 확인했다.
하지만 Mock Provider 테스트만으로 실제 외부 IdP와의 연동까지 검증할 수는 없다.
실제 계정으로 로그인하는 과정과 공개 HTTPS Callback, 사용자 동의(Consent) 화면, 외부 IdP가 적용하는 도메인 정책 등은 아직 확인하지 않았다.
그래서 실제 외부 IdP를 연결해 전체 로그인 흐름을 별도로 검증해야 한다.
## 적용 조건
- 외부 IdP를 붙일 때
- 계정 연결 규칙을 정할 때
- 검증 범위를 문서로 적을 때
- 브로커를 거치는 흐름과 직접 OIDC 흐름을 비교할 때
## 예외
- 애플리케이션이 Keycloak과 같은 브로커를 거치지 않고 Google 등의 외부 IdP와 직접 OIDC 연동을 한다면 상황이 달라진다.
이 경우 애플리케이션은 외부 IdP가 직접 발급한 Token을 사용하므로, 해당 외부 IdP를 신뢰하고 Token을 검증하게 된다.
- 조직에서 하나의 외부 IdP만 사용한다면 Keycloak과 같은 별도의 브로커를 두지 않고 애플리케이션이 해당 IdP와 직접 연동하는 구조도 선택할 수 있다.
이 경우 여러 외부 IdP에서 들어온 계정을 하나의 내부 사용자와 어떻게 연결할지 결정하는 계정 연결 정책은 대부분 필요 없다.
## 예시
- Google 로그인을 추가해도 애플리케이션이 고르는 것은 여전히 4가지 구조 중 하나다
- 브로커는 `provider alias + upstream subject` 조합을 기준으로 외부 계정을 식별한다.
- 애플리케이션이 신뢰하는 issuer는 외부 IdP가 아니라 브로커다
- mock OIDC provider로 확인한 것은 브로커와 claim mapping 계약까지다
@@ -0,0 +1,115 @@
---
id: 3f886154-1b85-407b-bda4-57d28370e745
kind: REFERENCE
slug: oauth-oidc-pattern-selection-criteria
title: OAuth/OIDC 인증 패턴 선택 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 23
verifiedOn: 2026-08-30
studio: "https://hyeonworks.com/studio/documents/3f886154-1b85-407b-bda4-57d28370e745/edit"
public: "https://hyeonworks.com/references/oauth-oidc-pattern-selection-criteria"
---
# OAuth/OIDC 인증 패턴 선택 기준
SPA, Mediator, BFF, OAuth2-Proxy는 Token과 인증 상태를 처리하는 방식이 서로 다르다.
브라우저가 Access Token을 직접 사용하는지, 실제 Resource Server를 누가 호출하는지, 서버에서 어떤 인증 상태를 보관하는지, Resource Server가 어떤 Credential을 검증하는지, CSRF를 어느 계층에서 처리하는지를 비교할 수 있다.
네 구조를 안전한 순서로 줄 세우지 않고, 애플리케이션의 요구사항과 배포·운영 환경에 맞춰 고른다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
mediator가 refresh token을 관리하고 브라우저가 access token으로 API를 직접 호출하는 구성을 확인했다.
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
BFF가 code 교환, token 보관, Resource Server 호출을 모두 처리하는 구성을 확인했다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
인증이 edge로 가면 보호 자원이 검증하는 것이 JWT에서 헤더로 바뀐다.
## 목적
브라우저에 OAuth Token이 노출되는 정도만 놓고 보면 구조별 차이는 있다.
Token을 다른 위치로 옮기면 브라우저에 노출되는 범위가 달라지고, 그 Token을 맡게 된 계층에서 처리해야 할 항목이 늘어난다.
예를 들어 BFF는 OAuth Token을 서버에 보관해 브라우저에서 Token 원문을 제거할 수 있다.
하지만 서버가 Session과 Authorized Client를 관리해야 하므로 Session 보호, CSRF 방어, 공유 저장소와 같은 새로운 설계가 필요해진다.
Forward-Auth 구조에서는 애플리케이션이 OAuth Token을 직접 관리하는 책임을 더 줄일 수 있다.
대신 애플리케이션이 Edge에서 전달된 사용자 정보 헤더를 신뢰하게 되므로, 헤더 위조 방지와 직접 접근 차단, 신뢰할 수 있는 네트워크 경계를 구성해야 한다.
그래서 어느 구조가 더 안전한지를 먼저 정하지 않고, 요구사항별로 무엇을 확인해야 하는지를 본다.
## 규칙
### 1. 다섯 항목으로 구조를 비교한다
구조를 비교할 때는 브라우저의 Access Token 사용 여부, Resource Server 호출 주체, 서버에서 관리하는 인증 상태, Resource Server가 검증하는 Credential, CSRF 처리 위치를 확인한다.
SPA는 Bearer Access Token을 직접 `Authorization` Header에 넣어 Resource Server를 호출하고, 인증에 Cookie를 사용하지 않는다.
SPA와 Mediator에서는 브라우저가 Access Token을 사용해 Resource Server를 직접 호출한다.
차이는 Mediator가 로그인 Session과 OAuth Token을 서버에서도 관리하고, 로그인 이후 브라우저에 Access Token을 전달한다는 점이다.
BFF에서는 브라우저가 Session Cookie로 BFF를 호출하고, BFF가 서버에 저장된 Access Token을 사용해 Resource Server를 호출한다. 따라서 브라우저에는 OAuth Token을 전달하지 않지만 Session과 Authorized Client를 서버에서 관리해야 한다.
Forward-Auth에서는 인증 Proxy가 Session을 관리하고, 인증이 완료된 요청에 사용자 정보를 추가해 애플리케이션으로 전달한다. 애플리케이션이 이 정보를 인증 근거로 사용한다면 Edge가 전달한 헤더를 신뢰할 수 있도록 직접 접근 차단, 헤더 덮어쓰기, 내부 Credential 검증과 같은 별도의 보호가 필요하다.
### 2. 피해야 할 조건을 먼저 확인한다
구조를 비교하기 전에 먼저 반드시 지켜야 하는 보안 요구사항을 확인한다.
정책상 OAuth Token을 브라우저에 둘 수 없다면 Token을 Local Storage 대신 JavaScript Memory에만 보관하는 것으로는 요구사항을 충족할 수 없다.
저장 위치가 달라졌을 뿐 브라우저 JavaScript가 여전히 Token을 직접 다루기 때문이다.
이 경우 브라우저가 Access Token을 받는 SPA나 현재의 Mediator 구조는 선택 대상에서 제외한다.
마찬가지로 애플리케이션에 직접 접근하는 경로를 차단할 수 없거나 외부에서 전달된 사용자 정보 헤더를 Edge에서 확실하게 제거하거나 덮어쓸 수 없다면, Edge가 전달한 사용자 정보를 인증 근거로 사용하는 구조는 선택하지 않는다.
### 3. 선택 조건과 운영 책임을 같이 문서화한다
어떤 인증 구조를 선택했는지만 기록하지 않는다. 어떤 보안 요구사항과 운영 조건 때문에 해당 구조를 선택했는지 함께 기록한다.
또한 해당 구조를 적용하기 어려운 조건도 남긴다.
예를 들어 브라우저에 OAuth Token을 둘 수 없는 환경에서는 SPA를 선택하기 어렵고, 애플리케이션의 직접 접근 경로나 사용자 정보 헤더를 안전하게 통제할 수 없는 환경에서는 Forward-Auth 구조를 적용하기 어렵다.
이렇게 선택 이유와 적용할 수 없는 조건을 함께 기록해야 이후 요구사항이나 운영 환경이 변경되었을 때
기존 선택이 여전히 유효한지 다시 판단할 수 있다.
### 4. 이름으로 운영 속성을 추정하지 않는다
실제 운영에 적용할 때는 서버가 재시작되거나 특정 인스턴스에 장애가 발생해도 로그인 상태를 유지할 수 있는지,
여러 Replica가 필요한 Session과 Token 정보를 공유할 수 있는지,
저장소 장애가 발생했을 때 어떻게 복구할지 등을 별도로 확인해야 한다.
내부 Credential이나 암호화 Key와 같은 Secret을 안전하게 보관하고 교체할 수 있는지도 함께 검증해야 한다.
구조를 고를 때 이런 운영 항목까지 같이 적는다.
### 5. Credential의 위치가 바뀌면 저장·전달·검증 주체도 바뀐다
인증 패턴을 변경하면 Credential의 위치만 달라지는 것이 아니라, Credential을 저장하고 전달하고 검증하는 주체도 함께 바뀐다.
따라서 패턴을 변경할 때는 기존 책임이 어느 계층으로 이동하는지까지 확인해야 한다.
예를 들어 Forward-Auth 구조에서는 Edge가 인증된 사용자 정보를 Header로 애플리케이션에 전달할 수 있다.
처음에는 사용자 이름이나 이메일처럼 인증에 필요한 정보만 전달하더라도, 애플리케이션의 요구사항이 늘어나면서 Role이나 권한, 도메인에 종속된 사용자 정보까지 Header에 계속 추가될 수 있다.
이처럼 Edge가 전달해야 하는 정보가 계속 늘어나고 애플리케이션의 인가 판단이나 화면에 필요한 여러 API 응답의 조합까지 필요해진다면,
해당 책임을 Edge에 계속 추가하기보다 BFF에서 인가와 API 호출을 처리하는 구조가 더 적절한지 다시 검토한다.
## 적용 조건
- 인증 구조를 처음 고를 때
- 한 구조에서 다른 구조로 옮기려 할 때
- 구조를 문서로 비교할 때
## 예외
- 요구가 하나로 좁혀지면 비교가 필요 없다. 브라우저에 token을 둘 수 없고 backend가 API를 조합해야 하면 선택지는 하나다.
- 학습이나 시연이 목적이면 운영 속성 비교를 하지 않아도 된다.
## 예시
- SPA : 브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다
- Mediator : refresh token은 server에 있고 access token은 응답 본문으로 브라우저에 반환한다.
- BFF : server가 code 교환·token 관리·API 호출을 담당하고 브라우저는 session cookie로 BFF를 호출한다
- Forward-Auth : edge가 인증하고 upstream은 edge가 붙인 헤더를 본다
@@ -0,0 +1,106 @@
---
id: ede6b9ce-eeed-40c8-9175-9e8116029395
kind: REFERENCE
slug: public-confidential-client-boundary
title: Public Client와 Confidential Client 구분 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 27
verifiedOn: 2026-08-30
studio: "https://hyeonworks.com/studio/documents/ede6b9ce-eeed-40c8-9175-9e8116029395/edit"
public: "https://hyeonworks.com/references/public-confidential-client-boundary"
---
# Public Client와 Confidential Client 구분 기준
OAuth Client의 종류는 client secret을 안전하게 보관할 수 있는지를 기준으로 결정한다.
SPA는 브라우저에서 실행되기 때문에 secret을 사용자에게 노출하지 않고 안전하게 보관할 수 없다.
따라서 SPA는 일반적으로 Public Client로 등록한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
SPA를 public client로 등록한 이유를 실제 구성에서 확인할 수 있다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
confidential client를 사용해도 access token 전달 방식은 별도로 설계된다는 예다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
client 종류에 따라 token endpoint의 client 인증 방식이 달라진다.
## 목적
먼저 OAuth Client가 Public Client인지 Confidential Client인지 결정해야 한다.
그래야 Authorization Code를 Token으로 교환할 때 PKCE를 사용할지, client secret을 이용한 Client 인증을 사용할지 결정할 수 있다.
Client 종류를 나누는 기준은 client secret을 사용자에게 노출하지 않고 안전하게 보관할 수 있는지다.
SPA는 브라우저에서 실행되기 때문에 코드에 secret을 넣어도 개발자 도구 등을 통해 사용자가 확인할 수 있다.
따라서 SPA는 secret을 안전하게 보관할 수 없는 Public Client로 구성한다.
반면 서버나 BFF는 secret을 서버 내부에 보관하고 브라우저에 전달하지 않을 수 있으므로 Confidential Client로 구성할 수 있다.
여기서 Client 종류와 Token을 누가 관리하는지는 구분해야 한다.
Confidential Client라고 해서 반드시 Token이 서버에만 있어야 하는 것은 아니다.
Client 종류는 secret을 안전하게 보관할 수 있는지로 정하고, Token을 브라우저와 서버 중 어디에서 관리할지는 애플리케이션의 인증 구조에 따라 별도로 정한다.
## 규칙
### 1. secret을 숨길 수 있는지로 종류를 정한다
애플리케이션의 배포 파일이나 실행 중인 메모리에서 사용자가 `client secret`을 확인할 수 있다면 이를 안전하게 보관할 수 없으므로 Public Client로 본다.
반대로 `client secret`을 서버 내부에만 보관하고 사용자에게 전달되지 않도록 통제할 수 있다면 Confidential Client로 구성할 수 있다.
Native App도 브라우저에서 실행되는 것은 아니지만 애플리케이션이 사용자 기기에 설치되기 때문에, 배포 파일을 분석하면 내부에 포함된 `client secret`을 확인할 수 있다. 따라서 Native App 역시 일반적으로 Public Client로 다룬다.
### 2. public client에서 Authorization Code Flow에 PKCE를 함께 쓴다
PKCE는 `client secret`을 대신해서 Client를 인증하는 방식이 아니다.
Authorization Code가 중간에 탈취되더라도 다른 사람이 그 Code를 Token으로 교환하기 어렵게 만드는 보호 장치다.
로그인을 시작할 때 Client는 임의의 `code_verifier`를 만들고, 이를 변환한 `code_challenge`를 Authorization Request에 함께 보낸다. 이후 Authorization Code를 Token으로 교환할 때 원래의 `code_verifier`를 제출한다.
Authorization Server는 처음 받은 `code_challenge`와 비교하여 같은 요청에서 시작된 교환인지 확인한다.
이때 `S256` 방식을 사용한다. `plain` 방식은 `code_verifier` 자체가 `code_challenge`로 전달되기 때문에 Authorization Request를 관찰한 사람이 그 값을 그대로 알 수 있다. 반면 `S256``code_verifier`를 SHA-256으로 변환한 값을 전달하므로 Authorization Request에 원래의 `code_verifier`가 노출되지 않는다.
### 3. confidential client에도 PKCE를 함께 쓸 수 있다
Client 인증을 사용하는 Confidential Client에서도 PKCE는 함께 사용할 수 있다.
Client 인증과 PKCE는 보호하는 대상이 다르기 때문이다.
Client 인증은 Token Endpoint에 요청한 Client가 올바른 Client인지 확인하고, PKCE는 Authorization Code를 받은 주체가 로그인 시작 시 생성한 `code_verifier`를 가지고 있는지 확인한다. 그래서 두 방식을 같이 사용하면 서로 다른 구간을 각각 보호할 수 있다.
### 4. public client에서는 implicit flow와 direct access grant를 끈다
Implicit Flow는 Authorization Code를 거치지 않고 Access Token을 브라우저의 Redirect URI로 직접 전달한다.
이 때문에 Token이 브라우저를 통과하고 노출될 수 있는 범위가 넓어진다.
Direct Access Grant는 애플리케이션이 사용자의 아이디와 비밀번호를 직접 받아 Authorization Server에 전달하는 방식이다.
원래 사용자가 IdP에만 제공하면 되는 비밀번호를 애플리케이션도 다루게 된다는 문제가 있다.
현재 구조에서는 Authorization Code Flow를 사용하고 있으므로 Implicit Flow와 Direct Access Grant는 비활성화했다.
### 5. Client 종류만으로 브라우저가 Token을 받는지 여부가 결정되지는 않는다.
Confidential Client가 Authorization Code를 Token으로 교환하더라도, 그 결과로 받은 Access Token을 다시 브라우저에 전달하는 구조를 만들 수 있다.
즉, Confidential Client라고 해서 Token이 반드시 서버 내부에만 있는 것은 아니다.
Client 종류는 `client secret`을 어디에 안전하게 보관할 수 있는지를 나타낸다.
반면 Access Token이 브라우저까지 전달되는지는 어느 계층이 실제 API 호출을 담당하도록 설계했는지에 따라 별도로 결정된다.
## 적용 조건
- 새 OAuth client를 등록할 때
- SPA와 server 중 어디가 code를 교환할지 정할 때
- PKCE와 client 인증을 어디에 둘지 정할 때
- 기존 client의 종류가 맞는지 다시 볼 때
## 예외
- 같은 서비스가 브라우저용 public client와 server용 confidential client를 따로 등록할 수 있다.
- backend가 사용자 없이 자기 자격으로 부르는 흐름은 Client Credentials를 쓰는 별도 client다.
## 예시
- SPA용 client : public, standard flow만 켜고 implicit flow와 direct grant는 끈다
- Mediator용 client : confidential, client_secret_basic으로 token endpoint에서 인증한다
- BFF용 client : confidential, PKCE S256을 함께 쓴다
- Proxy용 client : confidential, oauth2-proxy가 secret과 verifier로 code를 교환한다
- confidential client인 Mediator를 써도 access token은 브라우저 응답에 반환될 수 있다
@@ -0,0 +1,133 @@
---
id: 66c18e42-116c-459f-86bd-b7e4bf394866
kind: REFERENCE
slug: oauth-token-application-session-boundary
title: OAuth Token과 Application Session을 구분하는 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 33
verifiedOn: 2026-08-30
studio: "https://hyeonworks.com/studio/documents/66c18e42-116c-459f-86bd-b7e4bf394866/edit"
public: "https://hyeonworks.com/references/oauth-token-application-session-boundary"
---
# OAuth Token과 Application Session을 구분하는 기준
인증 과정에서 만들어지는 상태를 모두 하나의 `로그인 상태`로 보면 안 된다.
IdP의 SSO Session, Access Token, Refresh Token, 애플리케이션의 Session Cookie, 인증 Proxy의 Session Cookie는
각각 생성하는 주체와 사용하는 주체가 다르고 유효 시간도 서로 다르다.
예를 들어 Access Token이 만료되었다고 해서 애플리케이션 Session이나 IdP의 SSO Session까지 같이 만료된 건 아니다.
반대로 애플리케이션 Session을 삭제했다고 해서 IdP의 SSO Session이나 이미 발급된 Token까지 사라지는 것도 아니다.
그래서 어떤 Session이나 Token이 남아 있는지, 무엇이 만료되었는지, Logout할 때 어떤 상태를 삭제하거나 무효화해야 하는지를 각각 구분해서 확인한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
JavaScript memory의 OAuth token과 Keycloak SSO session을 구분한 Case다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
같은 요청 안에서 session cookie와 access token이 함께 움직인다.
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
BFF에서는 session cookie, JavaScript가 읽는 CSRF token, server-side OAuth token을 각각 다른 용도로 사용한다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
Forward-Auth에서는 upstream이 JWT를 직접 검증하지 않고 proxy session을 기반으로 edge가 만든 identity header를 사용한다.
## 목적
SPA, Mediator, BFF에서는 Resource Server가 Access Token을 검증한 뒤 JWT의 Claim에서 사용자 정보를 얻을 수 있다. 반면 Forward-Auth 구조에서는 애플리케이션이 인증 Proxy가 전달한 사용자 정보 Header를 사용한다.
최종적으로 같은 사용자 이름이 나오더라도, 한쪽은 Access Token을 검증해서 얻은 값이고 다른 한쪽은 신뢰할 수 있는 Proxy가 전달한 값이다.
Logout과 만료 처리에서도 같은 구분이 필요하다. IdP의 SSO Session, Access Token과 Refresh Token, Application Session은 서로 다른 주체가 관리하고 수명도 다르다. 따라서 Logout할 때 무엇을 삭제하거나 무효화할지, 특정 Credential이 만료되었을 때 어떤 상태를 계속 사용할 수 있는지를 각각 구분해서 설계한다.
## 규칙
### 1. 인증 상태를 종류별로 구분해서 기록한다.
IdP SSO Session, OAuth Access Token, OAuth Refresh Token, Application Session Cookie, Proxy Session Cookie는 각각 생성하는 주체와 사용하는 목적이 다른 별개의 상태다.
로그와 진단 정보에서도 어떤 상태를 확인한 것인지 구체적으로 기록한다.
예를 들어 단순히 `로그인 상태가 만료되었다`고 남기는 대신 `Application Session이 만료되었다`, `Access Token이 만료되었다`, `Proxy Session이 존재하지 않는다`처럼 실제 Session이나 Token의 종류를 명시한다.
이렇게 이름을 구분해야 장애를 분석하거나 Logout과 만료 동작을 확인할 때 어떤 상태가 남아 있고 어떤 상태를 삭제하거나 갱신해야 하는지 정확하게 판단할 수 있다.
### 2. 만든 주체와 주된 소비자로 구분한다
Access Token, Application Session Cookie, Proxy Session Cookie는 각각 발급하는 주체와 사용하는 주체가 다르다.
Access Token은 IdP가 발급하고 Resource Server가 요청을 처리할 때 검증한다.
Application Session Cookie는 애플리케이션이 발급하고, 이후 브라우저가 보낸 Cookie를 이용해 애플리케이션이 자신의 로그인 Session을 찾는 데 사용한다.
Proxy Session Cookie는 인증 Proxy가 발급하고, 이후 Proxy가 인증 상태를 확인할 때 사용한다.
이처럼 어떤 주체가 Credential을 발급했고, 요청을 처리할 때 어떤 주체가 이를 검증하는지가 다르다면 서로 다른 Credential과 인증 상태로 구분해서 다뤄야 한다.
### 3. 같은 사용자라도 Credential은 서로 다른 상태를 나타낸다
Application Session Cookie는 서버에 저장된 Session을 찾기 위한 `session ID`를 브라우저에 전달하는 데 사용한다.
실제 Access Token과 Refresh Token은 Cookie 안에 들어 있는 것이 아니라 Authorized Client와 같은 별도의 서버 저장소에 보관된다. 따라서 Session Cookie와 OAuth Token 저장소는 서로 구분해서 봐야 한다.
Proxy Session Cookie는 반드시 같은 방식으로 동작하는 것은 아니다.
별도의 서버 Session Store를 두지 않고, 인증 상태를 확인하는 데 필요한 정보를 Cookie 자체에 담은 뒤 Proxy가 Cookie의 유효성을 검증하는 방식으로 구성할 수도 있다.
이 경우 Cookie는 서버에 저장된 Session을 조회하기 위한 `session ID`와는 역할이 다르다.
### 4. 브라우저에 없는 것을 범위까지 적는다
브라우저 JavaScript에 OAuth Token을 전달하지 않는 구조에서도 브라우저에 인증과 관련된 상태는 남아 있을 수 있다.
예를 들어 BFF 구조에서는 애플리케이션의 `HttpOnly` Session Cookie가 유지될 수 있고, IdP에서는 자신의 도메인에 SSO Session Cookie를 유지할 수 있다.
따라서 단순히 `브라우저에 인증 정보가 없다`거나 `브라우저에 Credential이 없다`고 표현하면 안 된다.
`브라우저 JavaScript에 Access Token과 Refresh Token을 노출하지 않는다`처럼 무엇이 없고 어느 범위에서 접근할 수 없는지를 적는다.
### 5. 영구 저장과 메모리 보관을 구분한다.
OAuth Token을 JavaScript Memory에만 보관하면 Local Storage나 Session Storage와 같은 Web Storage에 Token을 지속적으로 저장하지 않을 수 있다.
다만 실행 중인 브라우저 JavaScript에서도 Token에 접근할 수 없다는 뜻은 아니다.
애플리케이션이 Token Endpoint의 응답을 JavaScript로 받아 처리한다면, 실행 중에는 Token 값이 JavaScript가 다루는 메모리에 존재한다. 같은 Origin에서 악성 Script가 실행될 수 있는 상황에서는 Token 응답이나 애플리케이션이 Token을 처리하는 경로가 공격 대상이 될 수 있기 때문에, Web Storage에 Token이 저장되지 않을 뿐이고 XSS를 통해 Token에 접근할 여지는 남는다.
### 6. 로그아웃 범위를 상태별로 적는다
애플리케이션에서 Logout하는 것과 IdP의 SSO Session을 종료하는 것은 서로 다른 동작이다.
애플리케이션 Session이나 Cookie를 삭제하더라도 IdP의 SSO Session은 그대로 남아 있을 수 있으며, 반대로 IdP Session을 종료하더라도 이미 발급된 Access Token의 처리 방식은 별도로 확인해야 한다.
특히 Resource Server가 Self-contained JWT Access Token을 매 요청마다 IdP에 확인하지 않고 자체적으로 검증하는 구조에서는 이미 발급된 Token이 Logout과 동시에 자동으로 무효화되지는 않는다.
Resource Server는 JWT의 서명과 만료 시간 등 필요한 Claim을 검증하고 Token이 아직 유효하면 요청을 받아들일 수 있다.
따라서 Denylist처럼 이미 발급된 Token의 상태를 추가로 확인하는 방법을 사용하지 않는다면, 애플리케이션 Logout만으로 기존 Access Token을 즉시 사용할 수 없게 만들 수는 없다.
이런 구조에서는 Access Token의 TTL을 짧게 설정해 Logout 이후에도 기존 Token을 사용할 수 있는 시간을 제한하고,
Refresh Token과 Session은 각각의 저장 위치와 관리 주체에 맞게 별도로 종료하거나 제거한다.
### 7. Logout 대상 credential을 구체적으로 적는다
SPA에서 JavaScript Memory에 보관하던 OAuth Token을 제거하더라도 Keycloak의 SSO Session까지 종료되는 것은 아니다. Keycloak의 SSO Session이 아직 유효하다면 이후 새로운 Authorization Request를 보냈을 때 사용자가 다시 아이디와 비밀번호를 입력하지 않고 인증 절차가 진행될 수 있다.
따라서 Logout을 단순히 브라우저의 Token이나 Cookie를 삭제하는 동작으로만 정의하면 안 된다.
어떤 수준까지 로그아웃할 것인지에 따라 애플리케이션 상태와 IdP의 SSO Session을 각각 어떻게 종료할지 정해야 한다.
Mediator나 BFF처럼 서버에서 Application Session과 Authorized Client를 함께 관리하는 구조에서는 두 상태의 정리 방법도 각각 명시한다.
Application Session을 무효화하는 것과 Authorized Client에 저장된 Access Token 및 Refresh Token을 제거하는 것은 서로 다른 처리기 때문에, Logout 시 어떤 상태를 삭제하고 어떤 상태를 유지할지를 별도로 확인한다.
## 적용 조건
- 인증 상태를 표나 문서로 정리할 때
- 로그아웃과 만료 동작을 설계할 때
- 브라우저가 어떤 credential을 저장하거나 전송하는지 설명할 때
- 여러 구조를 비교할 때
## 예외
- 하나의 요청 흐름 안에서 어떤 Session이나 Token을 의미하는지가 이미 명확한 경우에는 짧은 이름을 사용할 수 있다.
다만 문서에서 처음 등장할 때는 전체 이름을 먼저 적어 어떤 상태를 의미하는지 명확하게 정의한다.
이후 같은 문맥에서는 의미가 달라지지 않는 범위에서 `Session`, `Access Token`, `Refresh Token`처럼 줄여서 표현할 수 있다.
- IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO session과 access token, refresh token이 없다.
## 예시
- IdP SSO session : IdP 도메인의 cookie이고 애플리케이션 memory와 별개다
- access token : IdP가 만들고 Resource Server가 서명과 issuer, audience를 검증한다
- refresh token : 새 access token을 받는 장기 credential이다
- 애플리케이션 session cookie : server-side 로그인 상태를 찾는 credential이다.
- proxy session cookie : proxy의 auth endpoint에 제시하는 최소 상태다
- CSRF token : cookie가 자동으로 붙는 상태 변경 요청의 의도를 확인한다
- identity header : edge가 확인한 사용자 정보이고 JWT token이 아니다
@@ -0,0 +1,230 @@
{
"project": "keycloak",
"ssot": "final/document.md",
"generatedAt": "2026-09-04",
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.",
"topics": {
"oauth-oidc-auth-boundary": {
"topic": "oauth-oidc-auth-boundary",
"kinds": {
"case": [
{
"title": "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
"slug": "split-custody-access-token",
"file": "oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
"status": "게시 중",
"studioId": "488ce49b-afa4-42a5-a2ce-de2e0653cd82",
"assets": 1,
"evidence": 0
},
{
"title": "BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식",
"slug": "bff-session-csrf-responsibility",
"file": "oauth-oidc-auth-boundary/case/case-ap3-bff-session-csrf.md",
"status": "게시 중",
"studioId": "d85bd6af-7599-4ef7-9407-6609927d5b5c",
"assets": 2,
"evidence": 0
},
{
"title": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
"slug": "identity-header-trust",
"file": "oauth-oidc-auth-boundary/case/case-ap4-identity-header-trust.md",
"status": "게시 중",
"studioId": "a0e1cc05-92b3-4dac-bce1-513ab8cd862b",
"assets": 1,
"evidence": 0
},
{
"title": "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우",
"slug": "spa-browser-credential-boundary",
"file": "oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
"status": "게시 중",
"studioId": "bf675775-4f3e-4744-8014-f0efff51422a",
"assets": 0,
"evidence": 0
}
],
"concept": [
{
"title": "Authorization Code와 PKCE가 보호하는 구간",
"slug": "authorization-code-and-pkce",
"file": "oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
"status": "게시 전",
"studioId": "75c6c657-3e03-47a0-a9d0-5637fce9dd3f",
"assets": 0,
"evidence": 0
},
{
"title": "Bearer JWT가 인증된 principal이 되기까지",
"slug": "bearer-jwt-validation-chain",
"file": "oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
"status": "게시 전",
"studioId": "87000d59-b69f-4010-9481-0b71c8bde32d",
"assets": 0,
"evidence": 0
},
{
"title": "브라우저가 credential을 보관하는 위치와 그 성질",
"slug": "browser-credential-storage",
"file": "oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
"status": "게시 전",
"studioId": "bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07",
"assets": 0,
"evidence": 0
},
{
"title": "Cookie로 인증하는 요청에서 CSRF token이 하는 일",
"slug": "cookie-auth-csrf",
"file": "oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
"status": "게시 전",
"studioId": "5c8f12d5-1ead-469b-8e91-2de69401df48",
"assets": 0,
"evidence": 0
},
{
"title": "Forward-Auth와 Nginx auth_request의 동작",
"slug": "forward-auth-and-auth-request",
"file": "oauth-oidc-auth-boundary/concept/concept-forward-auth-and-auth-request.md",
"status": "게시 전",
"studioId": "a3493786-d3fb-4b01-b1c5-ecb23c3d5497",
"assets": 0,
"evidence": 0
},
{
"title": "외부 IdP Brokering의 동작",
"slug": "idp-brokering",
"file": "oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
"status": "게시 전",
"studioId": "d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719",
"assets": 0,
"evidence": 0
}
],
"reference": [
{
"title": "Authorization Code Flow의 Endpoint와 Credential 이동 기준",
"slug": "authorization-code-endpoint-credential-movement",
"file": "oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md",
"status": "게시 중",
"studioId": "39fdf472-82c4-43ed-abec-73de672f08ae",
"assets": 0,
"evidence": 0
},
{
"title": "BFF 인증 구조 설계 기준",
"slug": "bff-authentication-design-criteria",
"file": "oauth-oidc-auth-boundary/reference/reference-bff-auth-design.md",
"status": "게시 중",
"studioId": "97eddd97-1096-426a-a2c6-a6c5bf1cd09f",
"assets": 0,
"evidence": 0
},
{
"title": "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
"slug": "forward-auth-identity-header-trust",
"file": "oauth-oidc-auth-boundary/reference/reference-forward-auth-header-trust.md",
"status": "게시 중",
"studioId": "004dd0a2-5fb3-4f25-80c9-576f709de331",
"assets": 0,
"evidence": 0
},
{
"title": "외부 IdP 연동과 Application 인증 구조의 경계",
"slug": "external-idp-federation-application-boundary",
"file": "oauth-oidc-auth-boundary/reference/reference-idp-federation-boundary.md",
"status": "게시 중",
"studioId": "1a00a640-8987-4075-a9e4-7ec023cdffbb",
"assets": 0,
"evidence": 0
},
{
"title": "OAuth/OIDC 인증 패턴 선택 기준",
"slug": "oauth-oidc-pattern-selection-criteria",
"file": "oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
"status": "게시 중",
"studioId": "3f886154-1b85-407b-bda4-57d28370e745",
"assets": 0,
"evidence": 0
},
{
"title": "Public Client와 Confidential Client 구분 기준",
"slug": "public-confidential-client-boundary",
"file": "oauth-oidc-auth-boundary/reference/reference-public-confidential-client.md",
"status": "게시 중",
"studioId": "ede6b9ce-eeed-40c8-9175-9e8116029395",
"assets": 0,
"evidence": 0
},
{
"title": "OAuth Token과 Application Session을 구분하는 기준",
"slug": "oauth-token-application-session-boundary",
"file": "oauth-oidc-auth-boundary/reference/reference-token-vs-session.md",
"status": "게시 중",
"studioId": "66c18e42-116c-459f-86bd-b7e4bf394866",
"assets": 0,
"evidence": 0
}
],
"question": [
{
"title": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
"slug": "bff-session-authorized-client-store",
"file": "oauth-oidc-auth-boundary/question/question-bff-state-store.md",
"status": "게시 중",
"studioId": "18a5cde2-dd1e-4bff-9f1c-997577ae438f",
"assets": 0,
"evidence": 0
},
{
"title": "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가",
"slug": "edge-authorization-scope",
"file": "oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
"status": "게시 중",
"studioId": "7ff40767-a00b-4db2-98f6-0cdfce8c8936",
"assets": 0,
"evidence": 0
},
{
"title": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
"slug": "server-session-pattern-multi-instance",
"file": "oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
"status": "게시 중",
"studioId": "c72656b5-842d-45d9-b5f6-82b66b09d0b9",
"assets": 0,
"evidence": 0
},
{
"title": "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
"slug": "refresh-rotation-replica-contention",
"file": "oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
"status": "게시 중",
"studioId": "9ae4ec71-a32e-49a7-88c2-f7368541c28d",
"assets": 0,
"evidence": 0
}
],
"decision": [
{
"title": "BFF가 OAuth Token을 관리하는 조건",
"slug": "bff-owns-token-when-browser-must-not",
"file": "oauth-oidc-auth-boundary/decision/decision-bff-owns-token.md",
"status": "게시 중",
"studioId": "19b55c39-c583-4161-9775-df954280a568",
"assets": 0,
"evidence": 0
},
{
"title": "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.",
"slug": "federation-is-not-an-application-pattern",
"file": "oauth-oidc-auth-boundary/decision/decision-federation-not-a-pattern.md",
"status": "게시 중",
"studioId": "8c1ebea7-204e-445c-9812-0421d9eb0e9c",
"assets": 0,
"evidence": 0
}
]
}
}
}
}