feat: 가상화 문서들 추가

This commit is contained in:
DongHyeonka
2026-09-10 08:54:05 +09:00
parent e9f6a93327
commit 43e1aadef0
695 changed files with 153404 additions and 12754 deletions
@@ -12,13 +12,17 @@ verifiedOn: 2026-08-24
studio: "https://hyeonworks.com/studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit"
public: "https://hyeonworks.com/cases/split-custody-access-token"
assets:
- key: ap2-split-custody-779cb791
file: ../../../final/assets/tech-log-studio/ap2-split-custody.svg
- key: ap2-mediator-architecture
file: ../../../final/assets/ap2-mediator-architecture/ap2-mediator-architecture.svg
- key: ap2-mediator-handoff-flow
file: ../../../final/assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap2
- final/document.md#선택의-이유와-지킨-경계-ap2
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap2-완주
- final/document.md#결정이-지켜지는지-확인하는-방법-ap2
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
---
# Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
@@ -113,8 +117,7 @@ confidential client는 client secret을 서버에 두고 자기를 인증할 수
## 서버로 옮긴 값과 브라우저로 돌아오는 값
:::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"
:::
![브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처.](../../../final/assets/ap2-mediator-architecture/ap2-mediator-architecture.svg)
SPA 구조에서는 브라우저가 코드를 직접 교환하고 받은 토큰도 브라우저에서 관리했다. mediator를 두면 그 코드를 교환하는 쪽이 Spring mediator로 바뀌고, 액세스 토큰과 리프레시 토큰은 둘 다 서버 쪽 authorized client에 저장된다. 그런데 보호 자원 서버를 부르는 쪽은 여전히 브라우저라서, 액세스 토큰은 `/token/access`를 통해 다시 브라우저로 건너온다.
@@ -153,6 +156,8 @@ AP2_SESSION
## /token/access가 반환하는 세 가지 값
![브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도.](../../../final/assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg)
브라우저가 보호 자원 서버를 직접 부르려면 액세스 토큰이 있어야 하고, 그 값을 주는 것이 `/token/access`다.
```http label="브라우저 입력 — cookie 한 개"
@@ -200,6 +205,8 @@ Authorization: Bearer <raw-keycloak-jwt>
Origin: http://localhost:8082
```
이 요청에 Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
액세스 토큰이 지나가는 곳은 다음 세 곳이다.
```text
@@ -263,4 +270,4 @@ mediator를 넣은 이유는 하나다. 리프레시 토큰은 브라우저 Java
`OAuth2AuthorizedClientManager`에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 다만 실제로 만료를 기다린 뒤 갱신이 성공하는지, rotation된 토큰이 저장되는지는 아직 확인하지 않았다.
<!-- body:end -->
<!-- body:end -->
@@ -12,17 +12,19 @@ verifiedOn: 2026-08-25
studio: "https://hyeonworks.com/studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit"
public: "https://hyeonworks.com/cases/bff-session-csrf-responsibility"
assets:
- key: ap3-bff-custody-82fa18bd
file: ../../../final/assets/tech-log-studio/ap3-bff-custody.svg
- key: ap3-csrf-split-501dd1f7
file: ../../../final/assets/tech-log-studio/ap3-csrf-split.svg
- key: ap3-bff-architecture
file: ../../../final/assets/ap3-bff-architecture/ap3-bff-architecture.svg
- key: ap3-csrf-boundary
file: ../../../final/assets/ap3-csrf-boundary/ap3-csrf-boundary.svg
- key: ap3-bff-session-flow
file: ../../../final/assets/tech-log-studio/ap3-bff-session-flow.svg
file: ../../../final/assets/ap3-bff-session-flow/ap3-bff-session-flow.svg
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap3
- final/document.md#선택의-이유와-지킨-경계-ap3
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap3-완주
- final/document.md#결정이-지켜지는지-확인하는-방법-ap3
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3
---
# BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식
@@ -123,8 +125,7 @@ BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호
## 브라우저가 들고 있는 자격 증명은 쿠키 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"
:::
![Browser session zone과 server-side BFF zone 사이에서 AP3_SESSION이 downstream Bearer 요청으로 바뀌는 BFF 아키텍처.](../../../final/assets/ap3-bff-architecture/ap3-bff-architecture.svg)
로그인은 브라우저가 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` 쿠키뿐이다.
@@ -143,8 +144,7 @@ BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호
### 쿠키 하나로 시작한 요청이 Bearer 요청이 된다
:::evidence key="ap3-bff-session-flow" alt="브라우저에서 Spring BFF, Authorized-client store, Resource Server로 이어지는 여섯 단계 흐름. AP3_SESSION을 실은 GET /bff/api/me로 시작해 BFF가 현재 principal을 authorize하고 저장소에서 server-held access token을 받는다. 그 토큰으로 GET /api/me를 Bearer로 부르고 subject·username·issuer·audience를 받아 브라우저에 JSON으로 돌려준다." caption="" zoom="true"
:::
![브라우저에서 Spring BFF, Authorized-client store, Resource Server로 이어지는 여섯 단계 흐름. AP3_SESSION을 실은 GET /bff/api/me로 시작해 BFF가 현재 principal을 authorize하고 저장소에서 server-held access token을 받는다. 그 토큰으로 GET /api/me를 Bearer로 부르고 subject·username·issuer·audience를 받아 브라우저에 JSON으로 돌려준다.](../../../final/assets/ap3-bff-session-flow/ap3-bff-session-flow.svg)
브라우저가 `/bff/api/me`를 부를 때 요청에 붙는 자격 증명은 쿠키뿐이라, `Authorization` 헤더도 없고 브라우저 코드에는 액세스 토큰을 담는 변수도 없다.
@@ -231,8 +231,7 @@ 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"
:::
![BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.](../../../final/assets/ap3-csrf-boundary/ap3-csrf-boundary.svg)
값이 갈리는 것은 쿠키와 응답 본문을 서로 다른 구성요소가 채우기 때문이다. 쿠키는 `CookieCsrfTokenRepository.withHttpOnlyFalse()`가 만들면서 원래 값을 그대로 넣는 반면, 본문에 실리는 값은 `XorCsrfTokenRequestAttributeHandler`가 요청 속성으로 노출하는 토큰이라 XOR와 Base64로 가려진 상태다. 그래서 브라우저 쪽 코드는 본문의 `token`을 헤더 값으로 쓰지 않고, 본문에서는 `headerName`만 읽은 다음 실제 값은 `document.cookie`에서 `XSRF-TOKEN`의 raw 값을 꺼내 그 헤더에 넣는다.
@@ -307,4 +306,4 @@ CSRF 검증을 눈으로 보려고 둔 `theme` 값도 사용자별 저장소에
브라우저가 OAuth 토큰을 받으면 안 되고 백엔드가 화면에 필요한 여러 API를 조합해야 한다면 이 구조를 고른다. 다운스트림 API가 늘어나도 브라우저는 BFF 하나만 알면 되고, 토큰 갱신과 제공자마다 다른 처리도 서버 안에 둔다. OAuth 흐름을 브라우저에서 직접 확인하는 것이 목적이면 SPA(Single Page Application) 구조가, 브라우저의 보호 자원 서버 직접 호출을 유지해야 한다면 Mediator가 맞는다.
대신 BFF는 요청을 넘겨 주기만 하는 프록시가 아니라 로그인 상태와 토큰을 든 보안 구성요소가 됐고, 화면의 모든 요청이 이곳을 지나므로 지연과 장애 지점도 여기로 모인다. 지금 구현은 그 상태를 한 프로세스 메모리에 두고 검증 1개만 걸어 둔 단계라, 세션과 authorized client를 어디에 둘지와 저장한 토큰을 어떻게 암호화할지는 앞으로 정해야 한다.
<!-- body:end -->
<!-- body:end -->
@@ -7,30 +7,34 @@ topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 39
version: 42
verifiedOn: 2026-08-25
studio: "https://hyeonworks.com/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit"
public: "https://hyeonworks.com/cases/identity-header-trust"
assets:
- key: ap4-edge-trust-1cff2399
file: ../../../final/assets/tech-log-studio/ap4-edge-trust.svg
- key: ap4-edge-trust-architecture
file: ../../../final/assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.svg
- key: ap4-edge-forward-auth-flow
file: ../../../final/assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.svg
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap4
- final/document.md#선택의-이유와-지킨-경계-ap4
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap4-완주
- final/document.md#결정이-지켜지는지-확인하는-방법-ap4
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4
---
# Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
앞단에 세운 프록시가 로그인을 대신 받으면 업스트림은 OAuth를 몰라도 되고, 대신 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단한다. 이 헤더는 인증을 마친 프록시가 붙일 수도 있고 브라우저가 직접 적어 보낼 수도 있는데, 두 값은 업스트림이 받은 요청에서 이름도 형식도 같아 서로 구분되지 않는다.
앞단에 세운 프록시가 로그인을 대신 받으면, 업스트림은 요청에 붙어 온 X-Auth-Request-User 헤더 하나만 보고 누가 보낸 요청인지 정한다. 이 헤더는 인증을 마친 프록시가 붙일 수도 있고 브라우저가 직접 적어 보낼 수도 있는데, 업스트림에 도착한 요청만으로는 둘을 가려낼 수 없다.
그래서 이 구성에서는 헤더를 믿을 조건을 세 곳에 나눠 두었다. 밖에서 들어오는 길을 Nginx 8088 하나로 줄이고, Nginx가 클라이언트의 동명 헤더를 자기 값으로 덮어쓰고, 업스트림이 사용자 정보 헤더와 함께 내부 토큰까지 대조한다. 호스트 포트를 닫아도 같은 Compose 네트워크 안에서는 app의 8081에 닿을 수 있고 그 요청은 Nginx를 거치지 않으니, 덮어쓰기도 함께 지나친다. 업스트림이 내부 토큰을 따로 대조하는 것은 그 요청을 걸러 내기 위해서다.
그래서 이 구성에서는 헤더를 믿을 조건을 세 곳에 나눠 두었다. 밖에서 들어오는 길을 Nginx 8088 하나로 줄이고, Nginx가 클라이언트의 동명 헤더를 자기 값으로 덮어쓰고, 업스트림이 사용자 정보 헤더와 함께 내부 토큰까지 대조한다. 호스트 포트를 닫아도 같은 Compose 네트워크 안에서는 app의 8081에 닿을 수 있고 그 요청은 Nginx를 거치지 않으니, 덮어쓰기도 함께 지나친다. 업스트림이 내부 토큰을 따로 확인하는 것은 그 요청을 걸러 내기 위해서다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
그 기준이 세운 다섯 조건 Nginx 설정과 업스트림 코드에서 하나씩 찾아 어디에 들어가 있는지 확인했다.
그 기준이 세운 다섯 조건 Nginx 설정과 업스트림 코드 어디에 들어가 있는지 하나씩 확인했다.
- **OAuth Token과 Application Session을 구분하는 기준**
AP4_SESSION은 브라우저와 Nginx 사이에서만 오가고 사용자 정보 헤더는 Nginx와 업스트림 사이에서만 붙으며, 업스트림은 JWT를 입력으로 받지 않는다.
- **OAuth/OIDC 인증 패턴 선택 기준**
@@ -109,7 +113,7 @@ X-Auth-Request-User : spoofed-admin
X-Auth-Request-Email : spoofed-admin@example.test
X-Internal-Auth-Token : attacker-controlled-token
응답은 200이고, 응답의 user는 spoofed-admin이 아니라 실제로 인증된 사용자여야 한다.
응답은 200이고, 그 안의 user는 spoofed-admin이 아니라 실제로 인증된 사용자여야 한다.
6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인한다.
@@ -121,22 +125,21 @@ X-Internal-Auth-Token : attacker-controlled-token
## 본문
<!-- body:start -->
forward-auth는 실제 요청을 업스트림으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 묻는 방식이고, 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"
:::
![외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처.](../../../final/assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.svg)
앞단 프록시가 로그인을 맡으면 업스트림은 OAuth를 몰라도 된다. 로그인과 세션 검증은 앞단에 세운 oauth2-proxy가 맡는데, 이렇게 로그인을 대신 받는 관문을 엣지라고 부른다. 업스트림은 요청에 붙어 온 `X-Auth-Request-User` 하나로 사용자를 판단한다. 이 헤더는 oauth2-proxy가 확인한 로그인 사용자의 이름을 담아 엣지가 업스트림 요청에 붙이는 값이고, 아래에서는 엣지가 이렇게 만들어 붙이는 값을 사용자 정보 헤더라고 부른다.
로그인과 세션 검증은 앞단에 세운 oauth2-proxy가 맡는다. 이렇게 로그인을 대신 받는 관문을 엣지라고 부른다. 밖에서 오는 요청은 Nginx가 받고, Nginx는 oauth2-proxy에 세션이 유효한지 물어본 결과를 업스트림 요청에 연결한다. 브라우저가 직접 부를 수 있는 주소는 Nginx뿐이고, oauth2-proxy와 Spring 업스트림은 같은 배포 안에서만 부를 수 있다. 업스트림은 요청에 붙어 온 `X-Auth-Request-User` 하나로 사용자를 판단한다. 이 헤더는 oauth2-proxy가 확인한 로그인 사용자의 이름을 담아 엣지가 업스트림 요청에 붙이는 값이고, 아래에서는 엣지가 이렇게 만들어 붙이는 값을 사용자 정보 헤더라고 부른다.
같은 이름의 헤더는 브라우저도 직접 적어 보낼 수 있다. 업스트림이 받는 요청에서 두 값은 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 곳다. 그래서 업스트림은 `X-Auth-Request-User`가 엣지에서 온 값인지 브라우저가 적어 넣은 값인지 가리지 못한다. 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 보낼 수 있다.
같은 이름의 헤더는 브라우저도 직접 적어 보낼 수 있다. 업스트림이 받는 요청에서 두 값은 이름도 형식도 같고 어느 쪽이 붙였는지 적힌 곳어서, 업스트림은 `X-Auth-Request-User`가 엣지에서 온 값인지 브라우저가 적어 넣은 값인지 가리지 못한다. 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 요청을 보낼 수 있다.
이 구조를 고른 이유는 업스트림에 OAuth 코드를 넣기 어려워서였다. 업스트림을 거의 고치지 않으려고 앞단에 관문을 세웠는데, 세우고 나니 외부에서 위조할 수 있는 헤더를 그대로 믿는 구성이 됐다. 아래 세 곳은 그다음에 붙인 것이다.
이 구조를 고른 이유는 업스트림에 OAuth 코드를 넣기 어려워서였다. 사용자별 API를 조합하고 세밀한 인가까지 애플리케이션이 직접 맡아야 하는 경우였다면 그 조합을 백엔드가 맡는 구조가 더 자연스럽다. 업스트림을 거의 고치지 않으려고 앞단에 관문을 세웠는데, 세우고 나니 외부에서 위조할 수 있는 헤더를 그대로 믿는 구성이 됐다. 아래 세 곳은 그다음에 붙인 것이다.
### 상태 코드가 아니라 응답의 user로 판정한다
### 위조 헤더를 얹은 요청에서 무엇을 보는가
로그인을 마친 브라우저가 정상 요청에 헤더 3개를 얹어 보다.
로그인을 마친 브라우저가 정상 요청에 헤더 3개를 얹어 보다.
```http label="공격자가 보낸 요청"
GET http://localhost:8088/api/edge
@@ -146,25 +149,27 @@ X-Auth-Request-Email: spoofed-admin@example.test
X-Internal-Auth-Token: attacker-controlled-token
```
세션 자체는 유효하므로 이 요청이 200으로 처리되는 것은 정상이다. 볼 값은 응답의 `user`다. 여기에 `spoofed-admin`이 아니라 실제로 인증된 사용자가 들어 있어야 이 검사를 통과한다.
세션 자체는 유효해서 이 요청이 200으로 처리되는 것은 정상이다. 볼 값은 응답의 `user`이고, 여기에 `spoofed-admin`이 아니라 실제로 인증된 사용자가 들어 있어야 이 검사를 통과한다. 「로그인이 성공한다」를 성공 기준으로 삼으면 이 경계는 재지 못한다. 위조 헤더가 통과해도 정상 사용자는 자기 이름을 보기 때문이다.
## 세 곳에서 나눠 막는다
헤더를 믿으려면 세 곳에서 막아야 한다. 각각이 걸러 내는 요청과 놓치는 요청이 다르다.
![브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도.](../../../final/assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.svg)
앞단을 무엇으로 세울지에는 Traefik ForwardAuth도 있었다. 인증 판단을 맡길 수는 있지만 OIDC(OpenID Connect) 클라이언트나 세션 관리자 자체는 아니고, 지금 Nginx가 내는 속성을 그대로 내려면 네 가지가 더 필요하다. `trustForwardHeader=false`, 허용 목록에 있는 인증 응답 헤더만 복사, 로그인 리다이렉트를 따로 만드는 일, 그리고 업스트림 내부 토큰이나 더 강한 서비스 신원(workload identity) 주입이다. 마지막 항목이 대안 설정에 없어서 그대로 바꿔 끼울 수 있다고는 확인하지 못했다. Nginx를 쓴 것은 `auth_request`와 401 처리, 헤더 추출과 덮어쓰기를 한 파일에서 볼 수 있어서다.
앞단을 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개에는 밖에서 직접 붙을 수 없다.
밖으로 연 포트는 Nginx의 8088 하나다. `app`의 8081과 oauth2-proxy의 4180은 Compose 네트워크에 `expose`만 하고 호스트 `ports`로는 내보내지 않아서, 이 둘에는 밖에서 직접 붙을 수 없다.
인증 엔드포인트도 같은 이유로 닫아 두는데, `location = /oauth2/auth`가 `internal`이라 Nginx가 만든 하위 요청만 들어갈 수 있고, 외부에서 같은 경로를 부르면 404가 된다. `internal` 지정이 없으면 이 엔드포인트가 밖에서 부를 수 있는 인증 우회 지점이 된다.
인증 엔드포인트도 같은 이유로 닫아 두는데, `location = /oauth2/auth`가 `internal`이라 Nginx가 만든 하위 요청만 들어갈 수 있고, 외부에서 같은 경로를 부르면 404가 된다. `internal` 지정하지 않으면 브라우저가 이 경로를 직접 부를 수 있다.
이 경계가 막는 것은 엣지를 건너뛰고 업스트림이나 프록시로 바로 가는 경로서, 내부 서비스가 보낸 요청이나 Nginx가 잘못 넘긴 헤더는 여기서 걸리지 않는다.
이 경계 엣지를 건너뛰고 업스트림이나 프록시로 바로 가는 경로만 막아서, 내부 서비스가 보낸 요청이나 Nginx가 잘못 넘긴 헤더는 여기서 걸리지 않는다.
### 클라이언트가 보낸 헤더를 덮어써서 지운다
Nginx는 업스트림을 부르기 전에 인증 결과를 먼저 묻는다. `auth_request`는 원래 요청을 처리하기 전에 지정한 경로로 하위 요청을 보내고 그 응답 코드로 요청을 계속할지 정하는 디렉티브다.
Nginx는 업스트림을 부르기 전에 `auth_request`로 인증 결과를 먼저 묻고, 하위 요청이 돌려준 응답 코드로 요청을 계속할지 정다.
```nginx label="upstream을 부르기 전에 먼저 물어본다"
auth_request /oauth2/auth;
@@ -178,7 +183,7 @@ $auth_email ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie
```
그다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 헤더 3개는 클라이언트가 보낸 값과 **합치지 않고 덮어쓰기**로 채워진다.
그다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 헤더 3개는 클라이언트가 보낸 값합치지 않고 덮어다.
```http label="upstream이 실제로 받는 요청"
GET http://app:8081/edge/me
@@ -193,7 +198,7 @@ X-Internal-Auth-Token: <nginx-environment-secret>
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는데, 여기서 확인하는 값은 2개다. `X-Auth-Request-User`를 읽어 비어 있는지 보고, `X-Internal-Auth-Token`을 읽어 배포할 때 설정해 둔 내부 토큰(internal token)과 비교한다.
비교에는 일반 문자열 비교 대신 `MessageDigest.isEqual`을 썼는데, 두 바이트 배열이 앞에서 몇 바이트까지 같은지에 따라 실행 시간이 크게 달라지지 않는 비교다. 일반 비교를 쓰면 값이 어디까지 맞았는지가 응답 시간으로 새어 나갈 수 있다.
비교에는 `MessageDigest.isEqual`을 쓴다. 두 바이트 배열이 앞에서 몇 바이트까지 같은지에 따라 실행 시간이 달라지지 않는 비교다.
두 조건이 모두 맞을 때만 허용 목록에 있는 필드 4개를 응답에 넣는다.
@@ -206,6 +211,8 @@ X-Internal-Auth-Token: <nginx-environment-secret>
}
```
이 JSON이 Nginx를 지나 브라우저가 부른 `/api/edge`의 응답이 된다.
하나라도 다르면 401이 된다.
```json label="사용자 헤더가 없거나 내부 토큰이 틀릴 때"
@@ -220,11 +227,11 @@ X-Internal-Auth-Token: <nginx-environment-secret>
:::
검사가 컨트롤러 하나에만 들어 있어서 운영으로 넘어갈 때는 필터나 인터셉터, 시큐리티 체인처럼 대상 엔드포인트 전체에 걸리는 공통 경계로 옮겨야 한다. 이 검사가 걸러 내는 것은 엣지를 거치지 않고 들어온 내부 요청다. 다만 토큰을 얻은 쪽에는 소용이 없으므로, 호스트 포트는 계속 닫아 두어야 한다.
검사가 컨트롤러 하나에만 들어 있어서 운영으로 넘어갈 때는 필터나 인터셉터, 시큐리티 체인처럼 대상 엔드포인트 전체에 걸리는 공통 경계로 옮겨야 한다. 이 검사 엣지를 거치지 않고 들어온 내부 요청을 걸러 낸다. 다만 토큰을 얻은 쪽에는 소용이 없으므로, 호스트 포트는 계속 닫아 두어야 한다.
### 경로에 따라 다른 코드가 돌아온다
같은 미인증 요청이라도 경로에 따라 결과가 다르다. 쿠키 없이 `/`를 부르면 `/oauth2/start`로 302가 되고, 같은 상태에서 `/api/edge`를 부르면 `Location` 없는 401이 된다. 화면을 여는 요청과 프로그램이 부르는 요청은 원하는 실패 모양이 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 리다이렉트를 따라가는 대신 401을 받아야 다.
쿠키 없이 `/`를 부르면 `/oauth2/start`로 302가 되고, 같은 상태에서 `/api/edge`를 부르면 `Location` 없는 401이 된다. 사람은 로그인 화면으로 가야 하고 프로그램은 리다이렉트를 따라가는 대신 401을 받아야 해서, 같은 미인증 요청이라도 경로마다 결과를 다르게 두었다.
**리다이렉트 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.** 다른 경로는 로그인 리다이렉트 규칙을 따른다.
@@ -241,7 +248,7 @@ X-Internal-Auth-Token: <nginx-environment-secret>
## 이 학습 환경이 보장하는 범위
여기서 확인한 것은 Keycloak 26.7.0과 oauth2-proxy 7.15.2를 한 대에서 돌리는 학습 환경이다. 쿠키 속성과 리다이렉트를 눈으로 보려고 HTTPS 대신 HTTP를 쓴 설정도 있다. 코드를 실행해 봤다고 운영까지 확인한 것은 아니어서,
여기서 확인한 것은 Keycloak 26.7.0과 oauth2-proxy 7.15.2를 한 대에서 돌리는 학습 환경이다. 쿠키 속성과 리다이렉트를 눈으로 보려고 HTTPS 대신 HTTP를 쓴 설정도 있다. 코드를 실행해 봤다고 운영까지 확인한 것은 아니다.
### 브라우저에 남는 것은 opaque 쿠키 하나다
@@ -266,13 +273,13 @@ 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`을 쓴다.
### 업스트림이 믿는 입력
앞의 세 구조에서는 Resource Server가 서명된 JWT를 받아 서명과 issuer, audience를 직접 확인한다. `/edge/me`는 JWT를 입력으로 받지 않는다. 대신 요청이 엣지를 거쳐 들어왔다는 네트워크 위치와 `X-Internal-Auth-Token`, 엣지가 넘긴 사용자와 이메일을 믿는다. 믿는 입력이 JWT 1개에서 3개로 늘어난 셈이라, 백엔드 직접 경로나 클라이언트가 보낸 헤더 중 하나만 열려도 다른 사용자처럼 요청을 보낼 수 있다.
지금 엣지 응답은 사용자와 이메일만 전달하고 role, groups, tenant, 인증 방식, 토큰 만료는 전달하지 않는다. 패턴이 금지하는 것은 아니지만, 헤더를 하나 늘릴 때 아래 6개를 함께 정해야 한다.
지금 엣지 응답은 사용자와 이메일만 전달하고 role, groups, tenant, 인증 방식, 토큰 만료는 전달하지 않는다. role을 넘기면 무엇이 달라지는지는 확인하지 않았다. 패턴이 금지하는 것은 아니지만, 헤더를 하나 늘릴 때 아래 6개를 함께 정해야 한다.
- claim 출처 : oauth2-proxy나 별도 인증 서비스가 어느 값을 읽는가
- 허용 목록 : Nginx가 어느 응답 헤더만 복사하는가
@@ -281,16 +288,16 @@ issuer는 요청을 보내기 위한 주소가 아니라 Keycloak이 발급한
- 업스트림 검증 : 헤더 존재만 볼지 값과 서비스 신원까지 볼지
- 갱신 : 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), 세션 갱신, 레플리카 사이의 시크릿 공유, 내부 시크릿 교체 확인하지 않았다.
role 전달, 새 엔드포인트에 검사를 공통으로 거는 것, 상태를 바꾸는 요청의 CSRF(Cross-Site Request Forgery), 세션 갱신, 레플리카 사이의 시크릿 공유, 내부 시크릿 교체까지 여섯 가지는 이 계약 밖이라 확인하지 않았다.
지금 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾸기 때문에 `/orders/123` 같은 임의 경로를 보존하는 범용 리버스 프록시가 아니고, 그래서 경로와 메서드, 요청 본문, 스트리밍, 웹소켓, 큰 헤더 동작은 입증하지 못했다.
업스트림이 OAuth를 몰라도 되는 대신, 이 구조는 네트워크 경로와 identity header와 내부 토큰을 믿어야 한다. 그것을 지키려면 내부 토큰 검사를 컨트롤러 밖 공통 경계로 옮기고, 레플리카 사이에서 세션 시크릿을 배포하고 교체하는 방법을 정해야 한다. 둘 다 아직 하지 않았다.
업스트림이 OAuth를 몰라도 되는 대신, 이 구조는 네트워크 경로와 사용자 정보 헤더, 내부 토큰을 믿어야 한다. 그것을 지키려면 내부 토큰 검사를 컨트롤러 밖 공통 경계로 옮기고, 레플리카 사이에서 세션 시크릿을 배포하고 교체하는 방법을 정해야 한다. 둘 다 아직 하지 않았다.
<!-- body:end -->
@@ -12,13 +12,17 @@ verifiedOn: 2026-08-22
studio: "https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit"
public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
assets:
- key: ap1-custody-v3-6e0376d2
file: ../../../final/assets/tech-log-studio/ap1-credential-custody.svg
- key: ap1-direct-architecture
file: ../../../final/assets/ap1-direct-architecture/ap1-direct-architecture.svg
- key: ap1-browser-bearer-flow
file: ../../../final/assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg
sourceRevision: keycloak-patterns-lab@2026-08
source:
- final/document.md#검토한-선택지와-막힌-지점-ap1
- final/document.md#선택의-이유와-지킨-경계-ap1
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주
- final/document.md#결정이-지켜지는지-확인하는-방법-ap1
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap1
---
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
@@ -100,8 +104,7 @@ public client는 브라우저처럼 client secret을 안전하게 숨길 수 없
## SPA가 토큰을 다루는 위치
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true"
:::
![SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처.](../../../final/assets/ap1-direct-architecture/ap1-direct-architecture.svg)
authorization code 교환, 토큰 보관, `Authorization` 헤더 조립까지 모두 브라우저에서 일어난다. 액세스·리프레시·ID 토큰은 JavaScript 메모리에 있고, Resource Server를 부를 때 쓸 `Authorization` 헤더도 같은 페이지에서 만든다.
@@ -141,7 +144,7 @@ GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
```
API를 부르는 동안에는 액세스 토큰이 요청의 `Authorization` 헤더에도 실린다.
API를 부르는 동안에는 액세스 토큰이 요청의 `Authorization` 헤더에도 실린다. Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout 시점에 곧바로 무효화하는 처리도 넣지 않았다. logout은 Keycloak SSO 종료와 SPA의 사용자 제거까지만 하고, 발급된 access JWT를 deny-list로 따로 관리하지는 않는다.
@@ -157,6 +160,8 @@ issuer·audience : 검증
## PKCE가 적용되는 구간
![브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도.](../../../final/assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg)
PKCE(Proof Key for Code Exchange)를 쓰면 authorization request에는 `code_challenge`가 들어가고, authorization code를 토큰으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
@@ -218,4 +223,4 @@ exact callback만 허용하는 운영 가드레일, 잘못된 redirect를 거부
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거치고, 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
<!-- body:end -->
<!-- body:end -->