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 -->