docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+10
-9
@@ -3,7 +3,8 @@ id: 488ce49b-afa4-42a5-a2ce-de2e0653cd82
|
||||
kind: CASE
|
||||
slug: split-custody-access-token
|
||||
title: Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 22
|
||||
@@ -34,11 +35,11 @@ confidential client인 mediator가 code를 교환하고 refresh token을 server-
|
||||
|
||||
## 문제
|
||||
|
||||
Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고 access token과 refresh token을 server-side authorized-client service에 저장한다. 브라우저에는 HttpOnly `AP2_SESSION`만 관리하게 된다.
|
||||
Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고 access token과 refresh token을 server-side authorized-client service에 저장한다. 브라우저에는 HttpOnly AP2_SESSION만 관리하게 된다.
|
||||
|
||||
서버에서 token을 보관한다는 점만 보면 BFF와 비슷하다. 하지만 Mediator에서는 브라우저가 Resource Server를 직접 호출한다. Resource Server를 호출하려면 access token이 필요하기 때문에 mediator가 access token을 응답으로 다시 반환한다.
|
||||
|
||||
처음에는 refresh token을 서버로 옮기면 브라우저가 credential을 직접 다뤄야 하는 범위도 대부분 줄어든다고 봤다. `/token/access` 응답부터 Resource Server 요청까지 따라가 보니 refresh token은 서버에 남지만 access token은 계속 브라우저에서 사용되고 있었다.
|
||||
처음에는 refresh token을 서버로 옮기면 브라우저가 credential을 직접 다뤄야 하는 범위도 대부분 줄어든다고 봤다. /token/access 응답부터 Resource Server 요청까지 따라가 보니 refresh token은 서버에 남지만 access token은 계속 브라우저에서 사용되고 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
@@ -79,25 +80,25 @@ HTTP : o
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. Mediator UI에서 로그인한 뒤 `/token/boundary`를 호출한다.
|
||||
1. Mediator UI에서 로그인한 뒤 /token/boundary를 호출한다.
|
||||
|
||||
accessTokenStored : true
|
||||
refreshTokenStored : true
|
||||
browserReceivesRefreshToken : false
|
||||
|
||||
2. `/token/access` 응답에 다음 세 key만 있는지 확인한다.
|
||||
2. /token/access 응답에 다음 세 key만 있는지 확인한다.
|
||||
|
||||
access_token, token_type, expires_at
|
||||
|
||||
3. 같은 응답의 `Cache-Control`에 `no-store`가 있는지 확인한다.
|
||||
3. 같은 응답의 Cache-Control에 no-store가 있는지 확인한다.
|
||||
|
||||
4. 반환된 access JWT를 decode해 audience에 `keycloak-pattern-api`가 있는지 확인한다.
|
||||
4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인한다.
|
||||
|
||||
5. 브라우저가 해당 token으로 Resource Server를 직접 호출했을 때 200을 받는지 확인한다.
|
||||
|
||||
6. cookie가 `AP2_SESSION`이며 HttpOnly와 SameSite=Lax인지 확인한다.
|
||||
6. cookie가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
|
||||
|
||||
7. Local Storage와 Session Storage에 access token 원문이나 `refresh_token` 문자열이 없는지 확인한다.
|
||||
7. Local Storage와 Session Storage에 access token 원문이나 refresh_token 문자열이 없는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
|
||||
+14
-13
@@ -3,7 +3,8 @@ id: d85bd6af-7599-4ef7-9407-6609927d5b5c
|
||||
kind: CASE
|
||||
slug: bff-session-csrf-responsibility
|
||||
title: BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 30
|
||||
@@ -40,7 +41,7 @@ BFF 구조에서는 브라우저가 access token이나 refresh token을 받지
|
||||
|
||||
## 문제
|
||||
|
||||
BFF에서는 confidential client인 BFF 서버가 code를 교환하고 access token과 refresh token을 server-side authorized client에 저장한다. 브라우저에는 HttpOnly `AP3_SESSION`이 전달된다.
|
||||
BFF에서는 confidential client인 BFF 서버가 code를 교환하고 access token과 refresh token을 server-side authorized client에 저장한다. 브라우저에는 HttpOnly AP3_SESSION이 전달된다.
|
||||
|
||||
브라우저는 이후 요청마다 이 session cookie를 BFF로 보낸다. 상태를 변경하는 요청에서도 cookie는 자동으로 전송되기 때문에 session cookie만 확인해서는 해당 요청이 원래 페이지에서 보낸 요청인지 구분할 수 없다. 그래서 상태 변경 요청에는 CSRF 검증이 추가된다.
|
||||
|
||||
@@ -52,12 +53,12 @@ BFF에서는 confidential client인 BFF 서버가 code를 교환하고 access to
|
||||
|
||||
브라우저에서는 두 개의 cookie를 사용한다.
|
||||
|
||||
`AP3_SESSION` : HttpOnly, JavaScript 읽기 x
|
||||
`XSRF-TOKEN` : JavaScript 읽기 o
|
||||
AP3_SESSION : HttpOnly, JavaScript 읽기 x
|
||||
XSRF-TOKEN : JavaScript 읽기 o
|
||||
|
||||
`AP3_SESSION`은 브라우저가 요청을 보낼 때 자동으로 포함된다. 상태 변경 요청에서는 `XSRF-TOKEN`의 값을 `X-XSRF-TOKEN` 헤더에도 넣고 BFF가 이를 확인한다. JavaScript에서 값을 읽어 헤더에 넣어야 하기 때문에 `XSRF-TOKEN`은 HttpOnly가 아니다.
|
||||
AP3_SESSION은 브라우저가 요청을 보낼 때 자동으로 포함된다. 상태 변경 요청에서는 XSRF-TOKEN의 값을 X-XSRF-TOKEN 헤더에도 넣고 BFF가 이를 확인한다. JavaScript에서 값을 읽어 헤더에 넣어야 하기 때문에 XSRF-TOKEN은 HttpOnly가 아니다.
|
||||
|
||||
XSS가 없어지는 것은 아니다. same-origin의 악성 script는 `AP3_SESSION`을 직접 읽을 수는 없지만, 브라우저가 session cookie를 붙인 상태로 BFF를 호출하게 할 수 있다. `XSRF-TOKEN`은 JavaScript에서 읽을 수도 있다.
|
||||
XSS가 없어지는 것은 아니다. same-origin의 악성 script는 AP3_SESSION을 직접 읽을 수는 없지만, 브라우저가 session cookie를 붙인 상태로 BFF를 호출하게 할 수 있다. XSRF-TOKEN은 JavaScript에서 읽을 수도 있다.
|
||||
|
||||
이 구조에서 브라우저에 전달되지 않는 것은 access token과 refresh token 원문이다. 그래서 브라우저에서 유출된 OAuth token을 다른 client에서 사용하거나 Resource Server에 직접 보내는 형태의 재사용은 줄어든다.
|
||||
|
||||
@@ -87,24 +88,24 @@ HTTP : o
|
||||
|
||||
2. 브라우저 요청 목록에 Keycloak token endpoint와 8081 직접 호출이 없는지 확인한다.
|
||||
|
||||
3. cookie가 `AP3_SESSION`이며 HttpOnly와 SameSite=Lax인지 확인하고 Web Storage가 비어 있는지 확인한다.
|
||||
3. cookie가 AP3_SESSION이며 HttpOnly와 SameSite=Lax인지 확인하고 Web Storage가 비어 있는지 확인한다.
|
||||
|
||||
4. `/bff/token-boundary`를 호출한다.
|
||||
4. /bff/token-boundary를 호출한다.
|
||||
|
||||
accessTokenStoredOnServer : true
|
||||
refreshTokenStoredOnServer : true
|
||||
browserTokenCount : 0
|
||||
csrfProtectionEnabled : true
|
||||
|
||||
5. `/bff/api/me`가 200이고 downstream 응답에 username과 audience가 있는지 확인한다.
|
||||
5. /bff/api/me가 200이고 downstream 응답에 username과 audience가 있는지 확인한다.
|
||||
|
||||
6. `GET /bff/csrf`를 호출해 `XSRF-TOKEN` cookie와 token metadata를 받는지 확인한다. 응답 본문의 token과 cookie 값이 같은 문자열이 아닌지도 확인한다.
|
||||
6. GET /bff/csrf를 호출해 XSRF-TOKEN cookie와 token metadata를 받는지 확인한다. 응답 본문의 token과 cookie 값이 같은 문자열이 아닌지도 확인한다.
|
||||
|
||||
7. session cookie는 있지만 CSRF 헤더가 없는 `POST /bff/api/preferences`가 403인지 확인한다.
|
||||
7. session cookie는 있지만 CSRF 헤더가 없는 POST /bff/api/preferences가 403인지 확인한다.
|
||||
|
||||
8. cookie의 raw 값을 `X-XSRF-TOKEN`에 넣은 같은 POST가 200이고 theme이 dark인지 확인한다.
|
||||
8. cookie의 raw 값을 X-XSRF-TOKEN에 넣은 같은 POST가 200이고 theme이 dark인지 확인한다.
|
||||
|
||||
9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 `AP3_SESSION`이 요청에 실리지 않는지 확인한다.
|
||||
9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 AP3_SESSION이 요청에 실리지 않는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
|
||||
+62
-103
@@ -3,7 +3,8 @@ id: a0e1cc05-92b3-4dac-bce1-513ab8cd862b
|
||||
kind: CASE
|
||||
slug: identity-header-trust
|
||||
title: Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 39
|
||||
@@ -43,7 +44,7 @@ upstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문
|
||||
backend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면
|
||||
공격자가 인증된 사용자처럼 보낼 수 있다.
|
||||
|
||||
그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다.
|
||||
그래서 이 구조의 문제는 upstream이 X-Auth-Request-User의 출처를 구분할 수 없다는 점이다.
|
||||
|
||||
## 결론
|
||||
|
||||
@@ -121,19 +122,20 @@ X-Internal-Auth-Token : attacker-controlled-token
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
이 기록은 앞단 프록시가 로그인을 맡는 구성에서 upstream이 받은 사용자 헤더를 어디까지 믿을 수 있는지 확인한 것이다. forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 묻는 방식이고, Nginx에서는 `auth_request` 디렉티브가 그 질문을 subrequest로 만든다. 이 두 낱말만 알면 따라올 수 있고, 나머지 용어는 쓰는 자리에서 푼다. 먼저 위조 요청의 모양부터 보고, 그것을 막는 세 곳을 하나씩 따라간 뒤, 지금 확인한 범위와 확인하지 않은 범위를 나눠 적는다.
|
||||
|
||||
## 같은 이름의 헤더
|
||||
## 같은 이름의 헤더가 두 곳에서 만들어진다
|
||||
|
||||
:::evidence key="ap4-edge-trust-1cff2399" alt="왼쪽 외부 영역의 브라우저에 AP4_SESSION과 점선으로 표시된 client 제공 header가 있다. 가운데 Nginx는 8088만 공개하고 header 덮어쓰기를 맡는다. 오른쪽 점선 영역은 host port가 닫혀 있고 oauth2-proxy와 Spring upstream이 들어 있다. Nginx가 oauth2-proxy에 auth_request를 보내 user와 email을 받고, nginx-owned header와 internal token으로 upstream 요청을 만든다." caption="" zoom="true"
|
||||
:::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"
|
||||
:::
|
||||
|
||||
`X-Auth-Request-User`는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.
|
||||
앞단 프록시가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다. 로그인과 세션 검증은 앞단에 세운 oauth2-proxy가 맡는데, 이렇게 로그인을 대신 받는 관문을 edge라고 부른다. upstream은 요청에 붙어 온 `X-Auth-Request-User` 하나로 사용자를 판단한다. 이 헤더는 oauth2-proxy가 확인한 로그인 사용자의 이름을 담아 edge가 upstream 요청에 붙이는 값이다.
|
||||
|
||||
그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다.
|
||||
문제는 같은 이름의 헤더를 브라우저도 직접 적어 보낼 수 있다는 점이다. upstream이 받는 요청에서 두 값은 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 자리가 없다. 그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다. 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 보낼 수 있다.
|
||||
|
||||
## 위조 요청의 모양
|
||||
### 위조 헤더를 얹은 요청이 200이면 뚫린 걸까?
|
||||
|
||||
로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자.
|
||||
로그인을 마친 브라우저가 정상 요청에 헤더 3개를 얹었다고 해 보자.
|
||||
|
||||
```http label="공격자가 보낸 요청"
|
||||
GET http://localhost:8088/api/edge
|
||||
@@ -143,40 +145,37 @@ X-Auth-Request-Email: spoofed-admin@example.test
|
||||
X-Internal-Auth-Token: attacker-controlled-token
|
||||
```
|
||||
|
||||
이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다.
|
||||
세션 자체는 유효하므로 이 요청이 200으로 처리되는 것은 정상이고, 그래서 이 테스트의 판정 기준은 상태 코드가 아니다. 응답 코드만 보면 위조가 통했는지 알 수 없어서, 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않고 실제로 인증된 사용자로 남았는지다.
|
||||
|
||||
## 세 개의 독립된 경계
|
||||
## 세 곳에서 나눠 막는다
|
||||
|
||||
현재 OAuth2-Proxy 구조에서는 이 문제를 서로 독립된 세 곳에서 막는다.
|
||||
헤더를 믿으려면 서로 독립된 세 곳에서 막아야 한다. 포트를 닫아 두었으면 헤더 검사는 없어도 되지 않을까? 세 곳은 각각 다른 구간을 맡고 있어서 어느 하나도 나머지 둘의 자리를 대신하지 못한다.
|
||||
|
||||
| 위치 | 막는 것 |
|
||||
|---|---|
|
||||
| host port 닫힘 | 외부에서 upstream·proxy로 가는 직접 경로 |
|
||||
| Nginx header 덮어쓰기 | client가 보낸 동명 헤더 |
|
||||
| upstream internal token | edge를 거치지 않은 내부 요청 |
|
||||
### 밖에서 들어올 수 있는 길을 8088 하나로 줄인다
|
||||
|
||||
세 곳 중 하나가 빠지면 나머지 둘이 그 자리를 메우지 못한다. host port가 열려 있으면 헤더 검사만으로 막을 수 없고, 덮어쓰기가 없으면 인증을 안 거친 헤더가 그대로 upstream에 들어가고, internal token이 없으면 내부 workload가 edge처럼 동작할 수 있는 여지가 생긴다.
|
||||
밖으로 연 포트는 Nginx의 8088 하나다. `app`의 8081과 oauth2-proxy의 4180은 Compose 네트워크에 `expose`만 하고 호스트 `ports`로는 내보내지 않아서, 포트 2개에는 밖에서 직접 붙을 수 없다.
|
||||
|
||||
**network isolation만으로는 내부 위조를 막지 못한다. controller의 공유 token만으로는 외부 직접 접근을 막지 못한다.**
|
||||
인증 엔드포인트도 같은 이유로 닫아 두는데, `location = /oauth2/auth`가 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있고, 외부에서 같은 경로를 부르면 404가 된다. `internal` 지정이 없으면 이 엔드포인트가 밖에서 부를 수 있는 인증 우회 지점이 된다.
|
||||
|
||||
## Nginx가 헤더를 만드는 경계
|
||||
이 경계가 막는 것은 edge를 건너뛰고 upstream이나 프록시로 바로 가는 경로여서, 내부 workload가 보낸 요청이나 Nginx가 잘못 넘긴 헤더는 여기서 걸리지 않는다.
|
||||
|
||||
Nginx는 먼저 internal subrequest를 만든다.
|
||||
`location = /oauth2/auth`는 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있다.
|
||||
### 클라이언트가 보낸 헤더를 덮어써서 지운다
|
||||
|
||||
Nginx는 upstream을 부르기 전에 인증 결과를 먼저 묻는다. `auth_request`는 원래 요청을 처리하기 전에 지정한 경로로 subrequest를 보내고 그 응답 코드로 요청을 계속할지 정하는 디렉티브다.
|
||||
|
||||
```nginx label="upstream을 부르기 전에 먼저 물어본다"
|
||||
auth_request /oauth2/auth;
|
||||
```
|
||||
|
||||
oauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다.
|
||||
oauth2-proxy가 세션을 유효하다고 판단하면 결과를 응답 헤더로 돌려준다. Nginx는 `auth_request_set`으로 그 값을 지역 변수 3개에 복사한다.
|
||||
|
||||
```text label="auth_request_set — 값의 출처가 여기서 고정"
|
||||
```text label="auth_request_set — 값의 출처가 여기서 고정된다"
|
||||
$auth_user ← oauth2-proxy X-Auth-Request-User
|
||||
$auth_email ← oauth2-proxy X-Auth-Request-Email
|
||||
$auth_cookie ← oauth2-proxy Set-Cookie
|
||||
```
|
||||
|
||||
그 다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 세 헤더는 **merge가 아니라 덮어쓰기**로 채워진다.
|
||||
그다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 헤더 3개는 클라이언트가 보낸 값과 **merge하지 않고 덮어쓰기**로 채워진다.
|
||||
|
||||
```http label="upstream이 실제로 받는 요청"
|
||||
GET http://app:8081/edge/me
|
||||
@@ -185,18 +184,17 @@ X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
|
||||
X-Internal-Auth-Token: <nginx-environment-secret>
|
||||
```
|
||||
|
||||
그래서 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다.
|
||||
그래서 클라이언트가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다. 신뢰할 프록시 범위도 IP 1개로 좁혀 두었는데, 이 범위를 넓게 잡으면 같은 내부 네트워크에 있는 다른 서비스가 신뢰받는 프록시처럼 요청을 보낼 수 있기 때문이다.
|
||||
|
||||
## upstream이 확인하는 두 값
|
||||
### upstream이 내부 토큰까지 확인한다
|
||||
|
||||
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는다.
|
||||
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는데, 여기서 확인하는 값은 2개다. `X-Auth-Request-User`를 읽어 비어 있는지 보고, `X-Internal-Auth-Token`을 읽어 배포할 때 설정해 둔 내부 토큰(internal token)과 비교한다.
|
||||
|
||||
1. `X-Auth-Request-User`를 읽고 비어 있는지 확인한다.
|
||||
2. `X-Internal-Auth-Token`을 읽어 설정값과 `MessageDigest.isEqual`로 비교한다.
|
||||
비교에는 일반 문자열 비교 대신 `MessageDigest.isEqual`을 썼는데, 두 바이트 배열이 앞에서 몇 바이트까지 같은지에 따라 실행 시간이 크게 달라지지 않는 비교다. 일반 비교를 쓰면 값이 어디까지 맞았는지가 응답 시간으로 새어 나갈 수 있다.
|
||||
|
||||
두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다.
|
||||
두 조건이 모두 맞을 때만 허용 목록에 있는 필드 4개를 응답에 넣는다.
|
||||
|
||||
```json label="정상 응답 — 4가지 필드"
|
||||
```json label="정상 응답 — 필드 4개"
|
||||
{
|
||||
"pattern": "AP4-edge-forward-auth",
|
||||
"user": "regular-user",
|
||||
@@ -207,47 +205,44 @@ X-Internal-Auth-Token: <nginx-environment-secret>
|
||||
|
||||
하나라도 다르면 401이 된다.
|
||||
|
||||
```json label="user 헤더가 없거나 internal token이 틀릴 때"
|
||||
```json label="사용자 헤더가 없거나 내부 토큰이 틀릴 때"
|
||||
{
|
||||
"error": "trusted edge authentication is required"
|
||||
}
|
||||
```
|
||||
|
||||
internal token 비교에는 일반 문자열 비교 대신 `MessageDigest.isEqual`을 썼다. 비교 시간 차이로 값이 어디까지 맞았는지 새어 나가는 것을 줄이려는 선택이다.
|
||||
|
||||
:::danger
|
||||
|
||||
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 그 endpoint는 보호되지 않는다.
|
||||
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` 컨트롤러가 직접 내부 토큰을 확인한다. 새 edge 엔드포인트를 추가하면서 같은 메서드를 부르지 않으면 그 엔드포인트는 보호되지 않는다.
|
||||
|
||||
:::
|
||||
|
||||
운영으로 넘어갈 때는 이 검사를 filter나 interceptor, security chain처럼 **대상 endpoint 전체에 걸리는 공통 경계**로 옮겨야 한다.
|
||||
검사가 컨트롤러 하나에만 들어 있어서 운영으로 넘어갈 때는 필터나 인터셉터, security chain처럼 대상 엔드포인트 전체에 걸리는 공통 경계로 옮겨야 한다. 이 경계가 막는 것은 edge를 거치지 않은 내부 요청이고, 공유 토큰만으로는 외부에서 upstream으로 바로 가는 경로가 어려워지는 네트워크 속성을 대신할 수 없다.
|
||||
|
||||
## 경로마다 달라지는 결과
|
||||
### 경로에 따라 다른 코드가 돌아온다
|
||||
|
||||
같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다.
|
||||
같은 미인증 요청이라도 경로에 따라 결과가 다르다. 쿠키 없이 `/`를 부르면 `/oauth2/start`로 302가 되고, 같은 상태에서 `/api/edge`를 부르면 `Location` 없는 401이 된다. 화면을 여는 요청과 프로그램이 부르는 요청은 원하는 실패 모양이 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 리다이렉트를 따라가는 대신 401을 받아야 한다.
|
||||
|
||||
**리다이렉트 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.** 다른 경로는 로그인 리다이렉트 규칙을 따른다.
|
||||
|
||||
| 외부 입력 | 인증 상태 | 결과 |
|
||||
|---|---|---|
|
||||
| `GET /` | 미인증 | `/oauth2/start` 302 |
|
||||
| `GET /api/edge` | 미인증 | redirect 없는 401 |
|
||||
| `GET /api/edge` | 미인증 | 리다이렉트 없는 401 |
|
||||
| `GET /oauth2/auth` | 무관 | 404 |
|
||||
| `GET /` + 위조 헤더 | 정상 session | 실제 user 200 |
|
||||
| `/edge/me` + user 헤더만 | edge token 없음 | 401 |
|
||||
| `/edge/me` + 틀린 token | token 불일치 | 401 |
|
||||
| `GET /` + 위조 헤더 | 정상 세션 | 실제 사용자 200 |
|
||||
| `/edge/me` + 사용자 헤더만 | 내부 토큰 없음 | 401 |
|
||||
| `/edge/me` + 틀린 토큰 | 토큰 불일치 | 401 |
|
||||
|
||||
아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 `Location` 없는 401을 받아야 한다.
|
||||
위의 네 줄은 밖에서 들어온 요청이고, 아래 두 줄은 edge를 거치지 않고 내부에서 `/edge/me`로 바로 들어온 요청이다.
|
||||
|
||||
**redirect 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.**
|
||||
다른 경로는 로그인 redirect 규칙을 따른다.
|
||||
## 이 fixture가 보장하는 범위
|
||||
|
||||
셋째 줄은 auth endpoint다. 외부에서 `/oauth2/auth`를 직접 부르면 404다. `internal` 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다.
|
||||
### 브라우저에 남는 것은 opaque 쿠키 하나다
|
||||
|
||||
## 브라우저가 가지고 있는 것
|
||||
로그인이 끝나면 브라우저에는 `AP4_SESSION` 쿠키 하나가 남고, 서버 쪽 세션 저장소는 따로 두지 않았다. `session-cookie-minimal=true`를 쓰면 쿠키에는 access·refresh·ID 토큰 대신 edge에 필요한 최소 정보만 남는다. 여기서 opaque는 브라우저가 값을 해석하지 않고 다음 요청에 그대로 돌려준다는 뜻이다.
|
||||
|
||||
OAuth2-Proxy 구조는 server-side session store를 두지 않는다.
|
||||
|
||||
```text label="AP4_SESSION cookie 설정"
|
||||
```text label="AP4_SESSION 쿠키 설정"
|
||||
name = AP4_SESSION
|
||||
HttpOnly = true
|
||||
SameSite = Lax
|
||||
@@ -255,78 +250,42 @@ Secure = false in local HTTP fixture
|
||||
expire = 1 hour in proxy configuration
|
||||
```
|
||||
|
||||
`session-cookie-minimal=true`를 쓰면 cookie에는 access·refresh·ID token 대신 edge가 필요한 최소 정보만 남는다. 브라우저에 남는 것은 JavaScript로 읽을 수 없고 다음 요청에 자동으로 붙는 opaque cookie 하나뿐이다.
|
||||
`HttpOnly`가 붙어 있어 JavaScript로 읽을 수 없고, 유효 기간은 프록시 설정에서 1시간이다. `Secure`가 `false`인 것은 local HTTP fixture 기준이라 그렇고, HTTPS로 올리면 `Secure = true`로 바꿔야 한다. 레플리카를 늘린다면 같은 쿠키를 검증할 시크릿을 어떻게 배포하고 교체할지도 정해야 하는데, 지금 fixture에는 정해 둔 것이 없다.
|
||||
|
||||
지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다.
|
||||
코드 교환은 브라우저가 아니라 oauth2-proxy가 컨테이너 안에서 하기 때문에, automatic discovery를 끄고 같은 realm을 가리키는 주소 4개를 각각 관리한다.
|
||||
|
||||
## endpoint를 외부용과 내부용으로 나눈 이유
|
||||
|
||||
브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다.
|
||||
그래서 자동 discovery를 끄고 네 주소를 각각 관리한다.
|
||||
|
||||
```text label="issuer는 브라우저가 접속하는 부분"
|
||||
```text label="같은 realm을 가리키는 주소 4개"
|
||||
issuer expected value = http://localhost:8080/realms/keycloak-patterns
|
||||
login URL = http://localhost:8080/.../auth
|
||||
redeem/token URL = http://keycloak:8080/.../token
|
||||
JWKS/userinfo URL = http://keycloak:8080/...
|
||||
```
|
||||
|
||||
issuer는 요청을 보내기 위한 주소가 아니라, Keycloak이 발급한 토큰의 `iss` claim이 기대한 값과 같은지 검증하는 기준값이다. token URL과 userinfo URL은 oauth2-proxy가 내부에서 실제로 요청을 보내는 network 주소다.
|
||||
issuer는 요청을 보내기 위한 주소가 아니라 Keycloak이 발급한 토큰의 `iss` claim이 기대한 값과 같은지 검증하는 기준값이다. 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없어서 로그인에는 `localhost:8080`을 쓰고, 컨테이너 안에서는 자기 `localhost:8080`이 Keycloak이 아니므로 토큰과 JWKS(JSON Web Key Set) 요청에는 `keycloak:8080`을 쓴다.
|
||||
|
||||
둘 다 같은 Keycloak realm을 가리키지만 쓰임이 다르다. 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없어서 로그인에는 `localhost:8080`을 쓴다. 컨테이너 안에서는 자기 `localhost:8080`이 Keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 쓴다.
|
||||
### upstream이 믿는 입력이 JWT 1개에서 3개로 바뀐다
|
||||
|
||||
## upstream이 JWT를 받지 않는다
|
||||
앞의 세 구조에서는 Resource Server가 서명된 JWT를 받아 서명과 issuer, audience를 직접 확인한다. `/edge/me`는 JWT를 입력으로 받지 않는다. 대신 요청이 edge를 거쳐 들어왔다는 네트워크 위치와 `X-Internal-Auth-Token`, edge가 넘긴 사용자와 이메일을 믿는다. 믿는 입력이 JWT 1개에서 3개로 늘어난 셈이라, 백엔드 직접 경로나 사용자 제공 헤더 중 하나만 열려도 다른 사용자처럼 요청을 보낼 수 있게 된다.
|
||||
|
||||
앞의 세 구조에서는 Resource Server가 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 `/edge/me`는 **JWT를 입력으로 받지 않는다.**
|
||||
지금 edge 응답은 사용자와 이메일만 전달하고 role, groups, tenant, 인증 방식, 토큰 만료는 전달하지 않는다. 패턴이 금지하는 것은 아니지만, 헤더를 하나 늘릴 때 아래 6개를 함께 정해야 한다.
|
||||
|
||||
| 신뢰하는 입력 | AP1~AP3 | AP4 |
|
||||
|---|---|---|
|
||||
| 서명된 JWT | o | x |
|
||||
| network topology | x | o |
|
||||
| internal token | x | o |
|
||||
| edge의 user·email | x | o |
|
||||
|
||||
오른쪽 열이 AP4가 신뢰하는 입력이다. edge가 인증 경계가 되므로, backend 직접 경로나 사용자 제공 헤더를 허용하면 다른 사용자처럼 요청을 보낼 수 있게 된다.
|
||||
|
||||
## 헤더를 늘릴 때 정해야 하는 것
|
||||
|
||||
현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다.
|
||||
|
||||
- claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가
|
||||
- allowlist : Nginx가 어느 응답 헤더만 복사하는가
|
||||
- 덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
|
||||
- claim 출처 : oauth2-proxy나 별도 인증 서비스가 어느 값을 읽는가
|
||||
- 허용 목록 : Nginx가 어느 응답 헤더만 복사하는가
|
||||
- 덮어쓰기 : 클라이언트가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
|
||||
- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가
|
||||
- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지
|
||||
- 갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가
|
||||
- 갱신 : role이 바뀌면 프록시 세션과 downstream 인가가 언제 따라가는가
|
||||
|
||||
### 커밋된 테스트가 확인하도록 정의한 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이다.
|
||||
|
||||
| 항목 | 확인한 부분 |
|
||||
|---|---|
|
||||
| cookie 없는 root의 302 | o |
|
||||
| cookie 없는 `/api/edge`의 401 | o |
|
||||
| `edge-proxy` + S256 challenge | o |
|
||||
| `AP4_SESSION` HttpOnly · SameSite=Lax | o |
|
||||
| 브라우저 요청에 token endpoint 없음 | o |
|
||||
| Web Storage 비어 있고 cookie 읽기 불가 | o |
|
||||
| 위조 헤더를 보내도 실제 user로 200 | o |
|
||||
| 외부 `/oauth2/auth` 404 | o |
|
||||
| host의 4180 · 8081 접근 불가 | o |
|
||||
| user 헤더 없음 · token 없음 · token 불일치 401 | o |
|
||||
| role 전달 | x |
|
||||
| 새 endpoint의 공통 강제 | x |
|
||||
| 상태 변경 요청의 CSRF | x |
|
||||
| session 갱신 | x |
|
||||
| replica 간 secret 공유 | x |
|
||||
| internal secret 교체 | x |
|
||||
나머지 6개는 이 계약 밖이다. role 전달, 새 엔드포인트에 검사를 공통으로 거는 것, 상태를 바꾸는 요청의 CSRF(Cross-Site Request Forgery), 세션 갱신, 레플리카 사이의 시크릿 공유, 내부 시크릿 교체는 확인하지 않았다.
|
||||
|
||||
일곱째 줄의 assertion은 요청이 실패하는지가 아니다. **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다.
|
||||
지금 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾸기 때문에 `/orders/123` 같은 임의 경로를 보존하는 범용 리버스 프록시가 아니고, 그래서 path와 method, body, streaming, websocket, 큰 헤더 동작은 입증하지 못했다.
|
||||
|
||||
## 증명하지 않는 것
|
||||
|
||||
현재 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾼다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy가 아니다. 그래서 path, method, body, streaming, websocket 같은 큰 헤더 동작은 입증하지 못했다.
|
||||
지금까지 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 문제와, 그것을 호스트 포트·헤더 덮어쓰기·내부 토큰 세 곳으로 나눠 막은 구성을 살펴봤다. 위조 헤더를 얹은 요청이 200을 받으면서도 응답의 `user`는 실제 사용자로 남는다는 것까지가 지금 확인한 범위이고, role 전달이나 시크릿 교체처럼 운영에서 먼저 정해야 할 6개는 그 밖에 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
+9
-5
@@ -3,13 +3,17 @@ id: bf675775-4f3e-4744-8014-f0efff51422a
|
||||
kind: CASE
|
||||
slug: spa-browser-credential-boundary
|
||||
title: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 31
|
||||
verifiedOn: 2026-08-22
|
||||
studio: "https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit"
|
||||
public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
|
||||
assets:
|
||||
- key: ap1-custody-v3-6e0376d2
|
||||
file: ../../../final/assets/tech-log-studio/ap1-credential-custody.svg
|
||||
---
|
||||
|
||||
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
||||
@@ -39,9 +43,9 @@ SPA가 authorization code를 직접 교환한 뒤 token을 어디에 가지고
|
||||
|
||||
memory-only로 보관하면 새로고침 뒤에는 access token, refresh token, ID token이 남지 않는다.
|
||||
|
||||
하지만 페이지가 실행 중일 때는 JavaScript에서 token을 사용한다. 악성 script가 같은 페이지에서 실행되면 `fetch`를 가로채거나 사용자를 대신해서 API를 호출할 수 있다. access token도 JavaScript memory에만 있는 것이 아니라 Resource Server 요청의 `Authorization` 헤더에 들어간다.
|
||||
하지만 페이지가 실행 중일 때는 JavaScript에서 token을 사용한다. 악성 script가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해서 API를 호출할 수 있다. access token도 JavaScript memory에만 있는 것이 아니라 Resource Server 요청의 Authorization 헤더에 들어간다.
|
||||
|
||||
Resource Server는 `SessionCreationPolicy.STATELESS`로 동작한다. 서버에서 삭제할 application session이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 없다.
|
||||
Resource Server는 SessionCreationPolicy.STATELESS로 동작한다. 서버에서 삭제할 application session이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 없다.
|
||||
|
||||
현재 구성에서는 access token 수명을 300초로 두고 refresh token rotation을 사용한다. Resource Server에서는 issuer와 audience도 확인한다.
|
||||
|
||||
@@ -71,11 +75,11 @@ HTTP : o
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 `response_type=code`, `code_challenge_method=S256`, 비어 있지 않은 `code_challenge`를 확인한다.
|
||||
1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인한다.
|
||||
|
||||
2. token 응답의 access token, refresh token, ID token이 비어 있지 않은지 확인한다.
|
||||
|
||||
3. 브라우저 `fetch`를 hook하고 `/api/me` 요청의 `Authorization` 헤더에서 Bearer access token을 확인한다.
|
||||
3. 브라우저 fetch를 hook하고 /api/me 요청의 Authorization 헤더에서 Bearer access token을 확인한다.
|
||||
|
||||
4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인한다.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user