feat: 가상화 문서들 추가
This commit is contained in:
+12
-5
@@ -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"
|
||||
:::
|
||||

|
||||
|
||||
SPA 구조에서는 브라우저가 코드를 직접 교환하고 받은 토큰도 브라우저에서 관리했다. mediator를 두면 그 코드를 교환하는 쪽이 Spring mediator로 바뀌고, 액세스 토큰과 리프레시 토큰은 둘 다 서버 쪽 authorized client에 저장된다. 그런데 보호 자원 서버를 부르는 쪽은 여전히 브라우저라서, 액세스 토큰은 `/token/access`를 통해 다시 브라우저로 건너온다.
|
||||
|
||||
@@ -153,6 +156,8 @@ AP2_SESSION
|
||||
|
||||
## /token/access가 반환하는 세 가지 값
|
||||
|
||||

|
||||
|
||||
브라우저가 보호 자원 서버를 직접 부르려면 액세스 토큰이 있어야 하고, 그 값을 주는 것이 `/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 -->
|
||||
|
||||
+11
-12
@@ -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"
|
||||
:::
|
||||

|
||||
|
||||
로그인은 브라우저가 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"
|
||||
:::
|
||||

|
||||
|
||||
브라우저가 `/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"
|
||||
:::
|
||||

|
||||
|
||||
값이 갈리는 것은 쿠키와 응답 본문을 서로 다른 구성요소가 채우기 때문이다. 쿠키는 `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 -->
|
||||
|
||||
+41
-34
@@ -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"
|
||||
:::
|
||||

|
||||
|
||||
앞단 프록시가 로그인을 맡으면 업스트림은 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`이 아니라 실제로 인증된 사용자가 들어 있어야 이 검사를 통과한다. 「로그인이 성공한다」를 성공 기준으로 삼으면 이 경계는 재지 못한다. 위조 헤더가 통과해도 정상 사용자는 자기 이름을 보기 때문이다.
|
||||
|
||||
## 세 곳에서 나눠 막는다
|
||||
|
||||
헤더를 믿으려면 세 곳에서 막아야 한다. 각각이 걸러 내는 요청과 놓치는 요청이 다르다.
|
||||

|
||||
|
||||
앞단을 무엇으로 세울지에는 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 -->
|
||||
|
||||
+11
-6
@@ -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"
|
||||
:::
|
||||

|
||||
|
||||
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가 적용되는 구간
|
||||
|
||||

|
||||
|
||||
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 -->
|
||||
|
||||
+18
@@ -10,6 +10,9 @@ status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts
|
||||
studio: "https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit"
|
||||
assets:
|
||||
- key: login-api-phase-split
|
||||
file: ../../../final/assets/login-api-phase-split/login-api-phase-split.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-로그인-흐름과-api-흐름
|
||||
@@ -95,6 +98,21 @@ PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발
|
||||
|
||||
`state`는 PKCE 값과 하는 일이 다르다. `state`는 돌아온 callback이 브라우저가 처음 시작한 트랜잭션의 것인지 대조하고, verifier는 code를 교환하는 주체를 authorization request를 시작한 클라이언트에 묶는다.
|
||||
|
||||
## 로그인을 끝내는 쪽과 API를 부르는 쪽이 다르다
|
||||
|
||||
code 교환이 끝나도 API 요청까지 같은 곳에서 처리되는 것은 아니다. 누가 token을 받았는지와 누가 보호 자원을 부르는지가 패턴마다 갈린다.
|
||||
|
||||

