docs(keycloak): adopt the decomposition contract, fix the redirect URI, strip evaluative prose

- 계약 채택 — 독자 질문, 후보 29건(PROMOTE 24 · MERGE_INTO 4 · KEEP_IN_SSOT 1). 게시 중
  17건은 전부 유지. 저장소 keycloak-pattern 은 패턴 넷이 브랜치로 갈라져 있어 revisions 로
  tip 넷을 적었다. keycloak-session-store 는 같은 저장소 @ cdac9b8
- 게시된 기록의 redirect_uri 가 SSOT·코드와 달랐다 — OAuth2callback.html → callback.html
  (frontend/src/app.js 에서 확인). 계약 title 이 기록과 다른 7건도 기록 쪽으로 맞췄다
- 미작성 1건 작성 — 패턴 검증을 실제로 돌릴 때의 안전한 순서(Reference)
- 리뷰 100건 반영 — 설명 뒤에 붙은 평가·차례 예고·독자 오해 가정·작성 지시를 지웠다.
  삭제가 남긴 조각 4건을 고치고, 원래부터 잘려 있던 로컬 미리보기 라벨 1건도 닫았다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 12:39:20 +09:00
co-authored by Claude Fable 5.1
parent 62520a4dce
commit 4d50bb939a
26 changed files with 1947 additions and 1225 deletions
@@ -14,44 +14,49 @@ 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
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap2
- final/document.md#선택의-이유와-지킨-경계-ap2
- final/document.md#결정이-지켜지는지-확인하는-방법-ap2
---
# 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은 브라우저까지 전달된다.
confidential client인 mediator가 authorization code를 교환하고, 받은 두 토큰을 서버 쪽 authorized client에 넣는다. 그런데 보호 자원 서버(Resource Server)부르는 쪽은 브라우저라서 액세스 토큰이 브라우저에도 있어야 하고, 그 값은 JSON 응답으로 다시 내려온다. 그래서 이 구조가 브라우저 밖으로 옮긴 것은 client secret과 리프레시 토큰까지이고, 액세스 토큰을 다루는 일은 브라우저가 계속 한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
SPA에서는 브라우저가 code 교환과 token 보관을 모두 맡는다. 여기서는 그중 refresh token 관리를 서버로 옮다.
SPA에서는 브라우저가 코드 교환과 토큰 보관을 모두 맡는데, 여기서는 그중 리프레시 토큰을 서버로 옮다.
- **Public Client와 Confidential Client 구분 기준**
Mediator는 confidential client지만 access token을 브라우저 응답으로 반환한다. client 종류와 token 노출 위치가 같은 기준이 아니라는 사례다.
mediator는 confidential client인데도 액세스 토큰을 브라우저 응답으로 돌려준다. 클라이언트를 어느 종류로 등록했는지가 토큰이 브라우저까지 가는지를 정하지는 않는다.
- **OAuth Token과 Application Session을 구분하는 기준**
access token은 응답 본문, JavaScript 지역 변수, Authorization 헤더를 지나고 application session은 별도로 관리된다. 어떤 상태를 말하는지 이름을 나눠야 하는 이유다.
액세스 토큰은 응답 본문 JavaScript 지역 변수 Authorization 헤더를 지나가고, 애플리케이션 쪽 로그인 상태는 그와 별도로 관리된다.
- **OAuth/OIDC 인증 패턴 선택 기준**
refresh token은 서버에 두지만 access token은 브라우저에 전달되고 mediator의 server state도 함께 관리해야 하는 구조다.
리프레시 토큰은 서버에 두면서 액세스 토큰은 브라우저로 보내는 구성이라, 이 패턴을 고르면 mediator의 서버 상태까지 함께 운영해야 다.
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
여기서는 refresh token rotation과 재사용 0회 구성을 사용한다. 여러 replica에서 refresh가 겹치는 문제는 별도로 남아 있다.
여기서는 리프레시 토큰 rotation과 재사용 0회로 설정했다. 여러 replica에서 갱신이 겹치는 경우는 이 구성으로 확인하지 못해 그 질문에서 따로 다룬다.
## 문제
Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고 access token과 refresh token을 server-side authorized-client service에 저장한다. 브라우저에는 HttpOnly AP2_SESSION만 관리하게 된다.
Spring mediator가 confidential client가 되어 code를 교환한다. 받은 액세스 토큰과 리프레시 토큰은 server-side authorized-client service에 저장하고, 브라우저가 로그인 상태로 들고 있는 것은 HttpOnly가 붙은 AP2_SESSION뿐이다.
서버에서 token을 보관한다는 점만 보면 BFF와 비슷하다. 하지만 Mediator에서는 브라우저가 Resource Server를 직접 호출한다. Resource Server를 호출하려면 access token이 필요하기 때문에 mediator가 access token을 응답으로 다시 반환한다.
서버가 토큰을 보관한다는 점만 보면 BFF와 비슷한데, 이 구조에서는 보호 자원 서버를 브라우저가 직접 부른다. 그 요청에 액세스 토큰이 필요하기 때문에 mediator가 액세스 토큰을 다시 응답으로 돌려준다.
처음에는 refresh token을 서버로 옮기면 브라우저가 credential을 직접 다뤄야 하는 범위도 대부분 줄어든다고 봤다. /token/access 응답부터 Resource Server 요청까지 따라가 보니 refresh token은 서버에 남지만 access token은 계속 브라우저에서 사용되고 있었다.
/token/access 응답부터 보호 자원 서버 요청까지 따라가면 서버가 가져간 것은 리프레시 토큰까지였고 액세스 토큰을 다루는 일은 브라우저가 계속 하고 있었다.
## 결론
서버로 옮긴 것은 client secret과 refresh token이다. access token은 브라우저에서 다음 세 곳에 나타난다.
서버로 옮긴 것은 client secret과 리프레시 토큰이다. 액세스 토큰은 브라우저에서 다음 세 곳을 지나간다.
access token이 사용되는 위치
액세스 토큰이 사용되는 위치
/token/access 응답 본문 : o
JavaScript 지역 변수 : o
/api/me Authorization 헤더 : o
server state : mediator의 session과 authorized-client 저장소를 운영해야 한다.
browser 노출 : access token은 브라우저 실행 영역 안에 그대로 있다.
서버 상태 : mediator의 session과 authorized-client 저장소를 운영해야 한다.
브라우저 노출 : 액세스 토큰이 브라우저 실행 영역 안에서 쓰이는 것은 막지 못했다.
## 검증 환경
@@ -94,26 +99,24 @@ HTTP : o
4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인한다.
5. 브라우저가 해당 token으로 Resource Server를 직접 호출했을 때 200을 받는지 확인한다.
5. 브라우저가 그 토큰으로 보호 자원 서버를 직접 호출했을 때 200을 받는지 확인한다.
6. cookie가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
6. 쿠키가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
7. Local Storage와 Session Storage에 access token 원문이나 refresh_token 문자열이 없는지 확인한다.
7. Local Storage와 Session Storage에 액세스 토큰 원문이나 refresh_token 문자열이 없는지 확인한다.
## 본문
<!-- body:start -->
## Mediator에서 Access Token과 Refresh Token을 관리하는 위치
confidential client는 client secret을 서버에 두고 자기를 인증할 수 있는 애플리케이션이다. 여기 나오는 mediator가 그런 클라이언트이고, 브라우저 대신 authorization code를 토큰으로 바꿔 서버에 보관한다. 다만 보호 자원 서버(Resource Server)는 브라우저가 직접 부른다. 리프레시 토큰만 브라우저에서 걷어내면 브라우저가 무엇을 계속 다루게 되는지 확인했다.
## 서버로 옮긴 값과 브라우저로 돌아오는 값
:::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에 저장한다.
SPA 구조에서는 브라우저가 코드를 직접 교환하고 받은 토큰도 브라우저에서 관리했다. mediator를 두면 그 코드를 교환하는 쪽이 Spring mediator로 바뀌고, 액세스 토큰과 리프레시 토큰은 둘 다 서버 쪽 authorized client에 저장된다. 그런데 보호 자원 서버를 부르는 쪽은 여전히 브라우저라서, 액세스 토큰`/token/access`를 통해 다시 브라우저로 건너온다.
각 값의 위치는 다음과 같다.
@@ -124,13 +127,11 @@ SPA 구조에서는 브라우저가 code를 직접 교환하고 받은 token도
| access token | o | o |
| 로그인 상태 | AP2_SESSION | HttpSession |
access token은 서버에도 저장되지만 브라우저에도 전달된다.
## AP2_SESSION이 생성되는 시점
`AP2_SESSION`token 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
`AP2_SESSION`토큰 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
Spring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장한다. Browser가 KeyCloak으로 이동했다가 callback으로 다시 Spring에 돌아왔을 때 앞에서 시작한 로그인 요청을 찾을 수 있어야 하기 때문에 이 시점에 session cookie가 먼저 만들어진다.
Spring Security는 로그인을 시작하면서 인가 요청과 `state``HttpSession`에 저장한다. 브라우저가 KeyCloak으로 다가 callback으로 돌아왔을 때 앞에서 시작한 로그인 요청을 찾야 하기 때문에, 세션 쿠키가 이 시점에 먼저 만들어진다.
```text label="callback 하나가 두 개의 상태로 나뉜다"
AP2_SESSION
@@ -142,17 +143,17 @@ AP2_SESSION
→ access token + refresh token
```
`AP2_SESSION` 안에 token이 들어 있는 것은 아니다. 이 cookie는 HttpSession을 찾기 위한 session ID이고, HttpSession에 로그인 SecurityContext가 저장되어 있다. token기서 확인한 principal을 이용해 별도의 store에 저장 authorized client서 찾는다.
`AP2_SESSION` 안에 토큰이 들어 있는 것은 아니다. 이 쿠키는 `HttpSession`을 찾는 세션 ID이고, 그 `HttpSession`에 로그인 `SecurityContext`가 저장되어 있다. 토큰기서 확인한 principal로 별도 저장소의 authorized client를 조회해서 찾는다.
:::warning
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 token 상태가 모두 현재 process의 memory에 있다.
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 토큰 상태가 둘 다 지금 프로세스의 메모리에 있다. 세션과 authorized client를 여러 인스턴스가 공유하는지는 이 구성으로 확인하지 못했다.
:::
## /token/access가 반환하는 세 가지 값
브라우저가 Resource Server를 직접 호출하려면 access token이 필요하다. mediator는 `/token/access`를 통해 현재 access token을 반환한다.
브라우저가 보호 자원 서버를 직접 부르려면 액세스 토큰이 있어야 하고, 그 값을 주는 것이 `/token/access`다.
```http label="브라우저 입력 — cookie 한 개"
GET http://localhost:8082/token/access
@@ -160,7 +161,7 @@ Accept: application/json
Cookie: AP2_SESSION=<opaque-session-id>
```
controller는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣 `OAuth2AuthorizedClientManager.authorize()`를 호출한다. 반환된 authorized client에서 access token을 꺼내 다음 세 값 응답다.
컨트롤러는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣는다. 이 요청으로 `OAuth2AuthorizedClientManager.authorize()`를 부른 뒤, 돌아온 authorized client에서 액세스 토큰을 꺼내 다음 세 값 응답에 담는다.
```http label="응답 헤더"
HTTP/1.1 200 OK
@@ -177,13 +178,11 @@ Content-Type: application/json
}
```
HTTP 응답 본문에는 access token만 포함되고 refresh token은 포함되지 않는다.
응답 본문에 실리는 토큰은 액세스 토큰 하나이고 리프레시 토큰은 넣지 않는다. authorized client나 액세스 토큰이 없으면 401을 돌려준다.
authorized client나 access token이 없으면 401을 반환한다.
## 액세스 토큰이 브라우저에서 지나가는 세 곳
## Access Token이 브라우저에서 사용되는 위치
브라우저 JavaScript는 `/token/access` 응답에서 access token을 읽어 지역 변수에 넣는다.
브라우저 JavaScript는 `/token/access` 응답에서 액세스 토큰을 읽어 지역 변수에 넣는다.
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
const {
@@ -192,7 +191,7 @@ const {
} = await tokenResponse.json();
```
이 값은 바로 다음 Resource Server 요청의 `Authorization` 헤더에 사용된다.
이 값은 바로 다음 보호 자원 서버 요청의 `Authorization` 헤더에 들어간다.
```http label="mediator를 지나지 않는 경로"
GET http://localhost:8081/api/me
@@ -201,7 +200,7 @@ Authorization: Bearer <raw-keycloak-jwt>
Origin: http://localhost:8082
```
access token은 다음 세 곳에서 사용된다.
액세스 토큰이 지나가는 곳은 다음 세 곳다.
```text
/token/access response body
@@ -209,13 +208,11 @@ access token은 다음 세 곳에서 사용된다.
→ /api/me Authorization header
```
세 곳 모두 브라우저에서 요청을 처리하는 동안의 흐름 안에 있다.
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 token을 쓰지 않는다는 뜻이다. 실행 중인 script가 응답이나 지역 변수의 token에 접근할 수 없다는 뜻은 아니다.
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 토큰을 쓰지 않는다는 뜻이고, 실행 중인 스크립트가 응답이나 지역 변수의 토큰에 닿지 못한다는 뜻은 아니다.
## /token/access는 일회성 전달이 아니다
`/token/access`가 access token을 한 번만 전달하고 이후에는 다시 받을 수 없는 방식인지 확인했다.
`/token/access`의 동작을 one-time handoff라고 부를 수 있을지 살펴봤다. 한 번 건넨 뒤에는 같은 토큰을 다시 받을 수 없어야 그렇게 부를 수 있다.
| one-time handoff 요건 | 있나 |
|---|---|
@@ -225,7 +222,7 @@ memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 token
| 건넨 뒤 삭제 | x |
| 재호출 거부 | x |
현재 구현에는 한 번 전달한 token을 사용 처리하거나 이후 호출을 거부하는 동작이 없다. 같은 인증된 session에서는 현재 access token을 다시 요청할 수 있다.
지금 구현에는 한 번 건넨 토큰을 사용 처리하거나 다음 호출을 거부하는 코드가 없어서, 같은 인증된 세션이라면 현재 액세스 토큰을 다시 요청할 수 있다. 그래서 이 구현이 보장하는 것은 리프레시 토큰을 응답에서 빼는 것까지이고, 액세스 토큰을 한 번만 건네는 기능은 없다. 리프레시 토큰을 응답에서 뺀 것만 확인했고, 액세스 토큰을 한 번만 주는 기능은 없다.
```text
repeatable GET
@@ -233,18 +230,20 @@ repeatable GET
→ current raw access token response
```
브라우저에 반환하는 값은 access token뿐이고 refresh token은 응답에 넣지 않는다.
정말 한 번만 건네야 한다면 이 raw access token endpoint를 재사용할 수 없다. 짧게 사는 일회용 code를 만들고 audience가 제한된 exchange endpoint에서 한 번만 소비하는 별도 protocol이 필요하다.
## Mediator에서 서버 상태와 브라우저 노출
## 이 구조를 고를 때 함께 오는 서버 상태와 액세스 토큰 노출
- server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
- browser 노출 : access token은 여전히 응답 본문과 헤더에 있다
mediator를 넣은 이유는 하나다. 리프레시 토큰은 브라우저 JavaScript 메모리에서 서버로 옮기고, 브라우저가 보호 자원 서버를 직접 부르는 방식은 바꾸지 않으려고 했다. 둘을 같이 두려면 client secret을 서버에 보관할 수 있는 confidential client가 필요하다. 구현을 마치고 보니 이 선택은 두 비용을 함께 남겼다.
브라우저가 Resource Server를 직접 호출해야 한다면 이 구조를 사용할 수 있다. 브라우저에서 access token까지 없애야 한다면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
- 서버 상태 : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
- 브라우저 노출 : 액세스 토큰이 응답 본문과 `Authorization` 헤더를 지나가는 것은 막지 못했다
브라우저가 보호 자원 서버를 직접 불러야 한다는 요구가 분명하면 이 구조를 고를 수 있다. 이 구조는 두 구조의 비용을 함께 갖는다 — mediator 상태를 확장하고 복구해야 하는데 액세스 토큰은 여전히 XSS에 노출된다. 브라우저에서 액세스 토큰까지 없애야 한다면 이 구조로는 답이 되지 않고, 서버 상태 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
## 현재 자동 테스트로 확인한 범위
아래 항목은 커밋된 자동 테스트에서 확인하도록 정의한 내용이다.
아래 항목은 커밋된 자동 테스트 확인하도록 적어 둔 것들이다.
| 항목 | 확인했나? |
|---|---|
@@ -262,6 +261,6 @@ repeatable GET
| 재시작·replica 이동 뒤 복구 | x |
| 허용 밖 origin의 CORS 거부 | x |
manager에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 만료된 token을 갱신할 수 있는 구성은 들어가 있지만, 실제로 만료를 기다린 뒤 refresh가 성공하는지 rotation된 token이 저장되는지는 아직 확인하지 않았다.
`OAuth2AuthorizedClientManager`에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 만 실제로 만료를 기다린 뒤 갱신이 성공하는지, rotation된 토큰이 저장되는지는 아직 확인하지 않았다.
<!-- body:end -->
@@ -16,13 +16,18 @@ assets:
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
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap3
- final/document.md#선택의-이유와-지킨-경계-ap3
- final/document.md#결정이-지켜지는지-확인하는-방법-ap3
---
# BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식
BFF 구조에서는 브라우저가 access token도 refresh token도 받지 않는다. confidential client인 BFF 서버가 code를 교환해 두 토큰을 server-side authorized client에 넣어 두고, 로그인이 끝난 브라우저는 HttpOnly가 붙은 AP3_SESSION 쿠키 하나로 BFF를 부른다.
토큰이 빠진 자리를 세션 쿠키가 채우면서 브라우저는 상태를 바꾸는 요청에도 쿠키를 자동으로 붙이게 됐고, 그래서 그 요청이 사용자가 보던 화면에서 시작한 것인지 가려낼 CSRF 검증이 새로 필요해졌다. 이때 쓰는 XSRF-TOKEN 값은 스크립트가 읽어 헤더에 다시 넣어야 해서 이 쿠키에는 HttpOnly를 붙이지 않았다. 지금 구현에서 확인한 것은 여기까지이고, 재시작한 뒤 로그인을 유지하는 것과 여러 replica가 세션을 함께 쓰는 것은 아직 구현하지 않았다.
브라우저가 토큰 대신 세션 쿠키를 받게 되면서 상태를 바꾸는 요청에도 쿠키를 자동으로 붙이게 됐고, 그래서 그 요청이 사용자가 보던 화면에서 시작한 것인지 가려낼 CSRF 검증이 새로 필요해졌다. 이때 쓰는 XSRF-TOKEN 값은 스크립트가 읽어 헤더에 다시 넣어야 해서 이 쿠키에는 HttpOnly를 붙이지 않았다. 지금 구현에서 확인한 것은 여기까지이고, 재시작한 뒤 로그인을 유지하는 것과 여러 replica가 세션을 함께 쓰는 것은 아직 구현하지 않았다.
## 관계
@@ -43,15 +48,13 @@ BFF 구조에서는 브라우저가 access token도 refresh token도 받지 않
브라우저에 OAuth token을 두지 않으려면 code 교환과 token 보관을 서버가 맡아야 한다. BFF 구조에서는 confidential client인 BFF 서버가 code를 교환하고, access token과 refresh token을 server-side authorized client에 넣는다. 브라우저가 받는 것은 HttpOnly가 붙은 AP3_SESSION 쿠키다.
토큰이 사라진 자리를 이 세션 쿠키가 대신하면서 브라우저는 이후 요청마다 쿠키 자동으로 붙다. 상태를 바꾸는 요청에서도 마찬가지라, 세션 쿠키만 봐서는 그 요청이 사용자가 보던 화면에서 시작한 것인지 알 수 없다. 그래서 상태 변경 요청에는 CSRF 검증이 새로 붙는다.
브라우저가 토큰 대신 이 세션 쿠키를 받으면서 이후 요청마다 쿠키 자동으로 붙다. 상태를 바꾸는 요청에서도 마찬가지라, 세션 쿠키만 봐서는 그 요청이 사용자가 보던 화면에서 시작한 것인지 알 수 없다. 그래서 상태 변경 요청에는 CSRF 검증이 새로 붙는다.
서버가 들고 있게 된 상태에도 둘 곳이 필요하다. 지금 session과 authorized client는 모두 memory에 있어서, BFF를 재시작하거나 요청이 다른 replica로 가는 경우까지 처리하려면 이 상태를 어디에 저장할지 따로 정해야 한다.
그래서 브라우저에서 OAuth token을 없앤 뒤 브라우저에 무엇이 남는지, BFF가 무엇을 더 맡게 되는지 확인했다.
## 결론
브라우저에 남는 자격 증명은 cookie 2개다.
브라우저가 들고 있는 자격 증명은 cookie 2개다.
AP3_SESSION : HttpOnly, JavaScript 읽기 x
XSRF-TOKEN : JavaScript 읽기 o
@@ -114,16 +117,16 @@ HTTP : o
## 본문
<!-- body:start -->
BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호출하고 결과만 돌려주는 서버이고, 여기서는 로그인과 토큰 보관까지 이 서버가 맡는다. 브라우저에서 OAuth 토큰을 걷어내면 브라우저에는 무엇이 남고 서버는 무엇을 더 맡게 되는지 확인했다. 먼저 브라우저에 남는 값부터 보고, 그 값으로 시작한 요청이 보호 자원 서버(Resource Server)로 가는 Bearer 요청이 되기까지를 따라간 다음, 상태를 바꾸는 요청에서 새로 필요해진 검사와 아직 구현하지 않은 항목을 적는다.
BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호출하고 결과만 돌려주는 서버이고, 여기서는 로그인과 토큰 보관까지 이 서버가 맡는다. 브라우저에서 OAuth 토큰을 걷어내면 브라우저 무엇을 들고 있게 되고 서버는 무엇을 더 맡게 되는지 확인했다. 다음 상태를 바꾸는 요청에서 새로 필요해진 검사와 아직 구현하지 않은 항목을 적는다.
## 브라우저에는 쿠키 2개만 남는
## 브라우저가 들고 있는 자격 증명은 쿠키 2개다
:::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"
:::
로그인은 브라우저가 BFF의 `/oauth2/authorization/keycloak`을 여는 것으로 시작한다. 인가 요청에 실리는 클라이언트는 `bff-confidential`이고 `code_challenge_method``S256`이다. 가로챈 authorization code를 그대로 바꿔 가지 못하도록 PKCE(Proof Key for Code Exchange)를 함께 걸었다. 다만 그 코드를 토큰으로 바꾸는 쪽은 브라우저가 아니다. BFF가 서버끼리 통신하는 자리에`client_secret_basic`으로 토큰 엔드포인트를 부르고, 받은 액세스 토큰과 리프레시 토큰은 `OAuth2AuthorizedClientService`가 관리하는 authorized client에 남는다. 브라우저가 받는 것은 `/`로 돌아가는 리다이렉트와 `AP3_SESSION` 쿠키뿐이다.
로그인은 브라우저가 BFF의 `/oauth2/authorization/keycloak`을 여는 것으로 시작한다. 인가 요청에 실리는 클라이언트는 `bff-confidential`이고 `code_challenge_method``S256`이다. 가로챈 authorization code를 그대로 바꿔 가지 못하도록 PKCE(Proof Key for Code Exchange)를 함께 걸었다. 다만 그 코드를 토큰으로 바꾸는 쪽은 브라우저가 아니다. BFF가 서버끼리 통신하`client_secret_basic`으로 토큰 엔드포인트를 부르고, 받은 액세스 토큰과 리프레시 토큰은 `OAuth2AuthorizedClientService`가 관리하는 authorized client에 저장된다. 브라우저가 받는 것은 `/`로 돌아가는 리다이렉트와 `AP3_SESSION` 쿠키뿐이다.
그래서 브라우저에 남는 값은 쿠키 2개다. `AP3_SESSION`에는 `HttpOnly`가 붙어 있어 브라우저에서 실행되는 스크립트가 값을 읽을 수 없고, 대신 BFF로 요청을 보낼 때마다 브라우저가 알아서 붙인다. `XSRF-TOKEN``HttpOnly`를 붙이지 않은 것은 상태를 바꾸는 요청에서 스크립트가 이 값을 읽어 `X-XSRF-TOKEN` 헤더에 다시 넣어야 하기 때문이다. 액세스 토큰과 리프레시 토큰 2개는 어느 쪽으로도 브라우저에 내려가지 않는다.
`AP3_SESSION`에는 `HttpOnly`가 붙어 있어 브라우저에서 실행되는 스크립트가 값을 읽을 수 없고, 대신 BFF로 요청을 보낼 때마다 브라우저가 알아서 붙인다. `XSRF-TOKEN``HttpOnly`를 붙이지 않은 것은 상태를 바꾸는 요청에서 스크립트가 이 값을 읽어 `X-XSRF-TOKEN` 헤더에 다시 넣어야 하기 때문이다. 액세스 토큰과 리프레시 토큰 2개는 어느 응답에도 실리지 않는다.
| 무엇 | 브라우저에 있나 | JavaScript가 읽나 |
|---|---|---|
@@ -134,9 +137,11 @@ BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호
토큰 원문이 브라우저에서 사라졌다고 XSS(Cross-Site Scripting)까지 없어지는 것은 아니다. 같은 출처에서 실행되는 악성 스크립트는 `AP3_SESSION`을 직접 읽지 못해도 브라우저가 쿠키를 붙인 채 BFF를 부르게 만들 수 있고, 스크립트가 읽을 수 있는 `XSRF-TOKEN`에도 접근한다. 이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰 원문이 브라우저 스크립트에서 새어 나가 다른 클라이언트나 보호 자원 서버 직접 호출에 다시 쓰이는 범위다.
여기서 확인한 쿠키 속성은 HTTPS가 아니라 HTTP로 띄운 구성에서 본 값이다. 쿠키 속성과 리다이렉트를 눈으로 확인하려고 HTTP를 쓰는 설정을 뒀다.
### 쿠키 하나로 시작한 요청이 Bearer 요청이 된다
브라우저가 `/bff/api/me`를 부를 때 요청에 붙는 자격 증명은 쿠키뿐이라, `Authorization` 헤더도 없고 브라우저 코드 액세스 토큰을 변수에 담는 자리도 없다.
브라우저가 `/bff/api/me`를 부를 때 요청에 붙는 자격 증명은 쿠키뿐이라, `Authorization` 헤더도 없고 브라우저 코드에는 액세스 토큰을 담는 변수도 없다.
```http label="브라우저 입력 — cookie 하나"
GET http://localhost:8083/bff/api/me
@@ -156,7 +161,7 @@ AP3_SESSION
→ access token + refresh token
```
이 조회를 하는 코드가 `BffController.currentUser(Authentication)`인데, 클라이언트 등록 이름 `"keycloak"`과 현재 `Authentication`으로 `OAuth2AuthorizeRequest`를 만들어 `OAuth2AuthorizedClientManager.authorize()`에 넘긴다. 이 자리에 놓인 manager는 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고, 코드 교환과 refresh를 각각 맡는 provider 2개를 함께 쓴다. 그래서 액세스 토큰이 만료됐고 리프레시 토큰이 남아 있으면 서버끼리 갱신을 시도할 수 있다. 저장소에 그 항목이 없거나 쓸 수 있는 액세스 토큰이 없으면 여기서 401이 되고, 이유로는 `No authorized Keycloak client is available`가 붙는다.
이 조회를 하는 코드가 `BffController.currentUser(Authentication)`인데, 클라이언트 등록 이름 `"keycloak"`과 현재 `Authentication`으로 `OAuth2AuthorizeRequest`를 만들어 `OAuth2AuthorizedClientManager.authorize()`에 넘긴다. 여기에 주입된 manager는 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고, 코드 교환과 refresh를 각각 맡는 provider 2개를 함께 쓴다. 그래서 액세스 토큰이 만료됐고 리프레시 토큰이 있으면 서버끼리 갱신을 시도할 수 있다. 저장소에 그 항목이 없거나 쓸 수 있는 액세스 토큰이 없으면 여기서 401이 되고, 이유로는 `No authorized Keycloak client is available`가 붙는다.
액세스 토큰을 얻으면 BFF의 `RestClient`가 보호 자원 서버로 보낼 요청을 새로 조립한다.
@@ -196,11 +201,11 @@ external observation 브라우저 network → token endpoint 없음
Web Storage → token 문자열 없음
```
앞의 것은 서버가 자기 상태를 스스로 보고한 값이고 뒤의 것은 브라우저에서 관찰한 값이라, 두 근거는 따로 기록한다. 이 엔드포인트는 `OAuth2AuthorizedClientManager.authorize()`를 부르지 않고 `OAuth2AuthorizedClientService`에서 저장된 항목을 직접 조회한다. 그래서 여기를 호출하는 것만으로 액세스 토큰이 갱신되지는 않고, 인증된 세션인데 그 항목이 없으면 서버 보관 여부를 알리는 값 2개가 false인 200이 나올 수도 있다. 어느 경우에도 토큰 원문은 이 응답에 들어가지 않는다.
앞의 것은 서버가 자기 상태를 스스로 보고한 값이고 뒤의 것은 브라우저에서 관찰한 값이다. 이 엔드포인트는 `OAuth2AuthorizedClientManager.authorize()`를 부르지 않고 `OAuth2AuthorizedClientService`에서 저장된 항목을 직접 조회한다. 그래서 여기를 호출하는 것만으로 액세스 토큰이 갱신되지는 않고, 인증된 세션인데 그 항목이 없으면 서버 보관 여부를 알리는 값 2개가 false인 200이 나올 수도 있다. 어느 경우에도 토큰 원문은 이 응답에 들어가지 않는다.
## 쿠키가 자동으로 붙는 요청을 CSRF 토큰으로 가른다
브라우저는 BFF로 가는 요청마다 세션 쿠키를 자동으로 붙이고 상태를 바꾸는 POST에서도 마찬가지라서, 세션 쿠키만 확인해서는 그 요청이 사용자가 보던 화면에서 시작한 것인지 알 수 없다. CSRF(Cross-Site Request Forgery) 토큰은 그 판단을 서버가 하려고 상태 변경 요청에서만 추가로 대조하는 값이다.
브라우저는 BFF로 가는 요청마다 세션 쿠키를 자동으로 붙이고 상태를 바꾸는 POST에서도 마찬가지라서, 세션 쿠키만 확인해서는 그 요청이 사용자가 보던 화면에서 시작한 것인지 알 수 없다. CSRF(Cross-Site Request Forgery) 토큰은 그 요청을 받아들일지 서버가 판단하려고 상태 변경 요청에서만 로 대조하는 값이다.
POST를 보내기 전에 `/bff/csrf`를 부르면 응답 헤더로 `XSRF-TOKEN` 쿠키를 받는다.
@@ -224,15 +229,15 @@ Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
:::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()`가 만들면서 원래 값을 그대로 넣는 반면, 본문에 실리는 값은 `XorCsrfTokenRequestAttributeHandler`가 요청 속성으로 노출하는 토큰이라 XOR와 Base64로 가려진 상태다. 그래서 브라우저 쪽 코드는 본문의 `token`을 헤더 값으로 쓰지 않고, 본문에서는 `headerName`만 읽은 다음 실제 값은 `document.cookie`에서 `XSRF-TOKEN`의 raw 값을 꺼내 그 헤더에 넣는다.
값이 갈리는 것은 쿠키와 응답 본문을 서로 다른 구성요소가 채우기 때문이다. 쿠키는 `CookieCsrfTokenRepository.withHttpOnlyFalse()`가 만들면서 원래 값을 그대로 넣는 반면, 본문에 실리는 값은 `XorCsrfTokenRequestAttributeHandler`가 요청 속성으로 노출하는 토큰이라 XOR와 Base64로 가려진 상태다. 그래서 브라우저 쪽 코드는 본문의 `token`을 헤더 값으로 쓰지 않고, 본문에서는 `headerName`만 읽은 다음 실제 값은 `document.cookie`에서 `XSRF-TOKEN`의 raw 값을 꺼내 그 헤더에 넣는다.
```text label="세 자리의 값이 서로 다르다"
```text label="세 의 값이 서로 다르다"
body.token masked token
cookie XSRF-TOKEN raw token
X-XSRF-TOKEN raw token
```
같은 CSRF 자격 증명이 놓이는 자리는 3개이고 그중 같은 문자열인 것은 쿠키와 요청 헤더 2개라, 정상 요청은 세션 쿠키와 `XSRF-TOKEN` 쿠키에 더해 쿠키에서 읽은 값을 담은 `X-XSRF-TOKEN` 헤더를 함께 싣는다.
같은 CSRF 자격 증명이 쿠키와 응답 본문과 요청 헤더 세 곳에 실리는데, 그중 같은 문자열인 것은 쿠키와 요청 헤더 2개다. 그래서 정상 요청은 세션 쿠키와 `XSRF-TOKEN` 쿠키에 더해, 쿠키에서 읽은 값을 담은 `X-XSRF-TOKEN` 헤더를 함께 싣는다.
```http label="CSRF 검증을 통과하는 POST"
POST http://localhost:8083/bff/api/preferences
@@ -243,7 +248,7 @@ X-XSRF-TOKEN: <same-raw-csrf-token>
theme=dark
```
컨트롤러보다 Spring CSRF 필터가 먼저 돈다. 필터가 저장소의 기대값과 제출된 헤더를 비교해서 헤더가 없거나 값이 다르면 컨트롤러는 실행되지 않고 403이 되고, 통과한 요청만 `theme` 파라미터로 묶여 들어간다. 이 나눔을 맞추는 코드가 `SpaCsrfTokenRequestHandler`인데, 기대하는 헤더가 있으면 plain resolver로 제출된 raw 값을 읽고 없으면 XOR resolver 경로를 쓴다.
컨트롤러보다 Spring CSRF 필터가 먼저 돈다. 필터가 저장소의 기대값과 제출된 헤더를 비교해서 헤더가 없거나 값이 다르면 컨트롤러는 실행되지 않고 403이 되고, 통과한 요청만 `theme` 파라미터에 값을 바인딩해 컨트롤러로 넘어간다. 가려진 본문 값과 raw 쿠키 값을 함께 맞추는 코드가 `SpaCsrfTokenRequestHandler`인데, 기대하는 헤더가 있으면 plain resolver로 제출된 raw 값을 읽고 없으면 XOR resolver 경로를 쓴다.
```json label="컨트롤러가 돌려주는 값"
{
@@ -259,9 +264,9 @@ theme=dark
:::
### 쿠키를 빼는 것과 요청을 거절하는 것은 다른 방어선이다
### SameSite와 CSRF 토큰이 각각 막는 요청
요청 4개로 두 방어선이 각각 어디서 걸리는지 확인했다.
요청 4개로 두 방어선이 각각 어디서 걸리는지 확인했다. 같은 출처에서 온 요청과 포트만 다른 same-site 요청에는 세션 쿠키가 그대로 실렸고, `X-XSRF-TOKEN` 헤더가 없었기 때문에 둘 다 403이 됐다. 쿠키에서 읽은 값을 헤더에 넣은 same-origin 요청만 200이 됐다. `127.0.0.1`에서 `localhost`로 보낸 cross-site POST에서는 `AP3_SESSION` 자체가 요청에 실리지 않았고, 이 요청에서 확인한 것은 최종 상태 코드가 아니라 쿠키가 빠졌다는 사실이다.
| 입력 | 막는 것 | 응답 |
|---|---|---|
@@ -270,11 +275,13 @@ theme=dark
| cross-site POST | SameSite | cookie 누락 |
| same-origin, 값 일치 | 통과 | 200 |
같은 출처에서 온 요청과 포트만 다른 same-site 요청에는 세션 쿠키가 그대로 실렸고, `X-XSRF-TOKEN` 헤더가 없었기 때문에 둘 다 403이 됐다. `SameSite`는 쿠키를 cross-site 요청에 실을지 브라우저가 정하는 정책이고, CSRF 토큰은 그 요청을 받아들일지 서버가 정하는 검사다. 포트가 달라도 site 계산상 같은 site가 되는 경우가 있어서, 쿠키가 빠지는 것만으로는 same-site 요청까지 막지 못한다. `127.0.0.1`에서 `localhost`로 보낸 cross-site POST에서는 `AP3_SESSION` 자체가 요청에 실리지 않았고, 이 요청에서 확인한 것은 최종 상태 코드가 아니라 쿠키가 빠졌다는 사실이다.
`SameSite`는 쿠키를 cross-site 요청에 실을지 브라우저가 정하는 정책이고, CSRF 토큰은 그 요청을 받아들일지 서버가 정하는 검사다. 포트가 달라도 site 계산상 같은 site가 되는 경우가 있어서, 쿠키가 빠지는 것만으로는 same-site 요청까지 막지 못한다.
## 브라우저에서 덜어낸 일이 BFF로 옮겨 온다
## 세션과 토큰을 서버가 들면 무엇을 더 해야 하나
브라우저가 토큰을 들지 않게 되면서 BFF가 새로 맡는 일은 8개인데, 그중 지금 구현에 들어 있는 것은 상태 변경 요청의 CSRF 검증 1개다. 재시작 뒤 로그인 유지, 레플리카가 함께 쓰는 세션, 저장한 토큰 암호화, 로그아웃 때 세션과 authorized client 삭제, downstream 오류를 화면 오류로 바꾸는 처리, 타임아웃·재시도·서킷 브레이커, 경로별 인가 7개는 아직 없다.
이 구성을 실행했을 때 브라우저 쪽 JavaScript가 받는 응답에는 OAuth 토큰이 없었다. BFF 코드를 따라가 보니 BFF가 세션에서 authorized client를 찾아 액세스 토큰을 붙이고 내부 API를 대신 부르고 있었다.
브라우저가 토큰을 들지 않게 되면서 BFF가 새로 맡는 일 가운데 지금 구현에 들어 있는 것은 상태 변경 요청의 CSRF 검증뿐이다. 재시작 뒤 로그인 유지, 레플리카가 함께 쓰는 세션, 저장한 토큰 암호화, 로그아웃 때 세션과 authorized client 삭제, downstream 오류를 화면 오류로 바꾸는 처리, 타임아웃·재시도·서킷 브레이커, 경로별 인가 7개는 아직 없다.
지금 `HttpSession`과 `OAuth2AuthorizedClientService`는 한 프로세스의 메모리를 쓰기 때문에, 재시작 뒤 로그인을 유지하는 것과 여러 레플리카가 같은 세션을 쓰는 것은 이 상태를 어디에 둘지 정해야 처리할 수 있다. 토큰을 찾는 키도 세션 ID가 아니라 클라이언트 등록 이름과 principal 이름이다. 그래서 같은 사용자가 여러 브라우저 세션에서 로그인하면 그 세션들이 같은 항목을 함께 쓰거나, 나중에 로그인한 쪽이 덮어쓸 수 있다.
@@ -282,13 +289,17 @@ CSRF 검증을 눈으로 보려고 둔 `theme` 값도 사용자별 저장소에
### 커밋된 테스트가 확인하도록 정의한 17개
이 기록에서 확인했다고 적은 것은 마지막 실행 성적표가 아니라 커밋된 자동 테스트가 확인하도록 정의한 계약이다. 항목은 17개이고 그중 10개를 확인했다.
이 기록에서 확인했다고 적은 것은 마지막 실행 결과가 아니라 커밋된 자동 테스트가 확인하도록 정의한 계약이다. 항목은 17개이고 그중 10개를 확인했다.
인가 요청에는 `bff-confidential` 클라이언트와 `S256` challenge가 있어야 하고, 로그인 뒤 브라우저 요청 목록에는 Keycloak 토큰 엔드포인트 호출과 8081 직접 호출이 없어야 한다. 쿠키는 `AP3_SESSION`이고 `HttpOnly`와 `SameSite=Lax`가 붙어 있어야 하며 Web Storage는 비어 있어야 한다. `/bff/token-boundary`의 서버 보관 값 2개는 true여야 하고 `/bff/api/me`는 200과 함께 `username`과 기대한 audience를 돌려줘야 한다. CSRF 헤더가 없는 POST는 403, 쿠키에서 읽은 값을 헤더에 넣은 POST는 200, cross-site POST에서는 쿠키가 빠져야 한다.
나머지 7개는 이 계약 밖이라, `theme` 값의 사용자별 격리와 영속성, 여러 레플리카가 함께 쓰는 세션 저장소, 저장한 토큰 암호화, 로그아웃, downstream 401을 어떤 모양으로 전달하는지, 타임아웃과 경로별 인가는 확인하지 않았다.
브라우저가 OAuth 토큰을 받으면 안 되고 백엔드가 화면에 필요한 여러 API를 조합해야 한다면 이 구조를 고른다. OAuth 흐름을 브라우저에서 직접 확인하는 것이 목적이면 SPA 구조가, 브라우저의 보호 자원 서버 직접 호출을 유지해야 한다면 Mediator가 맞는다.
## 이 구조를 고를 때 함께 오는 서버 상태와 CSRF 검증
대신 BFF는 요청을 넘겨 주기만 하는 프록시가 아니라 로그인 상태와 토큰을 든 보안 구성요소가 됐고, 화면의 모든 요청이 이곳을 지나므로 지연과 장애 지점도 여기로 모인다. 지금 구현은 그 비용을 단일 인스턴스 메모리와 검증 1개로만 보여 주고 있어서, 세션과 authorized client를 어디에 두고 저장한 토큰을 어떻게 암호화할지는 아직 정하지 않았다.
이 구조로 온 이유는 앞선 Mediator 구조가 리프레시 토큰만 서버로 옮기고 액세스 토큰은 브라우저 응답에 그대로 내려보냈기 때문이다. 액세스 토큰까지 브라우저에서 없애려면 코드 교환뿐 아니라 API 호출도 서버가 대신해야 했고, 그 몫을 BFF가 맡았다.
브라우저가 OAuth 토큰을 받으면 안 되고 백엔드가 화면에 필요한 여러 API를 조합해야 한다면 이 구조를 고른다. 다운스트림 API가 늘어나도 브라우저는 BFF 하나만 알면 되고, 토큰 갱신과 제공자마다 다른 처리도 서버 안에 둔다. OAuth 흐름을 브라우저에서 직접 확인하는 것이 목적이면 SPA(Single Page Application) 구조가, 브라우저의 보호 자원 서버 직접 호출을 유지해야 한다면 Mediator가 맞는다.
대신 BFF는 요청을 넘겨 주기만 하는 프록시가 아니라 로그인 상태와 토큰을 든 보안 구성요소가 됐고, 화면의 모든 요청이 이곳을 지나므로 지연과 장애 지점도 여기로 모인다. 지금 구현은 그 상태를 한 프로세스 메모리에 두고 검증 1개만 걸어 둔 단계라, 세션과 authorized client를 어디에 둘지와 저장한 토큰을 어떻게 암호화할지는 앞으로 정해야 한다.
<!-- body:end -->
@@ -14,62 +14,67 @@ public: "https://hyeonworks.com/cases/identity-header-trust"
assets:
- key: ap4-edge-trust-1cff2399
file: ../../../final/assets/tech-log-studio/ap4-edge-trust.svg
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap4
- final/document.md#선택의-이유와-지킨-경계-ap4
- final/document.md#결정이-지켜지는지-확인하는-방법-ap4
---
# Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
앞단에 세운 프록시가 로그인을 대신 받으면 upstream은 OAuth를 몰라도 되고, 대신 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단한다. 이 헤더는 인증을 마친 프록시가 붙일 수도 있고 브라우저가 직접 적어 보낼 수도 있는데, 두 값은 upstream이 받은 요청에서 이름도 형식도 같아 서로 구분되지 않는다.
앞단에 세운 프록시가 로그인을 대신 받으면 업스트림은 OAuth를 몰라도 되고, 대신 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단한다. 이 헤더는 인증을 마친 프록시가 붙일 수도 있고 브라우저가 직접 적어 보낼 수도 있는데, 두 값은 업스트림이 받은 요청에서 이름도 형식도 같아 서로 구분되지 않는다.
그래서 이 구성에서는 헤더를 믿을 조건을 세 곳에 나눠 두었다. 밖에서 들어오는 길을 Nginx 8088 하나로 줄이고, Nginx가 클라이언트의 동명 헤더를 자기 값으로 덮어쓰고, upstream이 사용자 헤더와 함께 내부 토큰까지 대조한다. 호스트 포트를 닫아도 같은 Compose 네트워크 안에서는 app의 8081에 닿을 수 있고 그 요청은 Nginx를 거치지 않으니, 덮어쓰기도 함께 지나친다. upstream이 내부 토큰을 따로 대조하는 것은 그 요청을 걸러 내기 위해서다.
그래서 이 구성에서는 헤더를 믿을 조건을 세 곳에 나눠 두었다. 밖에서 들어오는 길을 Nginx 8088 하나로 줄이고, Nginx가 클라이언트의 동명 헤더를 자기 값으로 덮어쓰고, 업스트림이 사용자 정보 헤더와 함께 내부 토큰까지 대조한다. 호스트 포트를 닫아도 같은 Compose 네트워크 안에서는 app의 8081에 닿을 수 있고 그 요청은 Nginx를 거치지 않으니, 덮어쓰기도 함께 지나친다. 업스트림이 내부 토큰을 따로 대조하는 것은 그 요청을 걸러 내기 위해서다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
그 기준이 세운 다섯 조건을 Nginx 설정과 upstream 코드에서 하나씩 찾아 어디에 들어가 있는지 확인했다.
그 기준이 세운 다섯 조건을 Nginx 설정과 업스트림 코드에서 하나씩 찾아 어디에 들어가 있는지 확인했다.
- **OAuth Token과 Application Session을 구분하는 기준**
AP4_SESSION은 브라우저와 Nginx 사이에서만 오가고 identity 헤더는 Nginx와 upstream 사이에서만 붙으며, upstream은 JWT를 입력으로 받지 않는다.
AP4_SESSION은 브라우저와 Nginx 사이에서만 오가고 사용자 정보 헤더는 Nginx와 업스트림 사이에서만 붙으며, 업스트림은 JWT를 입력으로 받지 않는다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 구조를 고르면 OAuth를 모르는 upstream 앞에 공통 관문이 생기는 대신, 네트워크 경로와 헤더를 어디까지 믿을지를 따로 정해 두어야 한다.
이 구조를 고르면 OAuth를 모르는 업스트림 앞에 공통 관문이 생기는 대신, 네트워크 경로와 헤더를 어디까지 믿을지를 따로 정해 두어야 한다.
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
지금 edge는 사용자와 이메일만 넘기고 role은 넘기지 않는데, 그러면 인가를 어디서 판단할지가 남는다.
지금 엣지는 사용자와 이메일만 넘기고 role은 넘기지 않는데, 그러면 인가를 어디서 판단할지 정해야 한다.
## 문제
앞단 프록시가 로그인을 맡으면 upstream은 OAuth를 몰라도 되고, 대신 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단하게 된다. 그러려면 upstream은 이 헤더를 인증을 마친 edge가 붙였는지 브라우저가 직접 적어 넣었는지 가릴 수 있어야 한다.
앞단 프록시가 로그인을 맡으면 업스트림은 OAuth를 몰라도 되고, 대신 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단다. 그러려면 업스트림은 이 헤더를 인증을 마친 엣지가 붙였는지 브라우저가 직접 적어 넣었는지 가릴 수 있어야 한다.
가릴 방법이 없다. 두 값은 upstream이 받은 요청에서 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 자리가 없기 때문이다.
가릴 방법이 없다. 두 값은 업스트림이 받은 요청에서 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 곳이 없기 때문이다.
그래서 backend port가 외부에 열려 있거나 Nginx가 브라우저의 동명 헤더를 그대로 넘기면, 공격자가 원하는 이름을 적어 인증된 사용자처럼 요청을 보낼 수 있다.
그래서 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 동명 헤더를 그대로 넘기면, 공격자가 원하는 이름을 적어 인증된 사용자처럼 요청을 보낼 수 있다.
## 결론
헤더를 인증 근거로 쓰려면 세 곳에서 막아야 한다.
host port : 외부에서 upstream과 프록시로 바로 가는 경로를 막는다
Nginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다
upstream internal token : edge를 거치지 않은 내부 요청을 막는다
호스트 포트 : 외부에서 업스트림과 프록시로 바로 가는 경로를 막는다
Nginx 헤더 덮어쓰기 : 클라이언트가 보낸 동명 헤더를 합치지 않고 덮어쓴다
업스트림 내부 토큰 : 엣지를 거치지 않은 내부 요청을 막는다
네트워크를 나눠 두는 것만으로는 내부 workload가 보낸 요청이나 Nginx가 잘못 넘긴 헤더가 신뢰되는 것까지 막지는 못한다. 공유 토큰만 두고 포트를 열어 두면, 그 토큰을 얻은 쪽이 Nginx를 건너뛰고 upstream에 바로 붙을 수 있다.
네트워크를 나눠 두는 것만으로는 내부 서비스가 보낸 요청이나 Nginx가 잘못 넘긴 헤더까지 걸러 내지는 못한다. 공유 토큰만 두고 포트를 열어 두면, 그 토큰을 얻은 쪽이 Nginx를 건너뛰고 업스트림에 바로 붙을 수 있다.
## 검증 환경
Keycloak 26.7.0, oauth2-proxy 7.15.2
client : edge-proxy
클라이언트 : edge-proxy
confidential, PKCE S256 : o
외부 공개
Nginx : 8088
app 8081, oauth2-proxy 4180 : Compose network에 expose만, host publish x
app 8081, oauth2-proxy 4180 : Compose 네트워크에 expose만, 호스트 publish x
Nginx
auth_request /oauth2/auth
location = /oauth2/auth : internal
auth_request_set : user, email, Set-Cookie 복사
client 제공 동명 헤더 : 덮어쓰기
trusted proxy : 단일 IP
클라이언트가 보낸 동명 헤더 : 덮어쓰기
신뢰할 프록시 : 단일 IP
upstream
업스트림
EdgeIdentityController.currentUser(HttpServletRequest)
X-Internal-Auth-Token 비교 : MessageDigest.isEqual
SecurityConfig의 /edge/** : permitAll
@@ -77,11 +82,11 @@ SecurityConfig의 /edge/** : permitAll
AP4_SESSION
HttpOnly : true
SameSite : Lax
Secure : false, local HTTP fixture 기준
Secure : false, 로컬 HTTP 학습 환경 기준
expire : 1 hour, 프록시 설정값
session-cookie-minimal : true
server-side session store : x
서버 쪽 세션 저장소 : x
automatic discovery : x
login, token, JWKS, userinfo URL : 각각 따로 관리
@@ -89,17 +94,17 @@ HTTP : o
## 재현 조건
1. cookie 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인한다.
1. 쿠키 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인한다.
2. cookie 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인한다.
2. 쿠키 없이 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를 읽을 수 없어야 한다.
4. 로그인 뒤 쿠키가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
브라우저 요청 목록에 Keycloak 토큰 엔드포인트가 없어야 한다.
Web Storage가 비어 있어야 하고 document.cookie로 세션 쿠키를 읽을 수 없어야 한다.
5. 정상 session에 다음 헤더를 얹어 GET /api/edge를 보낸다.
5. 정상 세션에 다음 헤더를 얹어 GET /api/edge를 보낸다.
X-Auth-Request-User : spoofed-admin
X-Auth-Request-Email : spoofed-admin@example.test
X-Internal-Auth-Token : attacker-controlled-token
@@ -110,22 +115,24 @@ X-Internal-Auth-Token : attacker-controlled-token
7. host의 4180과 8081에 접근할 수 없는지 확인한다.
8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고,
8. 내부에서 /edge/me를 부를 때 사용자 정보 헤더만 있거나 내부 토큰이 없거나 틀리면 401이고,
둘 다 맞으면 200인지 확인한다.
## 본문
<!-- body:start -->
forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 묻는 방식이고, Nginx에서는 `auth_request` 디렉티브가 그 질문을 subrequest로 만든다. 이 두 낱말만 알면 따라올 수 있고, 나머지 용어는 쓰는 자리에서 푼다. 먼저 위조 요청의 모양부터 보고, 그것을 막는 세 곳을 하나씩 따라간 뒤, 지금 확인한 범위와 확인하지 않은 범위를 나눠 적는다.
forward-auth는 실제 요청을 업스트림으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 묻는 방식이고, Nginx에서는 `auth_request` 디렉티브가 그 질문을 하위 요청(subrequest)으로 만든다. 먼저 위조 요청의 모양부터 보고, 그것을 막는 세 곳을 하나씩 따라간 뒤, 지금 확인한 범위와 확인하지 않은 범위를 나눠 적는다.
## 같은 이름의 헤더가 두 곳에서 만들어진다
:::evidence key="ap4-edge-trust-1cff2399" alt="왼쪽 외부 영역의 브라우저에 AP4_SESSION과 점선으로 표시된 클라이언트 제공 헤더가 있다. 가운데 Nginx는 8088만 공개하고 헤더 덮어쓰기를 맡는다. 오른쪽 점선 영역은 호스트 포트가 닫혀 있고 oauth2-proxy와 Spring upstream이 들어 있다. Nginx가 oauth2-proxy에 auth_request를 보내 사용자와 이메일을 받고, Nginx가 만든 헤더와 내부 토큰으로 upstream 요청을 만든다." caption="" zoom="true"
:::
앞단 프록시가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다. 로그인과 세션 검증은 앞단에 세운 oauth2-proxy가 맡는데, 이렇게 로그인을 대신 받는 관문을 edge라고 부른다. upstream은 요청에 붙어 온 `X-Auth-Request-User` 하나로 사용자를 판단한다. 이 헤더는 oauth2-proxy가 확인한 로그인 사용자의 이름을 담아 edge가 upstream 요청에 붙이는 값이다.
앞단 프록시가 로그인을 맡으면 업스트림은 OAuth를 몰라도 된다. 로그인과 세션 검증은 앞단에 세운 oauth2-proxy가 맡는데, 이렇게 로그인을 대신 받는 관문을 엣지라고 부른다. 업스트림은 요청에 붙어 온 `X-Auth-Request-User` 하나로 사용자를 판단한다. 이 헤더는 oauth2-proxy가 확인한 로그인 사용자의 이름을 담아 엣지가 업스트림 요청에 붙이는 값이고, 아래에서는 엣지가 이렇게 만들어 붙이는 값을 사용자 정보 헤더라고 부른다.
같은 이름의 헤더는 브라우저도 직접 적어 보낼 수 있다. upstream이 받는 요청에서 두 값은 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 자리가 없다. 그래서 upstream`X-Auth-Request-User`edge에서 온 값인지 브라우저가 적어 넣은 값인지 가리지 못한다. 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 보낼 수 있다.
같은 이름의 헤더는 브라우저도 직접 적어 보낼 수 있다. 업스트림이 받는 요청에서 두 값은 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 곳이 없다. 그래서 업스트림`X-Auth-Request-User`엣지에서 온 값인지 브라우저가 적어 넣은 값인지 가리지 못한다. 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 보낼 수 있다.
이 구조를 고른 이유는 업스트림에 OAuth 코드를 넣기 어려워서였다. 업스트림을 거의 고치지 않으려고 앞단에 관문을 세웠는데, 세우고 나니 외부에서 위조할 수 있는 헤더를 그대로 믿는 구성이 됐다. 아래 세 곳은 그다음에 붙인 것이다.
### 상태 코드가 아니라 응답의 user로 판정한다
@@ -139,23 +146,25 @@ X-Auth-Request-Email: spoofed-admin@example.test
X-Internal-Auth-Token: attacker-controlled-token
```
세션 자체는 유효하므로 이 요청이 200으로 처리되는 것은 정상이고, 그래서 이 테스트의 판정 기준은 상태 코드가 아니다. 응답 코드만 보면 위조가 통했는지 알 수 없어서, 확인할 값은 응답의 `user` `spoofed-admin`으로 바뀌지 않고 실제로 인증된 사용자로 남았는지다.
세션 자체는 유효하므로 이 요청이 200으로 처리되는 것은 정상이다. 볼 값은 응답의 `user`다. 여기에 `spoofed-admin`이 아니라 실제로 인증된 사용자가 들어 있어야 이 검사를 통과한다.
## 세 곳에서 나눠 막는다
헤더를 믿으려면 세 곳에서 막아야 한다. 각각이 걸러 내는 요청과 놓치는 요청이 다르므로, 아래에서 하나씩 무엇을 통과시키는지 함께 적는다.
헤더를 믿으려면 세 곳에서 막아야 한다. 각각이 걸러 내는 요청과 놓치는 요청이 다르다.
앞단을 무엇으로 세울지에는 Traefik ForwardAuth도 있었다. 인증 판단을 맡길 수는 있지만 OIDC(OpenID Connect) 클라이언트나 세션 관리자 자체는 아니고, 지금 Nginx가 내는 속성을 그대로 내려면 네 가지가 더 필요하다. `trustForwardHeader=false`, 허용 목록에 있는 인증 응답 헤더만 복사, 로그인 리다이렉트를 따로 만드는 일, 그리고 업스트림 내부 토큰이나 더 강한 서비스 신원(workload identity) 주입이다. 마지막 항목이 대안 설정에 없어서 그대로 바꿔 끼울 수 있다고는 확인하지 못했다. Nginx를 쓴 것은 `auth_request`와 401 처리, 헤더 추출과 덮어쓰기를 한 파일에서 볼 수 있어서다.
### 밖에서 들어올 수 있는 길을 8088 하나로 줄인다
밖으로 연 포트는 Nginx의 8088 하나다. `app`의 8081과 oauth2-proxy의 4180은 Compose 네트워크에 `expose`만 하고 호스트 `ports`로는 내보내지 않아서, 포트 2개에는 밖에서 직접 붙을 수 없다.
인증 엔드포인트도 같은 이유로 닫아 두는데, `location = /oauth2/auth`가 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있고, 외부에서 같은 경로를 부르면 404가 된다. `internal` 지정이 없으면 이 엔드포인트가 밖에서 부를 수 있는 인증 우회 지점이 된다.
인증 엔드포인트도 같은 이유로 닫아 두는데, `location = /oauth2/auth`가 `internal`이라 Nginx가 만든 하위 요청만 들어갈 수 있고, 외부에서 같은 경로를 부르면 404가 된다. `internal` 지정이 없으면 이 엔드포인트가 밖에서 부를 수 있는 인증 우회 지점이 된다.
이 경계가 막는 것은 edge를 건너뛰고 upstream이나 프록시로 바로 가는 경로여서, 내부 workload가 보낸 요청이나 Nginx가 잘못 넘긴 헤더는 여기서 걸리지 않는다.
이 경계가 막는 것은 엣지를 건너뛰고 업스트림이나 프록시로 바로 가는 경로여서, 내부 서비스가 보낸 요청이나 Nginx가 잘못 넘긴 헤더는 여기서 걸리지 않는다.
### 클라이언트가 보낸 헤더를 덮어써서 지운다
Nginx는 upstream을 부르기 전에 인증 결과를 먼저 묻는다. `auth_request`는 원래 요청을 처리하기 전에 지정한 경로로 subrequest를 보내고 그 응답 코드로 요청을 계속할지 정하는 디렉티브다.
Nginx는 업스트림을 부르기 전에 인증 결과를 먼저 묻는다. `auth_request`는 원래 요청을 처리하기 전에 지정한 경로로 하위 요청을 보내고 그 응답 코드로 요청을 계속할지 정하는 디렉티브다.
```nginx label="upstream을 부르기 전에 먼저 물어본다"
auth_request /oauth2/auth;
@@ -169,7 +178,7 @@ $auth_email ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie
```
그다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 헤더 3개는 클라이언트가 보낸 값과 **merge하지 않고 덮어쓰기**로 채워진다.
그다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 헤더 3개는 클라이언트가 보낸 값과 **합치지 않고 덮어쓰기**로 채워진다.
```http label="upstream이 실제로 받는 요청"
GET http://app:8081/edge/me
@@ -178,9 +187,9 @@ X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
X-Internal-Auth-Token: <nginx-environment-secret>
```
그래서 클라이언트가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다. 신뢰할 프록시 범위도 IP 1개로 좁혀 두었는데, 이 범위를 넓게 잡으면 같은 내부 네트워크에 있는 다른 서비스가 신뢰받는 프록시처럼 요청을 보낼 수 있기 때문이다.
그래서 클라이언트가 무엇을 보냈든 업스트림 입력은 oauth2-proxy가 확인한 값이 된다. 신뢰할 프록시 범위도 IP 1개로 좁혀 두었는데, 이 범위를 넓게 잡으면 같은 내부 네트워크에 있는 다른 서비스가 신뢰받는 프록시처럼 요청을 보낼 수 있기 때문이다.
### upstream이 내부 토큰까지 확인한다
### 업스트림이 내부 토큰까지 확인한다
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는데, 여기서 확인하는 값은 2개다. `X-Auth-Request-User`를 읽어 비어 있는지 보고, `X-Internal-Auth-Token`을 읽어 배포할 때 설정해 둔 내부 토큰(internal token)과 비교한다.
@@ -207,11 +216,11 @@ X-Internal-Auth-Token: <nginx-environment-secret>
:::danger
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` 컨트롤러가 직접 내부 토큰을 확인한다. 새 edge 엔드포인트를 추가하면서 같은 메서드를 부르지 않으면 그 엔드포인트는 보호되지 않는다.
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` 컨트롤러가 직접 내부 토큰을 확인한다. 새 엣지 엔드포인트를 추가하면서 같은 메서드를 부르지 않으면 그 엔드포인트는 보호되지 않는다.
:::
검사가 컨트롤러 하나에만 들어 있어서 운영으로 넘어갈 때는 필터나 인터셉터, security chain처럼 대상 엔드포인트 전체에 걸리는 공통 경계로 옮겨야 한다. 이 검사가 걸러 내는 것은 edge를 거치지 않고 들어온 내부 요청이다. 다만 토큰을 얻은 쪽에는 소용이 없으므로, 호스트 포트 닫아 두는 일은 그대로 남는다.
검사가 컨트롤러 하나에만 들어 있어서 운영으로 넘어갈 때는 필터나 인터셉터, 시큐리티 체인처럼 대상 엔드포인트 전체에 걸리는 공통 경계로 옮겨야 한다. 이 검사가 걸러 내는 것은 엣지를 거치지 않고 들어온 내부 요청이다. 다만 토큰을 얻은 쪽에는 소용이 없으므로, 호스트 포트는 계속 닫아 두어야 한다.
### 경로에 따라 다른 코드가 돌아온다
@@ -225,16 +234,18 @@ X-Internal-Auth-Token: <nginx-environment-secret>
| `GET /api/edge` | 미인증 | 리다이렉트 없는 401 |
| `GET /oauth2/auth` | 무관 | 404 |
| `GET /` + 위조 헤더 | 정상 세션 | 실제 사용자 200 |
| `/edge/me` + 사용자 헤더만 | 내부 토큰 없음 | 401 |
| `/edge/me` + 사용자 정보 헤더만 | 내부 토큰 없음 | 401 |
| `/edge/me` + 틀린 토큰 | 토큰 불일치 | 401 |
위의 네 줄은 밖에서 들어온 요청이고, 아래 두 줄은 edge를 거치지 않고 내부에서 `/edge/me`로 바로 들어온 요청이다.
위의 네 줄은 밖에서 들어온 요청이고, 아래 두 줄은 엣지를 거치지 않고 내부에서 `/edge/me`로 바로 들어온 요청이다.
## 이 fixture가 보장하는 범위
## 이 학습 환경이 보장하는 범위
여기서 확인한 것은 Keycloak 26.7.0과 oauth2-proxy 7.15.2를 한 대에서 돌리는 학습 환경이다. 쿠키 속성과 리다이렉트를 눈으로 보려고 HTTPS 대신 HTTP를 쓴 설정도 있다. 코드를 실행해 봤다고 운영까지 확인한 것은 아니어서,
### 브라우저에 남는 것은 opaque 쿠키 하나다
로그인이 끝나면 브라우저에는 `AP4_SESSION` 쿠키 하나가 남고, 서버 쪽 세션 저장소는 따로 두지 않았다. `session-cookie-minimal=true`를 쓰면 쿠키에는 access·refresh·ID 토큰 대신 edge에 필요한 최소 정보만 남는다. 여기서 opaque는 브라우저가 값을 해석하지 않고 다음 요청에 그대로 돌려준다는 뜻이다.
여기서 opaque는 브라우저가 값을 해석하지 않고 다음 요청에 그대로 돌려준다는 뜻이다. 로그인이 끝나면 브라우저에는 `AP4_SESSION` 쿠키 하나가 남고, 서버 쪽 세션 저장소는 따로 두지 않았다. `session-cookie-minimal=true`를 쓰면 쿠키에는 액세스·리프레시·ID 토큰 대신 엣지에 필요한 최소 정보만 남는다.
```text label="AP4_SESSION 쿠키 설정"
name = AP4_SESSION
@@ -244,7 +255,7 @@ Secure = false in local HTTP fixture
expire = 1 hour in proxy configuration
```
`HttpOnly`가 붙어 있어 JavaScript로 읽을 수 없고, 유효 기간은 프록시 설정에서 1시간이다. `Secure`가 `false`인 것은 local HTTP fixture 기준이라 그렇고, HTTPS로 올리면 `Secure = true`로 바꿔야 한다. 레플리카를 늘린다면 같은 쿠키를 검증할 시크릿을 어떻게 배포하고 교체할지도 정해야 하는데, 지금 fixture에는 정해 둔 것이 없다.
`HttpOnly`가 붙어 있어 JavaScript로 읽을 수 없고, 유효 기간은 프록시 설정에서 1시간이다. `Secure`가 `false`인 것은 로컬에서 HTTP로 띄운 학습 환경 기준이라 그렇고, HTTPS로 올리면 `Secure = true`로 바꿔야 한다. 레플리카를 늘린다면 같은 쿠키를 검증할 시크릿을 어떻게 배포하고 교체할지도 정해야 하는데, 지금 학습 환경에는 정해 둔 것이 없다. 1시간이 지난 뒤 재인증이 어떻게 이어지는지, 쿠키에 담기지 않는 리프레시 토큰의 수명주기가 어떻게 되는지도 확인하지 않았다.
코드 교환은 브라우저가 아니라 oauth2-proxy가 컨테이너 안에서 하기 때문에, automatic discovery를 끄고 같은 realm을 가리키는 주소 4개를 각각 관리한다.
@@ -255,31 +266,31 @@ redeem/token URL = http://keycloak:8080/.../token
JWKS/userinfo URL = http://keycloak:8080/...
```
issuer는 요청을 보내기 위한 주소가 아니라 Keycloak이 발급한 토큰의 `iss` claim이 기대한 값과 같은지 검증하는 기준값이다. 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없어서 로그인에는 `localhost:8080`을 쓰고, 컨테이너 안에서는 자기 `localhost:8080`이 Keycloak이 아니므로 토큰과 JWKS(JSON Web Key Set) 요청에는 `keycloak:8080`을 쓴다.
issuer는 요청을 보내기 위한 주소가 아니라 Keycloak이 발급한 토큰의 `iss` claim이 기대한 값과 같은지 검증하는 기준값이다. 브라우저는 Docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없어서 로그인에는 `localhost:8080`을 쓰고, 컨테이너 안에서는 자기 `localhost:8080`이 Keycloak이 아니므로 토큰과 JWKS(JSON Web Key Set) 요청에는 `keycloak:8080`을 쓴다.
### upstream이 믿는 입력이 JWT 1개에서 3개로 바뀐다
### 업스트림이 믿는 입력
앞의 세 구조에서는 Resource Server가 서명된 JWT를 받아 서명과 issuer, audience를 직접 확인한다. `/edge/me`는 JWT를 입력으로 받지 않는다. 대신 요청이 edge를 거쳐 들어왔다는 네트워크 위치와 `X-Internal-Auth-Token`, edge가 넘긴 사용자와 이메일을 믿는다. 믿는 입력이 JWT 1개에서 3개로 늘어난 셈이라, 백엔드 직접 경로나 사용자 제공 헤더 중 하나만 열려도 다른 사용자처럼 요청을 보낼 수 있게 된다.
앞의 세 구조에서는 Resource Server가 서명된 JWT를 받아 서명과 issuer, audience를 직접 확인한다. `/edge/me`는 JWT를 입력으로 받지 않는다. 대신 요청이 엣지를 거쳐 들어왔다는 네트워크 위치와 `X-Internal-Auth-Token`, 엣지가 넘긴 사용자와 이메일을 믿는다. 믿는 입력이 JWT 1개에서 3개로 늘어난 셈이라, 백엔드 직접 경로나 클라이언트가 보낸 헤더 중 하나만 열려도 다른 사용자처럼 요청을 보낼 수 있다.
지금 edge 응답은 사용자와 이메일만 전달하고 role, groups, tenant, 인증 방식, 토큰 만료는 전달하지 않는다. 패턴이 금지하는 것은 아니지만, 헤더를 하나 늘릴 때 아래 6개를 함께 정해야 한다.
지금 엣지 응답은 사용자와 이메일만 전달하고 role, groups, tenant, 인증 방식, 토큰 만료는 전달하지 않는다. 패턴이 금지하는 것은 아니지만, 헤더를 하나 늘릴 때 아래 6개를 함께 정해야 한다.
- claim 출처 : oauth2-proxy나 별도 인증 서비스가 어느 값을 읽는가
- 허용 목록 : Nginx가 어느 응답 헤더만 복사하는가
- 덮어쓰기 : 클라이언트가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가
- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지
- 갱신 : role이 바뀌면 프록시 세션과 downstream 인가 언제 따라가는가
- 직렬화 : 다중 값, 구분자, 이스케이프, 최대 크기는 무엇인가
- 업스트림 검증 : 헤더 존재만 볼지 값과 서비스 신원까지 볼지
- 갱신 : role이 바뀌면 프록시 세션과 다운스트림 인가 언제 반영되는가
### 커밋된 테스트가 확인하도록 정의한 16개
이 기록에서 확인했다고 적은 것은 마지막 실행 성적표가 아니라, 커밋된 자동 테스트가 확인하도록 정의한 계약이다. 항목은 16개이고 그중 10개를 확인했다.
쿠키 없는 `/`는 302를 받고 쿠키 없는 `/api/edge`는 401을 받는다. authorization request에는 `edge-proxy` 클라이언트와 PKCE(Proof Key for Code Exchange) S256 challenge가 들어 있어야 한다. 로그인 뒤 쿠키는 `AP4_SESSION`이고 `HttpOnly`와 `SameSite=Lax`가 붙어 있어야 하며, 브라우저 요청 목록에 Keycloak 토큰 엔드포인트가 없고 Web Storage가 비어 있고 `document.cookie`로 세션 쿠키를 읽을 수 없어야 한다. 위조 헤더를 얹은 요청은 실제 사용자로 200을 받고, 외부에서 부른 `/oauth2/auth`는 404, 호스트의 4180과 8081은 접근 불가여야 한다. 사용자 헤더가 없거나 내부 토큰이 없거나 틀리면 401이다.
쿠키 없는 `/`는 302를 받고 쿠키 없는 `/api/edge`는 401을 받는다. authorization request에는 `edge-proxy` 클라이언트와 PKCE(Proof Key for Code Exchange) S256 challenge가 들어 있어야 한다. 로그인 뒤 쿠키는 `AP4_SESSION`이고 `HttpOnly`와 `SameSite=Lax`가 붙어 있어야 하며, 브라우저 요청 목록에 Keycloak 토큰 엔드포인트가 없고 Web Storage가 비어 있고 `document.cookie`로 세션 쿠키를 읽을 수 없어야 한다. 위조 헤더를 얹은 요청은 실제 사용자로 200을 받고, 외부에서 부른 `/oauth2/auth`는 404, 호스트의 4180과 8081은 접근 불가여야 한다. 사용자 정보 헤더가 없거나 내부 토큰이 없거나 틀리면 401이다.
나머지 6개는 이 계약 밖이다. role 전달, 새 엔드포인트에 검사를 공통으로 거는 것, 상태를 바꾸는 요청의 CSRF(Cross-Site Request Forgery), 세션 갱신, 레플리카 사이의 시크릿 공유, 내부 시크릿 교체는 확인하지 않았다.
지금 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾸기 때문에 `/orders/123` 같은 임의 경로를 보존하는 범용 리버스 프록시가 아니고, 그래서 path와 method, body, streaming, websocket, 큰 헤더 동작은 입증하지 못했다.
지금 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾸기 때문에 `/orders/123` 같은 임의 경로를 보존하는 범용 리버스 프록시가 아니고, 그래서 경로와 메서드, 요청 본문, 스트리밍, 웹소켓, 큰 헤더 동작은 입증하지 못했다.
upstream이 OAuth를 몰라도 되는 대신, 이 구조에서 믿어야 하는 입력은 서명된 JWT 1개에서 3개로 늘었다. 그 3개를 지키는 일 가운데 내부 토큰 검사를 컨트롤러 밖 공통 경계로 옮기는 것과, 레플리카 사이에서 세션 시크릿을 배포하고 교체하는 방법을 정하는 것은 아직 남아 있다.
업스트림이 OAuth를 몰라도 되는 대신, 이 구조는 네트워크 경로와 identity header와 내부 토큰을 믿어야 한다. 그것을 지키려면 내부 토큰 검사를 컨트롤러 밖 공통 경계로 옮기, 레플리카 사이에서 세션 시크릿을 배포하고 교체하는 방법을 정해야 한다. 둘 다 아직 하지 않았다.
<!-- body:end -->
@@ -14,42 +14,47 @@ public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
assets:
- key: ap1-custody-v3-6e0376d2
file: ../../../final/assets/tech-log-studio/ap1-credential-custody.svg
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap1
- final/document.md#선택의-이유와-지킨-경계-ap1
- final/document.md#결정이-지켜지는지-확인하는-방법-ap1
---
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
AP1에서는 SPA를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 access token, refresh token, ID token은 Web Storage에 저장하지 않고 JavaScript memory에만 둔다.
AP1에서는 SPA(Single Page Application)를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 액세스 토큰, 리프레시 토큰, ID 토큰은 Web Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
이렇게 하면 새로고침 뒤에는 token이 남지 않는다. 하지만 페이지가 열려 있는 동안에는 JavaScript에서 token을 사용하고 있고, Resource Server를 호출할 때도 access token을 `Authorization` 헤더에 넣는다. 실행 중 XSS가 발생했을 때 영향을 받는 부분은 그대로 남아 있다.
그래서 새로고침 뒤에는 토큰이 사라진다. 만 페이지가 열려 있는 동안에는 JavaScript가 토큰을 그대로 다루고, Resource Server를 부를 때도 액세스 토큰이 `Authorization` 헤더에 실린다. memory-only는 실행 중 XSS(Cross-Site Scripting, 사이트 간 스크립팅)가 같은 origin의 사용자 권한으로 API를 부르는 것을 막지 못한다.
## 관계
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
SPA에서 authorization request를 보내고 authorization code를 받은 뒤 token을 교환하는 과정을 직접 확인한 내용이다.
SPA에서 authorization request를 보내고 authorization code를 받은 뒤 토큰을 교환하는 과정을 직접 확인한 내용이다.
- **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 상태는 남아 있을 수 있다.
JavaScript 메모리에 있는 토큰과 Keycloak의 SSO 쿠키는 서로 다른 상태여서, 새로고침으로 SPA의 토큰이 없어져도 Keycloak의 SSO까지 끝나는 것은 아니다.
## 문제
AP1에서는 token을 Local Storage나 Session Storage에 저장하지 않고 JavaScript memory에만 둔다.
AP1에서는 토큰을 Local Storage나 Session Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
확인하고 싶었던 부분은 token을 Web Storage에 저장하지 않는 것만으로 실행 중 XSS까지 막을 수 있는지였다.
확인하고 싶었던 것은 토큰을 Web Storage에 지 않는 것만으로 실행 중 XSS까지 막을 수 있는지였다.
SPA가 authorization code를 직접 교환한 뒤 token을 어디에 가지고 있는지, API를 호출할 때 access token이 어디를 지나는지 확인했다. PKCE 실제로 어느 구간에 적용되는지 같이 봤다.
그래서 SPA가 authorization code를 직접 교환한 뒤 토큰을 어디에 고 있는지, API를 부를 때 액세스 토큰이 어느 곳을 지나는지 따라갔다. PKCE 실제로 어느 구간에 적용되는지 같이 봤다.
## 결론
memory-only로 보관하면 새로고침 뒤에는 access token, refresh token, ID token이 남지 않는다.
memory-only로 보관하면 새로고침 뒤에는 액세스·리프레시·ID 토큰이 모두 사라진다.
하지만 페이지가 실행 중일 때는 JavaScript에서 token을 사용한다. 악성 script가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해 API를 호출할 수 있다. access token도 JavaScript memory에만 있는 것이 아니 Resource Server 요청의 Authorization 헤더에 들어간다.
만 페이지가 실행 중일 때는 JavaScript가 토큰을 다루므로, 악성 스크립트가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. 액세스 토큰은 JavaScript 메모리에만 있는 값도 아니어서 Resource Server 요청의 Authorization 헤더에도 실린다.
Resource Server는 SessionCreationPolicy.STATELESS로 동작한다. 서버에서 삭제할 application session이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 다.
Resource Server는 SessionCreationPolicy.STATELESS로 동작 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 넣지 않았다.
현재 구성에서는 access token 수명을 300초로 두고 refresh token rotation을 사용한다. Resource Server에서 issuer와 audience도 확인한다.
그 대신 현재 구성은 액세스 토큰 수명을 300초로 두고 리프레시 토큰 rotation을 쓰며, Resource Server에서 issuer와 audience도 확인한다.
PKCE는 authorization code를 token으로 교환하는 구간에 사용한다. 이미 발급된 access token을 브라우저에서 숨겨주는 기능은 아니다.
PKCE는 authorization code를 토큰으로 교환하는 구간에 쓰는 것이지, 이미 발급된 액세스 토큰을 브라우저에서 숨겨 주는 기능은 아니다.
## 검증 환경
@@ -59,7 +64,7 @@ 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
redirect_uri : http://localhost:8088/callback.html
scope : openid profile email
userStore : InMemoryWebStorage
stateStore : sessionStorage
@@ -77,113 +82,109 @@ HTTP : o
1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인한다.
2. token 응답의 access token, refresh token, ID token이 비어 있지 않은지 확인한다.
2. 토큰 응답의 액세스·리프레시·ID 토큰이 비어 있지 않은지 확인한다.
3. 브라우저 fetch를 hook하고 /api/me 요청의 Authorization 헤더에서 Bearer access token을 확인한다.
3. 브라우저 fetch를 가로채 /api/me 요청의 Authorization 헤더에서 Bearer 액세스 토큰을 확인한다.
4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인한다.
4. Local Storage와 Session Storage에 액세스 토큰 문자열이 남지 않는지 확인한다.
5. 같은 정상 JWT를 expected issuer와 audience가 다른 diagnostic server 두 곳에 보내고 401이 반환되는지 확인한다.
5. 같은 정상 JWT를 expected issuer와 audience가 다른 진단용 서버 두 곳에 보내고 401이 반환되는지 확인한다.
6. refresh token으로 새 token을 받은 뒤 이전 refresh token이 거부되는지 확인한다. revocation 뒤에는 refresh가 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
6. 리프레시 토큰으로 새 토큰을 받은 뒤 이전 리프레시 토큰이 거부되는지 확인한다. revocation 뒤에는 갱신이 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
## 본문
<!-- body:start -->
## SPA에서 Token을 처리하는 위치
public client는 브라우저처럼 client secret을 안전하게 숨길 수 없는 애플리케이션이다. AP1의 SPA가 그런 클라이언트라 authorization code를 토큰으로 바꾸는 일까지 브라우저가 직접 하고, 받은 액세스·리프레시·ID 토큰은 `InMemoryWebStorage`를 써서 실행 중 메모리에만 둔다. 이 구성이 무엇을 줄이고 무엇은 줄이지 못하는지 확인했다.
## SPA가 토큰을 다루는 위치
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true"
:::
authorization code 교환, token 보관, `Authorization` 헤더 생성까지 모두 브라우저에서 처리한다.
authorization code 교환, 토큰 보관, `Authorization` 헤더 조립까지 모두 브라우저에서 일어난다. 액세스·리프레시·ID 토큰은 JavaScript 메모리에 있고, Resource Server를 부를 때 쓸 `Authorization` 헤더도 같은 페이지에서 만든다.
access token, refresh token, ID token도 JavaScript memory에 있고 Resource Server를 호출할 때 사용할 `Authorization` 헤더도 같은 페이지에서 만든다.
그래서 이 페이지에서 악성 script가 실행되면 JavaScript가 token을 사용하는 부분에도 접근할 수 있다.
그래서 이 페이지에서 악성 스크립트가 실행되면 JavaScript가 토큰을 다루는 경로에도 닿을 수 있다.
## 새로고침 전후에 브라우저에 남는 값
`oidc-client-ts``InMemoryWebStorage`를 사용해서 로그인 결과를 Local Storage나 Session Storage에 저장하지 않고 실행 중 memory에만 둔다.
`oidc-client-ts``InMemoryWebStorage` 로그인 결과를 Local Storage나 Session Storage가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 메모리에 있던 로그인 정보와 토큰이 사라진다.
새로고침하면 memory에 있던 로그인 정보와 token은 사라진다.
리프레시 토큰만 서버로 옮기거나 모든 토큰을 서버가 맡는 쪽도 검토했다. 다만 그렇게 하면 브라우저가 code를 교환하고 토큰의 수명을 관리하는 모습이 가려지기 때문에, 그 과정을 그대로 보려고 액세스·리프레시·ID 토큰을 JavaScript 메모리에 두었다. 그 대신 새로고침 뒤에는 인증 상태를 복구하지 못한다.
| 위치 | reload 전 | reload 후 |
| 위치 | 새로고침 전 | 새로고침 뒤 |
|---|---|---|
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| JavaScript 메모리 | `User`, 액세스·리프레시·ID 토큰, 만료 시각, 프로필 | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
| Local Storage | 해당 없음 | 해당 없음 |
| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |
| Keycloak origin 쿠키 | IdP의 SSO 상태가 존재할 수 있음 | 애플리케이션 메모리와 별개 |
JavaScript memory에 있던 `User`가 사라지는 것과 Keycloak의 SSO session이 끝나는 것은 별개다.
JavaScript 메모리의 `User`가 사라지는 것과 Keycloak의 SSO 세션이 끝나는 것은 다른 사건이다. SPA가 들고 있던 토큰이 없어져도 Keycloak SSO 쿠키가 그대로면 다음 authorization request에서 기존 로그인 상태가 다시 쓰일 수 있다.
SPA에서 가지고 있던 token이 사라져도 Keycloak SSO cookie가 남아 있으면 이후 authorization request에서 기존 로그인 상태가 다시 사용될 수 있다.
## memory-only가 줄이는 것과 줄이지 못하는 것
## Memory-only로 막을 수 있는 범위
memory-only로 바꾼 뒤 어떤 값이 없어지고 어떤 부분은 그대로 남는지 확인했다.
액세스·리프레시·ID 토큰은 실행 중 메모리에 있고, 악성 스크립트는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. memory-only로 줄어드는 것은 새로고침 뒤에도 남는 복사본이지 실행 중 XSS가 할 수 있는 일이 아니다.
| 위협 | memory-only가 막아주나 |
|---|---|
| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |
| 실행 중 script가 fetch를 가로채 | 막아주지 않는다 |
| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |
| network 요청 헤더에 실린 access token | 막아주지 않는다 |
| 새로고침 뒤에도 남는 토큰 복사본 | 막아준다 |
| 실행 중 스크립트가 fetch를 가로채는 것 | 막아주지 않는다 |
| 실행 중 스크립트가 사용자 대신 API를 부르는 것 | 막아주지 않는다 |
| 요청 헤더에 실리는 액세스 토큰 | 막아주지 않는다 |
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
Resource Server를 호출할 때 SPA에서 access token`Authorization` 헤더에 넣는다.
SPA가 Resource Server를 부를 때는 액세스 토큰`Authorization` 헤더에 넣는다.
```http label="브라우저가 Resource Server를 직접 부를 때"
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
```
그래서 access token은 JavaScript memory에만 존재하는 값은 아니다. API를 호출하는 동안에는 network 요청의 `Authorization` 헤더에도 들어간다.
API를 부르는 동안에는 액세스 토큰이 요청의 `Authorization` 헤더에도 실린다.
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서버에서 삭제할 application session이 없다.
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout 시점에 곧바로 무효화하는 처리도 넣지 않았다. logout은 Keycloak SSO 종료와 SPA의 사용자 제거까지만 하고, 발급된 access JWT를 deny-list로 따로 관리하지는 않는다.
이미 발급된 self-contained JWT를 logout 시점에 바로 무효화하는 처리도 넣지 않았다. logout에서는 Keycloak SSO 종료와 SPA의 user 제거를 처리하고, 발급된 access JWT를 deny-list로 따로 관리하지 않는다.
즉시 없앨 수단이 없으니 짧은 수명과 검증이 가드레일이 된다.
현재 access token 수명은 300초다.
access token : 300초
refresh token rotation, 재사용 허용 : x
액세스 토큰 : 300초
리프레시 토큰 rotation, 재사용 허용 : x
issuer·audience : 검증
Local Storage나 Session Storage에 token을 저장하면 새로고침 이후에도 값을 다시 읽을 수 있지만, 브라우저 저장소에도 token이 남게 된다.
저장 위치를 옮기는 선택지는 저마다 다른 것을 요구한다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침 에도 값을 다시 읽을 수 있지만 브라우저 저장소에 토큰 복사본이 생긴다. HttpOnly 쿠키로 옮기는 것은 저장 위치만 바꾸는 작업이 아니라, 브라우저가 액세스 토큰을 꺼내 Resource Server로 직접 보내는 지금 방식 대신 서버가 세션이나 토큰 중계를 맡는 구조가 있어야 한다.
HttpOnly cookie를 사용하려면 현재 SPA처럼 브라우저에서 access token을 꺼내 Resource Server로 직접 보내는 방식과는 달라진다. server가 session이나 token 전달을 맡는 구조가 필요하다.
실행 중 스크립트 자체의 위험은 CSP(Content Security Policy)와 의존성 무결성 검사로 따로 줄인다.
## PKCE가 적용되는 구간
PKCE(Proof Key for Code Exchange)를 사용할 때 authorization request에는 `code_challenge`가 들어가고, authorization code를 token으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
PKCE(Proof Key for Code Exchange)를 쓰면 authorization request에는 `code_challenge`가 들어가고, authorization code를 토큰으로 교환할 때는 원본인 `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
redirect_uri=http://localhost:8088/callback.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에 들어가는 것을 확인했다.
구성에서는 `response_type=code`를 쓰고, authorization request에 `code_challenge_method=S256`과 비어 있지 않은 `code_challenge`가 들어가는 것을 확인했다.
PKCE가 적용되는 곳은 authorization code를 token으로 교환하는 구간이다. token이 발급된 이후 access token을 브라우저에서 사용하지 못하게 는 기능은 아니다.
PKCE가 걸리는 구간은 authorization code를 토큰으로 교환하는 곳까지다. 토큰이 발급된 뒤에 브라우저가 액세스 토큰을 쓰지 못하게 막아 주는 기능은 아니다.
## 테스트에서 확인한 범위
커밋된 테스트에서 확인하도록 만들어 둔 항목은 다음과 같다.
여기서 확인했다고 적은 것은 마지막 실행 결과가 아니라 커밋된 테스트 확인하도록 정의한 항목이다.
| 정의 여부 | 정의 내용 |
| 테스트에 있나 | 항목 |
|---|---|
| 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 | 토큰 응답에 비어 있지 않은 액세스·리프레시·ID 토큰 |
| o | `/api/me` 200과 decode한 액세스 토큰의 audience 포함 |
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer 토큰 관측 |
| o | Local Storage와 Session Storage에 액세스 토큰 문자열 없음 |
| o | 리프레시 토큰 rotation — 새 토큰 발급, 이전 토큰 거부, revocation 뒤 갱신 실패 |
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
| x | 서명이 깨진 JWT, 만료된 JWT |
@@ -191,34 +192,30 @@ PKCE가 적용되는 곳은 authorization code를 token으로 교환하는 구
| x | callback에 error가 실려 돌아왔을 때의 화면 |
| x | `automaticSilentRenew`의 실제 갱신 경로 |
authorization request에서는 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 확인했다.
authorization request 쪽은 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 봤지만, token request body에 실제로 들어간 `code_verifier`, `client_id`, `redirect_uri`, code 값이 서로 어떻게 대조됐는지는 아직 확인하지 않았다. 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 필드를 같은 증거로 쓸 수 없다.
하지만 token request body에서 실제 `code_verifier`, `client_id`, `redirect_uri`, code 값이 어떻게 전달됐고 서로 대조됐는지는 아직 확인하지 않았다.
서명이 깨진 JWT와 만료된 JWT도 전용 테스트로 넣지 않았다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 내는 것은 봤지만, 그것이 실제 Nimbus 서명 검증과 issuer 검증을 지났다는 증거는 아니다.
:::warning
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지 대신 JSON parse error가 먼저 노출되게 된다.
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지보다 JSON parse error가 먼저 보일 수 있다.
:::
## Redirect URI와 CORS에서 아직 확인하지 않은 부분
## Redirect URI와 CORS에서 아직 확인하지 않은
local realm의 redirect allowlist는 다음과 같이 wildcard로 설정되어 있다.
local realm의 redirect allowlist는 wildcard로 설정되어 있다.
```text
http://localhost:8088/*
http://127.0.0.1:8088/*
```
SPA에서 실제 사용하는 callback은 `/OAuth2callback.html`다.
SPA 실제로 쓰는 callback은 `/callback.html` 하나인데 등록된 목록은 그보다 넓다.
SPA : `/OAuth2callback.html`만 o
exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사 x
SPA : `/callback.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 경로는 지나지 않았을 것이다.
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거치고, 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
<!-- body:end -->
@@ -10,18 +10,21 @@ status: 게시 전
version: 4
basisVersion: Keycloak 26.7.0 · oidc-client-ts
studio: "https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#문제를-어렵게-만든-제약-로그인-흐름과-api-흐름
---
# Authorization Code와 PKCE가 보호하는 구간
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 access token으로 바꾸는 구간을 PKCE가 보호한다. authorization request에 넣은 code_challenge token request에 넣은 code_verifier 맞아야 교환이 끝난다.
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 액세스 토큰으로 바꾸는 구간을 PKCE가 보호한다. authorization request에 code_challenge를 담아 보내고, token request에는 그 원본인 code_verifier를 보내 두 값이 대응하는지 확인한다. 두 값이 맞아야 교환이 끝난다.
## 관계
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
이 개념을 endpoint별 기준으로 정리한 기록이다.
- **Public Client와 Confidential Client 구분 기준**
client 종류에 따라 token endpoint의 인증 방식이 달라진다.
클라이언트 종류에 따라 token endpoint의 인증 방식이 달라진다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code를 직접 교환한 구성이다.
@@ -31,9 +34,11 @@ authorization code는 로그인을 마친 사용자가 애플리케이션으로
## code를 한 번 더 교환하는 이유
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth client가 code를 token endpoint에 제출해야 access token을 받는다.
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth 클라이언트가 code를 token endpoint에 제출해야 액세스 토큰을 받는다.
교환을 나눈 덕분에 access token이 브라우저 주소창을 지나지 않는다. authorization request는 full-page navigation이라 URL이 주소창과 히스토리, Authorization Server 접근 로그에 남는다. 여기 남아도 되는 값만 code로 고, token은 별도 요청의 body로 받는다.
authorization request는 브라우저 전체가 옮겨 가는 요청(full-page navigation)이어서 그 URL이 주소창과 히스토리 Authorization Server 접근 로그에 남는다. 그래서 이 요청에는 그런 곳에 남아도 되는 값만 code로 고, 액세스 토큰은 별도 요청의 body로 받는다. 교환을 둘로 나눈 덕분에 액세스 토큰이 브라우저 주소창을 지나지 않는다.
PKCE 값이 어디서 만들어져 어디서 확인되는지, 두 요청을 순서대로 본다.
## authorization request에 들어가는 challenge
@@ -50,9 +55,9 @@ GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
&code_challenge_method=S256
```
`response_type=code` Authorization Code Flow를 쓴다는 표시이고, `code_challenge`와 `code_challenge_method=S256` PKCE 사용을 나타낸다. `state`와 challenge 값은 요청마다 달라진다.
`response_type=code` Authorization Code Flow를 쓴다는 이고, `code_challenge`와 `code_challenge_method=S256` PKCE를 쓴다는 뜻이다. `state`와 challenge 값은 요청마다 달라진다.
`state`와 PKCE verifier는 redirect를 건너야 하므로 브라우저에 남는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
`state`와 PKCE verifier는 Keycloak에 갔다가 돌아오는 사이에도 있어야 해서 브라우저가 들고 있는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
## token request가 제출하는 verifier
@@ -69,31 +74,33 @@ grant_type=authorization_code
&code_verifier=<original-verifier>
```
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 verifier가 대응하는지 확인하고 교환을 끝낸다. 이 대응이 authorization request를 시작한 client와 code를 교환하는 주체를 연결한다.
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 verifier가 대응하는지 확인한 뒤에 교환을 끝낸다. 이 확인이 authorization request를 시작한 클라이언트와 code를 교환하는 주체를 이어 준다.
다만 커밋된 AP1 브라우저 테스트가 직접 보는 것은 authorization request의 challenge와 token request의 endpoint, `grant_type=authorization_code`까지다. body에 실린 `code_verifier`와 `client_id`, `redirect_uri`, code 값을 하나씩 비교하지는 않는다.
## S256과 plain의 차이
verifier에서 challenge를 만드는 방법 두 가지다.
verifier에서 challenge를 만드는 방법 두 가지다. `plain`은 verifier를 그대로 challenge로 보내는데, 중간에서 challenge를 본 쪽은 그 값을 그대로 verifier로 쓸 수 있다. `S256`은 verifier에 SHA-256을 적용한 값을 challenge로 보내기 때문에 challenge만으로는 verifier를 되돌릴 수 없다.
| 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가 수행한다.
AP1 realm은 S256을 요구한다. AP1 코드에는 `createPkcePair()`라는 손으로 만든 보조 함수도 있다. 이 함수는 무작위 32바이트를 패딩 없는 Base64URL verifier로 바꾸고, 거기에 SHA-256을 적용한 challenge와 `"S256"`을 함께 반환한다. 다만 로그인을 시작하는 `signinRedirect()`는 이 함수를 부르지 않는다. 화면의 PKCE 데모 버튼이 쓰는 코드이고, 실제 로그인은 버전을 고정한 oidc-client-ts가 수행한다.
## PKCE가 막지 않는 것
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 access token을 숨기지는 않는다. 브라우저가 token을 직접 다루는 구성에서 실행 중 악성 script가 Bearer token거나 사용자 권한으로 API를 부르는 문제는 PKCE 밖이다.
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 액세스 토큰을 숨겨 주지는 않는다. 브라우저가 토큰을 직접 다루는 구성에서 실행 중 악성 스크립트가 Bearer 토큰거나 사용자 권한으로 API를 부르는 것은 PKCE가 막는 문제가 아니다.
`state` PKCE와 다른 값이다. `state`는 callback이 원래 시작한 transaction의 것인지 대조하는 값이고, verifier는 code 교환 주체를 묶는 값이다.
`state` PKCE 값과 하는 일이 다르다. `state`는 돌아온 callback이 브라우저가 처음 시작한 트랜잭션의 것인지 대조하고, verifier는 code 교환하는 주체를 authorization request를 시작한 클라이언트에 묶는다.
## client 종류에 따라 달라지는 부분
## 클라이언트 종류에 따라 달라지는 인증
`spa-public`은 secret이 없는 public client다. token endpoint에서 client 인증을 하지 않고 PKCE만 사용한다.
`spa-public`은 secret이 없는 public client다. token endpoint에서 클라이언트 인증을 하지 않고 PKCE만 사용한다.
confidential client는 여기에 client 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 client를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 authorization request resolver에 장착해 framework가 state와 verifier를 만든다.
confidential client는 여기에 클라이언트 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 클라이언트를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 authorization request를 만드는 resolver에 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 장착한다. 그러면 프레임워크가 `state`와 verifier를 만든다.
AP2 client 설정에 S256을 강제하는 속성이 없고, AP2 테스트도 authorization request의 challenge를 검사하지 않는다. AP2에서 확인한 것은 Authorization Code Flow를 쓴다는 까지고, PKCE S256이 고정됐는지는 확인하지 않았다.
클라이언트 설정에 S256을 강제하는 속성이 없고, 테스트도 authorization request의 challenge를 검사하지 않는다. Authorization Code Flow를 쓴다는 까지는 확인했고, PKCE S256이 고정됐는지는 확인하지 않았다.
<!-- body:end -->
@@ -10,18 +10,21 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap1
---
# Bearer JWT가 인증된 principal이 되기까지
Resource Server가 받는 입력은 Authorization 헤더 문자열 하나다. 이 문자열 서명 검증, issuer와 시간 검증, audience 검증, role 변환을 차례로 지나 authenticated principal이 된다. 서명 검증을 통과해도 이 API를 위해 발급된 token인지는 audience 검증에서 따로 다.
Resource Server가 받는 입력은 `Authorization` 헤더에 실려 온 문자열 하나다. 이 문자열 서명 검증, issuer와 시간 검증, audience 검증, 역할 변환을 차례로 지나야 인증된 요청 주체(principal)가 된다. OAuth 2.0과 JWT를 한 번이라도 다뤄 본 사람을 대상으로, 먼저 Resource Server가 받는 입력부터 이 사슬이 단계마다 무엇을 확인하고 무엇을 다음 단계로 넘기는지 따라간다. 서명 검증을 통과했다고 이 API를 위해 발급된 토큰인 것은 아니고, 그 확인은 audience 검증 따로 맡는다.
## 관계
- **OAuth Token과 Application Session을 구분하는 기준**
이 검증을 통과한 JWT와 애플리케이션 session은 다른 상태다.
이 검증을 통과한 JWT와 애플리케이션 세션은 다른 상태다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
이 JWT가 어느 endpoint에서 발급되는지 정리한 기록이다.
이 JWT가 어느 엔드포인트에서 발급되는지 정리한 기록이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 이 헤더를 직접 만든 구성이다.
@@ -31,20 +34,20 @@ Resource Server가 받는 입력은 Authorization 헤더의 문자열 하나다.
## Resource Server가 받는 입력
브라우저나 BFF가 보내 요청의 모양은 같다.
브라우저가 보내든 BFF(Backend For Frontend, 프런트엔드 전용 백엔드)가 보내 요청의 모양은 같다.
```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`를 선택한다.
Spring이 받는 것은 이 헤더에 실려 온 문자열 그대로다. 요청마다 JWT로 인증하고 애플리케이션 세션을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 보호를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다.
session을 만들지 않으므로 logout 순간에 지울 server 상태 없다. 이미 발급된 self-contained JWT는 만료 전까지 유효하고, 짧은 TTL과 validator가 그 범위를 좁힌다.
세션을 만들지 않으니 로그아웃하는 순간에 서버에서 지울 상태 없다. 이미 발급된 JWT는 필요한 값을 자기 안에 담고 있어서 만료 전까지 유효하고, 짧은 TTL(Time To Live, 유효 시간)과 검증기가 그 범위를 좁힌다.
## 변환 순서
custom code가 지나는 순서는 다음과 같다.
직접 만든 코드가 지나는 순서는 다음과 같다.
```text label="raw Bearer JWT가 principal이 되기까지"
raw Bearer JWT
@@ -56,19 +59,19 @@ raw Bearer JWT
→ authenticated principal + ROLE_* authorities
```
Spring OAuth2 Resource Server가 헤더를 추출하고 JWT authentication provider를 거쳐 configured decoder를 호출한다. repository code가 Spring 내부 filter를 직접 만들지 않으므로, DSL이 설치하는 framework integration과 custom bean 경계를 나눠 읽어야 한다.
헤더를 꺼내 JWT 인증 제공자에 넘기고 설정된 디코더를 부르는 일은 Spring OAuth2 Resource Server가 한다. 저장소의 코드는 Spring 내부 필터를 직접 만들지 않으므로, 이 순서에는 설정 DSL(Domain Specific Language, 설정 전용 문법)이 붙여 주는 부분과 직접 만들어 끼운 빈이 함께 들어 있다.
## 서명을 통과한 뒤에 남는 확인
## 서명 확인 하나로 끝내지 않는 이유
서명이 맞다는 것은 그 IdP가 발급했다는 뜻이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다. 그래서 서명만 확인하고 끝내지 않는다.
서명이 맞다는 것은 그 IdP(Identity Provider, 인증 제공자)가 발급했다는 뜻이다. 서명 검증에 쓰는 JWK(JSON Web Key, 공개키를 JSON으로 표현한 형식)는 그 IdP의 공개키를 담고 있을 뿐이어서, 같은 IdP가 다른 API를 위해 발급한 토큰도 서명은 똑같이 맞다. 그래서 서명만 확인하고 끝내지 않는다.
| 확인 단계 | 확인하는 것 | 통과해도 남는 질문 |
| 확인 단계 | 확인하는 것 | 이 단계가 답하지 못하는 것 |
|---|---|---|
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 token인가 |
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 토큰인가 |
| issuer | 기대한 realm인가 | 아직 유효한가 |
| timestamp | 만료 전인가 | 이 API가 대상인가 |
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
| role converter | 어떤 권한을 갖는가 | — |
| 역할 변환 | 어떤 권한을 갖는가 | — |
## expected issuer와 JWK URL이 다른 이유
@@ -79,34 +82,36 @@ 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에 도달한다.
expected issuer는 토큰 안에 적혀 브라우저까지 보이는 값이다. 브라우저가 도달하는 주소로 발급됐으니 클레임을 검증하는 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 컨테이너 네트워크 경로이고, Resource Server 같은 Docker 네트워크 안에서 서비스 이름으로 Keycloak에 도달한다.
하나는 claim 검증 기준이고 하나는 network access 경로다. 두 값을 같게 맞추려다 issuer를 container 주소로 바꾸면 브라우저가 받은 token의 `iss`와 어긋난다.
두 값을 같게 맞추려고 expected issuer를 컨테이너 주소로 바꾸면 브라우저가 받은 토큰의 `iss`와 어긋난다.
## audience 검증
`AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 포함됐는지 확인한다. 누락되면 `invalid_token` 결과를 만든다.
audience는 이 토큰이 어느 API를 위해 발급됐는지 담는 값이다. `AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 들어 있는지 확인하고, 없으면 `invalid_token` 결과를 만든다.
같은 정상 JWT를 expected audience가 다른 진단용 Resource Server에 제출하면 401이 된다. issuer가 다른 서버도 마찬가지다. 두 서버가 같은 token에 401을 돌려준 것이 audience 검증과 issuer 검증이 실제로 걸린다는 관측이다.
정상적으로 발급받은 같은 JWT를 expected audience가 다른 진단용 Resource Server에 그대로 제출하면 401이 돌아온다. expected issuer가 다른 서버도 마찬가지로 401이다.
다만 이 두 서버가 재 준 범위는 audience 검증과 issuer 검증까지다. 서명이 틀린 JWT나 만료된 JWT를 넣는 전용 E2E 계약은 만들지 않았다. 단위 테스트에 합성 JWT를 주입해 컨트롤러가 200을 반환하는 것은 확인할 수 있지만, 그 200은 그 요청이 실제 `NimbusJwtDecoder`의 서명 검증과 issuer 검증을 지났다는 증거가 아니다. 사슬의 앞 두 단계는 이 계약 안에서 부정 입력으로 확인하지 않았다.
## realm role이 authority가 되는 변환
`KeycloakRealmRoleConverter`는 `realm_access.roles`의 string을 골라 `ROLE_` prefix를 붙인다.
`KeycloakRealmRoleConverter`는 `realm_access.roles`에 들어 있는 문자열을 골라 앞에 `ROLE_` 붙인다.
```text label="role 변환"
realm_access.roles: ["user-role"]
→ ROLE_user-role
```
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role` authority를 찾기 때문에 prefix가 필요하다.
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role`이라는 이름의 권한을 찾기 때문에 이 접두사가 필요하다.
## 인증과 인가는 다른 endpoint에서 갈린다
`/api/me`는 특정 role을 요구하지 않고 `.authenticated()`만 요구한다. role claim이 없어서 converter 결과가 빈 list여도 JWT가 유효하면 `/api/me`는 통과한다.
`/api/me`는 특정 역할을 요구하지 않고 `.authenticated()`만 요구한다. 그래서 역할 클레임이 없어 변환 결과가 빈 목록이어도, JWT가 유효하기만 하면 `/api/me`는 통과한다.
`admin-role`의 효과는 `/api/admin`에서 나타난다. regular user는 403, admin user는 200이다. 로그인 성공과 role 인가를 같은 테스트로 확인하면 이 차이가 가려진다.
`admin-role`의 효과는 `/api/admin`에서 나타난다. 일반 사용자는 403, 관리자 사용자는 200이다.
controller는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
`ApiController`는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
```json label="ApiController가 반환하는 JSON"
{
@@ -117,4 +122,6 @@ controller는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든
}
```
이 JSON의 `regular-user`는 프록시가 만들어 붙이는 identity header에도 똑같이 들어갈 수 있는 값이다. 화면에 보이는 이름은 같아도 그 값을 무엇이 보증했는지는 다르다. 여기서는 서명과 issuer, audience를 확인한 JWT에서 꺼냈다.
<!-- body:end -->
@@ -10,20 +10,23 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#문제를-어렵게-만든-제약-브라우저에-없다
---
# 브라우저가 credential을 보관하는 위치와 그 성질
브라우저에는 JavaScript memory, Session Storage, Local Storage, cookie가 있고 각각 수명과 접근 경로가 다르다. 어떤 credential 어디에 있는지에 따라 새로고침 뒤 남는 것, JavaScript가 읽을 수 있는 것, 요청에 자동으로 붙는 것이 갈린다.
브라우저에는 JavaScript 메모리, Session Storage, Local Storage, 쿠키가 있고 각각 수명과 접근 경로가 다르다. 자격 증명(credential)을 어디에 두느냐에 따라 새로고침 뒤에 무엇이 유지되는지, JavaScript가 무엇을 읽을 수 있는지, 어떤 값이 요청에 자동으로 실리는지가 갈린다.
## 관계
- **OAuth Token과 Application Session을 구분하는 기준**
여기 있는 값들에 각각 다른 이름을 쓰는이다.
여기 있는 값들을 어떤 이름으로 갈라 부르는지 정한이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
memory-only 구성을 실제로 확인한 기록이다.
- **BFF 인증 구조 설계 기준**
브라우저에 session cookie만 두는 구조의 설계 항목이다.
브라우저에 세션 쿠키만 두는 구조의 설계 항목이다.
## 본문
@@ -31,72 +34,72 @@ studio: "https://hyeonworks.com/studio/documents/bb5c37ae-2d94-48f7-ad4e-a37c61c
## 네 위치의 성질
네 곳을 수명, JavaScript의 접근 여부, 요청 자동 첨부 세 가지로 갈라 보면 이렇다. 표의 `HttpOnly`는 JavaScript가 쿠키 값을 직접 읽지 못하게 하는 쿠키 속성인데, 나머지 성질은 뒤에서 따로 살펴보겠다.
| 위치 | 새로고침 뒤 | JavaScript가 읽나 | 요청에 자동으로 붙나 |
|---|---|---|---|
| JavaScript memory | 초기화 | 읽는다 | 붙지 않는다 |
| JavaScript 메모리 | 초기화 | 읽는다 | 붙지 않는다 |
| Session Storage | 탭이 살아 있으면 유지 | 읽는다 | 붙지 않는다 |
| Local Storage | 유지 | 읽는다 | 붙지 않는다 |
| HttpOnly cookie | 만료까지 유지 | 읽지 못한다 | 붙는다 |
| HttpOnly 쿠키 | 만료까지 유지 | 읽지 못한다 | 붙는다 |
자동으로 붙는다는 성질이 cookie를 credential로 쓸 때 CSRF 검증이 필요해지는 이유다.
네 곳 가운데 쿠키만 요청에 자동으로 붙기 때문에, 쿠키를 자격 증명으로 쓰면 그 요청이 사용자가 의도한 것인지 확인하는 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 검증이 따라붙는다.
## userStore와 stateStore를 나눈다
oidc-client-ts의 `UserManager` 저장소를 따로 받는다.
oidc-client-ts의 `UserManager`는 저장소를 두 개 따로 받는다. AP1은 이렇게 설정했다.
```text label="AP1의 UserManager 저장소 설정"
userStore = InMemoryWebStorage
stateStore = sessionStorage
```
`userStore`는 로그인 뒤 `User`와 token set을 보관한다. `stateStore`는 redirect를 건너야 하는 authorization transaction을 보관한다.
두 저장소의 내용도 성격이 다르다.
`userStore`는 로그인 뒤 `User`와 토큰 묶음을 보관하고, `stateStore`는 리다이렉트를 건너야 하는 authorization transaction을 보관한다.
| 저장소 | 들어가는 것 | 언제까지 필요한가 |
|---|---|---|
| userStore | `User`, access·refresh·ID token, expiry, profile | 로그인 상태가 유지되는 동안 |
| stateStore | `state`, PKCE verifier | callback 처리가 끝날 때까지 |
| userStore | `User`, access·refresh·ID token, 만료 시각, 프로필 | 로그인 상태가 유지되는 동안 |
| stateStore | `state`, PKCE verifier | 콜백 처리가 끝날 때까지 |
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 memory에 둘 수 없다. 이 값이 Session Storage에 있는 것과 token이 Web Storage에 있는 것은 다른 설정이다.
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 메모리에 둘 수 없다. 이 값이 Session Storage에 있는 것과 토큰이 Web Storage에 있는 것은 서로 다른 설정이다.
## memory-only가 뜻하는 범위
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다. 새로고침하면 `User`와 token이 초기화되고, Local Storage와 Session Storage에는 token 복사본이 지 않는다.
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 `User`와 토큰이 초기화되고, Local Storage와 Session Storage에도 토큰 복사본이 만들어지지 않는다.
memory-only는 persistent storage에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저 fetch를 hook하면 API 호출의 Bearer access token을 관측할 수 있다.
이 설정은 저장 위치를 견준 뒤에 고른 것이다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침은 편해지지만 노출 시간도 길어진다. HttpOnly 쿠키로 옮기는 것은 저장 위치만 바꾸면 끝나는 작업이 아니라 서버가 세션이나 토큰 중계를 맡는 AP3 계열 구조가 있어야 한다. refresh token만 mediator로 옮기는 AP2도 함께 검토했지만, 두 구조 모두 브라우저가 code를 교환하고 토큰 수명을 관리하는 모습을 가린다. AP1은 그 과정을 보여 주려고 access·refresh·ID token을 JavaScript 메모리에 두었고, 새로고침 뒤에 인증 상태를 복구하지 못하는 것을 감수했다.
이 구성에서 관측한 두 결과는 다음과 같다.
memory-only는 영구 저장소에 쓰지 않는다는 뜻이지, 실행 중인 스크립트가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저의 `fetch`를 가로채면 API 호출에 실린 Bearer access token을 그대로 관측할 수 있다.
```text label="함께 읽어야 하는 두 결과"
```text label="관측한 두 결과"
Local Storage · Session Storage → access token 문자열 없음
실행 중 fetch hook → Authorization: Bearer 관측됨
```
AP2도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 된다. 세 경계를 지나는 동안 persistent storage에는 쓰이지 않는다.
AP2도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 되고, 세 경계를 지나는 동안 영구 저장소에는 쓰이지 않는다.
## HttpOnly cookie
HttpOnly는 JavaScript가 cookie 값을 직접 읽지 못하게 하는 속성이다. `document.cookie`로 조회되지 않지만 브라우저는 요청마다 붙여 보낸다.
`HttpOnly` 쿠키는 `document.cookie`로 조회되지 않지만, 브라우저는 요청마다 이 값을 자동으로 붙여 보낸다.
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. 브라우저 JavaScript에 OAuth token을 전달하지 않는 구조에서도 이 cookie는 남는다. 브라우저에 없는 은 애플리케이션이 쓰는 OAuth token이고, 인증 상태 자체는 이 cookie로 남아 있다.
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. AP3와 AP4는 브라우저 JavaScript에 OAuth 토큰을 넘기지 않는다. 브라우저를 확인하면 HttpOnly 세션 쿠키가 남아 있고 요청할 때마다 자동으로 붙는다. 그래서 이 기록에서 「브라우저에 없다」은 애플리케이션이 쓰는 OAuth 토큰에만 쓴다. 인증 상태 자체는 이 쿠키가 들고 있다.
Keycloak 도메인의 SSO cookie도 별도로 존재할 수 있다. 애플리케이션 memory의 `User`가 사라진 것과 IdP session이 끝난 것은 다른 사건이다.
Keycloak 도메인의 SSO 쿠키도 별도로 존재할 수 있다. 애플리케이션 메모리의 `User`가 사라진 것과 IdP(Identity Provider) 세션이 끝난 것은 다른 사건이다.
## opaque cookie
opaque는 내부 값을 브라우저가 해석하지 않고 그대로 돌려준다는 뜻이다.
AP2와 AP3의 session cookie는 server-side 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 cookie 안에는 없다. cookie가 token map을 직렬화한다고 설명하면 구현이 틀리게 된다.
AP2와 AP3의 세션 쿠키는 서버 쪽 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 쿠키 안에는 없다.
AP4에서 `session-cookie-minimal=true`를 쓰면 server-side session store 없이 edge가 필요한 최소 정보만 cookie 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속 보관한다고 말할 수 없다.
AP4에서 `session-cookie-minimal=true`를 쓰면 서버 쪽 세션 저장소 없이 엣지가 필요로 하는 최소 정보만 쿠키 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속해서 보관한다고 말할 수 없다.
AP2와 AP3의 cookie는 server-side 상태를 찾는 열쇠이고, AP4의 cookie는 최소 상태를 담은 값이다. 두 cookie를 같은 문장으로 설명하지 않는다.
AP2와 AP3의 쿠키는 서버 쪽 상태를 찾는 열쇠이고, AP4의 쿠키는 최소 상태를 담은 값이다.
## 학습 환경의 cookie 속성을 일반화하지 않는다
지금 구성은 cookie 속성과 redirect를 눈으로 확인하려고 HTTPS가 아 HTTP를 쓴다. 그래서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
지금 구성은 쿠키 속성과 리다이렉트를 눈으로 확인하려고 HTTPS가 아니라 HTTP를 쓰고 있어서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다. 여기서 관측한 값을 운영 cookie 기본값으로 옮겨 적지 않는다.
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다.
<!-- body:end -->
@@ -10,11 +10,14 @@ status: 게시 전
version: 4
basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap3
---
# Cookie로 인증하는 요청에서 CSRF token이 하는 일
session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다.
세션 쿠키는 브라우저가 요청마다 자동으로 붙이기 때문에, 상태를 바꾸는 요청이 사용자 의도한 것인지 서버가 따로 확인해야 한다. CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 토큰이 그 확인을 맡고, SameSite는 브라우저가 쿠키를 언제 보낼지 정하는 별도의 정책이다.
## 관계
@@ -23,7 +26,7 @@ session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
이 동작을 실제로 재현한 기록이다.
- **OAuth Token과 Application Session을 구분하는 기준**
session cookie와 CSRF token은 서로 다른 값이다.
세션 쿠키와 CSRF 토큰은 서로 다른 값이다.
## 본문
@@ -31,13 +34,15 @@ session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서
## cookie가 credential이 되면 생기는 일
브라우저 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다.
BFF(Backend for Frontend)는 브라우저 OAuth 토큰을 내려보내지 않는다. 대신 JavaScript가 읽을 수 없는 HttpOnly 세션 쿠키 하나로 로그인한 사용자를 알아본다.
cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. `GET /bff/api/me`만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다.
쿠키를 요청에 붙이는 쪽은 애플리케이션 코드가 아니라 브라우저다. 다른 사이트가 만든 요청에도 같은 쿠키가 실릴 수 있다는 뜻이다. `GET /bff/api/me`처럼 읽기만 하는 요청에서는 이것이 문제로 보이지 않으므로, 값을 바꾸는 요청을 따로 봐야 한다.
브라우저에 OAuth 토큰을 내려보내지 않기로 하면서 로그인 상태가 서버로 옮겨 왔고, 값을 바꾸는 요청을 가려내는 일도 그때 BFF가 맡게 됐다. 먼저 브라우저가 토큰을 받아 오는 요청부터 본다.
## token을 받아 오는 요청
브라우저가 먼저 CSRF material을 요청한다.
브라우저는 상태를 바꾸는 요청을 보내기 전에 CSRF 값부터 받아 온다.
```http label="CSRF token 요청"
GET http://localhost:8083/bff/csrf
@@ -45,7 +50,7 @@ Accept: application/json
Cookie: AP3_SESSION=<opaque-session-id>
```
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` cookie를 path `/`에 만든다. controller는 다음 JSON을 반환한다.
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` 쿠키를 경로 `/`에 만든다. 컨트롤러는 다음 JSON을 반환한다.
```json label="CsrfController가 반환하는 JSON"
{
@@ -57,23 +62,23 @@ Cookie: AP3_SESSION=<opaque-session-id>
## body의 token과 cookie의 값은 다르다
같은 CSRF material이 세 자리에 서로 다른 형태로 놓인다.
같은 CSRF 값이 응답 본문, 쿠키, 요청 헤더 세 곳에 서로 다른 형태로 놓인다.
`XorCsrfTokenRequestAttributeHandler`가 요청 속성에 넣는 토큰을 XOR와 Base64로 가리기 때문에, 위 JSON에는 가려진 값이 담긴다. 쿠키 쪽은 다르다. `XSRF-TOKEN`에는 가리지 않은 원본 값이 들어간다. 그래서 SPA는 JSON에서 `headerName`만 읽고, 실제로 보낼 값은 `document.cookie`에서 `XSRF-TOKEN`을 찾아 쓴다.
| 위치 | 값 |
|---|---|
| 응답 body의 `token` | XOR와 Base64로 mask된 값 |
| `XSRF-TOKEN` cookie | raw 값 |
| POST의 `X-XSRF-TOKEN` 헤더 | cookie와 같은 raw 값 |
| 응답 본문의 `token` | XOR와 Base64로 가린 값 |
| `XSRF-TOKEN` 쿠키 | 원본 값 |
| POST의 `X-XSRF-TOKEN` 헤더 | 쿠키와 같은 원본 값 |
`XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 `headerName`만 읽고, 실제 값은 `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 쓴다.
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. 서버가 기대하는 헤더가 요청에 있으면 제출된 원본 토큰을 그대로 읽고, 없으면 XOR resolver 경로를 쓴다.
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다.
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다.
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다.
## 검증이 controller보다 먼저 일어난다
정상 상태 변경 요청은 다음과 같다.
검증을 통과하는 상태 변경 요청은 다음과 같다.
```http label="CSRF 검증을 통과하는 POST"
POST http://localhost:8083/bff/api/preferences
@@ -84,33 +89,35 @@ X-XSRF-TOKEN: <same-raw-csrf-token>
theme=dark
```
Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다.
컨트롤러에 닿기 전에 Spring CSRF 필터가 저장소에 있는 기대값과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 컨트롤러는 실행되지 않고 403이 된다. 검증하는 곳이 컨트롤러보다 앞이라, 엔드포인트를 새로 추가해도 같은 필터를 지난다.
다만 이 필터가 보는 것은 CSRF 토큰뿐이다. 경로별 인가는 이 예제의 자동 테스트가 확인하는 범위 밖이라, 새 엔드포인트가 같은 필터를 지났다는 것으로 그 엔드포인트의 인가까지 확인되지는 않는다.
## SameSite가 정하는 것과 CSRF token이 정하는 것
| | SameSite | CSRF token |
SameSite는 쿠키를 다른 사이트로 보낼지 브라우저가 정하는 정책이고, CSRF 토큰은 상태를 바꾸는 요청이 사용자의 의도인지 서버가 검증하는 애플리케이션 규약이다.
| | SameSite | CSRF 토큰 |
|---|---|---|
| 누가 판단하나 | 브라우저 | 서버 |
| 무엇을 정하나 | cookie를 보낼지 | 요청을 받아들일지 |
| 무엇을 정하나 | 쿠키를 보낼지 | 요청을 받아들일지 |
| 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 |
port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 cookie를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
포트가 달라도 사이트 계산상 같은 사이트로 잡히는 경우가 있어서 둘은 서로를 대신하지 못한다. SameSite가 쿠키를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
네 가지 입력에서 cookie와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
네 가지 입력에서 쿠키와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
| 입력 | cookie 동작 | CSRF 동작 | 결과 |
| 입력 | 쿠키 동작 | CSRF 동작 | 결과 |
|---|---|---|---|
| same-origin, CSRF 헤더 없음 | session cookie 붙음 | token 부재로 거부 | 403 |
| same-origin, raw cookie와 헤더 일치 | session cookie 붙음 | token 일치 | 200 |
| 다른 port지만 same-site, 헤더 없음 | cookie가 붙을 수 있음 | token 부재로 거부 | 403 |
| cross-site POST | SameSite=Lax로 cookie 제외 | 이 지점 이후는 고정하지 않음 | cookie omission이 확인 지점 |
마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다.
| same-origin, CSRF 헤더 없음 | 세션 쿠키 붙음 | 토큰이 없어 거부 | 403 |
| same-origin, 원본 쿠키와 헤더 일치 | 세션 쿠키 붙음 | 토큰 일치 | 200 |
| 다른 포트지만 same-site, 헤더 없음 | 쿠키가 붙을 수 있음 | 토큰이 없어 거부 | 403 |
| cross-site POST | SameSite=Lax로 쿠키 제외 | 이후 서버 처리는 고정하지 않음 | 쿠키가 빠졌는지가 확인 지점 |
## CSRF가 XSS를 대신하지 않는다
브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 같이 읽을 수 있다.
브라우저에 OAuth 토큰을 주지 않아도 XSS가 무해해지지는 않는다. 같은 출처에서 실행되는 악성 스크립트는 피해자의 세션으로 BFF 엔드포인트를 그대로 부를 수 있고, JavaScript가 읽으라고 열어 둔 `XSRF-TOKEN`도 함께 읽을 수 있기 때문이다.
이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다.
이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰의 원문이 스크립트로 새어 나가 다른 클라이언트나 직접 API 호출에 다시 쓰이는 범위다. CSP, 출력 인코딩, 의존성 무결성, 애플리케이션 인가는 이 구조가 대신 막아 주지 않으므로 각각 따로 세워야 한다.
<!-- body:end -->
@@ -10,20 +10,23 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap4
---
# Forward-Auth와 Nginx auth_request의 동작
forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 endpoint에 허용 여부를 묻는 방식이다. Nginx에서는 auth_request directive가 그 질문을 subrequest로 만든다. 인증 결과는 upstream 요청의 헤더로 바뀌고, upstream은 JWT 대신 그 헤더를 입력으로 받는다.
forward-auth는 실제 요청을 업스트림으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 먼저 묻는 방식이다. Nginx에서는 `auth_request` 지시어가 그 질문을 하위 요청(subrequest)으로 만든다. 인증 결과는 업스트림으로 나가는 요청의 헤더로 바뀌기 때문에, 업스트림은 JWT 대신 그 헤더를 입력으로 받는다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
이 동작을 운영에서 신뢰하려면 무엇이 필요한지 정리한이다.
이 동작을 운영에서 신뢰하려면 무엇이 갖춰져야 하는지 적은이다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
헤더 위조를 실제로 재현한 기록이다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 구조를 언제 고르는지 비교한 기이다.
이 구조를 언제 고르는지 다른 패턴과 비교한 기이다.
## 본문
@@ -31,53 +34,53 @@ forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인
## 요청 하나가 두 번 평가된다
브라우저 요청이 들어오면 Nginx는 바로 upstream을 호출하지 않는다. `location /`에 다음 directive가 있다.
브라우저 요청이 들어와도 Nginx는 업스트림을 바로 호출하지 않는다. 업스트림은 Nginx가 요청을 최종으로 넘기는 뒤쪽 서버이고, 이 구성에서는 `app:8081`의 Spring 애플리케이션이다. 바로 넘기지 않는 것은 `location /`에 다음 지시어가 있기 때문이다.
```nginx label="general location의 auth_request"
auth_request /oauth2/auth;
```
Nginx는 먼저 `/oauth2/auth`로 subrequest를 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 한 번의 외부 요청이 인증 판단과 upstream 전달 두 단계로 나뉜다.
`auth_request`는 원래 요청을 처리하기 전에 지정한 경로로 인증용 하위 요청을 한 번 보내는 지시어다. 하위 요청은 Nginx가 원래 요청과 별개로 내부에서 만들어 보내는 요청을 말한다. 그래서 Nginx는 먼저 `/oauth2/auth`로 하위 요청을 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 밖에서 들어온 요청 한 번이 인증 판단과 업스트림 전달 두 단계로 나뉜다.
`location = /oauth2/auth`는 `internal`로 선언한다. Nginx가 만드는 subrequest만 들어갈 수 있 브라우저가 같은 URL을 직접 호출하면 정상 auth endpoint로 쓸 수 없다. 외부에서 이 경로를 부르면 404가 된다.
`location = /oauth2/auth`는 `internal`로 선언한다. Nginx가 만든 하위 요청만 들어갈 수 있어서 브라우저가 같은 URL을 직접 호출하면 정상 인증 엔드포인트로 쓸 수 없고, 밖에서 이 경로를 부르면 404가 된다.
## subrequest가 실어 보내는 것
subrequest는 body를 보내지 않고 `Content-Length`를 비운다. 원래 요청의 문맥은 헤더로 바다.
하위 요청은 본문을 보내지 않고 `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가 있을 때 원래 요청의 값 |
| `X-Original-URL` | 스킴과 호스트, 원래 요청의 URI |
| `X-Real-IP` | 클라이언트 주소 |
| `X-Forwarded-For` | 프록시 체인 |
| `X-Forwarded-Host` | 원래 요청의 호스트 |
| `X-Forwarded-Proto` | 원래 요청의 스킴 |
| `X-Forwarded-Uri` | 원래 요청의 URI |
| `Cookie` | 브라우저에 쿠키가 있을 때 원래 요청의 값 |
oauth2-proxy는 이 정보로 session이 유효한지 판단한다.
oauth2-proxy는 이렇게 넘어온 헤더를 보고 세션이 유효한지 판단한다.
## 미인증 401의 응답이 경로마다 다르다
인증 결과가 401일 때 무엇을 돌려지는 location마다 다르다.
인증 결과가 401일 때 그다음에 무엇을 돌려주는지는 요청이 걸린 `location`마다 다르다.
| 외부 입력 | 인증 상태 | 결과 |
|---|---|---|
| `GET /` | 미인증 | `/oauth2/start`로 302 |
| `GET /api/edge` | 미인증 | `Location` 없는 401 JSON |
general location은 `@oauth2_signin`으로 이동해 로그인을 시작한다.
`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 규칙을 따른다.
`/api/edge`를 정확히 일치로 잡은 `location`은 리다이렉트 없이 401을 만든다. 브라우저로 화면을 여는 쪽과 프로그램이 API를 부르는 쪽에 서로 다른 응답을 주려고 나눈 구성이다. 다만 이 분리는 `/api/edge` 경로에만 구성돼 있어서, 다른 경로로 들어온 미인증 요청은 `location /`의 로그인 리다이렉트 규칙을 따른다.
## 인증 결과를 변수로 옮긴다
oauth2-proxy가 session을 유효하다고 판단하면 auth 응답에 사용자와 이메일이 들어 있다. Nginx는 `auth_request_set`으로 그 값을 local 변수에 복사한다.
oauth2-proxy가 세션을 유효하다고 판단하면 인증 응답에 사용자와 이메일이 담겨 온다. Nginx는 `auth_request_set`으로 그 값을 지역 변수에 복사한다. `auth_request_set`은 인증 하위 요청의 응답에서 값을 꺼내 변수에 담아 두는 지시어다.
```text label="auth_request_set 변수"
$auth_user ← oauth2-proxy X-Auth-Request-User
@@ -85,9 +88,11 @@ $auth_email ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie
```
여기 복사한 사용자와 이메일이 다음 절에서 업스트림 요청의 헤더가 된다.
## upstream 요청을 새로 만든다
원래 요청을 그대로 전달하지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고 헤더는 Nginx가 만든 값으로 채워진다.
Nginx는 원래 요청을 그대로 전달하지 않는다. 밖에서 들어온 `/api/edge`는 안쪽 `/edge/me`로 다시 매핑되고, 헤더는 앞에서 변수에 담아 둔 값으로 채워진다.
```http label="Nginx가 만드는 upstream 요청"
GET http://app:8081/edge/me
@@ -96,13 +101,13 @@ 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가 된다.
클라이언트가 보낸 같은 이름의 헤더는 합치지 않고 덮어쓴다. 그래서 공격자가 `X-Auth-Request-User: spoofed-admin`을 보내도 업스트림이 받는 값은 oauth2-proxy가 확인한 실제 사용자가 된다.
upstream이 받는 요청에서 브라우저가 보낸 헤더와 edge가 만든 헤더 구분되지 않는다. 그래서 이 덮어쓰기가 edge에서 끝다.
업스트림에 도착한 요청만 봐서는 브라우저가 보낸 헤더와 바깥과 맞닿은 Nginx, 즉 엣지가 만든 헤더 구분할 수 없다. 덮어쓰기를 엣지에서 끝하는 이유가 여기에 있다.
## upstream은 두 겹을 확인한다
Spring controller는 헤더 두 개를 함께 본다.
Spring 컨트롤러는 헤더 두 개를 함께 본다.
```text label="/edge/me의 확인 순서"
1. X-Auth-Request-User가 blank인지 확인
@@ -111,9 +116,9 @@ Spring controller는 헤더 두 개를 함께 본다.
4. 둘 다 유효하면 allowlist된 identity field만 응답에 넣는다
```
`MessageDigest.isEqual`은 입력값의 일치 길이에 따라 실행 시간이 크게 달라지지 않는 비교다.
`MessageDigest.isEqual`은 두 값이 앞에서부터 얼마나 일치하는지에 따라 실행 시간이 크게 달라지지 않는 비교 메서드다.
user 헤더가 없거나 internal token이 틀리면 401다.
사용자 헤더가 없거나 내부 토큰이 틀리면 401을 돌려준다.
```json label="신뢰 조건을 만족하지 못한 응답"
{
@@ -121,7 +126,7 @@ user 헤더가 없거나 internal token이 틀리면 401이다.
}
```
이 검사는 Spring Security의 `/edge/**` rule이 아니라 controller가 직접 한다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 있어서, 새 edge endpoint를 추가하면서 같은 검사를 부르지 않으면 보호가 자동으로 따라오지 않는다.
이 검사는 Spring Security의 `/edge/**` 규칙이 아니라 컨트롤러가 직접 한다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 있어서, 엣지 엔드포인트를 새로 만드는 사람이 같은 검사를 직접 불러 주지 않으면 그 엔드포인트에는 검사가 붙지 않는다.
## 세 방어선이 각각 막는 것
@@ -131,12 +136,14 @@ header overwrite : client가 보낸 동명 헤더를 Nginx 값으로 덮어쓴
internal token : upstream이 edge를 거쳤다는 추가 신호를 확인한다
```
controller의 shared token만으로는 외부에서 app과 oauth2-proxy에 직접 닿지 못하게 할 수 없다. network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 경우를 걸러 내지 못한다.
컨트롤러가 확인하는 공유 토큰만으로는 에서 app의 8081과 oauth2-proxy의 4180에 직접 닿는 것을 막지 못한다. 두 포트를 호스트에 열지 않는 것만으로는 안쪽 워크로드가 보냈거나 잘못 붙은 프록시 헤더를 걸러 내지 못한다.
세 방어선을 함께 두어도 신뢰할 프록시 범위를 넓게 잡거나 공유 시크릿이 노출되면 다른 사용자를 흉내 낼 수 있다.
## 지금 구성이 보여 주지 않는 것
general `location /`도 `proxy_pass http://app:8081/edge/me`를 쓴다. `/orders/123` 같은 임의 upstream path를 보존하는 범용 reverse proxy가 아니다. auth-request와 header trust를 관찰하는 fixture다.
`location /`도 `proxy_pass http://app:8081/edge/me`를 쓰기 때문에, `/orders/123` 같은 임의 경로를 그대로 뒤로 넘기는 범용 리버스 프록시가 아니다. `/`와 `/api/edge`를 같은 사용자 응답으로 이어 붙여 `auth_request`와 헤더 신뢰만 관찰하려고 만든 실험 구성이다.
실제 upstream을 붙이면 URI rewrite, request body, timeout, retry, response header, logout, 상태 변경 요청 보호를 따로 설계해야 한다. 현재 edge 응답은 user와 email만 전달하고 role, groups, tenant, token expiry는 전달하지 않는다.
실제 업스트림을 붙이면 URI 재작성, 요청 본문, 타임아웃, 재시도, 응답 헤더, 로그아웃, 상태를 바꾸는 요청 보호를 하나씩 따로 설계해야 한다. 지금 엣지 응답이 넘기는 값은 사용자와 이메일 둘뿐이고 역할·그룹·테넌트·토큰 만료는 넘기지 않는다. 이 값이 필요한 업스트림을 붙이려면 무엇을 어디서 읽어 어떤 헤더로 넘길지 먼저 정해야 한다. 지금 `/edge/me`는 역할 판단을 하지 않는다.
<!-- body:end -->
@@ -10,20 +10,23 @@ status: 게시 전
version: 4
basisVersion: Keycloak 26.7.0 identity brokering
studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-google-login
---
# 외부 IdP Brokering의 동작
브로커는 외부 IdP의 응답을 검증해 자기 realm의 identity로 연결한 뒤, 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 code와 token은 언제나 브로커가 발급한 것이므로, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer는 바뀌지 않는다.
브로커링(brokering)은 브로커가 외부 IdP(Identity Provider)의 인증 결과를 대신 받아 검증하고, 자기 realm 안의 사용자와 연결하는 동작이다. 연결이 끝나면 브로커는 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 코드와 토큰은 언제나 브로커가 발급한 것이, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer, 곧 그 토큰을 발급한 주체는 바뀌지 않는다.
## 관계
- **외부 IdP 연동과 Application 인증 구조의 경계**
이 동작을 경계 기준으로 정리한 기록이다.
- **OAuth Token과 Application Session을 구분하는 기준**
upstream IdP session과 애플리케이션 상태를 구분하는 기준이다.
외부 IdP 세션과 애플리케이션 상태를 구분하는 기준이다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
브로커가 발급하는 code가 지나는 endpoint다.
브로커가 발급하는 코드가 지나는 endpoint다.
## 본문
@@ -31,7 +34,9 @@ studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9e
## 두 개의 OAuth 왕복이 이어진다
사용자가 브로커 로그인 화면에서 외부 IdP 고르면 인증이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이이고, 뒤의 왕복은 애플리케이션과 브로커 사이다.
사용자가 브로커 로그인 화면에서 어느 외부 IdP로 로그인할지 고르면 인증 왕복이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이에서, 뒤의 왕복은 애플리케이션과 브로커 사이에서 일어난다. 브로커보다 앞에 있는 외부 IdP 쪽을 upstream이라고 부른다.
먼저 브라우저가 외부 IdP에 authorization 요청을 보내고, 브로커는 돌아온 응답을 검증해 자기 realm의 사용자와 연결한다. 그다음 애플리케이션으로 나가는 값은 브로커가 다시 만든다. 무엇이 무엇으로 바뀌는지 순서로 적으면 이렇다.
```text label="brokering 변환 순서"
Google identity assertion
@@ -42,39 +47,35 @@ Google identity assertion
→ AP1·AP2·AP3·AP4 중 선택한 downstream 경계
```
브라우저가 upstream authorization을 수행하고, 브로커가 그 응답을 검증해 local identity와 연결한다. 그다음 애플리케이션으로 나가는 데이터는 다시 브로커가 만든다.
외부 IdP가 보낸 것은 첫 줄의 `Google identity assertion` 하나이고, 그 아래 `Keycloak local user/session`과 `Keycloak authorization code`는 브로커가 만든다.
## 애플리케이션이 상대하는 issuer는 그대로다
AP1의 Resource Server가 검증하는 issuer도 브로커이고, AP2와 AP3가 주고받는 authorization code의 issuer도 브로커이며, AP4의 oauth2-proxy가 OIDC(OpenID Connect) 공급자로 연결하는 곳도 브로커다. 외부 IdP가 발급한 토큰은 애플리케이션까지 내려가지 않는다.
| 계층 | 무엇을 발급하나 | 누가 검증하나 |
|---|---|---|
| 외부 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를 부르는지는 바뀌지 않는다. 그 선택은 네 패턴 중 무엇을 골랐는지가 정한다.
그래서 소셜 로그인을 붙여도 브라우저가 토큰을 받는지, 어느 계층이 API를 부르는지는 달라지지 않는다. 그 둘은 AP1부터 AP4까지 네 패턴 중 무엇을 골랐는지가 정한다.
## account identity를 정하는 key
브로커가 upstream 사용자를 local user와 연결할 때 쓰는 안정적인 key는 provider alias와 upstream `sub`의 조합이다.
브로커가 외부 IdP의 사용자를 자기 realm의 사용자와 연결할 때 쓰는 안정적인 는 provider alias와 upstream `sub`를 묶은 값이다. alias는 브로커에 등록한 외부 IdP마다 붙인 이름이고, `sub`는 그 IdP가 사용자 한 명에게 부여하는 고유 식별자다.
email은 key가 아니다. upstream email이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 email의 소유권을 증명하지 않은 상태에서 계정이 합쳐다. 계정 연결은 인증 구조와 분리된 별도 설계 항목이다.
이메일은 이 키가 아니다. 외부 IdP가 보낸 이메일이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 이메일의 소유권을 증명하지 않은 채로 계정이 합쳐지기 때문이다. 계정 연결은 인증 구조와 떼어서 따로 설계 항목이다.
## 경계를 섞으면 생기는 일
외부 IdP 애플리케이션 인증 구조 하나로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 된다. 두 경계는 검증 방법이 다르다.
외부 IdP 연동을 다섯 번째 애플리케이션 인증 구조로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 다르다. 그러면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
경계가 새는지는 다음 지점에서 본다. UI에서 provider를 고르게 하거나 provider별 계정 연결을 다루는 것은 자연스럽다. Resource Server의 token 검증이나 애플리케이션 인가가 upstream IdP별로 갈리기 시작하면 브로커 경계가 애플리케이션까지 새고 있는지 본다.
외부 IdP의 token을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단이 함께 빠진다.
화면에서 어느 외부 IdP로 로그인할지 고르게 하거나 IdP마다 계정 연결을 다루는 것은 브로커가 하는 일이라 경계를 넘지 않는다. Resource Server의 토큰 검증이나 애플리케이션 인가가 외부 IdP별로 갈리기 시작하면, 브로커 경계가 애플리케이션까지 샜는지 확인한다. 외부 IdP의 토큰을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단도 함께 빠진다.
## 현재 검증한 범위
지금 자동화는 controllable mock OIDC provider로 broker와 claim mapping 계약을 확인다. 실제 Google 계정, public HTTPS callback, consent와 production domain policy를 통과했다는 뜻 아니다.
지금 자동화는 실제 Google 대신 controllable mock OIDC provider를 세워 브로커와 claim mapping 계약을 확인하도록 작성되어 있다. 실제 Google 계정과 공개 HTTPS redirect가 성공하는지는 증명하지 않았다. 사용자 동의와 운영 도메인 정책을 통과했다는 뜻 아니다.
upstream IdP 검증 범위와 애플리케이션 credential 경계를 분리해서 적어야 이 사실 경계가 유지된다.
그래서 upstream IdP를 어디까지 검증했는지와 애플리케이션이 다루는 자격 증명 경계는 따로 적는다.
<!-- body:end -->
@@ -12,50 +12,54 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap3
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3
---
# BFF가 OAuth Token을 관리하는 조건
애플리케이션 계층에서 API 응답 조합과 인가를 처리하면서도 브라우저 JavaScript에는 OAuth Token을 노출하지 않아야 한다면 BFF 구조를 선택할 수 있다.
BFF(Backend for Frontend)는 화면에 필요한 API를 브라우저 대신 호출하고 결과만 돌려주는 백엔드다. 애플리케이션 계층에서 API 응답 조합과 인가를 처리하면서도 브라우저 JavaScript에는 OAuth 토큰을 노출하지 않아야 한다면 구조를 선택할 수 있다.
이 경우 BFF가 Authorization Code를 Token으로 교환하고, Access Token과 Refresh Token을 서버에 보관한다.
브라우저는 OAuth Token 대신 Application Session을 이용해 BFF를 호출하고,
BFF는 저장된 Access Token으로 Downstream Resource Server를 호출한다.
이 경우 BFF가 authorization code를 토큰으로 교환해 액세스 토큰과 리프레시 토큰을 서버에 보관한다.
브라우저는 OAuth 토큰 대신 애플리케이션 세션으로 BFF를 호출하고,
BFF는 보관한 액세스 토큰으로 downstream Resource Server를 호출한다.
## 근거
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
이 결정이 가리키는 구조를 실제로 실행해 본 기록이다.
이 결정대로 만든 구조를 실제로 실행해 본 기록이다.
- **BFF 인증 구조 설계 기준**
이 결정이 PROPOSED인 동안 실제 적용 기준이다.
이 결정이 PROPOSED인 동안 실제로 따르는 기준이다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 결정을 적용할 조건과 피해야 할 조건이 여기 있다.
이 결정을 적용할 조건과 피해야 할 조건을 갈라 놓은 기록이다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
access token이 브라우저로 나가 이 요구를 만족하지 못한 경우다.
액세스 토큰이 브라우저로 나가 이 요구를 만족하지 못한 경우다.
## 결정문
브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다.
브라우저에 OAuth 토큰을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 토큰 보관, downstream API 호출을 소유한다.
브라우저에는 애플리케이션 session만 제공한다.
브라우저에는 애플리케이션 세션만 제공한다.
## 판단 이유
브라우저에 OAuth token을 전달하지 않으려면 server가 authorization code를 교환하고 access token을 사용해 downstream API를 호출해야 한다.
브라우저에 OAuth 토큰을 전달하지 않으려면 서버가 authorization code를 교환하고 액세스 토큰으로 downstream API를 호출해야 한다.
Mediator 구조에서는 브라우저가 Resource Server를 직접 호출하므로 access token을 /token/access 응답으로 전달한다.
그래서 브라우저에 OAuth token을 제공하지 않는다는 요구에는 맞지 않는다.
Mediator 구조에서는 브라우저가 Resource Server를 직접 호출하다 보니 액세스 토큰을 /token/access 응답으로 내보내고,
그래서 브라우저에 OAuth 토큰을 주지 않는다는 요구에는 맞지 않는다.
Forward-Auth 구조도 브라우저에 OAuth token을 전달하지 않을 수 있지만 upstream은 JWT를 직접 검증하지 않고 edge가 제공한 identity header를 사용한다.
애플리케이션이 access token으로 여러 Resource Server를 직접 호출하거나 사용자별 API 조합을 처리해야 한다면 BFF 쪽이 요구에 더 잘 맞는다.
Forward-Auth 구조도 브라우저에 OAuth 토큰을 전달하지 않을 수 있지만, upstream은 JWT를 직접 검증하지 않고 edge가 넘겨준 identity header를 사용한다.
애플리케이션이 액세스 토큰으로 여러 Resource Server를 직접 호출하거나 사용자별 API 조합을 처리해야 한다면 BFF 쪽이 요구에 더 잘 맞는다.
그래서 이 결정을 적용할지는 브라우저에 OAuth token을 전달하지 않아야 하는지와 함께, 애플리케이션이 downstream API 호출과 조합을 직접 맡아야 하는지까지 보고 정한다.
처리량과 장애 복구 시간, session failover, secret rotation 절차는 확인하지 못해서 이 판단의 근거가 아니다.
## 영향
- BFF가 로그인 상태와 access token, refresh token을 보관하는 보안 구성요소가 된다. 요청을 그대로 넘기는 proxy와 같은 것으로 다루지 않는다.
- 상태 변경 요청마다 CSRF 검증이 필요해진다. 노출되는 값과 제출해야 하는 값이 다를 수 있어서 클라이언트 코드도 그 차이를 알고 있어야 한다.
- 재시작과 replica 이동을 견딜 공유 저장소 저장 token 암호화, 암호화 key 교체를 설계해야 하는데 아직 정하지 않은 문제로 남아 있다.
- logout이 애플리케이션 session과 authorized client를 함께 지워야 하는데, 관리가 달라서 한 번의 삭제로 두 상태가 함께 지워지지 않는다.
- 모든 UI 요청이 BFF를 지나게 되어서 지연과 단일 장애 지점을 준비해야 한다.
- 브라우저에서 token을 없애도 XSS는 여전히 고려해다.
- BFF가 로그인 상태와 액세스 토큰, 리프레시 토큰을 보관하는 보안 구성요소가 되므로, 요청을 그대로 넘기는 프록시처럼 다루지 않는다.
- 상태를 바꾸는 요청마다 CSRF 검증이 붙는다. 클라이언트에 내려가는 값과 실제로 제출해야 하는 값이 다를 수 있어서 클라이언트 코드도 그 차이를 알야 한다.
- 재시작과 replica 이동을 견딜 공유 저장소, 저장한 토큰의 암호화, 암호화 교체를 함께 설계해야 한다. 이 세 가지는 아직 정하지 못했다.
- 로그아웃은 애플리케이션 세션과 authorized client를 함께 지워야 하는데, 두 상태를 따로 보관하다 보니 한 번의 삭제로 둘이 같이 지워지지 않는다.
- 모든 UI 요청이 BFF를 지나므로 지연과 단일 장애 지점을 함께 준비해야 한다.
- 브라우저에서 토큰을 없애도 XSS는 막히지 않는다. 같은 origin에서 실행되는 악성 script는 사용자 세션으로 BFF를 호출할 수 있어서 XSS 방어는 따로 세워다.
@@ -12,62 +12,64 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-google-login
---
# 외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.
Google, Keycloak, 애플리케이션 인증 구조는 각각 역할이 다르다.
Google은 실제 사용자 인증을 수행하는 외부 IDP이고, Keycloak은 Google의 인증 결과를 받아 애플리케이션이 사용할 토큰을 발급한다.
외부 IdP(Identity Provider, 인증 제공자)는 사용자 인증을 실제로 수행하는 쪽이고, Google이 그중 하나다.
Keycloak은 Google의 인증 결과를 받아 애플리케이션이 사용할 토큰을 발급한다.
애플리케이션은 Google을 직접 신뢰하는 것이 아니라 Keycloak이 발급한 토큰을 기준으로 사용자를 인증한다.
SPA, BFF와 같은 구조는 로그인한 사용자의 토큰이나 세션을 어디에 관리할 것인지를 정한다.
따라서 외부 IDP가 붙더라도 인증 구조 바뀌는 것은 아니다.
SPA(Single Page Application)나 BFF(Backend for Frontend) 같은 구조는 로그인한 사용자의 토큰 세션을 어디에 관리할 정한다.
그래서 외부 IdP가 붙어도 애플리케이션의 인증 구조 바뀌지 않는다.
## 근거
- **외부 IdP 연동과 Application 인증 구조의 경계**
이 결정을 규칙으로 편 기준이다.
이 결정을 규칙 문장으로 적어 둔 기준이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브로커가 발급한 code를 받는 애플리케이션 경계다.
브로커가 발급한 Authorization Code를 받는 애플리케이션 경계다.
- **OAuth Token과 Application Session을 구분하는 기준**
upstream IdP 상태와 애플리케이션 상태를 같은 이름으로 부르지 않는다.
upstream IdP 상태와 애플리케이션 상태를 같은 이름으로 부르지 않는다고 갈라 둔 기준이다.
## 결정문
외부 IdP 연동은 별도의 인증 구조가 아니다.
Google과 같은 외부 IDP는 사용자의 인증을 담당하고, Keycloak은 그 인증 결과를 받아 애플리케이션이 신뢰할 수 있는 토큰을 발급한다.
Google과 같은 외부 IdP는 사용자의 인증을 담당한다.
Keycloak은 그 인증 결과를 받아 애플리케이션이 신뢰할 수 있는 토큰을 발급한다.
SPA, Mediator, BFF, OAuth2-proxy와 같은 4가지 구조는 이렇게 발급된 토큰이나 세션을 애플리케이션에서 어디까지 노출하고 관리할지를 구분한다.
## 판단 이유
사용자가 Keycloak 로그인 화면에서 Google 로그인을 선택하면 브라우저는 Google의 Authorization Endpoint로 이동한다.
Google에서 인증이 끝나면 그 결과는 Keycloak으로 돌아오고, Keycloak은 이 응답을 검증해 자신의 사용자 정보와 연결한다.
러고 나서 애플리케이션 callback에는 Keycloak이 발급한 Authorization Code가 전달된다.
사용자가 Keycloak 로그인 화면에서 Google 로그인을 선택하면 브라우저는 Google의 Authorization Endpoint로 이동한다.
Google에서 인증이 끝나면 그 결과는 Keycloak으로 돌아오고, Keycloak은 이 응답을 검증해 자신의 사용자 정보와 연결한다.
다음 애플리케이션 콜백에는 Keycloak이 발급한 Authorization Code가 전달된다.
애플리케이션은 Google과 직접 토큰을 교환하지 않는다.
애플리케이션은 Keycloak이 발급한 Authorization Code를 Keycloak의 Token Endpoint에서 토큰으로 교환한다.
Resource Server가 검증하는 issuer도 Google이 아니라 Keycloak이고, 애플리케이션은 Google token을 받지 않는다.
애플리케이션은 Google과 직접 토큰을 교환하지 않는다.
Keycloak이 발급한 Authorization Code를 Keycloak의 Token Endpoint에서 토큰으로 교환한다.
Resource Server가 검증하는 발급자(issuer)도 Google이 아니라 Keycloak이다.
애플리케이션은 Google이 발급한 토큰을 받지 않는다.
그래서 Google 로그인을 추가해도 애플리케이션 토큰 관리 구조는 달라지지 않는다.
SPA라면 여전히 브라우저에서 토큰을 관리하고, BFF라면 서버가 토큰을 관리하면서 API를 대신 호출해준다.
브라우저가 token을 받는지, 어느 계층이 API를 부르는지도 그대로다.
애플리케이션이 다루는 토큰을 Keycloak이 발급하기 때문에, Google 로그인을 추가해도 애플리케이션 토큰 관리하는 방식은 달라지지 않는다.
SPA라면 여전히 브라우저에서 토큰을 관리하고, BFF라면 서버가 토큰을 관리하면서 API를 대신 호출다.
Google을 구조 하나로 세게 되면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 서로 다르다.
두 경계를 섞어 두면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
래서 외부 IDP 연동과 애플리케이션 인증 구조는 별도의 경계로 나누어 설계하고 검증한다.
Google을 다섯 번째 구조로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증하는 방법이 서로 다르다.
그래서 외부 IdP 연동과 애플리케이션 인증 구조는 별도의 경계로 나누어 설계하고 검증한다.
비교표도 처음에는 다른 모양이었다.
네 구조를 설명하는 용어부터 나란히 놓고 견주었는데, 용어만으로는 어느 계층이 Authorization Code를 교환하고 어느 계층이 API를 부르는지 보이지 않아 로그인과 API 요청을 맡는 구성요소를 같은 표에 놓았다.
## 영향
- Google을 추가하더라도 애플리케이션이 신뢰하고 토큰을 검증하는 대상은 계속 Keycloak이다.
또한 토큰을 브라우저와 서버 중 어디에서 관리하고 어느 계층에서 API를 호출할지는 기존 4가지 구조가 정하는 그대로다.
- 외부 IDP의 계정을 기존 사용자와 어떻게 연결할지는 인증구조와 별개의 문제다.
외부 계정을 식별할 때는 Google과 같은 인증 제공자와 해당 제공자가 부여한 사용자 고유 식별자를 같이 사용한다.
이메일 주소는 변경될 수 있고 서로 다른 인증 제공자에서 같은 이메일을 사용할 수도 있기 때문에 이메일이 같다는 이유로 기존 계정 자동으로 연결하지 않는다.
- 외부 IDP 연동은 테스트 환경에서 확인할 부분과 실제 서비스 환경에서 확인할 부분을 나눠서 검증한다.
Mock Provider를 사용한 테스트에서는 KeyCloak이 외부 IDP의 인증 결과를 정상적으로 받아들이는지,
필요한 사용자 정보가 정상적으로 매핑되는지 확인한다.
실제 Google과 같은 외부 IDP를 연동할 때는 실제 계정으로 로그인이 가능한지, 공개 HTTPS Callback이 정상 작동 하는지, 사용자 동의 과정까지 진행되는지 확인해야 한다.
- Google과 같은 외부 IDP가 늘어나면 KeyCloak에서 관리해야 할 연동 설정도 많아진다.
이 연동 설정을 애플리케이션 팀이 관리할지 별도의 인프라 팀이 관리할지는 아직 정하지 않았다.
실무에서 어느 쪽이 맡는지도 확인하지 않았다.
- Google을 추가도 애플리케이션이 신뢰하고 토큰을 검증하는 대상은 계속 Keycloak이다.
토큰을 브라우저와 서버 중 어디에서 관리하고 어느 계층에서 API를 호출할지는 기존 4가지 구조가 그대로 정한다.
- 외부 IdP의 계정을 기존 사용자와 어떻게 연결할지는 인증 구조와 별개의 문제다.
외부 계정을 식별할 때는 Google과 같은 인증 제공자와 제공자가 부여한 사용자 고유 식별자를 함께 쓴다.
이메일 주소는 바뀔 수 있고 서로 다른 인증 제공자에서 같은 이메일을 수도 있어서, 이메일이 같다는 이유로 기존 계정 자동으로 연결하지 않는다.
- 외부 IdP 연동은 테스트 환경에서 확인할 부분과 실제 서비스 환경에서 확인할 부분을 나눠서 검증한다.
Mock Provider를 붙인 테스트에서는 Keycloak이 외부 IdP의 인증 결과를 제대로 받아들이는지, 필요한 사용자 정보가 제대로 매핑되는지 확인한다.
실제 Google과 같은 외부 IdP를 연동할 때는 실제 계정으로 로그인이 되는지, 공개 HTTPS 콜백이 정상 작동하는지, 사용자 동의 과정까지 진행되는지 확인해야 한다.
- Google과 같은 외부 IdP가 늘어나면 Keycloak에서 관리할 연동 설정도 같이 늘어난다.
이 설정을 애플리케이션 팀이 맡을지 별도의 인프라 팀이 맡을지는 아직 정하지 않았다.
@@ -11,124 +11,130 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap3
- final/document.md#문제를-어렵게-만든-제약-학습-환경
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3
---
# BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
Application Session과 Authorized Client는 저장하고 조회하는 기준이 서로 다르다.
Session은 `session ID`를 기준으로 조회하지만, Authorized Client는 `client registration 이름``principal name`을 기준으로 조회한다.
그래서 두 상태를 반드시 같은 저장소에 보관해야 하는 것은 아니며, 각각의 조회 방식과 운영 요구사항에 맞게 저장 구조를 결정해야 한다.
현재 BFF의 세션과 Authorized Client는 프로세스 메모리에 저장된다. 재시작과 레플리카 이동 뒤에도 로그인 상태를 유지하려면 두 상태를 어디에 저장할지 정해야 한다.
현재 Shared Store의 후보로는 Redis를 우선 생각하고 있지만 아직 최종 저장소로 결정한 것은 아니다.
특히 Access Token과 Refresh Token을 Redis에 저장할 경우 Token을 어떤 방식으로 암호화할지, Session과 Token의 만료 시간을 어떻게 맞출지, Logout할 때 Session과 Authorized Client가 모두 정상적으로 제거되는지까지는 확인하지 않았다.
BFF(Backend for Frontend)가 서버에 들고 있는 상태는 둘이다. 하나는 브라우저의 로그인 세션이고,
다른 하나는 Keycloak에서 받은 액세스 토큰과 리프레시 토큰을 담아 두는 Authorized Client다.
세션은 `session ID`로 찾지만 Authorized Client는 `client registration 이름``principal name`으로 찾는다.
따라서 현재 단계에서는 Redis를 저장소 후보로 작성하고, Token 보호와 만료 처리, Logout 시 상태 정리까지 검증한 뒤 실제 저장 구조를 결정한다.
찾는 열쇠가 이렇게 다르니 두 상태를 반드시 한 저장소에 담아야 하는 것은 아니다.
각각의 조회 방식과 운영 요구사항에 맞게 저장 구조를 따로 정할 수 있다.
공유 저장소 후보로 Redis를 우선 보고 있지만 아직 고르지 않았다.
액세스 토큰과 리프레시 토큰을 Redis에 담는다면 토큰을 어떤 방식으로 암호화할지, 세션과 토큰의 만료 시간을 어떻게 맞출지,
로그아웃할 때 세션과 Authorized Client가 모두 지워지는지를 확인해야 하는데 아직 확인하지 않았다.
토큰 보호와 만료 처리, 로그아웃 뒤 상태 정리까지 검증한 다음에 저장 구조를 정한다.
## 관계
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
이 질문에서 저장소 부분만 떼어 낸 것이다.
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
sessionauthorized client 열쇠가 다르다는 사실의 출처다.
세션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가 함께 구성돼 있어서,
저장소를 공유하게 되면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있게 된다.
- 현재 구성에 Spring Session도, Redis, JDBC 저장소도, 토큰을 암호화해 담는 저장소도 없다.
- HttpSession은 서블릿 컨테이너의 메모리 구현을 쓰기 때문에, 그 프로세스가 종료되면 세션 데이터도 같이 사라진다.
- OAuth2AuthorizedClientService도 Spring Boot 자동구성이 고르는 메모리 구현이다.
- 세션은 session ID로 조회하고 Authorized Client는 registration 이름과 principal name으로 조회한다.
두 저장 구조를 공유 저장소로 옮길 각각 따로 설계해야 한다.
- Authorized Client 매니저에는 authorization-code와 refresh-token provider가 함께 구성돼 있어서,
저장소를 공유하면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있다.
## 가정
- 두 상태를 같은 저장소에 둘 필요는 없다.
- 저장된 refresh token을 평문으로 두면 안 된다.
- session 만료와 token 만료 중 하나가 먼저 오게 되면 그 순간의 동작이 정의돼 있어야 한다.
예를 들면 token이 만료됐을 때 사용자의 로그인 상태가 여전히 유지되는지.
- 저장한 리프레시 토큰을 평문으로 두면 안 된다.
- 세션 만료와 토큰 만료 중 하나가 먼저 오면 그 순간에 무엇이 일어날지 정해 두어야 한다.
토큰이 만료됐을 때 사용자의 로그인 상태가 유지되는지가 그런 예다.
## 미지수
- Redis와 JDBC 중 무엇이 이 상태의 접근 패턴에 맞는가.
- Redis와 JDBC 중 어느 쪽이 이 상태의 접근 패턴에 맞는가.
요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
- sessionauthorized client를 같은 store에 둘지 나눌지.
- 암호화 key를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key로 저장 값은 어떻게 읽는가.
- session TTL과 refresh token 수명 중 어느 을 기준으로 만료를 맞추게 되는가.
- session과 Authorized Client 두 store를 logout에서 어떻게 한 번에 지우게 되는가.
- sticky session이 durable store의 대안이 되는가 보완이 되는가.
- 세션Authorized Client를 한 저장소에 둘지 나눌지.
- 암호화 를 어디에 두고 어떻게 교체하는가. 교체하는 동안 이전 로 저장 값은 어떻게 읽는가.
- 세션 TTL과 리프레시 토큰 수명 중 어느 을 기준으로 만료를 맞추는가.
- 로그아웃 한 번으로 세션과 Authorized Client 두 저장소를 어떻게 같이 지우는가.
- 스티키 세션이 영속 저장소의 대안인가 보완인가.
## 제약
- authorized client의 조회 시 session ID가 없어서 session만 공유해도 같은 사용자의 여러 session이 같은 token을 보게 된다.
- 현재 테스트에는 저장소 관련 계약이 없다.
- 모든 UI 요청이 BFF를 지나기 때문에 저장소 지연이 화면 지연으로 바로 드러나게 된다.
- Authorized Client를 찾을 때는 session ID를 쓰지 않아서, 세션만 공유해도 같은 사용자의 여러 세션이 같은 토큰을 본다.
- 현재 테스트에는 저장소 계약이 없다.
- 모든 화면 요청이 BFF를 지나기 때문에 저장소가 느려지면 화면도 바로 느려진다.
## 선택지
### 1. sessionauthorized client를 모두 Redis에 둔다
### 1. 세션Authorized Client를 모두 Redis에 둔다
Spring Session Redis와 Redis 기반 Authorized Client 저장소를 사용하면 여러 애플리케이션 인스턴스가 같은 Session과 Authorized Client 정보를 조회할 수 있다.
상태가 특정 인스턴스의 메모리에 묶이지 않으므로 서버가 재시작되거나 요청이 다른 Replica로 전달되는 환경에서도 인증 상태를 공유하기 쉬워진다.
Spring Session Redis와 Redis 기반 Authorized Client 저장소를 면 여러 애플리케이션 인스턴스가 같은 세션과 Authorized Client를 조회할 수 있다.
상태가 특정 인스턴스의 메모리에 묶이지 않으 서버가 재시작되거나 요청이 다른 레플리카로 전달되는 환경에서도 인증 상태를 이어 쓰기 쉬워진다.
Session과 Authorized Client에 설정 유효 시간에 따라 Redis의 TTL을 이용해 저장 상태를 만료시키는 구조도 구성할 수 있다.
다만 어떤 상태를 얼마 동안 유지할지는 애플리케이션의 Session 정책과 OAuth Token의 수명에 맞춰 별도로 정해야 한다.
세션과 Authorized Client에 설정 유효 시간에 맞춰 Redis의 TTL 저장 상태를 만료시키는 구조도 수 있다.
다만 어떤 상태를 얼마 동안 유지할지는 애플리케이션의 세션 정책과 OAuth 토큰 수명에 맞춰 로 정한다.
이 구성에서는 인증 경로가 Redis의 가용성에 의존하게 된다.
Redis에 장애가 발생했을 때 기존 Session과 Authorized Client를 조회하지 못하는 상황을 어떻게 처리할지 정해야 하,
장애 복구와 데이터 유지 방식도 함께 고려해야 한다.
대신 인증 경로가 Redis의 가용성에 매인다.
Redis에 장애가 을 때 기존 세션과 Authorized Client를 지 못하는 상황을 어떻게 처리할지 정해야 하, 장애 복구와 데이터 유지 방식도 같이 본다.
또한 Access Token과 Refresh Token을 Redis에 저장한다면 저장된 Token을 어떤 방식으로 보호할지도 정해야 한다.
Token을 암호화해서 저장할지, 암호화한다면 Key를 어디에 보관하고 어떻게 교체할지까지 저장소 설계에 포함한다.
액세스 토큰과 리프레시 토큰을 Redis에 담는다면 담은 토큰을 어떻게 보호할지도 정해야 한다.
암호화해서 저장할지, 암호화한다면 를 어디에 보관하고 어떻게 교체할지까지 저장소 설계에 포함한다.
### 2. sessionauthorized client를 모두 JDBC에 둔다
### 2. 세션Authorized Client를 모두 JDBC에 둔다
JDBC 기반 저장소를 사용하면 이미 운영 중인 관계형 DB에 Session과 Authorized Client 정보를 저장할 수 있다.
JDBC 기반 저장소를 면 이미 운영 중인 관계형 DB에 세션과 Authorized Client를 담을 수 있다.
인증 과정에서 Session이나 Authorized Client를 조회할 때마다 DB 접근이 발생하므로, 인증 요청이 기존 관계형 DB의 가용성과 성능에 영향을 받게 된다.
요청량이 증가했을 때 Session 조회가 지연되지 않는지 확인하고, 인증 관련 조회 기존 애플리케이션 쿼리 서로 영향을 주지 않는지 확인해야 한다.
인증 과정에서 세션이나 Authorized Client를 조회할 때마다 DB 접근이 일어나므로, 인증 요청이 기존 관계형 DB의 가용성과 성능에 매이게 된다.
요청량이 늘었을 때 세션 조회가 지연되지 않는지, 인증 조회 기존 애플리케이션 쿼리 서로 영향을 주지 않는지 확인해야 한다.
또한 만료된 Session과 Authorized Client 데이터가 계속 쌓이지 않도록 정리하는 방법과 주기해야 한다.
JDBC를 고르면 기존 DB 운영 체계를 그대로 쓰면서 조회 지연과 DB 부하, 만료 데이터 정리까지 같이 관리하게 된다.
만료된 세션과 Authorized Client 데이터가 계속 쌓이지 않도록 정리하는 방법과 주기 정한다.
### 3. session만 공유하고 sticky session을 쓴다
### 3. 세션만 공유하고 스티키 세션을 쓴다
Session Affinity를 사용하면 같은 사용자의 요청을 가능한 한 동일한 애플리케이션 인스턴스로 전달할 수 있으므로 기존 구조의 변경을 줄일 수 있다.
세션 어피니티를 쓰면 같은 사용자의 요청을 되도록 같은 애플리케이션 인스턴스로 보낼 수 있어서 기존 구조를 덜 건드린다.
하지만 이것만으로 인증 상태가 여러 인스턴스에 공유되는 것은 아니다.
Authorized Client가 여전히 특정 애플리케이션 인스턴스의 메모리에 저장되어 있다면, 요청이 다른 인스턴스로 전달되거나 해당 인스턴스가 종료되었을 때 기존 Token 정보를 조회할 수 없다.
하지만 이것만으로 인증 상태가 여러 인스턴스에 공유되않는다.
Authorized Client가 여전히 특정 애플리케이션 인스턴스의 메모리에 있다면, 요청이 다른 인스턴스로 전달되거나 인스턴스가 종료을 때 기존 토큰 정보를 찾지 못한다.
Session Affinity는 요청을 특정 인스턴스로 보내는 방법이다. Session과 Authorized Client를 공유하는 문제 장애 이후에 인증 상태를 유지하는 문제는 그대로 남는다.
인스턴스 장애와 Replica 간 이동까지 고려한다면 Session과 Authorized Client의 저장 방식을 별도로 설계해야 한다.
세션과 Authorized Client를 공유하는 문제, 장애 에 인증 상태를 는 문제는 따로 풀어야 한다.
인스턴스 장애와 레플리카 간 이동까지 보려면 세션과 Authorized Client의 저장 방식을 로 설계한다.
### 4. session은 Redis, token은 암호화 JDBC에 둔다
### 4. 세션은 Redis, 토큰은 암호화 JDBC에 둔다
Session과 Authorized Client를 서로 다른 저장소에 보관하는 방법도 있다.
요청마다 자주 조회되는 Session은 Redis와 같이 빠르게 접근할 수 있는 저장소에 두고, Access Token과 Refresh Token은 암호화와 장기 보관 정책을 적용하기 쉬운 관계형 DB에 저장할 수 있다.
각 데이터의 접근 패턴과 보호 요구사항에 맞춰 저장소를 선택할 수 있다는 장점이 있다.
세션과 Authorized Client를 서로 다른 저장소에 는 방법도 있다.
요청마다 자주 조회되는 세션은 Redis처럼 빠르게 접근할 수 있는 저장소에 두고, 액세스 토큰과 리프레시 토큰은 암호화와 장기 보관 정책을 적용하기 쉬운 관계형 DB에 담는 식이다.
두 저장소의 상태 함께 관리해야 한다.
Session이 만료되었는데 Authorized Client가 남거나, 반대로 Authorized Client가 먼저 제거되어 유효한 Session에서 Token을 찾지 못하는 상황이 발생할 수 있다.
따라서 각각의 만료 정책을 어떻게 맞출지 정해야 한다.
대신 두 저장소의 상태 함께 관리해야 한다.
세션이 만료는데 Authorized Client는 아직 지워지지 않거나, 반대로 Authorized Client가 먼저 지워져서 유효한 세션인데도 토큰을 찾지 못하는 상황이 생길 수 있다.
그래서 각각의 만료 정책을 어떻게 맞출지 정해야 한다.
Logout에서도 Session과 Authorized Client가 서로 다른 저장소에 있으므로 두 상태를 모두 정리해야 한다.
한쪽을 제거하는 과정에서 실패했을 때 어떻게 처리할지도 함께 결정해야 한다.
로그아웃에서도 세션과 Authorized Client가 서로 다른 저장소에 있으 두 상태를 모두 지워야 하고, 한쪽을 지우다 실패했을 때 어떻게 할지도 같이 정한다.
또한 Redis와 관계형 DB를 모두 인증 경로에서 사용하게 되므로 모니터링, 장애 대응, 백업 운영해야 하는 저장소 늘어난다.
저장소를 나눠서 얻는 것과 늘어나는 운영 대상을 같이 놓고 정한다.
Redis와 관계형 DB를 둘 다 인증 경로에서 게 되므로 모니터링, 장애 대응, 백업까지 운영 저장소 늘어난다.
## 다음 검증
후보마다 같은 입력으로 비교한다.
1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.
2. 저장소를 직접 열어 refresh token이 평문으로 는지 확인한다.
3. session TTL과 token 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
4. logout 뒤 두 store에 잔여 항목이 없는지 확인한다.
2. 저장소를 직접 열어 리프레시 토큰이 평문으로 보이는지 확인한다.
3. 세션 TTL과 토큰 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
4. 로그아웃 뒤 두 저장소에 잔여 항목이 없는지 확인한다.
5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.
암호화 key 교체 절차는 후보를 고른 뒤에 따로 설계한다.
암호화 교체 절차는 후보를 고른 뒤에 따로 설계한다.
@@ -11,115 +11,106 @@ version: 36
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/7ff40767-a00b-4db2-98f6-0cdfce8c8936/edit"
public: "https://hyeonworks.com/questions/edge-authorization-scope"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap4
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4
---
# Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
현재 Edge는 인증된 사용자의 `user``email`Header로 전달하고 있으며, Upstream 애플리케이션에서는 Role을 이용한 인가 판단하지 않는다.
지금 엣지는 인증된 사용자의 `user``email`헤더로 넘기고, 업스트림 애플리케이션은 역할(role)보고 인가 판단하지 않는다.
따라서 현재 구조만으로는 Role 기반 인가가 필요한 요구사항이 추가되었을 때 어떻게 처리할지 결정되어 있지 않다.
Edge가 사용자의 Role까지 확인해 Header로 전달할지, 아니면 애플리케이션이 Role과 권한을 확인하고 인가를 직접 판단하도록 할지 별도로 결정해야 한다.
그래서 역할 기반 인가가 필요한 요구가 들어왔을 때 어떻게 처리할지 아직 정해 두지 않다. 엣지가 사용자의 역할까지 확인해 헤더로 넘길지, 애플리케이션이 역할과 권한을 직접 조회해 인가를 판단할지를 정해야 한다.
Role이나 권한처럼 애플리케이션 기능과 밀접한 정보가 계속 늘어난다면, 이러한 정보를 Edge Header에 계속 추가하기보다 인가 책임을 애플리케이션에서 처리하는 구조가 더 적절한지도 함께 검토한다.
역할이나 권한처럼 애플리케이션 기능에 가까운 값이 계속 늘어난다면, 그 값을 엣지 헤더에 계속 더하는 대신 인가를 애플리케이션이 맡는 구조가 나은지도 함께 검토한다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
edge가 user와 email만 전달한다는 사실의 출처다.
엣지가 user와 email만 넘긴다는 사실을 여기서 가져왔다.
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
헤더 allowlist와 검증 조건이 이 기에 있다.
넘길 헤더를 허용 목록(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를 입력으로 받지 않아서 헤더로 넘어온 값을 검증할 방법이 없다.
- 지금 엣지 응답은 user와 email만 넘긴다. 역할과 그룹, 테넌트, 인증 방식, 토큰 만료는 넘기지 않는다.
- 업스트림의 신원 응답 엔드포인트는 역할을 확인하지 않 누가 왔는지만 돌려준다.
- 내부 토큰(internal token) 검증은 컨트롤러 한 곳에서만 하고, Spring Security 설정은 그 경로를 permitAll로 열어 둔다. 검증이 그 컨트롤러 안에만 있으니 같은 내부 경로 아래에 엔드포인트를 새로 추가해도 검증이 따라붙지 않는다. 필터나 인터셉터, Spring Security의 인증 처리 단계처럼 모든 요청이 지나는 공통 경계로 이 검사를 옮겨야 한다.
- Nginx는 클라이언트가 보낸 같은 이름의 헤더를 합치지 않고 덮어쓴다. 헤더를 늘리면 늘어난 헤더도 똑같이 덮어쓰게 해야 한다.
- 업스트림은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값이 맞는지 확인할 방법이 없다.
## 가정
- 헤더 종류가 늘어나면 정해야 할 계약도 늘어난다.
- role이 바뀌는 시과 요청이 오는 시이 달라서 그 사이에 들어온 요청은 바뀌기 전 값을 볼 수도 있다.
- 헤더 종류가 늘어나면 그만큼 정해야 할 계약도 늘어난다.
- 역할이 바뀌는 시과 요청이 들어오는 시이 달라서, 그 사이에 들어온 요청은 바뀌기 전 값을 볼 수도 있다.
## 미지수
- 다중 값 role을 어떤 구분자와 escaping으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
- 헤더 크기 상한을 넘으면 어떻게 되는지. proxy가 자르는지 요청 자체가 거부되는지.
- role이 바뀌었을 때 proxy session과 downstream 인가 언제 반영되는지. 권한 변경이 몇 분 뒤에 반영되는지.
- upstream이 헤더 존재만 볼지 값과 service identity까지 볼지.
- 역할이 여러 개일 때 어떤 구분자와 이스케이프 규칙으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
- 헤더 크기 상한을 넘으면 어떻게 되는지. 프록시가 잘라 내는지, 요청 자체가 거부되는지.
- 역할이 바뀌었을 때 프록시 세션과 다운스트림 인가 언제 반영되는지. 권한을 바꾸고 몇 분 뒤에 반영되는지.
- 업스트림이 헤더가 있는지만 볼지, 값과 내부 서비스 식별값(service identity)까지 함께 볼지.
## 제약
- 전달할 헤더는 allowlist로 해야 하고 client가 보낸 동명 헤더는 항상 덮어써야 한다.
- internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮겨야 다.
- 넘길 헤더는 허용 목록으해야 하고, 클라이언트가 보낸 같은 이름의 헤더는 언제나 덮어써야 한다.
- 내부 토큰 검사가 컨트롤러 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮겨야 다.
## 선택지
### 1. 인증만 edge에 둔다
### 1. 인증만 엣지에 둔다
Edge가 전달하는 Header를 user와 email 정도로 제한하면 Edge와 Upstream 사이의 계약을 작게 유지할 수 있다.
Role이나 Permission 정보를 Header에 계속 추가하지 않으므로 Header 크기가 커지는 문제도 줄일 수 있다.
엣지가 넘기는 헤더를 user와 email 정도로 묶어 두면 엣지와 업스트림 사이의 계약을 작게 유지할 수 있다. 역할이나 권한을 헤더에 계속 더하지 않으니 헤더가 커지는 문제도 줄일 수 있다.
이 경우 인가 판단은 각 Upstream 애플리케이션이 직접 수행한다.
애플리케이션은 전달받은 사용자 식별 정보를 기준으로 자신의 저장소에서 Role이나 Permission을 조회하고,
해당 요청을 허용할지 결정해야 한다.
대신 인가는 업스트림 애플리케이션이 직접 판단한다. 넘겨받은 사용자 식별 정보로 자기 저장소에서 역할이나 권한을 조회하고, 그 요청을 허용할지 정해야 한다.
이 구조에서는 서비스마다 권한 조회와 인가 로직을 별도로 구성해야 한다.
따라서 Edge의 책임은 단순하게 유지할 수 있지만, 서비스 수가 늘어나면 각 서비스에서 동일하거나 유사한 권한 조회 체계를 반복해서 구현하고 운영해야 할 수 있다.
인가를 서비스가 직접 판단하다 보니 권한 조회와 인가 코드를 서비스마다 따로 만들어야 한다.
### 2. role 전달까지 edge에 둔다
### 2. 역할 전달까지 엣지에 둔다
Edge가 공통 Role 정보를 확인해 Upstream에 전달하면 각 서비스가 별도로 사용자 권한을 조회해야 하는 작업을 줄일 수 있다.
엣지가 공통 역할을 확인해 업스트림에 넘기면, 서비스마다 사용자 권한을 따로 조회하는 을 줄일 수 있다.
대신 Role을 Header로 전달하기 위한 계약을 먼저 정해야 한다.
사용자가 여러 Role을 가질 때 어떤 형식으로 직렬화할지, Header에 허용할 최대 크기를 어디까지로 할지, 사용자의 Role이 변경되었을 때 언제부터 새로운 값이 요청에 반영되는지도 명확하게 정의해야 한다.
대신 역할을 헤더로 넘기는 계약을 먼저 정해야 한다. 한 사용자가 역할을 여럿 가질 때 어떤 형식으로 직렬화할지, 헤더에 허용할 최대 크기를 얼마로 둘지, 역할을 바꾼 뒤 언제부터 새 값이 요청에 실리는지를 적어 두어야 한다.
또한 Upstream은 전달받은 Role이 원래 인증 시스템의 값과 일치하는지 확인할 수 있어야 하고, 그러지 못하면 Edge가 전달한 값을 그대로 신뢰하게 된다. 따라서 Edge에서 Role을 잘못 계산하거나 오래된 값을 전달하면 Upstream의 인가 판단도 그대로 잘못될 수 있다.
업스트림은 넘겨받은 역할이 원래 인증 시스템의 값과 같은지 확인할 수 있어야 한다. 확인하지 못하면 엣지가 넘긴 값을 그대로 믿게 되는데, 엣지가 역할을 잘못 계산하거나 오래된 값을 넘기면 업스트림의 인가 판단도 그대로 틀어질 수 있다.
구조를 선택하게 되면 Edge가 Role 정보를 만드는 과정과 Header를 전달하는 경로 신뢰 경계의 일부로 보고,
Role 갱신과 전달 오류를 어떻게 검증할지도 함께 설계해야 한다.
선택지를 고르면 엣지가 역할을 만드는 과정과 헤더가 지나는 경로까지 신뢰 경계 안으로 들어온다. 역할을 언제 갱신하고 전달 오류를 어떻게 잡을지도 같이 설계해야 한다.
### 3. tenant와 인가 판단까지 edge에 둔다
### 3. 테넌트와 인가 판단까지 엣지에 둔다
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 호출을 처리하는 구조도 선택할 수 있다.
역할이나 테넌트 같은 값을 엣지 헤더에 계속 하는 대신, BFF(Backend For Frontend)가 필요한 정보를 직접 조회 인가 판단하고 API까지 부르는 구조도 고를 수 있다. BFF는 화면에 필요한 API를 브라우저 대신 불러 조합하는 서버다.
구조에서는 Edge가 애플리케이션의 Role, Tenant, 권한 정책까지 알 필요가 없다.
BFF가 필요한 사용자와 권한 정보를 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 호출해 결과를 조합할 수 있다. 따라서 애플리케이션 도메인에 가까운 책임을 Edge Header 계약에서 분리할 수 있다.
러면 엣지는 애플리케이션의 역할과 테넌트, 권한 정책 알 필요가 없다. BFF가 사용자와 권한을 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 불러 결과를 합칠 수 있다.
이 구조에서는 BFF를 도입하면서 서버가 다시 인증 상태를 관리해야 한다.
브라우저와 BFF 사이의 Application Session을 보호해야 하고, Cookie 기반 Session을 사용한다면 상태 변경 요청에 대한 CSRF 검증도 필요하다.
여러 BFF Replica에서 인증 상태를 유지해야 한다면 Session과 Authorized Client를 어떻게 공유할지 결정하고 Shared Store의 장애와 만료 처리도 운영해야 한다.
대신 BFF를 넣으면 서버가 다시 인증 상태를 들고 있어야 한다. 브라우저와 BFF 사이의 애플리케이션 세션을 보호해야 하고, 쿠키 기반 세션을 쓴다면 쿠키가 요청마다 자동으로 붙어서 값을 바꾸는 요청에는 사용자가 의도한 것인지 확인하는 CSRF(Cross-Site Request Forgery) 검증도 있어야 한다. BFF를 여러 대로 늘려 인증 상태를 유지하려면 세션과 authorized client를 어떻게 공유할지 정하고, 공유 저장소의 장애와 만료 처리까지 운영해야 한다.
이 구조를 골랐다가 다시 엣지 쪽으로 되돌린다면 BFF가 맡던 사용자별 인가를 업스트림이나 별도 정책 서비스로 다시 옮겨야 한다.
## 다음 검증
upstream이 실제로 요구하는 claim을 먼저 적는다.
업스트림이 실제로 요구하는 사용자 속성(claim)부터 적는다.
1. 전달하려는 claim이 계속 늘어나는가.
2. role이나 tenant 변경이 즉시 반영돼야 하는가.
1. 넘기려는 속성이 계속 늘어나는가.
2. 역할이나 테넌트를 바꾸면 곧바로 반영돼야 하는가.
3. 정책이 애플리케이션 도메인을 알아야 하는가.
4. 헤더 값이 인가 판단의 근거가 되는가.
5. 서비스 정책 차이가 커지는가.
5. 서비스마다 정책 차이가 커지는가.
2번부터 5번 하나라도 그렇다면 헤더를 늘리는 대신 BFF 구조를 검토한다.
2번부터 5번 가운데 하나라도 그렇다면 헤더를 늘리는 대신 BFF 구조를 검토한다.
role을 헤더에 담는 구성을 먼저 만들고, 다중 값과 크기 상한을 넣어 무엇이 먼저 잘못되는지 확인한다.
role을 바꾼 뒤 몇 번째 요청부터 반영되는지도 확인한다.
역할을 헤더에 담는 구성을 먼저 만들고, 값을 여러 개 넣고 크기 상한까지 올려 무엇이 먼저 깨지는지 확인한다. 역할을 바꾼 뒤 몇 번째 요청부터 새 값이 실리는지도 센다.
어느 쪽으로 옮길지는 새로 일을 맡는 쪽이 상태와 검증을 감당할 수 있는지를 보고 정한다.
@@ -11,122 +11,94 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#문제를-어렵게-만든-제약-학습-환경
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
---
# 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
Mediator와 BFF는 브라우저의 로그인 세션과 OAuth token을 서로 다른 저장소에서 관리한다.
현재 구현에서는 두 저장소 모두 애플리케이션 서버의 메모리에 있기 때문에, 서버 프로세스가 종료되면 저장된 상태도 같이 사라진다.
Mediator와 BFF(Backend for Frontend)는 브라우저의 로그인 세션과 OAuth 토큰을 서로 다른 저장소에 둔다. 지금 구현은 두 저장소를 모두 애플리케이션 서버의 메모리에 두기 때문에, 서버 프로세스가 끝나면 담아 둔 상태도 같이 사라진다.
따라서 운영 환경에서 서버를 여러 인스턴스로 구성하게 될 경우 추가 설계가 필요한데, 사용자의 요청이 로그인할 때와 다른 인스턴스로 전달되어도 세션과 토큰을 찾을 수 있어야 하고, 서버 재시작 뒤에도 로그인 상태를 유지할 것인지 결정해야 한다.
또한 로그아웃할 때 여러 인스턴스에 걸쳐 저장된 세션과 토큰을 어떻게 같이 제거할지도 정해야 한다.
운영에서 서버를 여러 인스턴스로 띄우려면 여기에 설계를 더해야 한다. 사용자의 요청이 로그인할 때와 다른 인스턴스로 도 세션과 토큰을 찾을 수 있어야 하고, 서버 재시작 뒤에도 로그인 상태를 이어 갈지 정해야 한다. 로그아웃할 때 여러 인스턴스에 흩어져 있는 세션과 토큰을 어떻게 함께 지울지도 정해야 한다.
## 관계
- **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 저장 방식을 변경하더라도 기존 동작이 그대로 유지되는지는 별도로 확인 및 검증이 필요하다.
- Mediator와 BFF는 로그인 상태와 OAuth 토큰을 서로 다른 저장소에 둔다. 로그인 상태는 HttpSession에 담고 세션 ID로 찾는다. OAuth 토큰은 OAuth2AuthorizedClientService에 담고, client registration 이름과 principal name으로 찾는다.
- 세션과 OAuth 토큰을 담을 저장소를 따로 설정하지 않아서, Spring Boot 자동 구성이 고르는 메모리 기반 기본 구현이 쓰인다. 다만 저장소를 직접 만드는 Bean이 코드에 없으므로, 어떤 구현체가 실제로 올라오는지는 자동 구성 결과까지 열어 봐야 정확히 알 수 있다.
- Spring Session이나 Redis, JDBC 기반 Token Store 같은 외부 저장소를 쓰지 않기 때문에, 로그인 세션과 OAuth 토큰은 모두 그 애플리케이션 인스턴스의 메모리에 있다. 인스턴스가 종료되거나 재시작되면 그 인스턴스가 들고 있던 로그인 세션과 OAuth 토큰도 같이 사라진다.
- Authorized Client는 세션별로 나뉘지 않는다. 조회 기준에 세션 ID가 없다 보니 같은 사용자가 여러 브라우저에서 로그인하면 모두 같은 Authorized Client 하나를 쓴다.
- OAuth2-Proxy 구조는 로그인 상태를 서버 저장소에 두지 않고, 필요한 최소한의 정보만 브라우저의 세션 쿠키에 담는다. 지금 설정에서 이 쿠키의 유효 시간은 1시간이다.
- 지금 테스트에는 서버를 재시작하거나 요청이 다른 인스턴스로 갔을 때 로그인 상태와 OAuth 토큰을 그대로 쓸 수 있는지 확인하는 항목이 없다. 그래서 세션이나 토큰의 저장 방식을 바꿔도 기존 동작이 깨지지 않는지는 따로 확인해야 한다.
## 가정
- 운영에서는 인스턴스가 둘 이상이다.
- 재시작과 배포가 로그인 상태를 끊어서는 안 되는데, 지금 구조에서는 끊기게 된다.
- 같은 사용자 여러 브라우저 session이 서로의 token 항목을 덮어써서는 안 된다.
- 재시작과 배포가 로그인 상태를 끊어서는 안 된다. 지금 구조에서는 끊다.
- 같은 사용자 여러 브라우저에서 로그인해도, 한쪽 세션이 다른 쪽의 토큰 항목을 덮어써서는 안 된다.
## 미지수
- 재시작 뒤 로그인이 유지되는가. 지금은 안 된다는 것까지 알지만 무엇을 바꿔야 되는지는 정하지 않았다.
- 인스턴스가 바뀌어도 같은 session을 찾게 되는가.
- 같은 사용자의 여러 sessionauthorized client 항목을 공유하거나 덮어쓰게 되는가.
한쪽에서 로그아웃하면 다른 쪽도 끊기게 되는가.
- 저장된 refresh token이 암호화되는가.
저장소를 여는 사람이 그 값을 그대로 읽게 되는가.
- logout에서 HttpSession과 authorized client를 모두 정리하는가.
한쪽만 삭제했을 때 다음 요청이나 재로그인에서 어떤 상태가 복원되는가.
- session 만료와 token 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 어떻게 보이게 되는가.
- OAuth2-Proxy 구조의 replica들이 같은 cookie secret을 어떻게 공유하고 교체하게 되는가.
교체하는 동안 로그인해 있던 사람은 어떻게 되는가.
- 재시작 로그인이 유지되는가. 지금은 유지되지 않는다는 것까지만 확인했고, 무엇을 바꿀지는 아직 정하지 않았다.
- 요청이 다른 인스턴스로 가도 같은 세션을 찾는가.
- 같은 사용자의 여러 세션Authorized Client 항목 하나를 함께 쓰는가, 아니면 서로 덮어쓰는가. 한쪽에서 로그아웃하면 다른 쪽 로그인도 끊기는가.
- 저장한 Refresh Token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽는가.
- 로그아웃할 때 HttpSession과 Authorized Client를 모두 지우는가. 한쪽만 지웠을 때 다음 요청이나 재로그인에서 어떤 상태가 살아나는가.
- 세션 만료와 토큰 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 무엇이 보이는가.
- OAuth2-Proxy 구조의 레플리카들은 같은 Cookie Secret을 어떻게 나눠 갖고 어떻게 바꾸는가. 바꾸는 동안 이미 로그인해 있던 사람은 어떻게 되는가.
## 제약
- 현재 구조는 단일 인스턴스로 실행하고 있어 replica 간 session 조회와 failover 동작은 아직 구현되지 않았다.
- Authorized Client는 session ID를 기준으로 저장하거나 조회하지 않는다.
따라서 여러 인스턴스가 같은 세션을 사용할 수 있도록 Session Store를 공유 저장소로 변경하는 것만으로는 충분하지 않다.
로그인 세션을 여러 인스턴스에서 공유하는 방법과 OAuth Token이 저장된 Authorized Client를 어떻게 저장하고 공유할지는 각각 별도로 설계해야 한다.
- Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록 network에서 강제된 상태가 아니다.
- 지금은 인스턴스 하나로만 실행하고 있어서 레플리카 사이의 세션 조회나 장애 조치(failover) 동작은 아직 만들지 않았다.
- Authorized Client는 세션 ID로 저장하지도 조회하지 않는다. 그래서 여러 인스턴스가 같은 세션을 쓰도록 세션 저장소만 공유 저장소로 바꿔서는 부족하다. 로그인 세션을 여러 인스턴스가 나눠 쓰는 방법과, OAuth 토큰이 들어 있는 Authorized Client를 어디에 어떻게 저장할지는 각각 따로 설계해야 한다.
- Resource Server의 8081이 host에도 열려 있어서, 모든 클라이언트가 BFF만 거치도록 네트워크가 강제하고 있지는 않다.
## 선택지
### 1. 공유 저장소를 사용한다
HttpSession과 Authorized Client를 모두 외부의 공유 저장소에 보관하면 여러 애플리케이션 인스턴스가 동일한 로그인 세션과 OAuth Token 정보를 조회할 수 있다.
따라서 사용자의 요청이 다른 인스턴스로 전달되거나 특정 인스턴스가 재시작되더라도 기존 로그인 상태를 계속 사용할 수 있다.
HttpSession과 Authorized Client를 모두 바깥의 공유 저장소에 면 여러 애플리케이션 인스턴스가 같은 로그인 세션과 OAuth 토큰을 조회할 수 있다. 요청이 다른 인스턴스로 가거나 인스턴스 하나가 재시작해도 기존 로그인 상태를 그대로 쓸 수 있다.
다만 인증 과정이 외부 저장소에 의존하게 되므로 추가로 고려해야 할 사항이 생긴다.
저장소에 장애가 발생했을 때 인증 요청을 어떻게 처리할지 정해야 하고, 세션과 Token을 어떤 형식으로 저장할지와 저장된 Token을 어떻게 보호할지도 결정해야 한다.
또한 세션은 남아 있는데 Token은 이미 만료되는 것과 같은 불일치가 발생하지 않도록 두 상태의 만료 시간과 제거 시점도 함께 설계해야 한다.
저장소에 장애가 나면 인증 요청을 어떻게 처리할지 정해야 하고, 세션과 토큰을 어떤 형식으로 저장할지와 저장한 토큰을 어떻게 보호할지도 정해야 한다. 세션은 아직 유효한데 토큰은 이미 만료된 것 같은 어긋남이 생기지 않도록, 두 상태의 만료 시간과 지우는 시점도 함께 설계해야 한다.
### 2. session affinity로 같은 인스턴스에 붙인다
Sticky Session을 사용하면 같은 세션 요청을 가능한 한 동일한 애플리케이션 인스턴스로 전달할 수 있다.
기존의 메모리 기반 세션과 Token 저장 방식을 그대로 사용할 수 있기 때문에 애플리케이션 코드의 변경이 적고 별도의 공유 저장소도 필요하지 않다.
Sticky Session 같은 세션에서 온 요청을 되도록 같은 애플리케이션 인스턴스로 보내는 방식이다. 지금의 메모리 기반 세션·토큰 저장을 그대로 두어도 되므로 애플리케이션 코드는 거의 손대지 않고, 공유 저장소도 따로 두지 않는다.
하지만 해당 인스턴스가 종료되면 그 인스턴스의 메모리에 저장되어 있던 로그인 세션과 OAuth Token 정보도 함께 사라진다. 따라서 배포나 오토스케일링으로 인스턴스가 자주 교체되는 환경에서는 Sticky Session만으로 로그인 상태를 안정적으로 유지하기 어렵고, 인스턴스가 사라졌을 때 상태를 어떻게 복구할지 별도로 설계해야 한다.
다만 그 인스턴스가 종료되면 그 메모리에 있던 로그인 세션과 OAuth 토큰도 함께 사라진다. 배포나 오토스케일링으로 인스턴스가 자주 바뀌는 환경이라면 Sticky Session만으로 로그인 상태를 지키기 어렵고, 인스턴스가 사라졌을 때 상태를 어떻게 되살릴지 따로 설계해야 한다.
### 3. 브라우저가 token을 들고 API를 직접 부른다
서버에 로그인 세션이나 OAuth Token 상태를 저장하지 않는 구조로 바꾸면, 여러 인스턴스가 공유해야 할 상태 자체가 없어지므로 별도의 공유 저장소나 Session Affinity 필요하지 않다.
Resource Server는 각 요청에 포함된 Access Token을 검증하여 요청을 처리한다.
서버에 로그인 세션이나 OAuth 토큰을 두지 않는 구조로 바꾸면 여러 인스턴스가 나눠 가질 상태 자체가 없어서, 공유 저장소도 session affinity 필요하지 않다. Resource Server는 요청마다 실려 온 Access Token을 검증해서 처리한다.
SPA처럼 브라우저가 OAuth Token을 직접 보관하고 API 요청에 사용하는 구조가 여기에 해당한다.
다만 브라우저에 OAuth Token을 노출하지 않아야 한다면 이 선택지는 제외한다.
SPA(Single Page Application)처럼 브라우저가 OAuth 토큰을 직접 고 API를 부르는 구조가 여기에 해당한다. 다만 브라우저에 OAuth 토큰을 노출하지 않는 것이 조건이라면 이 선택지는 뺀다.
### 4. 층이 다른 선택지 — 최소 정보만 담은 client-side cookie
이 방식은 기존 세션이나 Token 저장소를 다른 저장소로 교체하는 방법이 아니다.
서버에 인증 상태를 저장하는 구조 자체를 없애고, 필요한 인증 상태를 쿠키에 담아 전달하는 방식으로 변경하는 것이다.
따라서 공유 저장소나 Sticky Session처럼 기존 서버 상태를 어떻게 유지할지 결정하는 방법과 같이 비교하긴 어렵다.
이 방식은 세션이나 토큰 저장소를 다른 저장소로 바꾸는 것이 아니다. 서버에 인증 상태를 두는 구조 자체를 없애고, 필요한 인증 상태만 쿠키에 담아 보낸다. 그래서 공유 저장소나 Sticky Session처럼 서버 상태를 어떻게 지킬지 정하는 방법과 나란히 놓고 견주기 어렵다.
Forward-Auth 구조로 전환하면 애플리케이션이 OAuth Token을 서버에 직접 저장하고 관리할 필요가 없어진다.
대신 여러 인스턴스가 동일한 인증 쿠키를 처리할 수 있도록 Cookie Secret을 공유해야 한다.
또한 인증 프록시가 전달하는 사용자 정보를 애플리케이션이 신뢰하게 되므로, 외부 요청이 해당 헤더를 위조할 수 없도록 네트워크 접근 경로와 전달 헤더를 함께 관리해야 한다.
Forward-Auth는 실제 요청을 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 먼저 묻는 방식이다. 이 구조로 옮기면 애플리케이션이 OAuth 토큰을 서버에 직접 저장하고 관리하지 않아도 된다. 대신 여러 인스턴스가 같은 인증 쿠키를 풀 수 있도록 Cookie Secret을 나눠 가져야 한다. 그리고 인증 프록시가 넘겨 주는 사용자 정보를 애플리케이션이 믿으므로, 바깥 요청이 그 헤더를 위조하지 못하게 네트워크 접근 경로와 전달 헤더를 함께 관리해야 한다.
## 다음 검증
인스턴스를 둘로 띄우고 순서대로 확인한다.
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 유지되는지 본다.
2. 인스턴스를 재시작하고 같은 session cookie로 로그인 상태가 는지 본다.
3. 같은 사용자로 두 브라우저에서 로그인해 authorized client 항목이 서로를 덮어쓰는지 본다.
4. 한쪽에서 logout한 뒤 다른 쪽 요청이 어떻게 되는지 본다.
5. session 만료를 token 만료보다 짧게, 다시 길게 두고 각 경우의 응답과 화면을 기록한다.
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 그대로 나오는지 본다.
2. 인스턴스 하나를 재시작하고 같은 세션 쿠키로 로그인 상태가 유지되는지 본다.
3. 같은 사용자로 두 브라우저에서 로그인해 Authorized Client 항목이 서로를 덮어쓰는지 본다.
4. 한쪽에서 로그아웃한 뒤 다른 쪽 요청이 어떤 응답을 받는지 본다.
5. 세션 만료를 토큰 만료보다 짧게 두고, 다시 길게 두고, 각 경우의 응답과 화면을 기록한다.
여기서 확인한 결과로 선택지를 좁힌다.
각 항목의 결과는 어느 엔드포인트와 핸들러가 요청을 받았는지, 다음 요청에 무엇이 입력으로 들어갔는지, 최종 응답이 무엇이었는지까지 적는다.
@@ -11,54 +11,62 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택의-이유와-지킨-경계-ap1
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
---
# Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
realm refresh token rotation과 재사용 허용 0회를 쓰고 있다.
두 replica가 같은 refresh token으로 동시에 갱신할 수 있고, 그때 두 번째 사용이 거부될 가능성이 있다.
실제 Keycloak 응답과 session 영향은 아직 재현해 보지 않았다.
realm refresh token rotation과 재사용 허용 0회를 쓴다. 한 번 갱신하면 이전 refresh token이 바로
무효가 되기 때문에, 두 replica가 같은 refresh token으로 동시에 갱신하면 두 번째 사용이 거부될 있다.
실제 Keycloak 응답과 session에 미치는 영향은 아직 재현지 않았다.
## 관계
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
저장소 결정이 이 질문보다 앞선다.
이 질문에 답하기 전에 저장소부터 정해야 한다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
rotation과 재사용 0회를 쓰는 구성의 출처다.
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에서 계속 통하기 때문에, 갱신이 실패해도 그동안은 화면이 정상으로 보이게 된다.
- realm에는 refresh token rotation과 재사용 허용 0회가 설정되어 있다.
- 커밋된 테스트는 새 refresh token 발급되는지, 이전 token 거부되는지, revocation 뒤 refresh
실패하는지를 확인한다. 다만 이 셋은 refresh token을 직접 써서 받은 결과라, 애플리케이션이 스스로
갱신할 때도 같은 결과가 나온다고 보지 않다.
- authorized client manager에는 refresh-token provider가 구성되어 있어, access token이 만료되면
refresh를 시도할 수 있다.
- access token 만료될 때까지 기다려 실제로 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다.
- 현재 authorized client 저장소는 프로세스 안에만 있어서(process-local) replica끼리 같은 refresh token
상태를 공유하지 않는다. 그래서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.
- 이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안 화면은
정상으로 보인다.
## 가정
- 운영에서는 replica 둘 이상고 저장소를 공유 같은 authorized client 항목을 보게 된다.
- 두 replica가 비슷한 시각에 만료를 만나면 각각 갱신을 시도하게 된다.
- 운영에서는 replica 둘 이상 띄우고 저장소를 공유하므로, 두 replica가 같은 authorized client 항목을 읽는다.
- 두 replica의 access token이 비슷한 시각에 만료되면 각 replica가 따로 갱신을 시도다.
## 미지수
- 같은 refresh token으로 두 replica가 동시에 갱신하면 각 replica가 어떻게 동작하게 되는지.
- 재사용 허용 0회에서 요청이 사용자 화면에 어떻게 보이게 되는지.
로그인 만료로 보이는가 일시적 오류로 보이는.
- 저장소에서 새 token을 다시 읽어 재시도하면 성공하게 되는지, 아니면 재인증이 필요해지는지.
- 갱신을 한 곳에서만 할 것인, 각자 하게 두고 실패는 재시도로 처리할 것인.
- lock을 쓴다면 어디에 두고 얼마나 잡을지. 잡은 채로 프로세스가 내려가면 어떻게 푸는.
- 갱신 실패를 로그인 만료와 구분해 표시할 수 있는.
- 같은 refresh token으로 두 replica가 동시에 갱신하면 각 replica가 어떻게 동작하는가.
- 재사용 허용 0회에서 두 번째 사용이 거부되면 사용자 화면에 무엇이 보이는가.
로그인 만료로 보이는가, 일시적 오류로 보이는.
- 저장소에서 새 token을 다시 읽어 재시도하면 성공하는가, 아니면 재인증까지 해야 하는가.
- 갱신을 한 곳에서만 할 것인, 각자 하게 두고 실패는 재시도로 처리할 것인.
- lock을 쓴다면 어디에 두고 얼마나 잡는가. 잡은 채로 프로세스가 내려가면 어떻게 푸는.
- 갱신 실패를 로그인 만료와 구분해 표시할 수 있는.
## 제약
- rotation과 재사용 0회는 이미 realm에 설정한 상태다. 이 전제는 바꾸지 않고 답한다.
- 이미 발급된 access token은 만료 전까지 사용할 수 있으므로 refresh 실패는 즉시 보이지 않을 수 있다.
- rotation과 재사용 0회는 이미 realm에 설정다. 이 전제는 바꾸지 않고 답한다.
- 이미 발급된 access token은 만료 전까지 수 있으므로 refresh 실패가 곧바로 드러나지 않을 수 있다.
재현 테스트는 access token 만료 직후에 맞춰 실행한다.
- 이 경쟁은 저장소를 공유한 뒤에야 재현되므로 저장소를 정한 다음에 이어서 푼다.
@@ -66,31 +74,33 @@ realm이 refresh token rotation과 재사용 허용 0회를 쓰고 있다.
### 1. 분산 lock으로 갱신을 직렬화한다
한 replica만 갱신하고 나머지는 끝나기를 기다렸다가 결과를 읽는 구성이다. 같은 refresh token을 두 replica가 동시에 쓰는 상황 자체를 만들지 않는 것이 목표다.
분산 lock은 여러 프로세스가 같은 자원을 동시에 건드리지 못하도록 프로세스 밖에 두는 잠금이다.
한 replica만 갱신하고 나머지는 그 갱신이 끝나기를 기다렸다가 결과를 읽는다.
분산 lock을 사용하면 refresh 구간을 직렬화할 수 있다.
lock 저장소의 가용성, lock 만료, 재진입, lock 보유 process 종료 상황까지 함께 처리해야 한다.
대신 lock 저장소의 가용성, lock 만료, 재진입, lock을 쥔 프로세스가 내려간 상황까지 함께 처리해야 한다.
### 2. 각자 갱신하고 실패는 재시도로 처리한다
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는 전제인데,
이 재시도가 성립하는지 확인이 필요하다.
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는
전제인데, 이 재시도가 성립하는지부터 확인해야 한다.
reuse detection 정책에 따라 같은 refresh token 두 번째 사용이 token family 전체에 영향을 줄 수 있다.
이 경우 단순 retry로 끝나지 않고 재인증이 필요할 수 있다.
갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 다시 조회하는 순서도 별도로 확인해야 한다.
같은 refresh token 두 번 들어오는 것을 잡아내는 reuse detection 정책에 따라, 두 번째 사용이
token family 전체에 영향을 줄 수 있다. 그러면 재시도 한 번으로 끝나지 않고 재인증이 필요할 수 있다.
갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 저장소를 다시 읽는 순서도 따로
확인해야 한다.
### 3. 갱신 전용 경로를 하나 둔다
refresh를 전담하는 구성요소 하나만 refresh token을 사용하고 다른 replica는 갱신 결과를 조회하도록 구성할 수 있다.
refresh를 전담하는 구성요소 하나만 refresh token을 쓰고, 다른 replica는 결과를 조회하게 둘 수 있다.
refresh를 전담하는 구성요소가 중단되면 access token 만료 이후 갱신을 수행할 주체가 없어지므로 해당 구성요소의 가용성과 복구 방식이 중요해진다.
구성요소가 멈추면 access token 만료된 뒤에 갱신할 주체가 없어진다. 그래서 이 구성요소를 어떻게
살려 두고 어떻게 복구할지를 먼저 정해야 한다.
### 4. 제약상 제외 — 재사용 허용을 늘린다
재사용을 짧게 허용하면 두 번째 사용이 거부되지 않고 코드도 고치지 않는다.
다만 rotation과 재사용 0회는 이미 realm에 설정한 상태이고,
훔친 refresh token을 그 시간 안에 쓸 수 있다는 문제도 남는다.
재사용을 짧게 허용하면 두 번째 사용이 거부되지 않고 코드도 고칠 필요가 없다.
다만 rotation과 재사용 0회는 이미 realm에 설정했고, 허용한 시간 안에는 훔친 refresh token이 들어와도
막지 못한다.
## 다음 검증
@@ -11,102 +11,103 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#문제를-어렵게-만든-제약-로그인-흐름과-api-흐름
- final/document.md#선택이-코드와-흐름에-반영되는-방식-추적-규칙
---
# Authorization Code Flow의 Endpoint와 Credential 이동 기준
Authorization Endpoint에서 Redirect, Token Endpoint, JWK 검증, Resource API까지 각 지점에서 무엇이 이동하고 무엇이 이동하지 않는지 확인한다. 먼저 client_secret이 가는 곳과 가지 않는 곳을 나눈다.
Authorization Endpoint에서 리다이렉트, Token Endpoint, JWK(JSON Web Key) 검증, Resource API까지 지점마다 무엇이 이동하고 무엇이 이동하지 않는지 확인한다. 먼저 client_secret이 가는 곳과 가지 않는 곳을 나눈다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code를 직접 교환하는 흐름에서 endpoint별 이동을 관측했다.
브라우저가 code를 직접 교환하는 흐름에서 엔드포인트마다 무엇이 오가는지 확인했다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
confidential client가 token endpoint에서 자기 client를 인증하는 실례다.
confidential client가 Token Endpoint를 부를 때 자기 자신을 인증한다.
- **Public Client와 Confidential Client 구분 기준**
client 종류가 정해져야 PKCE와 client 인증의 자리가 정해진다.
클라이언트 종류가 정해져야 PKCE와 클라이언트 인증을 어디에 걸지 정해진다.
## 목적
Authorization Endpoint와 Token Endpoint는 역할과 호출 방식 다르다.
이 구분을 해야 SPA에서 client_secret이 어디로 갔는지, PKCE가 어느 구간을 지키는지 이해하기 쉽다.
Authorization Endpoint와 Token Endpoint는 하는 일도 다르고 요청이 나가는 방식 다르다.
하나는 브라우저 full-page navigation이고 하나는 server-to-server 호출이 될 수도 있고 browser-to-server 호출이 될 수도 있다.
노출되는 것도, 인증하는 방법도 다르다.
하나는 브라우저가 페이지째 넘어가는 full-page navigation이고, 다른 하나는 서버가 보낼 수도 있고 브라우저가 직접 보낼 수도 있는 호출이다.
Authorization Endpoint
경로 : 브라우저 주소창 남는 곳 : 히스토리·서버 로그·referrer client 인증 : x
Token Endpoint
경로 : body와 Authorization 헤더 보내는 쪽 : client 종류에 따라 server 또는 브라우저 client 인증 : o
Authorization Endpoint 요청은 브라우저 주소창을 지나기 때문에 URL이 히스토리와 서버 로그, referrer에 남고, 이 요청에서는 클라이언트를 인증하지 않는다.
Token Endpoint 요청은 값을 요청 본문과 Authorization 헤더에 싣고, 보내는 쪽은 클라이언트 종류에 따라 서버이거나 브라우저이며, 클라이언트 인증을 여기서 한다.
## 규칙
### 1. Authorization Endpoint에는 client_secret을 보내지 않는다
이 요청은 브라우저 주소창을 통해 나간다. 그래서 URL이 주소창에도, 브라우저 히스토리에도, 서버 접근 로그에도, 그리고 링크를 타고 온 경우 referrer에도 남는다. 여기 실리는 값은 client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method다. secret이 필요한 인증은 아직 하지 않는다.
이 요청은 브라우저 주소창을 통해 나가기 때문에 URL이 주소창 브라우저 히스토리, 서버 접근 로그에 남고, 링크를 타고 온 경우에는 referrer에도 남는다. 여기 실리는 값은 client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method다. 클라이언트 시크릿이 필요한 인증은 아직 하지 않는다.
반대로 말하면 이 목록에 없는 값을 여기으면 그 값도 같은 곳에 남는다.
이 목록에 없는 값을 여기에 실으면 그 값도 같은 곳에 함께 남는다.
### 2. Token Endpoint에서 비로소 client를 인증한다
### 2. Token Endpoint에서 비로소 클라이언트를 인증한다
code를 access token으로 바꾸는 요청은 credential을 URL query가 아니라 body와 Authorization 헤더에 싣는다. 그래서 client 인증을 여기서 한다. confidential client는 client_secret_basic처럼 secret을 함께 보낸다.
code를 액세스 토큰으로 바꾸는 요청은 자격 증명을 URL 쿼리 문자열이 아니라 요청 본문과 Authorization 헤더에 싣기 때문에, 클라이언트 인증도 이 요청에서 한다. confidential client는 client_secret_basic처럼 클라이언트 시크릿을 함께 보낸다.
주소창과 히스토리에 남지 않는다는 뜻이지 어디에도 기록되지 않는다는 뜻은 아니다. 애플리케이션 debug 로그, reverse proxy 로그, tracing과 APM, packet capture에 남을 수 있어서 credential masking을 따로 둔다.
주소창과 히스토리에 남지 않는다는 뜻이지 어디에도 기록되지 않는다는 뜻은 아니다. 애플리케이션 디버그 로그, 리버스 프록시 로그, 추적 도구와 APM(성능 모니터링 도구), 패킷 캡처에 남을 수 있어서 자격 증명을 가리는 마스킹을 따로 둔다.
이 요청을 누가 보내는지는 client 종류에 따라 갈린다. server가 보내면 server-to-server이고, secret이 없는 SPA가 보내면 브라우저가 직접 보낸다. token endpoint를 server 안에서만 부르게 하려면 client 종류부터 confidential로 정해야 한다.
이 요청을 누가 보내는지는 클라이언트 종류에 따라 갈린다. 서버가 보내면 서버끼리 주고받는 호출이고, 클라이언트 시크릿이 없는 SPA가 보내면 브라우저가 직접 보낸다. Token Endpoint를 서버 안에서만 부르게 하려면 클라이언트 종류부터 confidential client로 정해야 한다.
### 3. PKCE는 두 요청을 같은 주체에 묶는다
처음 요청에 code_challenge를 담아 보내고, 교환할 때 원본인 code_verifier를 보낸다.
Authorization Server가 이 둘이 대응하는지 확인하고, 대응해야 토큰 교환이 끝난다.
로그인을 시작하는 요청에 code_challenge를 담아 보내고, code를 토큰으로 바꿀 때 원본인 code_verifier를 보낸다.
Authorization Server가 이 둘이 대응하는지 확인하고, 대응할 때만 토큰 교환이 끝난다.
code를 누가 훔쳐 가도 verifier가 없으면 token으로 바꾸지 못한다.
그래서 code를 누가 훔쳐 가도 code_verifier가 없으면 토큰으로 바꾸지 못한다.
### 4. issuer 검증값과 JWK 조회 주소를 같은 값으로 맞추려 하지 않는다
issuer는 요청을 보내는 주소가 아니라 token의 canonical issuer identifier다. 검증은 발급된 token의 iss claim이 그 값과 같은지를 본다.
issuer는 요청을 보내는 주소가 아니라 토큰을 발급한 주체를 가리키는 정해진 식별자다. 검증은 발급된 토큰의 iss 클레임이 그 값과 같은지를 본다.
JWK 조회 주소는 실제로 공개키를 가져오는 network 경로다. 이 예제에서는 브라우저가 보는 주소와 컨테이너 안에서 닿는 주소가 다르다. 컨테이너 안에서는 자기 localhost가 그 서버가 아니므로 service 이름을 써야 하, 브라우저는 그 이름에 닿지 못한다.
JWK 조회 주소는 공개키를 실제로 가져오는 네트워크 경로다. 이 예제에서는 브라우저가 보는 주소와 컨테이너 안에서 닿는 주소가 다르다. 컨테이너 안에서는 자기 localhost가 그 서버가 아니어서 서비스 이름을 써야 하는데, 브라우저는 그 이름에 닿지 못한다.
issuer 검증값과 endpoint 연결 주소는 따로 구성한다. 둘을 하나로 맞추려 하면 로그인 redirect가 깨지거나 서버가 키를 못 가져온다.
issuer 검증값과 엔드포인트에 연결하는 주소는 따로 구성한다. 둘을 하나로 맞추려 하면 로그인 리다이렉트가 깨지거나 서버가 공개키를 가져오지 못한다.
### 5. Resource API는 서명만 보고 끝내지 않는다
서명이 맞다는 것은 그 IdP가 발급했다는 뜻일 뿐이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다.
서명이 맞다는 것은 그 IdP가 발급했다는 뜻일 뿐이다. 같은 IdP가 다른 API용으로 발급한 토큰도 서명은 맞다.
그래서 issuer와 유효 시간, 그리고 이 API를 위해 발급됐다는 audience를 함께 본다. audience 검증이 빠지면 옆 서비스의 token으로 우리 API가 열린다.
그래서 issuer와 유효 시간, 그리고 이 API를 위해 발급됐다는 audience를 함께 본다. audience 검증이 빠지면 옆 서비스에 발급된 토큰으로 우리 API가 열린다.
### 6. redirect_uri는 exact match로 좁힌다
### 6. redirect_uri는 정확히 일치하는 주소로 좁힌다
wildcard allowlist는 학습 환경에서 편하다. 다만 허용 범위가 넓으면 같은 호스트의 다른 경로로도 code가 갈 수 있다.
리다이렉트 허용 목록에 와일드카드를 두면 학습 환경에서 편하다. 다만 허용 범위가 넓으면 같은 호스트의 다른 경로로도 code가 갈 수 있다.
실제로 쓰는 callback 주소만 등록해 두면 code가 도착할 수 있는 곳이 그 하나로 줄어든다.
실제로 쓰는 콜백 주소만 등록해 두면 code가 도착할 수 있는 곳이 그 하나로 줄어든다.
등록하지 않은 redirect_uri를 보냈을 때 거부하는지 확인하는 검사도 따로 둔다.
### 7. 로그인 구간과 API 호출 구간을 한 줄로 그리지 않는
### 7. 로그인 구간과 API 호출 구간은 따로 따라간
로그인 구간은 authorization request에서 시작해 callback과 code 교환을 지나 로그인 상태를 만드는 데까지다. API 호출 구간은 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답이다.
로그인 구간은 authorization request에서 시작해 콜백과 code 교환을 지나 로그인 상태를 만드는 데까지다. API 호출 구간은 브라우저 입력, 중간 계층의 자격 증명 변환, 보호 자원의 검증, 최종 응답이다.
두 구간을 한 줄로 이어 그리면 누가 code를 바꾸고 누가 API를 부르는지가 겹쳐 보인다. 네 구조가 갈리는 자리가 여기라서 나눠서 그린다.
로그인 버튼을 누른 뒤 JSON이 돌아올 때까지 code와 토큰, 쿠키가 차례로 바뀐다.
두 구간을 한 줄로 이어 그리면 누가 code를 바꾸고 누가 API를 부르는지가 겹쳐 보인다. 네 가지 구조는 여기서 갈리므로 나눠서 그린다.
## 적용 조건
- Authorization Code Flow를 쓰는 client를 설정하거나 문서로 설명할 때
- 브라우저 요청과 server-to-server 요청이 한 흐름에 섞여 있을 때
- endpoint별로 무엇이 노출되는지 나눠야 할 때
- PKCE와 client 인증의 자리를 정할 때
- Authorization Code Flow를 쓰는 클라이언트를 설정하거나 문서로 설명할 때
- 브라우저 요청과 서버끼리 주고받는 요청이 한 흐름에 섞여 있을 때
- 엔드포인트마다 무엇이 드러나는지 나눠야 할 때
- PKCE와 클라이언트 인증을 어디에 걸지 정할 때
## 예외
- Client Credentials처럼 사용자 없이 token을 받는 흐름은 Authorization Endpoint를 지나지 않는다.
- Device Authorization Grant는 브라우저 redirect 대신 별도의 사용자 code 단계를 쓴다. redirect_uri 항목이 그대로 적용되지 않는다.
- Client Credentials처럼 사용자 없이 토큰을 받는 흐름은 Authorization Endpoint를 지나지 않는다.
- Device Authorization Grant는 브라우저 리다이렉트 대신 별도의 사용자 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 주소를 쓴다
- authorization request에는 code_challenge_method=S256이 있고 클라이언트 시크릿은 없다
- token request에는 code_verifier가 있다. 시크릿을 가진 클라이언트는 이 요청에서 자기를 인증한다
- issuer 검증값은 http://localhost:8080/realms/keycloak-patterns 이고 JWK 조회는 컨테이너 네트워크 주소를 쓴다
- audience에 keycloak-pattern-api 가 없으면 invalid_token 결과가 되어 401이 된다
- redirect allowlist에 wildcard가 있으면 등록한 host의 다른 경로로도 code가 갈 수 있다
- 리다이렉트 허용 목록에 와일드카드가 있으면 등록한 호스트의 다른 경로로도 code가 갈 수 있다
@@ -11,114 +11,98 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap3
- final/document.md#선택의-이유와-지킨-경계-ap3
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3
---
# BFF 인증 구조 설계 기준
BFF 구조에서는 OAuth Token을 서버에서 관리하고, 브라우저는 Token 대신 Session Cookie를 사용해 BFF에 요청한다.
BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호출해 주는 백엔드다. 이 구조에서는 OAuth 토큰을 서버가 보관하고, 브라우저는 토큰 대신 세션 쿠키로 BFF에 요청한다.
Cookie를 이용한 요청을 보호하기 위한 CSRF 검증, OAuth Token을 보관할 Authorized Client 저장소, 로그아웃할 때 Session과 Token을 함께 정리하는 방법, 그리고 BFF가 호출한 Resource Server에서 오류가 발생했을 때 이를 브라우저에 어떻게 전달할지를 같이 설계해야 한다.
쿠키로 인증하는 요청을 지킬 CSRF 검증, OAuth 토큰을 보관할 인가된 클라이언트(authorized client) 저장소, 로그아웃할 때 세션과 토큰을 함께 지우는 방법, BFF가 호출한 Resource Server에서 오류가 을 때 이를 브라우저에 무엇으로 바꿔 돌려줄지를 같이 설계해야 한다.
## 관계
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
이 기준의 항목 중 실제로 구현된 것과 비어 있는 것을 센 기록이다.
이 기준의 항목 중 무엇이 실제로 구현되었고 무엇이 비어 있는 센 기록이다.
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
저장소 항목 아직 답이 없는 질문으로 남아 있다.
이 기준의 저장소 항목 아직 답을 주지 못한 질문이다.
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
어느 저장소에 둘지 이 기준의 미결 항목이다.
세션과 토큰을 어느 저장소에 둘지 이 기준에서 아직 정하지 않았다.
- **BFF가 OAuth Token을 관리하는 조건**
결정이 PROPOSED인 동안 실제 적용 기준은 이 문서다.
결정이 PROPOSED인 동안에는 이 문서가 실제 적용 기준다.
## 목적
BFF 구조에서는 BFF가 authorization code를 token으로 교환하고, access token을 사용해 Resource Server를 호출한다.
따라서 session과 authorized client를 함께 관리해야 한다.
BFF 구조에서는 BFF가 authorization code를 토큰으로 교환하고 그 액세스 토큰으로 Resource Server를 호출하므로, 브라우저의 로그인 상태를 담은 세션과 토큰을 보관하는 인가된 클라이언트를 함께 관리해야 한다.
cookie가 credential이 되면 브라우저가 요청마다 자동으로 붙여 보낸다. 그래서 값을 바꾸는 요청은 사용자 의도인지 따로 확인해야 한다. 재시작과 replica 이동을 견딜 저장소도 같이 필요하다.
쿠키가 자격 증명이 되면 브라우저가 요청마다 자동으로 붙여 보내기 때문에, 값을 바꾸는 요청은 사용자 의도한 것인지 따로 확인해야 한다. 서버를 재시작하거나 요청이 다른 레플리카로 가더라도 로그인 상태를 이어 갈 저장소도 함께 필요하다.
## 규칙
### 1. 브라우저에는 OAuth token을 전달하지 않는다
### 1. 브라우저에는 OAuth 토큰을 전달하지 않는다
Access Token과 Refresh Token은 BFF 서버의 Authorized Client에 보관한다.
브라우저는 OAuth Token을 직접 사용하지 않고 Session Cookie를 이용해 BFF에 요청한다.
액세스 토큰과 리프레시 토큰은 브라우저가 아니라 BFF 서버 쪽 저장소인 인가된 클라이언트에 보관한다. 브라우저는 이 토큰을 직접 쓰지 않고 세션 쿠키로 BFF에 요청한다.
BFF는 이 Session을 확인한 뒤, 저장해 둔 Access Token으로 Authorization: Bearer ... 헤더를 새로 만들어 Resource Server를 호출한다. 브라우저가 보낸 Session Cookie는 Resource Server로 전달되지 않는다. Session Cookie는 브라우저와 BFF 사이의 Credential이고, Access Token은 BFF와 Resource Server 사이의 Credential이다.
BFF는 이 세션을 확인한 뒤, 보관해 둔 액세스 토큰으로 Authorization: Bearer ... 헤더를 새로 만들어 Resource Server를 호출한다. 브라우저가 보낸 세션 쿠키는 Resource Server까지 가지 않는다. 세션 쿠키는 브라우저와 BFF 사이의 자격 증명이고, 액세스 토큰은 BFF와 Resource Server 사이의 자격 증명이다.
### 2. cookie가 credential이면 상태 변경 요청에 CSRF 검증을 둔다
### 2. 쿠키가 자격 증명이면 상태 변경 요청에 CSRF 검증을 둔다
BFF 구조에서는 브라우저가 요청할 때 Session Cookie를 자동으로 전송한다.
그래서 데이터 생성, 수정, 삭제처럼 서버의 상태를 변경하는 요청에는 해당 요청이 실제 사용자의 의도에 의해 만들어졌는지 확인하기 위한 CSRF 검증이 필요하다.
BFF 구조에서는 브라우저가 요청할 때 세션 쿠키를 자동으로 보내기 때문에, 데이터를 만들고 고치고 지우는 것처럼 서버의 상태를 바꾸는 요청에는 그 요청이 실제 사용자의 의도에서 나왔는지 확인하는 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 검증이 필요하다.
현재 구성에서는 서버가 CSRF Token을 Cookie로 전달하고, JavaScript가 그 값을 읽어 요청 Header에 다시 담아 보낸다.
서버는 Cookie와 Header를 함께 확인해 요청을 검증한다.
현재 구성에서는 서버가 CSRF 토큰을 쿠키로 내려보내고, JavaScript가 그 값을 읽어 요청 헤더에 다시 담아 보낸다. 서버는 쿠키와 헤더를 함께 확인해 요청을 검증한다.
이때 화면이나 응답 본문에 표시되는 CSRF Token과 실제 요청 Header에 넣어야 하는 값이 항상 같다고 생각하면 안 된다.
응답 본문에 노출된 값이 별도의 처리를 거친 값이라면, 클라이언트는 실제 CSRF Cookie에서 값을 읽어 Header에 넣어야 한다.
잘못된 값을 보내면 정상적인 요청이라도 CSRF 검증에 실패해 403 Forbidden 응답을 받게 된다.
응답 본문에 실린 값이 별도의 처리를 거친 값이라면, 클라이언트는 CSRF 쿠키에서 값을 읽어 헤더에 넣어야 한다. 잘못된 값을 보내면 정상적인 요청이라도 CSRF 검증에 실패해 403 Forbidden 응답을 받는다.
SameSite와 CSRF Token도 서로 다른 역할을 한다.
SameSite는 브라우저가 Cross-Site 요청에 Cookie를 전송할지 제한하는 정책이고, CSRF Token은 Cookie가 포함되어 들어온 상태 변경 요청이 정상적인 클라이언트에서 만들어졌는지를 확인하기 위한 값이다.
또한 SameSite는 Origin이 아니라 Site를 기준으로 판단하므로, Origin은 다르지만 같은 Site에 속하는 요청도 존재할 수 있다.
SameSite와 CSRF 토큰도 서로 하는 일이 다르다. SameSite는 다른 사이트에서 시작한 요청에 브라우저가 쿠키를 실어 보낼지 제한하는 정책이고, CSRF 토큰은 쿠키가 붙어 들어온 상태 변경 요청이 정상적인 클라이언트에서 만들어졌는지 서버가 확인하는 값이다. 또 SameSite는 오리진이 아니라 사이트를 기준으로 판단하므로, 포트가 달라 오리진은 다르지만 같은 사이트에 속하는 요청도 있을 수 있다.
### 3. session과 authorized client의 수명주기를 따로 설계한다
### 3. 세션과 인가된 클라이언트의 수명주기를 따로 설계한다
Application Session과 Authorized Client는 서로 다른 값을 저장하고 조회한다.
Session은 session ID를 기준으로 조회하지만, Authorized Client는 client registration 이름과 principal name을 기준으로 조회한다.
따라서 여러 인스턴스에서 상태를 공유하기 위해 Shared Store를 도입할 때도 Session 저장소와 Authorized Client 저장소를 각각 어떻게 구성할지 확인해야 한다.
애플리케이션 세션과 인가된 클라이언트는 저장하고 조회하는 값이 서로 다르다. 세션은 세션 ID로 조회하지만, 인가된 클라이언트는 client registration 이름과 principal name으로 조회한다. 그래서 여러 인스턴스가 상태를 나눠 쓰도록 공유 저장소를 들일 때도 세션 저장소와 인가된 클라이언트 저장소를 각각 어떻게 구성할지 확인해야 한다.
특히 Authorized Client의 조회 기준에는 session ID가 포함되지 않는다.
그래서 같은 사용자가 두 브라우저에서 동일한 Client로 로그인하면 두 Session이 같은 Authorized Client 정보를 사용하거나, 나중에 로그인하면서 저장된 Token 정보가 갱신될 수 있다.
브라우저나 Session마다 서로 다른 Token을 유지해야 한다면 session ID까지 포함해 Token을 구분할 수 있도록 별도의 저장 구조를 설계해야 한다.
특히 인가된 클라이언트의 조회 기준에는 세션 ID가 들어가지 않는다. 그래서 같은 사용자가 두 브라우저에서 같은 클라이언트로 로그인하면 두 세션이 같은 인가된 클라이언트를 함께 쓰거나, 나중에 로그인하면서 저장된 토큰 정보가 갱신될 수 있다. 브라우저나 세션마다 서로 다른 토큰을 유지해야 한다면 세션 ID까지 조회 기준에 넣어 토큰을 구분하는 저장 구조를 따로 설계해야 한다.
운영 환경에서는 서버가 재시작되거나 요청이 다른 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이 발생한 경우에는 권한 부족으로 처리해야 한다.
BFF가 Resource Server의 오류를 그대로 브라우저에 넘기면 화면에서는 오류의 원인을 일관되게 판단하기 어렵다. Resource Server가 401 Unauthorized를 돌려줬다면 액세스 토큰이 만료되었거나 더 이상 유효하지 않은 상황인지 확인하고, 필요하면 토큰 갱신이나 재로그인으로 이어야 한다. 하지만 인증은 정상적으로 되었는데 해당 기능을 쓸 권한이 없어 403 Forbidden이 나온 경우에는 권한 부족으로 처리해야 한다.
Resource Server가 응답하지 않거나 처리가 지연되는 경우도 별도의 규칙이 필요하다.
요청을 얼마 동안 기다릴지 Timeout을 정하고, 실패한 요청을 다시 시도할 수 있는 경우에는 Retry 정책을 적용한다.
반복적으로 장애가 발생하는 Resource Server에 계속 요청을 보내지 않도록 Circuit Breaker를 적용할지도 함께 결정한다.
Resource Server가 응답하지 않거나 처리가 늦어지는 경우도 별도의 규칙이 필요하다. 요청을 얼마나 기다릴지 타임아웃을 정하고, 다시 보내도 되는 요청에는 재시도 정책을 적용한다. 장애가 반복되는 Resource Server에 계속 요청을 보내지 않도록 서킷 브레이커를 둘지도 함께 결정한다.
모든 UI 요청이 BFF를 거치는 구조라면 이러한 오류 처리 규칙도 BFF에서 일관되게 적용하는 다.
그렇지 않으면 같은 종류의 오류를 화면마다 서로 다른 방식으로 판단하고 처리하게 될 수 있다.
화면의 모든 요청이 BFF를 지나는 구조라면 이 오류 처리 규칙도 BFF에서 일관되게 적용하는 다. 그렇지 않으면 같은 종류의 오류를 화면마다 서로 다르게 판단하고 처리할 수 있다.
이 규칙이 정하는 것은 Resource Server가 돌려준 오류를 무엇으로 바꿔 내보낼지까지다. 토큰 갱신이 실패했을 때와 로그아웃한 뒤에 브라우저가 무엇을 받는지도 같은 종류의 질문이지만, 그것을 어느 계층이 최종 HTTP 응답으로 바꿀지는 이 기준에서 정하지 않았다.
### 5. BFF에서도 XSS 방어는 별도로 필요하다
BFF 구조에서는 Access Token과 Refresh Token을 서버 보관하므로 브라우저의 JavaScript가 OAuth Token 원문에 직접 접근하지 않도록 할 수 있다. 하지만 이것이 브라우저에서 실행되는 악성 JavaScript까지 막아 주는 것은 아니다.
BFF 구조에서는 액세스 토큰과 리프레시 토큰을 서버 보관하므로 브라우저의 JavaScript가 OAuth 토큰 원문에 직접 접근하지 않 할 수 있다. 하지만 이것이 브라우저에서 실행되는 악성 JavaScript까지 막아 주않는다.
같은 Origin에서 악성 Script가 실행되면 사용자의 Session을 이용해 BFF Endpoint를 호출할 수 있다.
현재처럼 JavaScript가 CSRF Cookie를 읽어 Header에 넣는 구조라면 악성 Script 역시 같은 방식으로 CSRF Token을 읽어 요청을 만들 수 있다.
같은 오리진에서 악성 스크립트가 실행되면 사용자의 세션으로 BFF 엔드포인트를 호출할 수 있다. 지금처럼 JavaScript가 CSRF 쿠키를 읽어 헤더에 넣는 구조라면 악성 스크립트도 같은 방법으로 CSRF 토큰을 읽어 요청을 만들 수 있다.
BFF에서는 OAuth Token 원문이 브라우저 JavaScript에 직접 노출되지 않지만, XSS 자체를 방지하기 위한 CSP, Output Encoding 등의 보호 조치와 외부 Script 및 의존성을 안전하게 관리하는 방법은 별도로 적용해야 한다.
또한 악성 Script가 사용자의 Session을 이용해 BFF를 호출하더라도 허용된 작업만 수행할 수 있도록 애플리케이션의 인가 역시 각 요청에서 검증해야 한다.
BFF에서는 OAuth 토큰 원문이 브라우저 JavaScript에 직접 드러나지 않지만, XSS(Cross-Site Scripting) 자체를 막는 CSP(Content Security Policy)와 출력 인코딩 같은 보호 조치, 그리고 외부 스크립트와 의존성을 안전하게 관리하는 방법은 로 적용해야 한다. 악성 스크립트가 사용자의 세션으로 BFF를 호출하더라도 허용된 작업만 할 수 있도록, 애플리케이션의 인가 역시 요청마다 검증해야 한다.
## 적용 조건
- 브라우저가 OAuth token을 받아서는 안 될 때
- backend가 화면에 맞춰 여러 API를 조합해야 할 때
- 브라우저가 OAuth 토큰을 받아서는 안 될 때
- 백엔드가 화면에 맞춰 여러 API를 조합해야 할 때
- 로그인 상태를 애플리케이션이 소유해야 할 때
- downstream API가 늘어나도 브라우저는 하나만 알게 하고 싶을 때
- 호출할 API가 늘어나도 브라우저는 BFF 하나만 알게 하고 싶을 때
## 예외
- stateless 직접 API 호출과 독립 client가 핵심이면 BFF 구조로 설계하지 않는다.
server state와 단일 장애 지점만 늘어난다.
- 브라우저의 직접 API 호출을 남겨야 하면 refresh credential만 서버로 분리하는 구조가 다.
- server state를 둘 수 없는 환경이면 브라우저가 token을 직접 다루는 구조가 더 단순하다.
- 서버에 상태를 두지 않는 직접 API 호출과 독립 클라이언트가 핵심이면 BFF 구조로 설계하지 않는다. 서버 상태와 단일 장애 지점만 늘어난다.
- 브라우저가 API를 직접 호출해야 하는 구조라면 갱신용 자격 증명만 서버로 분리하는 구조가 맞다.
- 서버에 상태를 둘 수 없는 환경이면 브라우저가 토큰을 직접 다루는 구조가 더 단순하다.
## 예시
- 브라우저 요청에는 Authorization 헤더가 없고 session cookie만 있다
- BFF가 authorized client에서 access token을 읽어 downstream Bearer 요청을 새로 만든다
- CSRF 헤더가 없는 POST는 403이 되고 cookie의 raw 값을 헤더에 넣은 POST는 200이 된다
- 응답 본문의 token은 가려 값이고 헤더에 넣는 값은 cookie의 raw 값이다
- 브라우저 요청에는 Authorization 헤더가 없고 세션 쿠키만 있다
- BFF가 인가된 클라이언트에서 액세스 토큰을 읽어 Resource Server용 Bearer 요청을 새로 만든다
- CSRF 헤더가 없는 POST는 403이 되고, 쿠키에 든 원래 값을 헤더에 넣은 POST는 200이 된다
- 응답 본문의 토큰은 가려 놓은 값이고, 헤더에 넣는 값은 쿠키에 든 원래 값이다
@@ -11,17 +11,18 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap4
- final/document.md#선택의-이유와-지킨-경계-ap4
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4
---
# Forward-Auth에서 Identity Header를 신뢰하기 위한 조건
애플리케이션이 프록시가 전달한 사용자 정보 헤더만으로 사용자를 판단하는 구조에서는, 해당 헤더가 실제로 신뢰할 수 있는 프록시에서 전달되었다는 것을 보장해야 한다.
애플리케이션이 프록시가 붙인 사용자 정보 헤더만 보고 로그인한 사용자를 하는 구조라면, 그 헤더가 정말 신뢰할 수 있는 프록시에서 온 것인지를 보장해야 한다.
이를 위해 외부 사용자가 애플리케이션에 직접 접근하지 못하도록 네트워크 경로를 제한하고,
사용자가 같은 이름의 헤더를 임의로 보내더라도 프록시가 이를 제거하거나 올바른 값으로 덮어써야 한다.
또한 필요한 경우 프록시에서 전달된 요청임을 확인할 수 있는 내부용 Credential도 함께 검증한다.
세 가지는 각각 다른 구간을 막으므로 하나만 적용하지 않고 같이 구성한다.
그러려면 외부 사용자가 애플리케이션에 직접 지 못하도록 네트워크 경로를 좁히고, 사용자가 같은 이름의 헤더를 실어 보내도 프록시가 그 값을 지우거나 자기가 확인한 값으로 덮어써야 한다. 프록시를 거친 요청인지 따로 확인해야 하면 내부용 자격 증명도 같이 검증한다.
## 관계
@@ -30,103 +31,83 @@ public: "https://hyeonworks.com/references/forward-auth-identity-header-trust"
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
헤더를 어디까지 늘릴지가 이 기준의 미결 항목이다.
- **OAuth Token과 Application Session을 구분하는 기준**
identity 헤더를 JWT나 session과 같은 이름으로 부르지 않는다.
사용자 정보 헤더를 JWT나 세션과 같은 이름으로 부르지 않는다.
## 목적
외부 요청이 인증 프록시를 거쳐 애플리케이션으로 전달되는 구조에서는, 애플리케이션이 프록시가 추가한 사용자 정보 헤더를 기준으로 로그인한 사용자를 판단할 수 있다.
Forward-auth는 요청을 애플리케이션에 넘기기 전에 별도의 인증 엔드포인트에 이 요청을 통과시켜도 되는지 먼저 묻는 방식이다. 인증에 성공하면 프록시가 사용자 정보 헤더를 붙여 주므로, 애플리케이션은 그 헤더를 기준으로 로그인한 사용자를 판단할 수 있다.
문제는 같은 이름의 헤더를 외부 사용자가 직접 만들어 보낼 수도 있다는 이다.
애플리케이션 입장에서는 전달받은 헤더만 보고 이것이 인증을 완료한 프록시가 추가한 값인지, 외부 사용자가 임의로 넣은 값인지 구분할 수 없다.
문제는 같은 이름의 헤더를 외부 사용자가 직접 만들어 보낼 수도 있다는 이다. 애플리케이션은 전달받은 헤더만 봐서는 인증을 마친 프록시가 붙인 값인지 외부 사용자가 임의로 넣은 값인지 구분할 수 없다.
그래서 사용자 정보 헤더를 인증 근거로 사용하려면 먼저 외부 요청이 반드시 인증 프록시를 거쳐만 애플리케이션에 도달하도록 구성해야 한다. 또한 애플리케이션이 받은 요청과 헤더가 신뢰할 수 있는 프록시를 통해 전달된 것인지 확인할 수 있는 방법도 같이 생각해야 한다.
그래서 사용자 정보 헤더를 인증 근거로 쓰려면 외부 요청이 인증 프록시를 거쳐만 애플리케이션에 닿도록 경로부터 짜야 한다. 여기에 더해 애플리케이션이 받은 요청과 헤더가 신뢰할 수 있는 프록시를 지나온 것인지 확인할 방법도 같이 한다.
## 규칙
### 1. 외부에서 애플리케이션과 인증 프록시에 직접 접근하지 못하게 한다
외부에서는 Edge에만 접근할 수 있도록 하고, 애플리케이션과 인증 프록시는 내부 네트워크에만 두어 Host Port로 직접 노출하지 않는다.
외부에 여는 것은 엣지 하나뿐이고, 애플리케이션과 인증 프록시는 내부 네트워크에만 두어 호스트 포트로 직접 노출하지 않는다.
애플리케이션이 외부에 직접 노출되어 있으면 공격자가 Edge의 인증 과정을 거치지 않고 애플리케이션으로 요청을 보낼 수 있다.
이 경우 공격자가 사용자 정보 헤더까지 직접 만들어 보낼 수 있으므로, 애플리케이션은 해당 헤더가 인증을 거쳐 생성된 값인지 신뢰할 수 없게 된다.
그래서 사용자 정보 헤더를 인증 근거로 사용하려면 먼저 모든 외부 요청이 반드시 Edge를 거치도록 네트워크 경로부터 제한해야 한다.
애플리케이션이 외부에 직접 노출되어 있으면 공격자는 엣지의 인증을 거치지 않고 애플리케이션으로 요청을 보낼 수 있고, 이때 사용자 정보 헤더까지 직접 만들어 실을 수 있다. 그러면 애플리케이션은 받은 헤더가 인증을 거쳐 만들어진 값인지 신뢰할 수 없다. 사용자 정보 헤더를 인증 근거로 쓰려면 네트워크 경로부터 좁혀서 모든 외부 요청이 엣지를 지나게 해야 한다.
### 2. client가 보낸 헤더를 항상 덮어쓴다
### 2. 클라이언트가 보낸 헤더를 항상 덮어쓴다
사용자 정보 헤더는 외부 요청에 들어 있던 값과 합치지 않고, 인증 프록시가 확인한 값으로 기존 헤더를 제거하거나 덮어쓴 뒤 애플리케이션에 전달한다.
사용자 정보 헤더는 외부 요청에 실려 온 값과 합치지 않는다. 인증 프록시가 확인한 값으로 기존 헤더를 지우거나 덮어쓴 뒤 애플리케이션으로 넘긴다.
기존 헤더와 인증 결과를 합쳐서 전달하면 공격자가 넣은 값과 프록시가 추가한 값이 하나의 헤더에 함께 포함될 수 있다.
이때 애플리케이션이 어떤 값을 사용자 정보로 사용할지는 헤더 처리 방식에 따라 달라질 수 있으므로, 인증된 값만 전달되도록 해야 한다.
기존 헤더와 인증 결과를 합쳐서 넘기면 공격자가 넣은 값과 프록시가 붙인 값이 헤더에 같이 담길 수 있다. 이때 애플리케이션이 둘 중 어느 값을 사용자 정보로 읽을지는 헤더 처리 방식에 따라 달라질 수 있으므로, 인증된 값만 남겨서 넘긴다.
또한 신뢰할 수 있는 프록시의 범위도 필요한 대상만 포함하도록 제한한다.
이 범위를 너무 넓게 설정하면 같은 내부 네트워크에 있는 다른 서비스가 신뢰받는 프록시처럼 요청을 보낼 수 있다.
특히 Forwarded나 X-Forwarded-* 헤더를 신뢰하는 구조에서는 어떤 프록시의 요청까지 신뢰할지를 먼저 좁혀 둔다.
신뢰할 프록시의 범위도 필요한 대상까지만 열어 둔다. 이 범위가 넓으면 같은 내부 네트워크에 있는 다른 서비스도 신뢰받는 프록시처럼 요청을 보낼 수 있다. 특히 Forwarded나 X-Forwarded-* 헤더를 믿는 구조라면 어느 프록시의 요청까지 믿을지를 먼저 좁혀 둔다.
### 3. auth endpoint는 subrequest 전용으로 둔다
### 3. 인증 엔드포인트는 하위 요청 전용으로 둔다
Endpoint는 외부 사용자가 직접 호출하는 API가 아니라, 인증 과정에서 Proxy가 내부적으로 호출하기 위한 Endpoint다.
따라서 외부 요청으로는 접근할 수 없게 하고 Proxy가 생성한 내부 요청만 허용해야 한다.
엔드포인트는 외부 사용자가 직접 부르는 API가 아니라, 인증 과정에서 프록시가 내부에서 부르려고 둔 것이다. 그래서 외부 요청으로는 닿을 수 없게 하고 프록시가 만든 내부 요청만 받는다.
Nginx에서는 해당 Location에 internal을 설정해 외부에서 직접 호출하는 것을 차단할 수 있다.
Nginx에서는 해당 location에 internal을 설정해 외부에서 직접 부르는 것을 막을 수 있다.
### 4. upstream이 헤더 존재만 보지 않는다
### 4. 업스트림이 헤더 존재만 보지 않는다
요청이 신뢰할 수 있는 Proxy에서 전달된 것인지 확인하기 위해, 배포할 때 설정한 내부용 Credential과 요청에 포함된 Credential을 비교한다. 이때 Credential 값의 일부가 얼마나 일치하는지에 따라 비교 시간이 크게 달라지지 않는 안전한 비교 방식을 사용한다.
요청이 신뢰할 수 있는 프록시에서 온 것인지 보려고, 배포할 때 설정한 내부용 자격 증명과 요청에 실려 온 값을 비교한다. 이때 값의 일부가 얼마나 일치하는지에 따라 비교에 걸리는 시간이 크게 달라지지 않는 안전한 비교 방식을 다.
이 검증을 각 Controller에서 개별적으로 처리하면 새로운 Endpoint를 추가할 때 검증 로직을 빠뜨릴 수 있기 때문에 운영 환경에서는 Filter, Interceptor, Security Chain과 같은 공통 처리 지점에서 모든 대상 요청에 동일한 검증이 적용되도록 구성해야 한다.
이 검증을 컨트롤러마다 따로 넣으면 새 엔드포인트를 추가할 때 빠뜨리기 쉽다. 운영 환경에서는 필터나 인터셉터, 시큐리티 체인처럼 대상 요청이 모두 지나는 곳 한 군데에 두어 같은 검증이 걸리게 한다.
### 5. Network 격리와 헤더 검증을 모두 적용한다
### 5. 네트워크 격리와 헤더 검증을 모두 적용한다
네트워크 격리는 외부 사용자가 인증 경로를 우회해 애플리케이션에 직접 접근하는 것을 막는다.
헤더 검증은 내부 네트워크에서 전달된 요청이라도 사용자 정보 헤더가 신뢰할 수 있는 값인지 확인한다.
두 방식은 보호하는 구간과 대상이 다르므로 둘 다 구성한다.
네트워크 격리는 외부 사용자가 인증 경로를 건너뛰고 애플리케이션에 직접 접근하는 것을 막는다. 헤더 검증은 내부 네트워크에서 온 요청이라도 사용자 정보 헤더가 신뢰할 수 있는 값인지 확인한다.
### 6. 인증된 사용자 정보 헤더만 전달한다
인증 프록시가 애플리케이션으로 전달할 사용자 정보 헤더를 미리 정해 두고, 허용하지 않은 헤더는 전달하지 않는다.
새로운 헤더를 추가할 때는 해당 값이 어떤 Claim에서 만들어지는지, 여러 값이 있을 때 어떤 형식으로 전달할지, 특수 문자를 어떻게 처리할지, 허용할 최대 크기는 얼마인지, 애플리케이션에서는 그 값을 어떻게 검증하고 사용할지를 함께 정해야 한다.
인증 프록시가 애플리케이션으로 넘길 사용자 정보 헤더를 미리 정해 두고, 목록에 없는 헤더는 넘기지 않는다. 헤더를 하나 더 늘릴 때는 그 값이 어떤 클레임에서 만들어지는지, 값이 여럿일 때 어떤 형식으로 넘길지, 특수 문자를 어떻게 처리할지, 허용할 최대 크기는 얼마인지, 애플리케이션에서 그 값을 어떻게 검증하고 사용할지를 같이 정해야 한다.
현재처럼 사용자 이름과 이메일만 전달하는 구조에서는 로그인한 사용자가 누구인지 알 수 있지만, 해당 사용자가 어떤 권한을 가지고 있는지까지 알 수 없다.
Role을 이용해 인가까지 처리하려면 Role 정보를 어떤 방식으로 전달할지뿐만 아니라, 사용자의 Role이 변경되었을 때 기존 Proxy Session과 애플리케이션의 인가 결과에 언제 반영할지도 별도로 정해야 한다.
사용자 이름과 이메일만 넘기는 지금 구조는 로그인한 사용자가 누구인지까지만 알 수 있고, 그 사용자가 어떤 권한을 가졌는지는 알 수 없다. 역할(role)로 인가까지 처리하려면 역할을 어떤 방식으로 넘길지에 더해, 사용자의 역할이 바뀌었을 때 이미 열려 있는 프록시 세션과 애플리케이션의 인가 결과에 언제 반영할지도 따로 정해야 한다.
### 7. 요청 성공 여부가 아니라 전달된 사용자 정보를 확인한다
정상적으로 로그인된 세션에서 사용자 정보 헤더만 위조해 요청했다면, 세션 자체는 유효하므로 요청이 200 OK로 처리되는 것은 정상이다.
정상적으로 로그인된 세션에서 사용자 정보 헤더만 위조해 요청했다면 세션 자체는 유효하므로, 요청이 200 OK로 처리되는 것은 정상이다.
테스트에서 확인해야 하는 것은 요청의 성공이나 실패가 아니라 애플리케이션이 어떤 사용자를 인증된 사용자로 인식했는지다.
공격자가 임의로 넣은 사용자 정보가 아니라, 인증 프록시가 확인한 실제 사용자 정보가 사용되어야 한다.
응답 코드만으로는 알 수 없으므로 실제 응답에 사용된 사용자 정보까지 확인한다.
테스트에서 확인 것은 요청의 성공이나 실패가 아니라 애플리케이션이 누구를 인증된 사용자로 인식했는지다. 응답 코드만으로는 알 수 없으니 응답에 실려 나온 사용자 정보까지 열어서, 공격자가 임의로 넣은 값이 아니라 인증 프록시가 확인한 실제 사용자 정보가 쓰였는지 확인한다.
### 8. 지금 확인한 것과 운영에서 더 필요한 것을 나눠 적는다
현재 테스트 환경에서 외부에서 애플리케이션으로 직접 접근할 수 없는지,
외부 사용자가 넣은 사용자 정보 헤더를 인증된 값으로 덮어쓰는지,
인증 Endpoint를 내부 요청으로만 호출할 수 있는지,
그리고 애플리케이션이 내부 Credential을 검증하는지까지 확인했다.
지금 테스트 환경에서 확인한 것은 네 가지다. 외부에서 애플리케이션으로 직접 접근할 수 없는지, 외부 사용자가 넣은 사용자 정보 헤더를 인증된 값으로 덮어쓰는지, 인증 엔드포인트를 내부 요청으로만 부를 수 있는지, 애플리케이션이 내부 자격 증명을 검증하는지까지다.
다만 실제 운영 환경에서는 추가적인 보안 구성이 필요하다.
내부 Credential과 같은 Secret은 Secret Manager 등을 통해 안전하게 주입하고 주기적으로 교체할 수 있어야 한다.
또한 Network Policy 등을 이용해 모든 요청이 정해진 인증 경로를 거치도록 제한해야 한다.
더 강한 서비스 간 인증이 필요하다면 mTLS나 Workload Identity를 적용하는 방법도 고려할 수 있다.
운영 환경에서는 여기에 더 얹어야 한다. 내부 자격 증명 같은 비밀값은 Secret Manager 등으로 안전하게 주입하고 주기적으로 교체할 수 있어야 하고, Network Policy 등으로 모든 요청이 정해진 인증 경로를 지나게 제한해야 한다. 서비스 사이를 더 강하게 묶어야 한다면 mTLS나 Workload Identity를 적용하는 방법도 검토할 수 있다.
## 적용 조건
- upstream에 OAuth client나 JWT 검증 코드를 넣기 어려울 때
- 여러 legacy service 앞에 같은 로그인 정책을
- edge에서 정책을 강제할 수 있을 때
- 이미 forward-auth를 쓰고 있는 구조를 점검할 때
- 업스트림에 OAuth 클라이언트나 JWT 검증 코드를 넣기 어려울 때
- 레거시 서비스 여러 개 앞에 같은 로그인 정책을
- 엣지에서 정책을 강제할 수 있을 때
- 이미 forward-auth로 돌아가는 구조를 점검할 때
## 예외
- backend 직접 경로나 헤더 덮어쓰기를 닫을 수 없는 환경이면 이 구조를 쓰지 않는다.
- 애플리케이션이 사용자별 API 조합과 세밀한 인가를 직접 맡아야 하면 BFF 구조를 검토한다.
- 임의 경로와 body, streaming을 그대로 넘기는 범용 reverse proxy가 필요하면 URI rewrite와 timeout, 응답 헤더 처리를 따로 설계해야 한다.
- 백엔드로 바로 가는 경로나 헤더 덮어쓰기를 닫을 수 없는 환경이면 이 구조를 쓰지 않는다.
- 애플리케이션이 사용자별 API 조합과 세밀한 인가를 직접 맡아야 하면 BFF(Backend For Frontend) 구조를 검토한다.
- 임의 경로와 본문, 스트리밍을 그대로 넘기는 범용 리버스 프록시가 필요하면 URI 재작성과 타임아웃, 응답 헤더 처리를 따로 설계해야 한다.
## 예시
- 외부에는 edge만 공개하고 app과 auth proxy의 port는 host에 publish하지 않는다
- 정상 session에 위조 헤더를 얹은 요청은 200을 받지만 응답 사용자는 실제 사용자다
- 외부에서 auth endpoint를 직접 부르면 404가
- upstream은 user 헤더와 internal token을 같이 확인하고 하나라도 틀리면 401을 반환한다
- 내부 검사가 특정 controller에만 있으면 새 endpoint에는 보호되지 않는다
- 외부에 여는 것은 엣지뿐이고 애플리케이션과 인증 프록시의 포트는 호스트에 열지 않는다
- 정상 세션에 위조 헤더를 얹은 요청은 200을 받지만 응답에 실린 사용자는 실제 사용자다
- 외부에서 인증 엔드포인트를 직접 부르면 404가 돌아온
- 업스트림은 사용자 헤더와 내부 토큰을 같이 확인하고 하나라도 틀리면 401을 반환한다
- 내부 검사가 특정 컨트롤러에만 있으면 새로 추가한 엔드포인트는 보호되지 않는다
@@ -11,14 +11,16 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-google-login
---
# 외부 IdP 연동과 Application 인증 구조의 경계
Google 로그인을 추가한다고 해서 새로운 다섯 번째 인증 구조가 생기는 것은 아니다.
사용자가 Google에서 인증을 마치면 Keycloak이 그 인증 결과를 받아 사용자를 확인하고, 애플리케이션에는 자신의 Authorization Code를 발급한다.
Google 로그인을 붙였다고 해서 다섯 번째 인증 구조가 새로 생기는 것은 아니다. 사용자가 Google에서 인증을 마치면 Keycloak이 그 결과를 받아 사용자를 확인하고, 애플리케이션에는 Keycloak 자신이 발급한 Authorization Code를 전달한다.
이후의 흐름은 기존과 같다. 애플리케이션은 여전히 Keycloak을 기준으로 인증을 처리하고, Token을 브라우저에서 관리할지 서버에서 관리할지에 따라 앞에서 구분한 네 가지 구조 중 하나를 사용한다.
다음 흐름은 기존과 같다. 애플리케이션은 여전히 Keycloak을 기준으로 인증을 처리하고, 토큰을 브라우저에서 관리할지 서버에서 관리할지에 따라 앞에서 구분한 네 가지 구조 중 하나를 다.
## 관계
@@ -27,54 +29,43 @@ Google 로그인을 추가한다고 해서 새로운 다섯 번째 인증 구조
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브로커가 만든 authorization code를 애플리케이션이 받는 흐름이다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
외부 IdP가 있어도 애플리케이션 endpoint 이동은 그대로다.
외부 IdP를 붙여도 애플리케이션이 오가는 endpoint의 순서는 바뀌지 않는다.
## 목적
Google은 Keycloak 앞에서 실제 사용자 인증을 담당하는 외부 IdP다.
사용자가 Keycloak 로그인 화면에서 Google을 선택하면 브라우저는 Google로 이동해 인증을 진행한다.
인증이 완료되면 Keycloak이 그 결과를 확인하고 자신의 사용자 정보와 연결한 뒤, 애플리케이션에는 Keycloak이 발급한 Authorization Code를 전달한다.
외부 IdP(Identity Provider)는 Keycloak 앞에 서서 사용자 인증을 실제로 수행하는 인증 공급자이고, Google이 그중 하나다. 사용자가 Keycloak 로그인 화면에서 Google을 고르면 브라우저는 Google로 이동해 인증을 마치고, Keycloak이 그 결과를 확인해 자신이 가진 사용자 정보와 연결한다. 그런 다음 Keycloak이 애플리케이션에 자신이 발급한 Authorization Code를 전달한다.
따라서 Google 같은 외부 IdP를 추가하더라도 애플리케이션의 인증 구조 달라지는 것은 아니다.
Token을 브라우저가 직접 받을지 서버에서 관리할지, 그리고 브라우저와 서버 중 어느 계층이 Resource Server의 API를 호출할지는 기존 SPA, Mediator, BFF, OAuth2-Proxy 구조 중 어떤 방식을 선택했는지에 따라 결정된다.
그래서 Google 같은 외부 IdP를 추가도 애플리케이션의 인증 구조 달라지지 않는다. 토큰을 브라우저가 직접 받을지 서버에서 관리할지, 브라우저와 서버 중 어느 계층이 Resource Server의 API를 호출할지는 기존 SPA, Mediator, BFF, OAuth2-Proxy 구조 중 무엇을 골랐는지가 정한다.
## 규칙
### 1. 외부 IdP 인증과 애플리케이션 인증 구조를 나눈다
외부 IdP는 Keycloak 앞에서 사용자 인증을 담당한다. 애플리케이션이 선택하는 SPA, Mediator, BFF, OAuth2-Proxy 구조는 Keycloak에서 인증이 끝난 이후 Token과 API 호출을 어떻게 처리할지를 정한다.
외부 IdP는 Keycloak 앞에서 사용자 인증을 맡고, 애플리케이션이 고른 SPA, Mediator, BFF, OAuth2-Proxy 구조는 Keycloak에서 인증이 끝난 뒤 토큰과 API 호출을 어떻게 처리할지를 정한다.
Google에서 인증이 완료되면 그 결과는 먼저 Keycloak이 검증한다.
이후 애플리케이션은 Google이 아니라 Keycloak이 발급한 Authorization Code와 Token을 사용한다.
Resource Server 역시 Keycloak이 발급한 Token을 검증한다.
따라서 Google 로그인을 추가하더라도 애플리케이션의 OAuth 처리 방식은 기존 SPA, Mediator, BFF, OAuth2-Proxy 구조를 그대로 따른다.
Google에서 인증이 끝나면 그 결과는 먼저 Keycloak이 검증한다. 이후 애플리케이션이 쓰는 Authorization Code와 토큰은 Google이 아니라 Keycloak이 발급한 것이고, Resource Server가 검증하는 토큰도 Keycloak이 발급한 것이다.
로그인 화면에서 Google이나 다른 Provider를 선택하게 하거나, Provider별 계정을 Keycloak 사용자와 어떻게 연결할지를 별도로 처리하는 것은 자연스럽다.
하지만 Resource Server가 Google과 Keycloak의 Token을 각각 다르게 검증하거나, 애플리케이션의 인가 로직이 로그인에 사용한 Provider에 따라 달라지기 시작한다면 외부 IdP와 애플리케이션 사이를 분리하던 Keycloak의 역할이 제대로 유지되고 있는지 확인할 필요가 있다.
로그인 화면에서 Google이나 다른 IdP를 고르게 하거나, IdP마다 다른 계정을 Keycloak 사용자와 어떻게 연결할지를 로 처리하는 것은 자연스럽다. 다만 Resource Server가 Google이 발급한 토큰과 Keycloak이 발급한 토큰을 각각 다르게 검증하거나, 애플리케이션의 인가 로직이 로그인에 쓴 IdP에 따라 갈리기 시작한다면 외부 IdP와 애플리케이션을 갈라놓던 Keycloak의 역할이 제대로 지켜지고 있는지 확인할 필요가 있다.
### 2. 외부 계정은 provider와 subject 조합으로 식별한다
### 2. 외부 계정은 IdP와 subject 조합으로 식별한다
이메일 주소는 변경될 수 있고 다른 계정과 중복될 가능성도 있기 때문에 외부 계정을 식별하고 연결하는 기준으로 사용하기에 적절하지 않다.
이메일 주소는 바뀔 수 있고 다른 계정과 겹칠 가능성도 있어서 외부 계정을 식별하고 연결하는 기준으로 기에 적절하지 않다.
대신 어떤 Provider에서 인증했는지와 해당 Provider가 사용자에게 부여한 고유 식별자(subject)를 함께 사용해 외부 계정을 식별한다.
예를 들어 Google 사용자는 Google + subject의 조합으로 구분한다.
대신 어떤 IdP에서 인증했는지와 그 IdP가 사용자에게 부여한 고유 식별자(subject)를 함께 써서 외부 계정을 식별한다. 예를 들어 Google 사용자는 Google + subject의 조합으로 구분한다.
이메일만 기준으로 계정을 연결하면 사용자가 이메일 주소를 변경했을 때 기존 계정과의 연결을 찾지 못하거나, 동일한 이메일을 가진 다른 계정을 잘못 연결할 수 있다.
이메일만 기준으로 계정을 연결하면 사용자가 이메일 주소를 바꿨을 때 기존 계정과의 연결을 찾지 못하거나, 같은 이메일을 가진 다른 계정을 잘못 연결할 수 있다.
### 3. email 충돌은 별도의 계정 연결 문제로 다룬다
### 3. 이메일 충돌은 별도의 계정 연결 문제로 다룬다
외부 IdP에서 전달받은 이메일 주소가 기존 계정의 이메일과 같더라도 두 계정을 자동으로 연결하지 않는다.
이메일이 같다는 사실만으로 두 계정이 같은 사용자의 것이라고 확신할 수 없기 때문이다.
외부 IdP 전달 이메일 주소가 기존 계정의 이메일과 같더라도, 이메일이 같다는 사실만으로 두 계정이 같은 사용자의 것이라고 확신할 수 없기 때문에 자동으로 연결하지 않는다.
계정을 연결해야 한다면 기존 계정으로 다시 로그인하거나 추가 인증을 요구하는 등, 사용자가 해당 계정의 실제 소유자임을 확인하는 별도의 절차를 거친다.
계정을 연결해야 한다면 기존 계정으로 다시 로그인하게 하거나 추가 인증을 요구하는 등, 사용자가 계정의 실제 소유자인지 확인하는 절차를 따로 거친다.
### 4. mock provider 테스트와 실제 IdP 검증을 구분한다
현재는 Mock Provider를 사용해 Keycloak이 외부 IdP의 인증 결과를 정상적으로 받아들이는지와 필요한 사용자 정보가 올바르게 매핑되는지까지 확인했다.
지금까지 mock provider로 확인한 것은 Keycloak이 외부 IdP의 인증 결과를 정상적으로 받아들이는지와, 필요한 사용자 정보가 올바르게 매핑되는지까지다.
하지만 Mock Provider 테스트만으로 실제 외부 IdP와의 연동까지 검증할 수 없다.
실제 계정으로 로그인하는 과정과 공개 HTTPS Callback, 사용자 동의(Consent) 화면, 외부 IdP가 적용하는 도메인 정책 등은 아직 확인하지 않았다.
그래서 실제 외부 IdP를 연결해 전체 로그인 흐름을 별도로 검증해야 한다.
mock provider 테스트만으로 실제 외부 IdP와의 연동까지 검증할 수 없다. 실제 계정으로 로그인하는 과정과 공개 HTTPS 콜백, 사용자 동의(Consent) 화면, 외부 IdP가 적용하는 도메인 정책 등은 아직 확인하지 않았다. 그래서 실제 외부 IdP를 연결해 전체 로그인 흐름을 따로 검증해야 한다.
## 적용 조건
@@ -85,15 +76,12 @@ Resource Server 역시 Keycloak이 발급한 Token을 검증한다.
## 예외
- 애플리케이션이 Keycloak 같은 브로커를 거치지 않고 Google 등 외부 IdP와 직접 OIDC 연동을 한다면 상황이 달라진다.
이 경우 애플리케이션은 외부 IdP 직접 발급한 Token을 사용하므로, 해당 외부 IdP를 신뢰하고 Token을 검증하게 된다.
- 조직에서 하나의 외부 IdP만 사용한다면 Keycloak과 같은 별도의 브로커를 두지 않고 애플리케이션이 해당 IdP와 직접 연동하는 구조도 선택할 수 있다.
이 경우 여러 외부 IdP에서 들어온 계정을 하나의 내부 사용자와 어떻게 연결할지 결정하는 계정 연결 정책은 대부분 필요 없다.
- 애플리케이션이 Keycloak 같은 브로커를 거치지 않고 Google 등 외부 IdP와 직접 OIDC 연동을 한다면 이야기가 달라진다. 이때는 애플리케이션이 외부 IdP가 직접 발급한 토큰을 쓰므로, 그 외부 IdP를 신뢰하고 토큰을 검증한다.
- 조직에서 외부 IdP를 하나만 쓴다면 Keycloak 같은 브로커를 따로 두지 않고 애플리케이션이 그 IdP 직접 연동하는 구조도 고를 수 있다. 이때는 여러 외부 IdP에서 들어온 계정을 하나의 내부 사용자와 어떻게 연결할지 정하는 계정 연결 정책이 대부분 필요 없다.
## 예시
- Google 로그인을 추가해도 애플리케이션이 고르는 것은 여전히 4가지 구조 중 하나다
- 브로커는 provider alias + upstream subject 조합을 기준으로 외부 계정을 식별한다.
- 브로커는 provider alias + upstream subject 조합을 기준으로 외부 계정을 식별한다
- 애플리케이션이 신뢰하는 issuer는 외부 IdP가 아니라 브로커다
- mock OIDC provider로 확인한 것은 브로커와 claim mapping 계약까지다
@@ -11,91 +11,77 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-책임과-데이터
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-사다리가-아니라
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-변경-경로
---
# OAuth/OIDC 인증 패턴 선택 기준
SPA, Mediator, BFF, OAuth2-Proxy는 Token과 인증 상태를 처리하는 방식이 서로 다르다.
브라우저가 Access Token을 직접 사용하는지, 실제 Resource Server를 누가 호출하는지, 서버에서 어떤 인증 상태를 보관하는지, Resource Server가 어떤 Credential을 검증하는지, CSRF를 어느 계층에서 처리하는지를 비교할 수 있다.
네 구조를 안전한 순서로 줄 세우지 않고, 애플리케이션의 요구사항과 배포·운영 환경에 맞춰 고른다.
SPA(Single Page Application), Mediator, BFF(Backend for Frontend), Forward-Auth는 토큰과 인증 상태를 다루는 방식이 서로 다르다. 브라우저가 액세스 토큰을 직접 쓰는지, Resource Server를 누가 호출하는지, 서버가 어떤 인증 상태를 보관하는지, Resource Server가 어떤 자격 증명을 검증하는지, CSRF(Cross-Site Request Forgery)를 어느 계층에서 처리하는지를 나란히 놓고 비교할 수 있다. 애플리케이션의 요구사항과 배포·운영 환경에 맞춰 고른다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다.
브라우저가 인가 코드를 토큰으로 바꾸고, 그 토큰을 들고 있다가, API까지 직접 호출한다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
mediator가 refresh token을 관리하고 브라우저가 access token으로 API를 직접 호출하는 구성을 확인했다.
Mediator가 리프레시 토큰을 서버에 두는데, 브라우저는 넘겨받은 액세스 토큰으로 Resource Server를 직접 호출한다.
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
BFF가 code 교환, token 보관, Resource Server 호출을 모두 처리하는 구성을 확인했다.
BFF가 인가 코드 교환과 토큰 보관, Resource Server 호출을 모두 처리하고 브라우저는 세션 쿠키만 받는다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
인증이 edge로 가면 보호 자원이 검증하는 이 JWT에서 헤더로 바뀐다.
인증을 엣지로 옮기면 보호 자원이 검증하는 대상이 JWT에서 헤더로 바뀐다.
## 목적
브라우저에 OAuth Token이 노출되는 정도만 놓고 보면 구조 차이는 있다.
Token을 다른 위치로 옮기면 브라우저에 노출되는 범위가 달라지고, 그 Token을 맡게 된 계층에서 처리해야 할 항목이 늘어난다.
브라우저에 OAuth 토큰이 노출되는 정도만 놓고 보면 구조마다 차이가 난다. 다만 토큰을 다른 계층으로 옮기면 브라우저에 노출되는 범위가 달라지는데, 그 토큰을 맡은 계층에서는 처리해야 할 항목이 늘어난다.
예를 들어 BFF는 OAuth Token을 서버에 보관해 브라우저에서 Token 원문을 제거할 수 있다.
하지만 서버가 Session과 Authorized Client를 관리해야 하므로 Session 보호, CSRF 방어, 공유 저장소와 같은 새로운 설계가 필요해진다.
예를 들어 BFF는 OAuth 토큰을 서버에 보관해 브라우저에서 토큰 원문을 없앨 수 있다. 하지만 그러려면 서버가 세션과 Authorized Client를 관리해야 한다. Authorized Client는 서버가 액세스 토큰과 리프레시 토큰을 보관하는 곳이다. 그래서 세션 보호와 CSRF 방어, 공유 저장소 같은 설계가 새로 필요해진다.
Forward-Auth 구조에서는 애플리케이션이 OAuth Token을 직접 관리하는 책임을 더 줄일 수 있다.
대신 애플리케이션이 Edge에서 전달된 사용자 정보 헤더를 신뢰하게 되므로, 헤더 위조 방지와 직접 접근 차단, 신뢰할 수 있는 네트워크 경계를 구성해야 한다.
Forward-Auth 구조에서는 애플리케이션이 OAuth 토큰을 직접 관리하는 책임을 더 줄일 수 있다. 대신 애플리케이션이 엣지에서 넘어온 사용자 정보 헤더를 신뢰하게 되므로, 헤더 위조 방지와 직접 접근 차단, 신뢰할 수 있는 네트워크 경계를 구성해야 한다.
그래서 어느 구조가 더 안전한지를 먼저 정하지 않고, 요구사항별로 무엇을 확인해야 하는지를 본다.
어느 구조가 더 안전한지를 먼저 정하지 않고, 요구사항마다 무엇을 확인해야 하는지를 본다.
## 규칙
### 1. 다섯 항목으로 구조를 비교한다
구조를 비교할 때는 브라우저의 Access Token 사용 여부, Resource Server 호출 주체, 서버에서 관리하는 인증 상태, Resource Server가 검증하는 Credential, CSRF 처리 위치를 확인한다.
브라우저가 액세스 토큰을 쓰는지, Resource Server를 누가 호출하는지, 서버가 어떤 인증 상태를 관리하는지, Resource Server가 어떤 자격 증명을 검증하는지, CSRF를 어디에서 처리하는지를 확인한다.
SPA는 Bearer Access Token을 직접 Authorization Header에 넣어 Resource Server를 호출하고, 인증에 Cookie를 사용하지 않는다.
이 다섯 항목은 패턴 이름 대신 요청 하나를 끝까지 따라가서 채운다. 실제 엔드포인트와 메서드, 중간에 생기는 데이터, 성공 응답과 실패 응답까지 봐야 한다. 같은 질문을 로그인할 때와 로그인 뒤 API를 부를 때 각각 던진다.
SPA와 Mediator에서는 브라우저가 Access Token을 사용해 Resource Server를 직접 호출한다.
차이는 Mediator가 로그인 Session과 OAuth Token을 서버에서도 관리하고, 로그인 이후 브라우저에 Access Token을 전달한다는 점이다.
SPA와 Mediator에서는 브라우저가 액세스 토큰으로 Resource Server를 직접 호출한다. SPA는 Bearer 액세스 토큰을 Authorization 헤더에 직접 넣고 인증에는 쿠키를 쓰지 않는다. Mediator는 로그인 세션과 OAuth 토큰을 서버에서도 관리하고, 로그인이 끝나면 브라우저에 액세스 토큰을 전달한다.
BFF에서는 브라우저가 Session Cookie로 BFF를 호출하고, BFF가 서버에 저장된 Access Token을 사용해 Resource Server를 호출한다. 따라서 브라우저에는 OAuth Token을 전달하지 않지만 Session과 Authorized Client를 서버에서 관리해야 한다.
BFF에서는 브라우저가 세션 쿠키로 BFF를 호출하고, BFF가 서버에 저장한 액세스 토큰으로 Resource Server를 호출한다. 그래서 브라우저에는 OAuth 토큰을 전달하지 않지만 세션과 Authorized Client를 서버에서 관리해야 한다.
Forward-Auth에서는 인증 Proxy가 Session을 관리하고, 인증이 완료된 요청에 사용자 정보를 추가해 애플리케이션으로 전달한다. 애플리케이션이 이 정보를 인증 근거로 사용한다면 Edge가 전달한 헤더를 신뢰할 수 있도록 직접 접근 차단, 헤더 덮어쓰기, 내부 Credential 검증 같은 별도의 보호가 필요하다.
Forward-Auth에서는 인증 프록시가 세션을 관리하고, 인증이 끝난 요청에 사용자 정보를 붙여 애플리케이션으로 넘긴다. 애플리케이션이 이 정보를 인증 근거로 쓴다면 엣지가 붙인 헤더를 믿을 수 있도록 직접 접근 차단 헤더 덮어쓰기, 내부 자격 증명 검증 같은 보호를 따로 둬야 한다.
### 2. 피해야 할 조건을 먼저 확인한다
구조를 비교하기 전에 먼저 반드시 지켜야 하는 보안 요구사항을 확인한다.
정책상 OAuth 토큰을 브라우저에 둘 수 없다면, 토큰을 Local Storage 대신 JavaScript 메모리에만 보관해도 요구사항을 채우지 못한다. 저장 위치만 달라졌을 뿐 브라우저 JavaScript가 여전히 토큰을 직접 다루기 때문이다. 이때는 브라우저가 액세스 토큰을 받는 SPA와 현재의 Mediator 구조를 선택 대상에서 뺀다.
정책상 OAuth Token을 브라우저에 둘 수 없다면 Token을 Local Storage 대신 JavaScript Memory에만 보관하는 것으로는 요구사항을 충족할 수 없다.
저장 위치가 달라졌을 뿐 브라우저 JavaScript가 여전히 Token을 직접 다루기 때문이다.
이 경우 브라우저가 Access Token을 받는 SPA나 현재의 Mediator 구조는 선택 대상에서 제외한다.
마찬가지로 애플리케이션에 직접 접근하는 경로를 차단할 수 없거나 외부에서 전달된 사용자 정보 헤더를 Edge에서 확실하게 제거하거나 덮어쓸 수 없다면, Edge가 전달한 사용자 정보를 인증 근거로 사용하는 구조는 선택하지 않는다.
마찬가지로 애플리케이션으로 바로 들어오는 경로를 막을 수 없거나, 밖에서 들어온 사용자 정보 헤더를 엣지에서 확실히 지우거나 덮어쓸 수 없다면, 엣지가 전달한 사용자 정보를 인증 근거로 쓰는 구조는 고르지 않는다.
### 3. 선택 조건과 운영 책임을 같이 문서화한다
어떤 인증 구조를 선택했는지만 기록하지 않는다. 어떤 보안 요구사항과 운영 조건 때문에 해당 구조를 선택했는지 함께 기록한다.
어떤 인증 구조를 골랐는지만 지 않는다. 어떤 보안 요구사항과 운영 조건 때문에 구조를 골랐는지 함께 적는다.
또한 해당 구조를 적용하기 어려운 조건도 남긴다.
예를 들어 브라우저에 OAuth Token을 둘 수 없는 환경에서는 SPA를 선택하기 어렵고, 애플리케이션의 직접 접근 경로나 사용자 정보 헤더를 안전하게 통제할 수 없는 환경에서는 Forward-Auth 구조를 적용하기 어렵다.
이렇게 선택 이유와 적용할 수 없는 조건을 함께 기록해야 이후 요구사항이나 운영 환경이 변경되었을 때
기존 선택이 여전히 유효한지 다시 판단할 수 있다.
구조를 적용하기 어려운 조건도 같이 적는다. 브라우저에 OAuth 토큰을 둘 수 없는 환경에서는 SPA를 고르기 어렵다. 애플리케이션으로 바로 들어오는 경로나 사용자 정보 헤더를 안전하게 통제할 수 없는 환경에서는 Forward-Auth를 적용하기 어렵다.
### 4. 이름으로 운영 속성을 추정하지 않는다
실제 운영에 적용할 때는 서버가 재시작되거나 특정 인스턴스에 장애가 발생해도 로그인 상태를 유지할 수 있는지,
여러 Replica가 필요한 Session과 Token 정보를 공유할 수 있는지,
저장소 장애가 발생했을 때 어떻게 복구할지 등을 별도로 확인해야 한다.
내부 Credential이나 암호화 Key와 같은 Secret을 안전하게 보관하고 교체할 수 있는지도 함께 검증해야 한다.
구조를 고를 때 이런 운영 항목까지 같이 적는다.
실제 운영에 적용할 때는 서버가 재시작되거나 특정 인스턴스에 장애가 도 로그인 상태를 유지할 수 있는지, 여러 레플리카가 필요한 세션과 토큰 정보를 공유할 수 있는지, 저장소 장애가 났을 때 어떻게 복구할지를 따로 확인해야 한다.
### 5. Credential의 위치가 바뀌면 저장·전달·검증 주체도 바뀐다
내부 자격 증명이나 암호화 키와 같은 비밀값을 안전하게 보관하고 교체할 수 있는지도 함께 검증한다. 구조를 고를 때 이런 운영 항목까지 같이 적는다.
인증 패턴을 변경하면 Credential의 위치만 달라지는 것이 아니라, Credential을 저장하고 전달하고 검증하는 주체도 함께 바뀐다.
따라서 패턴을 변경할 때는 기존 책임이 어느 계층으로 이동하는지까지 확인해야 한다.
### 5. 자격 증명의 위치가 바뀌면 저장·전달·검증 주체도 바뀐다
예를 들어 Forward-Auth 구조에서는 Edge가 인증된 사용자 정보를 Header로 애플리케이션에 전달할 수 있다.
처음에는 사용자 이름이나 이메일처럼 인증에 필요한 정보만 전달하더라도, 애플리케이션의 요구사항이 늘어나면서 Role이나 권한, 도메인에 종속된 사용자 정보까지 Header에 계속 추가될 수 있다.
패턴을 바꿀 때는 기존 책임이 어느 계층으로 옮겨 가는지까지 확인해야 한다.
이처럼 Edge가 전달해야 하는 정보가 계속 늘어나고 애플리케이션의 인가 판단이나 화면에 필요한 여러 API 응답의 조합까지 필요해진다면,
해당 책임을 Edge에 계속 추가하기보다 BFF에서 인가와 API 호출을 처리하는 구조가 더 적절한지 다시 검토한다.
예를 들어 Forward-Auth 구조에서는 엣지가 인증된 사용자 정보를 헤더로 애플리케이션에 전달할 수 있다. 처음에는 사용자 이름이나 이메일처럼 인증에 필요한 정보만 전달하더라도, 애플리케이션의 요구사항이 늘면서 역할이나 권한, 도메인에 묶인 사용자 정보까지 헤더에 계속 붙을 수 있다.
엣지가 전달해야 하는 정보가 이렇게 늘어나고, 인가 판단이나 화면에 필요한 여러 API 응답의 조합까지 애플리케이션에 필요해진다면, 그 책임을 엣지에 계속 얹기보다 BFF에서 인가와 API 호출을 처리하는 구조가 더 맞는지 다시 검토한다.
## 적용 조건
@@ -105,12 +91,12 @@ Forward-Auth에서는 인증 Proxy가 Session을 관리하고, 인증이 완료
## 예외
- 요구가 하나로 좁혀지면 비교가 필요 없다. 브라우저에 token을 둘 수 없고 backend가 API를 조합해야 하면 선택지는 하나다.
- 학습이나 시연이 목적이면 운영 속성 비교하지 않아도 된다.
- 브라우저에 토큰을 둘 수 없고 서버가 API를 조합해야 하면 남는 선택지는 하나다.
- 학습이나 시연이 목적이면 운영 속성까지 비교하지 않아도 된다.
## 예시
- SPA : 브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다
- Mediator : refresh token은 server에 있고 access token은 응답 본문으로 브라우저에 반환한다.
- BFF : server가 code 교환·token 관리·API 호출을 담당하고 브라우저는 session cookie로 BFF를 호출한다
- Forward-Auth : edge가 인증하고 upstream은 edge가 붙인 헤더를 본다
- SPA: 브라우저가 인가 코드 교환과 토큰 보관, API 호출을 모두 맡는다.
- Mediator: 리프레시 토큰은 서버에 두고, 액세스 토큰은 응답 본문으로 브라우저에 돌려준다.
- BFF: 서버가 인가 코드 교환과 토큰 관리, API 호출을 고 브라우저는 세션 쿠키로 BFF를 호출한다.
- Forward-Auth: 엣지가 인증하고 애플리케이션은 엣지가 붙인 헤더를 본다.
@@ -11,97 +11,100 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-책임과-데이터
- final/document.md#선택의-이유와-지킨-경계-ap1
- final/document.md#선택의-이유와-지킨-경계-ap2
---
# Public Client와 Confidential Client 구분 기준
OAuth Client의 종류는 client secret을 안전하게 보관할 수 있는지를 기준으정한다.
SPA는 브라우저에서 실행되기 때문에 secret을 사용자에게 노출하지 않고 안전하게 보관할 없다.
따라서 SPA는 일반적으로 Public Client로 등록한다.
OAuth 클라이언트의 종류는 클라이언트 시크릿을 안전하게 보관할 수 있는지로 정한다.
SPA(Single Page Application)는 브라우저에서 실행되기 때문에 시크릿을 사용자에게 노출하지 않고 보관할 방법이 없다.
그래서 SPA는 대개 public client로 등록한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
SPA를 public client로 등록한 이유를 실제 구성에서 확인할 수 있다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
confidential client를 사용해도 access token 전달 방식은 별도로 설계된다는 예다.
confidential client로 등록해도 액세스 토큰을 브라우저까지 보낼지는 따로 정한다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
client 종류에 따라 token endpoint의 client 인증 방식이 달라진다.
클라이언트 종류에 따라 token endpoint에서 하는 클라이언트 인증이 달라진다.
## 목적
먼저 OAuth Client가 Public ClientConfidential Client인지 결정해야 한다.
그래야 Authorization Code를 Token으로 교환할 때 PKCE를 사용할지, client secret을 이용한 Client 인증을 사용할지 정할 수 있다.
OAuth 클라이언트를 등록하려면 이 클라이언트를 public client로 볼confidential client로 볼지부터 정해야 한다.
그래야 authorization code를 토큰으로 교환할 때 PKCE(Proof Key for Code Exchange)를 쓸지, 클라이언트 시크릿으로 클라이언트를 인증할지 정할 수 있다.
Client 종류를 나누는 기준은 client secret을 사용자에게 노출하지 않고 안전하게 보관할 수 있는지다.
SPA는 브라우저에서 실행되기 때문에 코드에 secret을 넣어도 개발자 도구 등을 통해 사용자가 확인할 수 있다.
따라서 SPA는 secret을 안전하게 보관할 수 없는 Public Client로 구성한다.
클라이언트 종류를 나누는 기준은 클라이언트 시크릿을 사용자에게 노출하지 않고 안전하게 보관할 수 있는지다.
SPA는 브라우저에서 실행되기 때문에 코드에 시크릿을 넣어도 개발자 도구 같은 것으로 사용자가 확인할 수 있고, 그래서 시크릿을 안전하게 보관할 수 없는 public client로 구성한다.
반면 서버나 BFF는 secret을 서버 내부에 보관하고 브라우저에 전달하지 않을 수 있으므로 Confidential Client로 구성할 수 있다.
서버나 BFF(Backend for Frontend)는 시크릿을 서버 안에만 두고 브라우저에 전달하지 않을 수 있으므로 confidential client로 구성할 수 있다.
여기서 Client 종류와 Token을 누가 관하는지는 구분해야 한다.
Confidential Client라고 해서 반드시 Token이 서버에만 있어야 하는 것은 아니다.
Client 종류는 secret을 안전하게 보관할 수 있는지로 정하고, Token을 브라우저와 서버 중 어디에서 관리할지는 애플리케이션의 인증 구조에 따라 별도로 정한다.
클라이언트 종류가 토큰을 누가 관하는지까지 정해 주지는 않는다.
클라이언트 종류는 시크릿을 안전하게 보관할 수 있는지로 정하고, 토큰을 브라우저와 서버 중 어디에서 관리할지는 애플리케이션의 인증 구조에 따라 따로 정한다.
## 규칙
### 1. secret을 숨길 수 있는지로 종류를 정한다
### 1. 클라이언트 시크릿을 숨길 수 있는지로 종류를 정한다
애플리케이션의 배포 파일이나 실행 중인 메모리에서 사용자가 client secret을 확인할 수 있다면 이를 안전하게 보관할 수 없으므로 Public Client로 본다.
반대로 client secret을 서버 내부에만 보관하고 사용자에게 전달되지 않도록 통제할 수 있다면 Confidential Client로 구성할 수 있다.
애플리케이션의 배포 파일이나 실행 중인 메모리에서 사용자가 클라이언트 시크릿을 확인할 수 있다면 그 시크릿은 안전하게 보관할 수 없으므로 public client로 본다.
시크릿을 서버 에만 보관하고 사용자에게 전달되지 않도록 통제할 수 있다면 confidential client로 구성할 수 있다.
Native App도 브라우저에서 실행되는 것은 아니지만 애플리케이션이 사용자 기기에 설치되기 때문에, 배포 파일을 분석하면 내부에 포함된 client secret을 확인할 수 있다. 따라서 Native App 역시 일반적으로 Public Client로 다룬다.
네이티브 앱은 브라우저에서 실행되지만 애플리케이션이 사용자 기기에 설치되기 때문에, 배포 파일을 분석하면 안에 들어 있는 클라이언트 시크릿을 확인할 수 있다. 그래서 네이티브 앱도 대개 public client로 다룬다.
### 2. public client에서 Authorization Code Flow에 PKCE를 함께 쓴다
PKCE는 client secret을 대신해서 Client를 인증하는 방식이 아니다.
Authorization Code가 중간에 탈취되더라도 다른 사람이 그 Code를 Token으로 교환하기 어렵게 만드는 보호 장치다.
PKCE는 authorization code가 중간에 탈취되더라도 다른 사람이 그 code를 토큰으로 바꾸기 어렵게 만드는 보호 장치다.
클라이언트 시크릿을 대신해 클라이언트를 인증하는 방식은 아니다.
로그인을 시작할 때 Client는 임의의 code_verifier를 만들고, 이 변환한 code_challenge를 Authorization Request에 함께 보낸다. 이후 Authorization Code를 Token으로 교환할 때 원래의 code_verifier를 제출한다.
Authorization Server는 처음 받은 code_challenge와 비교하여 같은 요청에서 시작된 교환인지 확인한다.
로그인을 시작할 때 클라이언트는 임의의 code_verifier를 만들고, 이 값을 변환한 code_challenge를 authorization request에 함께 보낸다. 이후 authorization code를 토큰으로 교환할 때 원래의 code_verifier를 제출한다.
Authorization Server는 처음 받은 code_challenge와 맞춰 보고 같은 요청에서 시작된 교환인지 확인한다.
이때 S256 방식을 사용한다. plain 방식은 code_verifier 자체가 code_challenge로 전달되기 때문에 Authorization Request를 관찰한 사람이 그 값을 그대로 알 수 있다. 반면 S256은 code_verifier를 SHA-256으로 변환한 값을 전달하므로 Authorization Request에 원래의 code_verifier가 노출되지 않는다.
변환 방식은 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를 가지고 있는지 확인한다. 그래서 두 방식을 같이 사용하면 서로 다른 구간을 각각 보호할 수 있다.
클라이언트 인증과 PKCE는 보호하는 대상이 다르기 때문에 confidential client에서 둘을 같이 쓸 수 있다.
클라이언트 인증은 token endpoint에 요청을 보낸 쪽이 그 클라이언트가 맞는지 확인하고, PKCE는 authorization code를 받은 쪽이 로그인을 시작할 때 만든 code_verifier를 가지고 있는지 확인한다.
### 4. public client에서는 implicit flow와 direct access grant를 끈다
Implicit Flow는 Authorization Code를 거치지 않고 Access Token을 브라우저의 Redirect URI로 직접 전달다.
이 때문에 Token이 브라우저를 통과하고 노출될 수 있는 범위가 넓어진다.
implicit flow는 authorization code를 거치지 않고 액세스 토큰을 브라우저의 redirect URI로 바로 전달하기 때문에, 토큰이 지나가는 구간과 노출될 수 있는 범위가 넓어진다.
Direct Access Grant는 애플리케이션이 사용자의 아이디와 비밀번호를 직접 받아 Authorization Server에 전달하는 방식이다.
원래 사용자가 IdP에만 제공하면 되는 비밀번호를 애플리케이션도 다루게 된다는 문제가 있다.
direct access grant는 애플리케이션이 사용자의 아이디와 비밀번호를 직접 받아 Authorization Server에 전달하는 방식이다.
사용자가 IdP에만 면 되는 비밀번호를 애플리케이션도 함께 다룬다.
현재 구조에서는 Authorization Code Flow를 사용하고 있으므로 Implicit Flow와 Direct Access Grant는 비활성화했다.
현재 구조는 Authorization Code Flow를 므로 implicit flow와 direct access grant는 비활성화했다.
### 5. Client 종류만으로 브라우저가 Token을 받는지 여부가 결정되지는 않는다.
### 5. 클라이언트 종류만으로 브라우저가 토큰을 받는지가 정해지지는 않는다
Confidential Client가 Authorization Code를 Token으로 교환하더라도, 그 결과로 받은 Access Token을 다시 브라우저에 전달하는 구조를 만들 수 있다.
즉, Confidential Client라고 해서 Token이 반드시 서버 내부에만 있는 것은 아니다.
confidential client가 authorization code를 토큰으로 교환하더라도, 그렇게 받은 액세스 토큰을 다시 브라우저에 전달하는 구조를 만들 수 있다.
Client 종류는 client secret을 어디에 안전하게 보관할 수 있는지를 나타낸다.
반면 Access Token이 브라우저까지 전달되는지는 어느 계층이 실제 API 호출을 담당하도록 설계했는지에 따라 별도로 결정된다.
클라이언트 종류는 클라이언트 시크릿을 어디에 안전하게 보관할 수 있는지를 말한다.
액세스 토큰이 브라우저까지 는지는 어느 계층이 실제 API 호출을 도록 설계했는지에 따라 따로 정해진다.
이 기준으로 등록한 클라이언트 넷 중 셋이 confidential인데, 그 셋에서 액세스 토큰이 브라우저까지 가는지는 갈렸다.
mediator 구성에서는 브라우저가 API를 직접 불러서 액세스 토큰이 응답으로 내려갔고, BFF와 프록시 구성에서는 서버 쪽이 API를 불러서 브라우저에 줄 것이 없었다.
## 적용 조건
- 새 OAuth client를 등록할 때
- SPA와 server 중 어디가 code를 교환할지 정할 때
- PKCE와 client 인증을 어디에 지 정할 때
- 기존 client의 종류가 맞는지 다시 볼 때
- 새 OAuth 클라이언트를 등록할 때
- SPA와 서버 중 어디가 code를 교환할지 정할 때
- PKCE와 클라이언트 인증을 어디에 지 정할 때
- 기존 클라이언트의 종류가 맞는지 다시 볼 때
## 예외
- 같은 서비스가 브라우저용 public client와 server용 confidential client를 따로 등록할 수 있다.
- backend가 사용자 없이 자기 자격으로 부르는 흐름은 Client Credentials를 쓰는 별도 client다.
- 같은 서비스가 브라우저용 public client와 서버용 confidential client를 따로 등록할 수 있다.
- 백엔드가 사용자 없이 자기 자격으로 부르는 흐름은 Client Credentials를 쓰는 별도 클라이언트다.
## 예시
- 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은 브라우저 응답에 반환될 수 있다
- SPA용 클라이언트 : public, standard flow만 켜고 implicit flow와 direct grant는 끈다
- mediator용 클라이언트 : confidential, client_secret_basic으로 token endpoint에서 인증한다
- BFF용 클라이언트 : confidential, PKCE S256을 함께 쓴다
- 프록시용 클라이언트 : confidential, oauth2-proxy가 클라이언트 시크릿과 verifier로 code를 교환한다
- confidential client인 mediator를 써도 액세스 토큰이 브라우저 응답에 실려 나갈 수 있다
@@ -0,0 +1,99 @@
---
id:
kind: REFERENCE
slug: runtime-verification-safe-order
title: 패턴 검증을 실제로 돌릴 때의 안전한 순서
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
studio: ""
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#결정이-지켜지는지-확인하는-방법-실제-runtime-검증을-수행할-때의-안전한-순서
---
# 패턴 검증을 실제로 돌릴 때의 안전한 순서
네 패턴의 경계가 실제로 성립하는지 보려면 스택을 띄우고 브라우저로 로그인까지 해 봐야 하는데, 패턴별 검증 절차는 스택을 다시 만들기 전에 볼륨을 초기화한다. 보존해야 할 realm이나 데이터베이스가 같은 Compose 프로젝트에 있으면 검증을 돌리는 사이에 그 데이터를 잃는다.
대상 환경이 일회용인지 확정한 다음에 첫 검사를 돌리고, 마지막에는 테스트용 스택을 내리고 원래 환경을 복구해 다시 확인하는 것으로 닫는다. 중간에 하나라도 어긋나면 나머지를 계속 돌려 전체 통과를 만들지 않는다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
이 절차로 확인하는 경계 가운데 하나이고, 위조 헤더를 보내 보는 것이 5번 규칙의 부정 입력에 해당한다.
- **OAuth/OIDC 인증 패턴 선택 기준**
어느 구조를 고를지 정하는 기준과, 고른 구조가 코드에서 실제로 성립하는지 확인하는 절차는 같이 쓴다.
## 목적
이 절차가 막으려는 것은 두 가지다. 하나는 지워서는 안 될 데이터를 지우는 일이다. 검증 절차에 볼륨 초기화가 들어 있으므로, 실행 순서를 정하기 전에 대상이 지워도 되는 환경인지부터 확정해야 한다.
다른 하나는 앞 단계가 어긋났는데도 나머지를 계속 돌려 마지막에 전체 통과를 남기는 일이다.
## 규칙
### 1. 대상이 일회용 환경인지 확정한 뒤에 시작한다
보존해야 할 Keycloak realm과 사용자, PostgreSQL 데이터가 같은 Compose 프로젝트에 있으면 안 된다. 볼륨의 소유와 용도를 확정하지 못했다면 검증을 미룬다.
지금 쓰는 볼륨이 계속 필요하다면 별도 프로젝트로 복제하거나 백업이나 스냅숏을 만들어 둔 뒤에 진행한다.
### 2. 비밀값은 환경으로 주입하고 출력에 남기지 않는다
secret과 테스트 사용자 비밀번호는 환경변수로 주입하고 출력 로그에 값을 찍지 않는다. 커맨드라인 인자나 브라우저 출력, 버전 관리되는 파일에 값이 들어갔다면 그때 검증을 멈춘다.
### 3. 한 번에 한 패턴만 올린다
한 번에 한 패턴만 대상으로 고르고, 여러 패턴 스택을 같은 포트에 동시에 올리지 않는다. 포트가 겹치면 어느 스택이 응답한 것인지 구분되지 않아서 관측값이 어디에서 나온 것인지 알 수 없다.
### 4. 정적 검사를 먼저 돌리고 실패하면 다음으로 가지 않는다
정적 realm 검증과 단위 테스트를 먼저 실행하고, 여기서 client 종류나 redirect URI, audience mapper, controller 계약이 어긋나면 브라우저 E2E로 진행하지 않는다.
두 검사가 통과하면 일회용 볼륨이 맞는지 다시 확인한 뒤에 패턴 스택을 빌드한다. 빌드한 뒤에도 순서는 같아서, health check가 안정되지 않으면 로그인 테스트를 시작하지 않는다.
### 5. 부정 입력까지 관측한 뒤에 경계가 유지된다고 판단한다
브라우저 E2E에서는 엔드포인트별 상태 코드와 쿠키 속성, 브라우저가 실제로 보낸 네트워크 요청, 응답 payload의 키를 함께 확인한다. 리프레시 토큰이 브라우저에 노출된 채로도 화면은 열리고, 브라우저에 토큰이 없다고 알려 주는 응답의 숫자는 서버가 적어 넣은 값이라 네트워크와 저장소를 따로 봐야 확인된다.
그다음에 패턴마다 다른 부정 입력을 넣는다. 위조 헤더를 보냈을 때 프록시가 값을 덮어쓰는지, 잘못된 issuer와 audience를 가진 토큰이 거부되는지, CSRF(Cross-Site Request Forgery, 교차 사이트 요청 위조) 헤더가 없는 요청이 막히는지를 각각 관측하고,
위조 헤더를 넣은 요청이 200으로 돌아오는 것은 로그인 세션 자체가 유효하기 때문이다. 여기서 볼 값은 상태 코드가 아니라 응답에 남은 사용자 이름이고, 그 이름이 위조한 값이 아니라 원래 사용자면 덮어쓰기가 작동한 것이다.
여기 넣는 부정 입력에 서명이 깨진 토큰과 만료된 토큰은 들어 있지 않다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 주는 것을 본 것도 실제 서명 검증과 issuer 검증을 통과했다는 증거는 아니다.
### 6. 중단 조건에 걸리면 멈추고 그 지점을 보존한다
다음 넷 중 하나라도 성립하면 나머지 단계를 실행하지 않는다.
- 대상 볼륨의 소유와 용도를 확정할 수 없음
- redirect URI나 host가 테스트 픽스처가 아니라 실제 환경을 가리킴
- secret이 커맨드라인이나 브라우저 출력, 버전 관리 파일에 노출됨
- health check와 기대한 401·403, 헤더 덮어쓰기 중 하나라도 불일치
멈춘 뒤에 가장 먼저 하는 일은 실패한 홉의 실제 입력과 출력을 보존하는 것이다. 로그만 남기고 스택을 내리면 그 상태를 다시 만들 수 없다. 보존이 끝나면 설정과 네트워크, 애플리케이션 가운데 어느 경계가 깨졌는지 나눠서 진단한다.
### 7. 끝나면 원래 환경으로 되돌리고 다시 확인한다
검증이 끝나면 테스트용 스택을 내린다. 백업이 필요했던 환경이라면 원래 프로젝트와 볼륨을 복구한 뒤 health와 로그인이 되는지 다시 확인한다.
## 적용 조건
- 검증 절차에 볼륨 초기화나 스키마 재생성처럼 되돌릴 수 없는 단계가 들어 있을 때
- 실제 브라우저와 컨테이너를 띄워야 확인되는 경계를 다룰 때
- 여러 구성을 같은 호스트에서 번갈아 검증할 때
## 예외
- 일회용 환경이 아니면 이 절차를 그대로 실행하지 않는다. 별도 프로젝트로 복제한 뒤 그쪽에서 돌린다.
- 정적 검사만으로 판정이 끝나는 항목은 스택을 띄우지 않는다. 4번 규칙의 앞 절반만 적용한다.
## 예시
- 볼륨 소유자를 확정하지 못한 경우: 시작하지 않고 별도 프로젝트로 복제한다
- 정적 realm 검증에서 redirect URI 불일치: 브라우저 E2E로 진행하지 않고 설정을 고친다
- 위조 헤더 응답이 200인데 사용자 이름이 원래 사용자: 통과로 기록한다
- 위조 헤더 응답의 사용자 이름이 위조한 값: 중단하고 그 요청의 입력과 출력을 보존한다
- 검증 종료 후: 스택을 내리고 백업한 볼륨을 복구한 뒤 로그인을 다시 확인한다
@@ -11,124 +11,105 @@ 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"
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#문제를-어렵게-만든-제약-같은-사용자를-나타내도
- final/document.md#문제를-어렵게-만든-제약-브라우저에-없다
---
# OAuth Token과 Application Session을 구분하는 기준
인증 과정에서 만들어지는 상태를 모두 하나의 `로그인 상태`로 보면 안 된다.
IdP의 SSO Session, Access Token, Refresh Token, 애플리케이션의 Session Cookie, 인증 Proxy의 Session Cookie는
각각 생성하는 주체와 사용하는 주체가 다르고 유효 시간도 서로 다르다.
IdP의 SSO 세션, 액세스 토큰, 리프레시 토큰, 애플리케이션의 세션 쿠키, 인증 프록시의 세션 쿠키는 만드는 쪽과 쓰는 쪽이 각각 다르고 유효 시간도 서로 다르다.
예를 들어 Access Token이 만료되었다고 해서 애플리케이션 Session이나 IdP의 SSO Session까지 같이 만료된 아니다.
반대로 애플리케이션 Session을 삭제했다고 해서 IdP의 SSO Session이나 이미 발급된 Token까지 사라지는 것도 아니다.
액세스 토큰이 만료되었다고 해서 애플리케이션 세션이나 IdP의 SSO 세션까지 같이 만료된 것은 아니고, 애플리케이션 세션을 삭제했다고 해서 IdP의 SSO 세션이나 이미 발급된 토큰이 사라지는 것도 아니다.
그래서 어떤 Session이나 Token이 남아 있는지, 무엇이 만료되었는지, Logout할 때 어떤 상태를 삭제하거나 무효화해야 하는지를 각각 구분해서 확인한다.
그래서 어떤 세션과 토큰이 아직 살아 있는지, 무엇이 만료되었는지, 로그아웃할 때 어떤 상태를 삭제하거나 무효화해야 하는지를 종류마다 나눠서 확인한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
JavaScript memory의 OAuth token과 Keycloak SSO session을 구분한 Case다.
JavaScript 메모리의 OAuth 토큰과 Keycloak SSO 세션을 구분한 사례다.
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
같은 요청 안에서 session cookie와 access token이 함께 움직인다.
같은 요청 안에서 세션 쿠키와 액세스 토큰이 함께 움직인다.
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
BFF에서는 session cookie, JavaScript가 읽는 CSRF token, server-side OAuth token을 각각 다른 용도로 사용한다.
BFF에서는 세션 쿠키, JavaScript가 읽는 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 토큰, 서버 쪽 OAuth 토큰을 서로 다른 용도로 다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
Forward-Auth에서는 upstream이 JWT를 직접 검증하지 않고 proxy session을 기반으로 edge가 만든 identity header를 사용한다.
Forward-Auth에서는 업스트림이 JWT를 직접 검증하지 않고, 프록시 세션을 근거로 엣지가 만든 사용자 정보 헤더를 쓴다.
## 목적
SPA, Mediator, BFF에서는 Resource Server가 Access Token을 검증한 뒤 JWT의 Claim에서 사용자 정보를 얻을 수 있다. 반면 Forward-Auth 구에서는 애플리케이션이 인증 Proxy가 전달한 사용자 정보 Header를 사용한다.
최종적으로 같은 사용자 이름이 나오더라도, 한쪽은 Access Token을 검증해서 얻은 값이고 다른 한쪽은 신뢰할 수 있는 Proxy가 전달한 값이다.
SPA(Single Page Application, 단일 페이지 애플리케이션), Mediator, BFF(Backend For Frontend) 구성에서는 Resource Server가 액세스 토큰을 검증한 뒤 JWT의 클레임에서 사용자 정보를 얻을 수 있다. Forward-Auth 구에서는 애플리케이션이 인증 프록시가 전달한 사용자 정보 헤더를 쓴다. 두 경로가 같은 사용자 이름을 내놓더라도 한쪽은 액세스 토큰을 검증해서 얻은 값이고, 다른 한쪽은 신뢰하기로 정한 프록시가 전달한 값이다.
Logout과 만료 처리에서도 같은 구분이 필요하다. IdP의 SSO Session, Access Token과 Refresh Token, Application Session은 서로 다른 주체가 관리하고 수명도 다르다. 따라서 Logout할 때 무엇을 삭제하거나 무효화할지, 특정 Credential이 만료되었을 때 어떤 상태를 계속 사용할 수 있는지를 각각 구분해서 설계한다.
로그아웃과 만료 처리에서도 같은 구분이 필요하다. IdP의 SSO 세션, 액세스 토큰과 리프레시 토큰, 애플리케이션 세션은 서로 다른 주체가 관리하고 수명도 다르다. 그래서 로그아웃할 때 무엇을 삭제하거나 무효화할지, 어떤 자격 증명이 만료되었을 때 어떤 상태를 계속 수 있는지를 각각 나눠서 설계한다.
## 규칙
### 1. 인증 상태를 종류별로 구분해서 기록한다.
### 1. 인증 상태를 종류별로 구분해서 기록한다
IdP SSO Session, OAuth Access Token, OAuth Refresh Token, Application Session Cookie, Proxy Session Cookie는 각각 생성하는 주체와 사용하는 목적이 다른 별개의 상태다.
IdP SSO 세션, OAuth 액세스 토큰, OAuth 리프레시 토큰, 애플리케이션 세션 쿠키, 프록시 세션 쿠키는 만드는 주체와 는 목적이 저마다 다른 별개의 상태다.
로그와 진단 정보에도 어떤 상태를 확인한 것인지 구체적으로 기록한다.
예를 들어 단순히 로그인 상태가 만료되었다고 남기는 대신 Application Session이 만료되었다, Access Token이 만료되었다, Proxy Session이 존재하지 않는다처럼 실제 Session이나 Token의 종류를 명시한다.
로그와 진단 정보에도 어떤 상태를 확인한 것인지 구체적으로 적는다. 로그인 상태가 만료되었다고만 남기는 대신 애플리케이션 세션이 만료되었다, 액세스 토큰이 만료되었다, 프록시 세션이 없다처럼 실제 세션이나 토큰의 종류를 밝혀서 적는다.
이렇게 이름을 구분해야 장애를 분석하거나 Logout과 만료 동작을 확인할 때 어떤 상태가 남아 있고 어떤 상태를 삭제하거나 갱신해야 하는지 정확하게 판단할 수 있다.
### 2. 발급하는 쪽과 사용하는 쪽으로 구분한다
### 2. 만든 주체와 주된 소비자로 구분한다
액세스 토큰, 애플리케이션 세션 쿠키, 프록시 세션 쿠키는 각각 발급하는 주체와 사용하는 주체가 다르다.
Access Token, Application Session Cookie, Proxy Session Cookie는 각각 발급하는 주체와 사용하는 주체가 다르다.
액세스 토큰은 IdP가 발급하고 Resource Server가 요청을 처리할 때 검증한다. 애플리케이션 세션 쿠키는 애플리케이션이 발급하고, 이후 브라우저가 보낸 쿠키로 애플리케이션이 자기 로그인 세션을 찾는 데 쓴다. 프록시 세션 쿠키는 인증 프록시가 발급하고, 이후 프록시가 인증 상태를 확인할 때 쓴다.
Access Token은 IdP가 발급하고 Resource Server가 요청을 처리할 때 검증한다.
Application Session Cookie는 애플리케이션이 발급하고, 이후 브라우저가 보낸 Cookie를 이용해 애플리케이션이 자신의 로그인 Session을 찾는 데 사용한다.
Proxy Session Cookie는 인증 Proxy가 발급하고, 이후 Proxy가 인증 상태를 확인할 때 사용한다.
### 3. 같은 사용자라도 자격 증명마다 가리키는 상태가 다르다
이처럼 어떤 주체가 Credential을 발급했고, 요청을 처리할 때 어떤 주체가 이를 검증하는지가 다르다면 서로 다른 Credential과 인증 상태로 구분해서 다뤄야 한다.
애플리케이션 세션 쿠키는 서버에 저장된 세션을 찾을 세션 ID를 브라우저에 전달하는 데 쓴다. 액세스 토큰과 리프레시 토큰은 쿠키 안에 들어 있지 않고 Authorized Client 같은 별도의 서버 저장소에 보관되므로, 세션 쿠키와 OAuth 토큰 저장소는 갈라서 봐야 한다.
### 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와는 역할이 다르다.
프록시 세션 쿠키가 반드시 같은 방식으로 동작하지는 않는다. 서버에 세션 저장소를 따로 두지 않고 인증 상태를 확인하는 데 필요한 정보를 쿠키 자체에 담은 뒤, 프록시가 그 쿠키의 유효성을 검증하는 방식으로 구성할 수도 있다. 이때 쿠키는 서버에 저장된 세션을 조회하는 세션 ID와 역할이 다르다.
### 4. 브라우저에 없는 것을 범위까지 적는다
브라우저 JavaScript에 OAuth Token을 전달하지 않는 구조에서도 브라우저에 인증과 관련된 상태는 남아 있을 수 있다.
예를 들어 BFF 구조에서는 애플리케이션의 HttpOnly Session Cookie가 유지될 수 있고, IdP에서는 자신의 도메인에 SSO Session Cookie를 유지할 수 있다.
브라우저 JavaScript에 OAuth 토큰을 넘기지 않는 구조에서도 브라우저에 인증과 관련된 상태가 있을 수 있다. BFF 구조에서는 애플리케이션의 HttpOnly 세션 쿠키가 유지될 수 있고, IdP는 자기 도메인에 SSO 세션 쿠키를 유지할 수 있다.
따라서 단순히 브라우저에 인증 정보가 없다거나 브라우저에 Credential이 없표현하면 안 된다.
브라우저 JavaScript에 Access Token과 Refresh Token을 노출하지 않는다처럼 무엇이 없고 어느 범위에서 접근할 수 없는지를 적는다.
브라우저 JavaScript에 액세스 토큰과 리프레시 토큰을 노출하지 않는다처럼 무엇이 없고 어느 범위에서 접근할 수 없는지를 적는다.
### 5. 영구 저장과 메모리 보관을 구분한다.
### 5. 영구 저장과 메모리 보관을 구분한다
OAuth Token을 JavaScript Memory에만 보관하면 Local Storage나 Session Storage 같은 Web Storage에 Token을 지속적으로 저장하지 않을 수 있다.
다만 실행 중인 브라우저 JavaScript에서도 Token에 접근할 수 없다는 뜻은 아니다.
OAuth 토큰을 JavaScript 메모리에만 두면 Local Storage나 Session Storage 같은 Web Storage에 토큰을 계속 저장하지 않을 수 있다. 다만 실행 중인 브라우저 JavaScript에서 토큰에 접근할 수 없다는 뜻은 아니다.
애플리케이션이 Token Endpoint의 응답을 JavaScript로 받아 처리한다면, 실행 중에는 Token 값이 JavaScript가 다루는 메모리에 존재한다. 같은 Origin에서 악성 Script가 실행될 수 있는 상황에서는 Token 응답이나 애플리케이션이 Token을 처리하는 경로가 공격 대상이 될 수 있기 때문에, Web Storage에 Token이 저장지 않을 뿐이고 XSS를 통해 Token에 접근할 여지는 남는다.
애플리케이션이 Token Endpoint의 응답을 JavaScript로 받아 처리한다면 실행 중에는 토큰 값이 JavaScript가 다루는 메모리에 다. 같은 Origin에서 악성 스크립트가 실행될 수 있는 상황이라면 토큰 응답이나 애플리케이션이 토큰을 처리하는 경로가 공격 대상이 될 수 있으므로, Web Storage에 토큰을 저장지 않는 것만으로는 XSS(Cross-Site Scripting, 사이트 간 스크립팅)로 토큰에 접근하는 것까지 막지 못한다.
### 6. 로그아웃 범위를 상태별로 적는다
애플리케이션에서 Logout하는 것과 IdP의 SSO Session을 종료하는 것은 서로 다른 동작이다.
애플리케이션 Session이나 Cookie를 삭제하더라도 IdP의 SSO Session은 그대로 남아 있을 수 있으며, 반대로 IdP Session을 종료하더라도 이미 발급된 Access Token의 처리 방식은 별도로 확인해야 한다.
애플리케이션에서 로그아웃하는 것과 IdP의 SSO 세션을 끝내는 것은 서로 다른 동작이다. 애플리케이션 세션이나 쿠키를 삭제해도 IdP의 SSO 세션은 살아 있을 수 있고, 반대로 IdP 세션을 끝내도 이미 발급된 액세스 토큰을 어떻게 처리할지는 따로 확인해야 한다.
특히 Resource Server가 Self-contained JWT Access Token을 매 요청마다 IdP에 확인하지 않고 자체적으로 검증하는 구조에서는 이미 발급된 Token이 Logout과 동시에 자동으로 무효되지는 않는다.
Resource Server는 JWT의 서명과 만료 시간 등 필요한 Claim을 검증하고 Token이 아직 유효하면 요청을 받아들일 수 있다.
특히 Resource Server가 Self-contained JWT 액세스 토큰을 요청마다 IdP에 물어보지 않고 스스로 검증하는 구조에서는, 이미 발급된 토큰이 로그아웃과 동시에 자동으로 무효되지는 않는다. Resource Server는 JWT의 서명과 만료 시간처럼 필요한 클레임을 검증하고, 토큰이 아직 유효하면 요청을 받아들일 수 있다.
따라서 Denylist처럼 이미 발급된 Token의 상태를 추가로 확인하는 방법을 사용하지 않는다면, 애플리케이션 Logout만으로 기존 Access Token을 즉시 사용할 수 없게 만들 수 없다.
이런 구조에서는 Access Token의 TTL을 짧게 설정해 Logout 이후에도 기존 Token을 사용할 수 있는 시간을 제한하고,
Refresh Token과 Session은 각각의 저장 위치와 관리 주체에 맞게 별도로 종료하거나 제거한다.
그래서 이미 발급된 토큰의 상태를 한 번 더 확인하는 차단 목록(Denylist) 같은 장치를 두지 않면, 애플리케이션 로그아웃만으로 기존 액세스 토큰을 곧바로 못 쓰게 만들 수 없다. 이런 구조에서는 액세스 토큰의 TTL을 짧게 잡아 로그아웃 뒤에 기존 토큰을 쓸 수 있는 시간을 제한하고, 리프레시 토큰과 세션은 각각의 저장 위치와 관리 주체에 맞게 따로 끝내거나 지운다.
### 7. Logout 대상 credential을 구체적으로 적는다
### 7. 로그아웃할 자격 증명을 구체적으로 적는다
SPA에서 JavaScript Memory에 보관하던 OAuth Token을 제거하더라도 Keycloak의 SSO Session까지 종료되는 것은 아니다. Keycloak의 SSO Session이 아직 유효하다면 이후 새로운 Authorization Request 보냈을 때 사용자가 다시 아이디와 비밀번호를 입력하지 않고 인증 절차가 진행될 수 있다.
SPA에서 JavaScript 메모리에 두었던 OAuth 토큰을 지워도 Keycloak의 SSO 세션까지 끝나지는 않는다. Keycloak의 SSO 세션이 아직 유효하다면 다음에 새 인가 요청(Authorization Request)을 보냈을 때 사용자가 아이디와 비밀번호를 다시 입력하지 않고 인증 절차가 진행될 수 있다. 브라우저를 새로고침해서 메모리에 있던 토큰이 사라진 경우도 로그아웃과 같지 않다.
따라서 Logout을 단순히 브라우저의 Token이나 Cookie를 삭제하는 동작으로만 정의하면 안 된다.
어떤 수준까지 로그아웃할 것인지에 따라 애플리케이션 상태와 IdP의 SSO Session을 각각 어떻게 종료할지 정해야 한다.
어느 수준까지 로그아웃할 것인지에 따라 애플리케이션 상태와 IdP의 SSO 세션을 각각 어떻게 끝낼지 정해야 한다.
Mediator나 BFF처럼 서버에서 Application Session과 Authorized Client를 함께 관리하는 구조에서는 두 상태 정리 방법도 각각 명시한다.
Application Session을 무효화하는 것과 Authorized Client에 저장된 Access Token 및 Refresh Token을 제거하는 것은 서로 다른 처리기 때문에, Logout 시 어떤 상태를 삭제하고 어떤 상태를 유지할지를 별도로 확인한다.
Mediator나 BFF처럼 서버가 애플리케이션 세션과 Authorized Client를 함께 관리하는 구조에서는 두 상태 정리하는 방법도 각각 적는다. 애플리케이션 세션을 무효화하는 것과 Authorized Client에 저장된 액세스 토큰과 리프레시 토큰을 지우는 것은 서로 다른 처리이므로, 로그아웃할 때 어떤 상태를 삭제하고 어떤 상태를 유지할지 따로 확인한다.
## 적용 조건
- 인증 상태를 표나 문서로 정리할 때
- 로그아웃과 만료 동작을 설계할 때
- 브라우저가 어떤 credential을 저장하거나 전송하는지 설명할 때
- 브라우저가 어떤 자격 증명을 저장하거나 전송하는지 설명할 때
- 여러 구조를 비교할 때
## 예외
- 하나의 요청 흐름 안에서 어떤 Session이나 Token을 의미하는지 이미 명확한 경우에는 짧은 이름을 사용할 수 있다.
다만 문서에 처음 등장할 때는 전체 이름을 먼저 적어 어떤 상태를 의미하는지 명하게 정의한다.
이후 같은 문맥에서는 의미가 달라지지 않는 범위에서 Session, Access Token, Refresh Token처럼 줄여서 표현할 수 있다.
- IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO session과 access token, refresh token이 없다.
- 하나의 요청 흐름 안에서 어떤 세션이나 토큰을 가리키는지 이미 분명하면 짧은 이름을 수 있다.
다만 문서에 처음 나올 때는 전체 이름을 먼저 적어 어떤 상태를 가리키는지 명하게 정의하고,
이후 같은 문맥에서는 뜻이 달라지지 않는 범위에서 세션, 액세스 토큰, 리프레시 토큰처럼 줄여서 수 있다.
- IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO 세션과 액세스 토큰, 리프레시 토큰이 없다.
## 예시
- 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이 아니다
- IdP SSO 세션 : IdP 도메인의 쿠키이고 애플리케이션 메모리와 별개다
- 액세스 토큰 : IdP가 만들고 Resource Server가 서명과 issuer, audience를 검증한다
- 리프레시 토큰 : 새 액세스 토큰을 받는 장기 자격 증명이다
- 애플리케이션 세션 쿠키 : 서버에 있는 로그인 상태를 찾는 자격 증명이다
- 프록시 세션 쿠키 : 프록시의 인증 엔드포인트에 제시하는 최소 상태다
- CSRF 토큰 : 쿠키가 자동으로 붙는 상태 변경 요청의 의도를 확인한다
- 사용자 정보 헤더 : 엣지가 확인한 사용자 정보이고 JWT 토큰이 아니다
File diff suppressed because it is too large Load Diff