|
||||
|
||||
AP2에서는 mediator가 token을 받고 API는 브라우저가 부른다. AP3에서는 BFF가 두 일을 모두 맡는다. AP4에서는 oauth2-proxy가 code 교환과 `AP4_SESSION` 검증을 하고, Nginx가 upstream 요청과 identity header를 만든다.
|
||||
|
||||
그래서 `Browser → Keycloak → API`처럼 한 줄로 그리면 서로 다른 이동이 하나로 뭉친다. 두 구간으로 나누어 읽는다.
|
||||
|
||||
| 구간 | 여기서 무엇이 일어나나 |
|
||||
|---|---|
|
||||
| 로그인 구간 | authorization request, callback, code 교환, 로그인 상태 생성 |
|
||||
| 애플리케이션 요청 구간 | 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답 |
|
||||
|
||||
## 클라이언트 종류에 따라 달라지는 인증
|
||||
|
||||
`spa-public`은 secret이 없는 public client다. token endpoint에서 클라이언트 인증을 하지 않고 PKCE만 사용한다.
|
||||
|
||||
+2
-3
@@ -12,7 +12,7 @@ basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
|
||||
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
|
||||
assets:
|
||||
- key: ap3-csrf-boundary
|
||||
file: ../../../final/assets/tech-log-studio/ap3-csrf-boundary.svg
|
||||
file: ../../../final/assets/ap3-csrf-boundary/ap3-csrf-boundary.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap3
|
||||
@@ -63,8 +63,7 @@ Cookie: AP3_SESSION=<opaque-session-id>
|
||||
}
|
||||
```
|
||||
|
||||
:::evidence key="ap3-csrf-boundary" alt="BFF의 /bff/csrf 하나에서 두 갈래가 갈리는 그림. Set-Cookie로 나가는 CSRF 쿠키에는 가리지 않은 원본 값이 들어가고 JSON 본문에는 가린 토큰과 headerName이 들어간다. 브라우저 코드는 JSON에서 headerName만 쓰고 실제 헤더 값은 쿠키의 원본 값을 쓴다. Spring CSRF filter가 raw cookie와 raw header를 대조해 일치하면 controller로 보내고 부재나 불일치면 403을 낸다." caption="" zoom="true"
|
||||
:::
|
||||

|
||||
|
||||
## body의 token과 cookie의 값은 다르다
|
||||
|
||||
|
||||
+2
-3
@@ -12,7 +12,7 @@ basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request module
|
||||
studio: "https://hyeonworks.com/studio/documents/a3493786-d3fb-4b01-b1c5-ecb23c3d5497/edit"
|
||||
assets:
|
||||
- key: ap4-edge-forward-auth-flow
|
||||
file: ../../../final/assets/tech-log-studio/ap4-edge-forward-auth-flow.svg
|
||||
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
|
||||
@@ -37,8 +37,7 @@ forward-auth는 실제 요청을 업스트림으로 넘기기 전에 별도의
|
||||
|
||||
## 요청 하나가 두 번 평가된다
|
||||
|
||||
:::evidence key="ap4-edge-forward-auth-flow" alt="브라우저에서 Nginx edge, oauth2-proxy, Spring upstream으로 이어지는 여섯 단계 흐름. AP4_SESSION을 실은 /api/edge 요청이 들어오면 Nginx가 internal /oauth2/auth로 subrequest를 보내 authenticated user와 email을 받는다. 그 값으로 만든 trusted header와 internal token을 붙여 /edge/me를 부르고, upstream이 돌려준 trusted identity JSON이 브라우저로 나간다." caption="" zoom="true"
|
||||
:::
|
||||

|
||||
|
||||
브라우저 요청이 들어와도 Nginx는 업스트림을 바로 호출하지 않는다. 업스트림은 Nginx가 요청을 최종으로 넘기는 뒤쪽 서버이고, 이 구성에서는 `app:8081`의 Spring 애플리케이션이다. 바로 넘기지 않는 것은 `location /`에 다음 지시어가 있기 때문이다.
|
||||
|
||||
|
||||
+18
-22
@@ -1,5 +1,5 @@
|
||||
---
|
||||
id:
|
||||
id: 036563a1-3a43-4931-a017-0e693b591e89
|
||||
kind: REFERENCE
|
||||
slug: runtime-verification-safe-order
|
||||
title: 패턴 검증을 실제로 돌릴 때의 안전한 순서
|
||||
@@ -7,7 +7,7 @@ topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
studio: ""
|
||||
studio: "https://hyeonworks.com/studio/documents/036563a1-3a43-4931-a017-0e693b591e89/edit"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-실제-runtime-검증을-수행할-때의-안전한-순서
|
||||
@@ -17,52 +17,48 @@ source:
|
||||
|
||||
네 패턴의 경계가 실제로 성립하는지 보려면 스택을 띄우고 브라우저로 로그인까지 해 봐야 하는데, 패턴별 검증 절차는 스택을 다시 만들기 전에 볼륨을 초기화한다. 보존해야 할 realm이나 데이터베이스가 같은 Compose 프로젝트에 있으면 검증을 돌리는 사이에 그 데이터를 잃는다.
|
||||
|
||||
대상 환경이 일회용인지 확정한 다음에 첫 검사를 돌리고, 마지막에는 테스트용 스택을 내리고 원래 환경을 복구해 다시 확인하는 것으로 닫는다. 중간에 하나라도 어긋나면 나머지를 계속 돌려 전체 통과를 만들지 않는다.
|
||||
그래서 대상 환경이 일회용인지 확정한 다음에 첫 검사를 돌리고, 마지막에는 테스트용 스택을 내린 뒤 원래 환경을 복구해 다시 확인한다. 중간에 하나라도 어긋나면 나머지를 계속 돌려 전체 통과를 만들지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
이 절차로 확인하는 경계 가운데 하나이고, 위조 헤더를 보내 보는 것이 5번 규칙의 부정 입력에 해당한다.
|
||||
이 절차로 확인하는 경계 가운데 하나다. 위조 헤더를 보내 보는 것이 5번 규칙의 부정 입력이다.
|
||||
- **OAuth/OIDC 인증 패턴 선택 기준**
|
||||
어느 구조를 고를지 정하는 기준과, 고른 구조가 코드에서 실제로 성립하는지 확인하는 절차는 같이 쓴다.
|
||||
어느 구조를 고를지는 그 기준으로 정하고, 고른 구조가 실제로 그렇게 동작하는지는 이 절차로 확인한다.
|
||||
|
||||
## 목적
|
||||
|
||||
이 절차가 막으려는 것은 두 가지다. 하나는 지워서는 안 될 데이터를 지우는 일이다. 검증 절차에 볼륨 초기화가 들어 있으므로, 실행 순서를 정하기 전에 대상이 지워도 되는 환경인지부터 확정해야 한다.
|
||||
|
||||
다른 하나는 앞 단계가 어긋났는데도 나머지를 계속 돌려 마지막에 전체 통과를 남기는 일이다.
|
||||
이 절차는 두 가지를 막는다. 보존해야 할 realm과 데이터베이스를 검증 도중에 지우는 일과, 앞 단계가 어긋났는데도 나머지를 계속 돌려 마지막에 전체 통과를 남기는 일이다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 대상이 일회용 환경인지 확정한 뒤에 시작한다
|
||||
|
||||
보존해야 할 Keycloak realm과 사용자, PostgreSQL 데이터가 같은 Compose 프로젝트에 있으면 안 된다. 볼륨의 소유와 용도를 확정하지 못했다면 검증을 미룬다.
|
||||
보존해야 할 Keycloak realm과 사용자, PostgreSQL 데이터가 같은 Compose 프로젝트에 있으면 안 된다. 볼륨의 소유와 용도를 확정하지 못했다면 검증을 미룬다. 지금 쓰는 볼륨이 계속 필요하다면 별도 프로젝트로 복제하거나, 백업이나 스냅숏을 만들어 둔 뒤에 진행한다.
|
||||
|
||||
지금 쓰는 볼륨이 계속 필요하다면 별도 프로젝트로 복제하거나 백업이나 스냅숏을 만들어 둔 뒤에 진행한다.
|
||||
### 2. 비밀값은 환경변수로 주입하고 출력에 남기지 않는다
|
||||
|
||||
### 2. 비밀값은 환경으로 주입하고 출력에 남기지 않는다
|
||||
|
||||
secret과 테스트 사용자 비밀번호는 환경변수로 주입하고 출력 로그에 값을 찍지 않는다. 커맨드라인 인자나 브라우저 출력, 버전 관리되는 파일에 값이 들어갔다면 그때 검증을 멈춘다.
|
||||
secret과 테스트 사용자 비밀번호는 환경변수로 주입하고 출력 로그에 값을 찍지 않는다. 커맨드라인 인자나 브라우저 출력, 버전 관리되는 파일에 값이 들어갔다면 검증을 멈춘다.
|
||||
|
||||
### 3. 한 번에 한 패턴만 올린다
|
||||
|
||||
한 번에 한 패턴만 대상으로 고르고, 여러 패턴 스택을 같은 포트에 동시에 올리지 않는다. 포트가 겹치면 어느 스택이 응답한 것인지 구분되지 않아서 관측값이 어디에서 나온 것인지 알 수 없다.
|
||||
한 번에 한 패턴만 대상으로 고르고, 여러 패턴 스택을 같은 포트에 동시에 올리지 않는다. 검증은 고른 경계의 입력과 출력에 직접 연결되어야 하므로, 관측한 상태 코드와 쿠키가 어느 패턴에서 나온 값인지 분명해야 한다.
|
||||
|
||||
### 4. 정적 검사를 먼저 돌리고 실패하면 다음으로 가지 않는다
|
||||
|
||||
정적 realm 검증과 단위 테스트를 먼저 실행하고, 여기서 client 종류나 redirect URI, audience mapper, controller 계약이 어긋나면 브라우저 E2E로 진행하지 않는다.
|
||||
정적 realm 검증과 단위 테스트를 먼저 실행하고, 여기서 클라이언트 종류나 redirect URI, audience mapper, 컨트롤러 계약이 어긋나면 브라우저 E2E로 진행하지 않는다.
|
||||
|
||||
두 검사가 통과하면 일회용 볼륨이 맞는지 다시 확인한 뒤에 패턴 스택을 빌드한다. 빌드한 뒤에도 순서는 같아서, health check가 안정되지 않으면 로그인 테스트를 시작하지 않는다.
|
||||
두 검사가 통과하면 일회용 볼륨이 맞는지 다시 확인한 뒤에 패턴 스택을 빌드한다. 빌드한 뒤에는 health check가 안정될 때까지 로그인 테스트를 시작하지 않는다.
|
||||
|
||||
### 5. 부정 입력까지 관측한 뒤에 경계가 유지된다고 판단한다
|
||||
|
||||
브라우저 E2E에서는 엔드포인트별 상태 코드와 쿠키 속성, 브라우저가 실제로 보낸 네트워크 요청, 응답 payload의 키를 함께 확인한다. 리프레시 토큰이 브라우저에 노출된 채로도 화면은 열리고, 브라우저에 토큰이 없다고 알려 주는 응답의 숫자는 서버가 적어 넣은 값이라 네트워크와 저장소를 따로 봐야 확인된다.
|
||||
성공 기준을 「로그인이 성공한다」로 두면 네 패턴 모두에서 너무 넓다. 리프레시 토큰이 브라우저에 노출된 채로도 화면은 열리고, 위조한 identity 헤더가 통과하는 구성에서도 정상 사용자는 자기 이름을 그대로 본다. 그래서 브라우저 E2E에서는 엔드포인트별 상태 코드와 쿠키 속성, 브라우저가 실제로 보낸 네트워크 요청, 응답 payload의 키를 함께 확인한다. 브라우저에 토큰이 없다고 알려 주는 응답 값도 서버가 적어 넣은 숫자라, 네트워크와 저장소를 따로 봐야 확인된다.
|
||||
|
||||
그다음에 패턴마다 다른 부정 입력을 넣는다. 위조 헤더를 보냈을 때 프록시가 값을 덮어쓰는지, 잘못된 issuer와 audience를 가진 토큰이 거부되는지, CSRF(Cross-Site Request Forgery, 교차 사이트 요청 위조) 헤더가 없는 요청이 막히는지를 각각 관측하고,
|
||||
그다음에 패턴마다 다른 부정 입력을 넣는다. 위조 헤더를 보냈을 때 프록시가 값을 덮어쓰는지, 잘못된 issuer와 audience를 가진 토큰이 거부되는지, CSRF(Cross-Site Request Forgery, 교차 사이트 요청 위조) 헤더가 없는 요청이 막히는지를 각각 관측한다.
|
||||
|
||||
위조 헤더를 넣은 요청이 200으로 돌아오는 것은 로그인 세션 자체가 유효하기 때문이다. 여기서 볼 값은 상태 코드가 아니라 응답에 남은 사용자 이름이고, 그 이름이 위조한 값이 아니라 원래 사용자면 덮어쓰기가 작동한 것이다.
|
||||
위조 헤더를 넣어도 로그인 세션 자체는 유효하기 때문에 요청은 200으로 돌아온다. 판정은 응답에 남은 사용자 이름으로 한다. 그 이름이 위조한 값이 아니라 원래 사용자면 프록시가 헤더를 덮어쓴 것이다.
|
||||
|
||||
여기 넣는 부정 입력에 서명이 깨진 토큰과 만료된 토큰은 들어 있지 않다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 주는 것을 본 것도 실제 서명 검증과 issuer 검증을 통과했다는 증거는 아니다.
|
||||
여기서 관측할 값과 넣을 부정 입력은 커밋된 자동 테스트가 확인하도록 정의한 계약에서 온 것이고, 최신 실행 성적표가 아니다. 그래서 여기 넣는 부정 입력에 서명이 깨진 토큰과 만료된 토큰은 들어 있지 않다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 주는 것을 확인했더라도, 실제 서명 검증과 issuer 검증을 통과했다는 증거는 아니다.
|
||||
|
||||
### 6. 중단 조건에 걸리면 멈추고 그 지점을 보존한다
|
||||
|
||||
@@ -73,11 +69,11 @@ secret과 테스트 사용자 비밀번호는 환경변수로 주입하고 출
|
||||
- secret이 커맨드라인이나 브라우저 출력, 버전 관리 파일에 노출됨
|
||||
- health check와 기대한 401·403, 헤더 덮어쓰기 중 하나라도 불일치
|
||||
|
||||
멈춘 뒤에 가장 먼저 하는 일은 실패한 홉의 실제 입력과 출력을 보존하는 것이다. 로그만 남기고 스택을 내리면 그 상태를 다시 만들 수 없다. 보존이 끝나면 설정과 네트워크, 애플리케이션 가운데 어느 경계가 깨졌는지 나눠서 진단한다.
|
||||
멈추면 실패한 홉의 실제 입력과 출력부터 보존한다. 검증 절차는 스택을 다시 세우기 전에 볼륨을 지우므로, 그대로 다시 돌리면 그 상태가 남지 않는다. 보존이 끝나면 설정과 네트워크, 애플리케이션 가운데 어느 경계가 깨졌는지 나눠서 진단한다.
|
||||
|
||||
### 7. 끝나면 원래 환경으로 되돌리고 다시 확인한다
|
||||
|
||||
검증이 끝나면 테스트용 스택을 내린다. 백업이 필요했던 환경이라면 원래 프로젝트와 볼륨을 복구한 뒤 health와 로그인이 되는지 다시 확인한다.
|
||||
검증이 끝나면 테스트용 스택을 내린다. 백업이 필요했던 환경이라면 원래 프로젝트와 볼륨을 복구한 뒤 health와 로그인을 다시 확인한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
},
|
||||
"verified": "네 패턴이 각각 다른 브랜치에 있어 단일 커밋으로 표현되지 않는다. 각 tip 에서 문서가 인용한 것을 찾아 대조했다 — pattern1 의 frontend/src/app.js 에 InMemoryWebStorage 와 redirect_uri http://localhost:8088/callback.html, pattern2 의 /token/access, pattern3 의 BffController /bff/token-boundary, pattern4 의 EdgeIdentityController /edge/me. 공통 realm(keycloak/import/keycloak-patterns-realm.json)의 client 넷과 mock-google-realm.json 도 확인했다. 저장소 HEAD 는 keycloak-session-store 작업이라 이 문서의 상태가 아니다 (2026-09-07 확인)"
|
||||
},
|
||||
"ssotSha256": "0625bc875ab31ca6f331e6f64bd93f26397d54f3ac6e0162f4128a53a1d97e2d",
|
||||
"ssotSha256": "5a5269ed55097d21a643ffbba8853df768ae69ebed904e4d1570f2f27e3485d5",
|
||||
"sourceRevision": "keycloak-patterns-lab@2026-08",
|
||||
"generatedAt": "2026-09-07",
|
||||
"generatedAt": "2026-09-08",
|
||||
"candidateScope": {
|
||||
"document": "final/document.md",
|
||||
"sections": [
|
||||
@@ -58,37 +58,37 @@
|
||||
}
|
||||
},
|
||||
"assetLedger": {
|
||||
"note": "final/assets/ 의 그림 13장 가운데 글감에 배정한 것과, 배정하지 않은 것의 이유를 적는다. verify-project-layout.py 의 「기록이 쓰지 않는 SSOT 그림」이 세는 숫자가 여기서 설명된다",
|
||||
"note": "final/assets/ 의 그림 10장이 모두 글감에 배정돼 있다. Case 는 구조 그림과 흐름 그림을 한 장씩 갖고, Concept 은 그 기록이 설명하는 동작 하나를 갖는다",
|
||||
"assigned": [
|
||||
"ap1-direct-architecture",
|
||||
"ap1-browser-bearer-flow",
|
||||
"ap2-mediator-architecture",
|
||||
"ap2-mediator-handoff-flow",
|
||||
"ap3-bff-architecture",
|
||||
"ap3-bff-session-flow",
|
||||
"ap3-csrf-boundary",
|
||||
"ap4-edge-forward-auth-flow"
|
||||
"ap4-edge-trust-architecture",
|
||||
"ap4-edge-forward-auth-flow",
|
||||
"login-api-phase-split"
|
||||
],
|
||||
"unassigned": [
|
||||
"unassigned": [],
|
||||
"removed": [
|
||||
{
|
||||
"asset": [
|
||||
"ap1-direct-architecture",
|
||||
"ap2-mediator-architecture",
|
||||
"ap3-bff-architecture",
|
||||
"ap4-edge-trust-architecture"
|
||||
"ap1-credential-custody",
|
||||
"ap2-split-custody",
|
||||
"ap3-bff-custody",
|
||||
"ap4-edge-trust"
|
||||
],
|
||||
"reason": "같은 구조를 그린 그림이 assets/tech-log-studio/ 에 따로 있고 기록이 그것을 쓴다. 한 기록에 같은 것을 두 번 그리지 않는다"
|
||||
"reason": "같은 절의 구조를 다시 그린 것이라 지웠다. SSOT 가 이미 가진 ap*-architecture 4장이 더 자세하고 document.md 가 제자리에서 싣는다. 그쪽의 렌더 결함을 고쳐서 쓴다"
|
||||
},
|
||||
{
|
||||
"asset": [
|
||||
"ap1-browser-bearer-flow",
|
||||
"ap2-mediator-handoff-flow"
|
||||
],
|
||||
"reason": "마지막 단계인 /api/me 응답 4필드(subject·username·issuer·audience)를 두 기록의 본문이 말하지 않는다. 근거는 final/document.md L474 에 있으므로 본문을 먼저 보강해야 이을 수 있다 — 그림만 넣으면 설명 없는 주장이 남는다"
|
||||
},
|
||||
{
|
||||
"asset": [
|
||||
"four-pattern-request-boundaries",
|
||||
"credential-custody-map",
|
||||
"credential-contract-migration",
|
||||
"login-api-phase-split"
|
||||
"four-pattern-request-boundaries"
|
||||
],
|
||||
"reason": "네 패턴을 나란히 비교하는 그림이라 붙을 자리가 Reference 인데 Reference 에는 본문이 없다. 이 비교를 담을 Case 나 Concept 이 아직 없다"
|
||||
"reason": "표로 되는 그림이라 지웠다 — 네 패턴을 같은 속성으로 늘어놓았을 뿐 관계선이 없었다. 내용은 final/document.md 의 마크다운 표로 옮겼다"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -106,7 +106,9 @@
|
||||
"source": [
|
||||
"final/document.md#검토한-선택지와-막힌-지점-ap1",
|
||||
"final/document.md#선택의-이유와-지킨-경계-ap1",
|
||||
"final/document.md#결정이-지켜지는지-확인하는-방법-ap1"
|
||||
"final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주",
|
||||
"final/document.md#결정이-지켜지는지-확인하는-방법-ap1",
|
||||
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap1"
|
||||
],
|
||||
"code": [
|
||||
"spa-public client",
|
||||
@@ -120,16 +122,22 @@
|
||||
"concept:bearer-jwt-validation-chain",
|
||||
"reference:public-confidential-client-boundary"
|
||||
],
|
||||
"ssot-assets": [
|
||||
"ap1-direct-architecture",
|
||||
"ap1-browser-bearer-flow"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "게시됨",
|
||||
"file": "oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"status": "게시 중",
|
||||
"studioId": "bf675775-4f3e-4744-8014-f0efff51422a",
|
||||
"assets": [
|
||||
"ap1-custody-v3-6e0376d2"
|
||||
"ap1-direct-architecture",
|
||||
"ap1-browser-bearer-flow"
|
||||
],
|
||||
"assetFiles": [
|
||||
"ap1-credential-custody"
|
||||
"ap1-direct-architecture",
|
||||
"ap1-browser-bearer-flow"
|
||||
],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
@@ -140,7 +148,9 @@
|
||||
"source": [
|
||||
"final/document.md#검토한-선택지와-막힌-지점-ap2",
|
||||
"final/document.md#선택의-이유와-지킨-경계-ap2",
|
||||
"final/document.md#결정이-지켜지는지-확인하는-방법-ap2"
|
||||
"final/document.md#선택이-코드와-흐름에-반영되는-방식-ap2-완주",
|
||||
"final/document.md#결정이-지켜지는지-확인하는-방법-ap2",
|
||||
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2"
|
||||
],
|
||||
"code": [
|
||||
"Spring oauth2Login mediator",
|
||||
@@ -155,16 +165,22 @@
|
||||
"reference:oauth-token-application-session-boundary",
|
||||
"question:refresh-rotation-replica-contention"
|
||||
],
|
||||
"ssot-assets": [
|
||||
"ap2-mediator-architecture",
|
||||
"ap2-mediator-handoff-flow"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "게시됨",
|
||||
"file": "oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"status": "게시 중",
|
||||
"studioId": "488ce49b-afa4-42a5-a2ce-de2e0653cd82",
|
||||
"assets": [
|
||||
"ap2-split-custody-779cb791"
|
||||
"ap2-mediator-architecture",
|
||||
"ap2-mediator-handoff-flow"
|
||||
],
|
||||
"assetFiles": [
|
||||
"ap2-split-custody"
|
||||
"ap2-mediator-architecture",
|
||||
"ap2-mediator-handoff-flow"
|
||||
],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
@@ -175,7 +191,9 @@
|
||||
"source": [
|
||||
"final/document.md#검토한-선택지와-막힌-지점-ap3",
|
||||
"final/document.md#선택의-이유와-지킨-경계-ap3",
|
||||
"final/document.md#결정이-지켜지는지-확인하는-방법-ap3"
|
||||
"final/document.md#선택이-코드와-흐름에-반영되는-방식-ap3-완주",
|
||||
"final/document.md#결정이-지켜지는지-확인하는-방법-ap3",
|
||||
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3"
|
||||
],
|
||||
"code": [
|
||||
"bff-confidential client",
|
||||
@@ -192,7 +210,9 @@
|
||||
"question:bff-session-authorized-client-store"
|
||||
],
|
||||
"ssot-assets": [
|
||||
"ap3-bff-session-flow"
|
||||
"ap3-bff-architecture",
|
||||
"ap3-bff-session-flow",
|
||||
"ap3-csrf-boundary"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "게시됨",
|
||||
@@ -200,13 +220,13 @@
|
||||
"status": "게시 중",
|
||||
"studioId": "d85bd6af-7599-4ef7-9407-6609927d5b5c",
|
||||
"assets": [
|
||||
"ap3-bff-custody-82fa18bd",
|
||||
"ap3-csrf-split-501dd1f7",
|
||||
"ap3-bff-architecture",
|
||||
"ap3-csrf-boundary",
|
||||
"ap3-bff-session-flow"
|
||||
],
|
||||
"assetFiles": [
|
||||
"ap3-bff-custody",
|
||||
"ap3-csrf-split",
|
||||
"ap3-bff-architecture",
|
||||
"ap3-csrf-boundary",
|
||||
"ap3-bff-session-flow"
|
||||
],
|
||||
"evidenceFiles": []
|
||||
@@ -218,7 +238,9 @@
|
||||
"source": [
|
||||
"final/document.md#검토한-선택지와-막힌-지점-ap4",
|
||||
"final/document.md#선택의-이유와-지킨-경계-ap4",
|
||||
"final/document.md#결정이-지켜지는지-확인하는-방법-ap4"
|
||||
"final/document.md#선택이-코드와-흐름에-반영되는-방식-ap4-완주",
|
||||
"final/document.md#결정이-지켜지는지-확인하는-방법-ap4",
|
||||
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4"
|
||||
],
|
||||
"code": [
|
||||
"oauth2-proxy 7.15.2",
|
||||
@@ -233,16 +255,22 @@
|
||||
"reference:forward-auth-identity-header-trust",
|
||||
"question:edge-authorization-scope"
|
||||
],
|
||||
"ssot-assets": [
|
||||
"ap4-edge-trust-architecture",
|
||||
"ap4-edge-forward-auth-flow"
|
||||
],
|
||||
"kind": "case",
|
||||
"publication": "게시됨",
|
||||
"file": "oauth-oidc-auth-boundary/case/case-ap4-identity-header-trust.md",
|
||||
"status": "게시 중",
|
||||
"studioId": "a0e1cc05-92b3-4dac-bce1-513ab8cd862b",
|
||||
"assets": [
|
||||
"ap4-edge-trust-1cff2399"
|
||||
"ap4-edge-trust-architecture",
|
||||
"ap4-edge-forward-auth-flow"
|
||||
],
|
||||
"assetFiles": [
|
||||
"ap4-edge-trust"
|
||||
"ap4-edge-trust-architecture",
|
||||
"ap4-edge-forward-auth-flow"
|
||||
],
|
||||
"evidenceFiles": []
|
||||
}
|
||||
@@ -262,12 +290,19 @@
|
||||
"reference:authorization-code-endpoint-credential-movement"
|
||||
],
|
||||
"kind": "concept",
|
||||
"ssot-assets": [
|
||||
"login-api-phase-split"
|
||||
],
|
||||
"publication": "게시됨",
|
||||
"file": "oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "75c6c657-3e03-47a0-a9d0-5637fce9dd3f",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"assets": [
|
||||
"login-api-phase-split"
|
||||
],
|
||||
"assetFiles": [
|
||||
"login-api-phase-split"
|
||||
],
|
||||
"evidenceFiles": []
|
||||
},
|
||||
{
|
||||
@@ -584,10 +619,10 @@
|
||||
"reference:oauth-oidc-pattern-selection-criteria"
|
||||
],
|
||||
"kind": "reference",
|
||||
"publication": "초안",
|
||||
"publication": "게시됨",
|
||||
"file": "oauth-oidc-auth-boundary/reference/reference-runtime-verification-order.md",
|
||||
"status": "게시 전",
|
||||
"studioId": "",
|
||||
"studioId": "036563a1-3a43-4931-a017-0e693b591e89",
|
||||
"assets": [],
|
||||
"assetFiles": [],
|
||||
"evidenceFiles": []
|
||||
@@ -1074,7 +1109,7 @@
|
||||
"id": "SSOT-learning-environment-scope",
|
||||
"kindCandidate": "CONCEPT",
|
||||
"sourceRefs": [
|
||||
"final/document.md#문제를-어렵게-만든-제약-학습-환경"
|
||||
"final/document.md#문제를-어렵게-만든-제약-현재-구현은-운영-참조-아키텍처가-아니라"
|
||||
],
|
||||
"summary": "현재 구현은 운영 참조 아키텍처가 아니라 관찰 가능한 학습 환경이다",
|
||||
"disposition": "KEEP_IN_SSOT",
|
||||
@@ -1110,7 +1145,7 @@
|
||||
"id": "SSOT-four-patterns-not-a-ladder",
|
||||
"kindCandidate": "REFERENCE",
|
||||
"sourceRefs": [
|
||||
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-사다리가-아니라"
|
||||
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-네-패턴은-사다리가-아니라"
|
||||
],
|
||||
"summary": "네 패턴은 사다리가 아니라 서로 다른 운영 계약이다",
|
||||
"disposition": "MERGE_INTO",
|
||||
@@ -1129,6 +1164,78 @@
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "reference:oauth-oidc-pattern-selection-criteria",
|
||||
"reason": "그 Reference 의 규칙 5 「자격 증명의 위치가 바뀌면 저장·전달·검증 주체도 바뀐다」가 같은 내용이다"
|
||||
},
|
||||
{
|
||||
"id": "SSOT-browser-absence-scope",
|
||||
"kindCandidate": "REFERENCE",
|
||||
"sourceRefs": [
|
||||
"final/document.md#문제를-어렵게-만든-제약-“브라우저에-없다”도-무엇이-없는지"
|
||||
],
|
||||
"summary": "「브라우저에 없다」를 애플리케이션 OAuth token 으로만 한정한 용어 규정과 패턴별 보관 표",
|
||||
"disposition": "MERGE_INTO",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "reference:oauth-token-application-session-boundary",
|
||||
"reason": "그 Reference 의 규칙 4 「브라우저에 없는 것을 범위까지 적는다」와 규칙 5 「영구 저장과 메모리 보관을 구분한다」가 이 절의 용어 규정과 표를 이미 담는다. concept:browser-credential-storage 도 같은 절을 근거로 쓴다 — 절 하나가 두 기록의 재료이지 세 번째 기록이 아니다"
|
||||
},
|
||||
{
|
||||
"id": "SSOT-ap1-walkthrough",
|
||||
"kindCandidate": "CASE",
|
||||
"sourceRefs": [
|
||||
"final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주"
|
||||
],
|
||||
"summary": "AP1 요청 완주 — callback code 가 브라우저 Bearer 요청이 되기까지의 입력·변환·출력·다음 홉",
|
||||
"disposition": "MERGE_INTO",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "case:spa-browser-credential-boundary",
|
||||
"reason": "그 Case 의 본문이 이 절을 재료로 쓴다. ap1-browser-bearer-flow 그림의 techviz context 도 이 절에서 잘라 왔다(.techviz/ap1-browser-bearer-flow/context.json 의 current_section). 완주 절을 따로 떼면 같은 패턴을 두 편이 설명한다"
|
||||
},
|
||||
{
|
||||
"id": "SSOT-ap2-walkthrough",
|
||||
"kindCandidate": "CASE",
|
||||
"sourceRefs": [
|
||||
"final/document.md#선택이-코드와-흐름에-반영되는-방식-ap2-완주"
|
||||
],
|
||||
"summary": "AP2 요청 완주 — server 의 authorized client 가 browser Bearer 가 되기까지",
|
||||
"disposition": "MERGE_INTO",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "case:split-custody-access-token",
|
||||
"reason": "그 Case 본문의 코드블록 8 개가 이 절에서 나왔고 ap2-mediator-handoff-flow 그림의 techviz context 도 이 절이다"
|
||||
},
|
||||
{
|
||||
"id": "SSOT-ap3-walkthrough",
|
||||
"kindCandidate": "CASE",
|
||||
"sourceRefs": [
|
||||
"final/document.md#선택이-코드와-흐름에-반영되는-방식-ap3-완주"
|
||||
],
|
||||
"summary": "AP3 요청 완주 — session cookie 가 BFF 의 downstream Bearer 가 되기까지",
|
||||
"disposition": "MERGE_INTO",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "case:bff-session-csrf-responsibility",
|
||||
"reason": "그 Case 본문의 코드블록 10 개가 이 절에서 나왔고 ap3-bff-session-flow 와 ap3-csrf-boundary 두 그림의 techviz context 도 이 절이다"
|
||||
},
|
||||
{
|
||||
"id": "SSOT-ap4-walkthrough",
|
||||
"kindCandidate": "CASE",
|
||||
"sourceRefs": [
|
||||
"final/document.md#선택이-코드와-흐름에-반영되는-방식-ap4-완주"
|
||||
],
|
||||
"summary": "AP4 요청 완주 — proxy session 이 trusted identity JSON 이 되기까지",
|
||||
"disposition": "MERGE_INTO",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "case:identity-header-trust",
|
||||
"reason": "그 Case 본문의 코드블록 8 개와 경로별 상태코드 표, header 를 늘릴 때 정할 계약 6 개가 이 절에서만 나온다. ap4-edge-forward-auth-flow 그림의 techviz context 도 이 절이다 — 그래서 이 절을 그 Case 의 source 에 넣었다"
|
||||
},
|
||||
{
|
||||
"id": "SSOT-ap1-adopt-or-leave-criteria",
|
||||
"kindCandidate": "REFERENCE",
|
||||
"sourceRefs": [
|
||||
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap1"
|
||||
],
|
||||
"summary": "AP1 을 다시 고를 조건과 memory-only 로는 채우지 못하는 정책, 유지할 때의 가드레일",
|
||||
"disposition": "MERGE_INTO",
|
||||
"dispositionReview": "CONFIRMED",
|
||||
"target": "reference:oauth-oidc-pattern-selection-criteria",
|
||||
"reason": "선택 조건과 「정책상 브라우저에 토큰을 둘 수 없으면 memory-only 로도 안 된다」는 그 Reference 의 규칙 2·3 이 이미 담는다. 가드레일(PKCE S256, implicit·direct grant 끄기, 300초 수명, rotation, issuer·audience 검증, CSP)은 case:spa-browser-credential-boundary 의 검증 환경과 결론에 들어 있다. AP2·AP3·AP4 의 같은 절도 독립 기록이 아니라 각 Reference·Question 의 근거로만 쓰였다"
|
||||
}
|
||||
],
|
||||
"unlisted": [],
|
||||
@@ -1139,6 +1246,6 @@
|
||||
"written": 24,
|
||||
"unwritten": 0,
|
||||
"unlisted": 0,
|
||||
"candidates": 29
|
||||
"candidates": 35
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user