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:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -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-Controlno-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 문자열이 없는지 확인한다.
## 본문
@@ -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이 요청에 실리지 않는지 확인한다.
## 본문
@@ -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 -->
@@ -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이 남지 않는지 확인한다.
@@ -3,7 +3,8 @@ id: 75c6c657-3e03-47a0-a9d0-5637fce9dd3f
kind: CONCEPT
slug: authorization-code-and-pkce
title: Authorization Code와 PKCE가 보호하는 구간
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
@@ -3,7 +3,8 @@ id: 87000d59-b69f-4010-9481-0b71c8bde32d
kind: CONCEPT
slug: bearer-jwt-validation-chain
title: Bearer JWT가 인증된 principal이 되기까지
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
@@ -3,7 +3,8 @@ id: bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07
kind: CONCEPT
slug: browser-credential-storage
title: 브라우저가 credential을 보관하는 위치와 그 성질
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
@@ -3,7 +3,8 @@ id: 5c8f12d5-1ead-469b-8e91-2de69401df48
kind: CONCEPT
slug: cookie-auth-csrf
title: Cookie로 인증하는 요청에서 CSRF token이 하는 일
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
@@ -3,7 +3,8 @@ id: a3493786-d3fb-4b01-b1c5-ecb23c3d5497
kind: CONCEPT
slug: forward-auth-and-auth-request
title: Forward-Auth와 Nginx auth_request의 동작
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
@@ -3,7 +3,8 @@ id: d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719
kind: CONCEPT
slug: idp-brokering
title: 외부 IdP Brokering의 동작
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 4
@@ -3,7 +3,8 @@ id: 19b55c39-c583-4161-9775-df954280a568
kind: PROJECT_DECISION
slug: bff-owns-token-when-browser-must-not
title: BFF가 OAuth Token을 관리하는 조건
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 23
@@ -42,7 +43,7 @@ BFF는 저장된 Access Token으로 Downstream Resource Server를 호출한다.
브라우저에 OAuth token을 전달하지 않으려면 server가 authorization code를 교환하고 access token을 사용해 downstream API를 호출해야 한다.
Mediator 구조에서는 브라우저가 Resource Server를 직접 호출하므로 access token을 `/token/access` 응답으로 전달한다.
Mediator 구조에서는 브라우저가 Resource Server를 직접 호출하므로 access token을 /token/access 응답으로 전달한다.
그래서 브라우저에 OAuth token을 제공하지 않는다는 요구에는 맞지 않는다.
Forward-Auth 구조도 브라우저에 OAuth token을 전달하지 않을 수 있지만 upstream은 JWT를 직접 검증하지 않고 edge가 제공한 identity header를 사용한다.
@@ -3,7 +3,8 @@ id: 8c1ebea7-204e-445c-9812-0421d9eb0e9c
kind: PROJECT_DECISION
slug: federation-is-not-an-application-pattern
title: 외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 17
@@ -3,7 +3,8 @@ id: 18a5cde2-dd1e-4bff-9f1c-997577ae438f
kind: QUESTION
slug: bff-session-authorized-client-store
title: BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 32
@@ -3,7 +3,8 @@ id: 7ff40767-a00b-4db2-98f6-0cdfce8c8936
kind: QUESTION
slug: edge-authorization-scope
title: Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 36
@@ -34,7 +35,7 @@ Role이나 권한처럼 애플리케이션의 기능과 밀접한 정보가 계
- 지금 edge 응답은 user와 email만 전달한다. role과 groups, tenant, 인증 방식, token 만료는 전달하지 않는다.
- upstream의 identity endpoint는 role을 확인하지 않는다. 누가 왔는지만 응답한다.
- 현재 Internal Token 검증은 특정 Controller에서만 수행하고 있으며, Security 설정에서는 해당 경로를 `permitAll`로 허용하고 있다.
- 현재 Internal Token 검증은 특정 Controller에서만 수행하고 있으며, Security 설정에서는 해당 경로를 permitAll로 허용하고 있다.
이 구조에서는 같은 내부 경로 아래에 새로운 Endpoint를 추가하더라도 Internal Token 검증이 자동으로 적용되지 않는다.
그래서 Filter, Interceptor, 또는 Spring Security의 인증 처리 단계처럼 공통 경계에서 검증하도록 옮겨야 한다.
@@ -62,7 +63,7 @@ Role이나 권한처럼 애플리케이션의 기능과 밀접한 정보가 계
### 1. 인증만 edge에 둔다
Edge가 전달하는 Header를 `user``email` 정도로 제한하면 Edge와 Upstream 사이의 계약을 작게 유지할 수 있다.
Edge가 전달하는 Header를 useremail 정도로 제한하면 Edge와 Upstream 사이의 계약을 작게 유지할 수 있다.
Role이나 Permission 정보를 Header에 계속 추가하지 않으므로 Header 크기가 커지는 문제도 줄일 수 있다.
이 경우 인가 판단은 각 Upstream 애플리케이션이 직접 수행한다.
@@ -3,7 +3,8 @@ id: c72656b5-842d-45d9-b5f6-82b66b09d0b9
kind: QUESTION
slug: server-session-pattern-multi-instance
title: 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 39
@@ -3,7 +3,8 @@ id: 9ae4ec71-a32e-49a7-88c2-f7368541c28d
kind: QUESTION
slug: refresh-rotation-replica-contention
title: Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 33
@@ -3,7 +3,8 @@ id: 39fdf472-82c4-43ed-abec-73de672f08ae
kind: REFERENCE
slug: authorization-code-endpoint-credential-movement
title: Authorization Code Flow의 Endpoint와 Credential 이동 기준
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 34
@@ -3,7 +3,8 @@ id: 97eddd97-1096-426a-a2c6-a6c5bf1cd09f
kind: REFERENCE
slug: bff-authentication-design-criteria
title: BFF 인증 구조 설계 기준
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 21
@@ -43,7 +44,7 @@ cookie가 credential이 되면 브라우저가 요청마다 자동으로 붙여
Access Token과 Refresh Token은 BFF 서버의 Authorized Client에 보관한다.
브라우저는 OAuth Token을 직접 사용하지 않고 Session Cookie를 이용해 BFF에 요청한다.
BFF는 이 Session을 확인한 뒤, 저장해 둔 Access Token으로 `Authorization: Bearer ...` 헤더를 새로 만들어 Resource Server를 호출한다. 브라우저가 보낸 Session Cookie는 Resource Server로 전달되지 않는다. Session Cookie는 브라우저와 BFF 사이의 Credential이고, Access Token은 BFF와 Resource Server 사이의 Credential이다.
BFF는 이 Session을 확인한 뒤, 저장해 둔 Access Token으로 Authorization: Bearer ... 헤더를 새로 만들어 Resource Server를 호출한다. 브라우저가 보낸 Session Cookie는 Resource Server로 전달되지 않는다. Session Cookie는 브라우저와 BFF 사이의 Credential이고, Access Token은 BFF와 Resource Server 사이의 Credential이다.
### 2. cookie가 credential이면 상태 변경 요청에 CSRF 검증을 둔다
@@ -55,21 +56,21 @@ BFF 구조에서는 브라우저가 요청할 때 Session Cookie를 자동으로
이때 화면이나 응답 본문에 표시되는 CSRF Token과 실제 요청 Header에 넣어야 하는 값이 항상 같다고 생각하면 안 된다.
응답 본문에 노출된 값이 별도의 처리를 거친 값이라면, 클라이언트는 실제 CSRF Cookie에서 값을 읽어 Header에 넣어야 한다.
잘못된 값을 보내면 정상적인 요청이라도 CSRF 검증에 실패해 `403 Forbidden` 응답을 받게 된다.
잘못된 값을 보내면 정상적인 요청이라도 CSRF 검증에 실패해 403 Forbidden 응답을 받게 된다.
`SameSite`와 CSRF Token도 서로 다른 역할을 한다.
`SameSite`는 브라우저가 Cross-Site 요청에 Cookie를 전송할지 제한하는 정책이고, CSRF Token은 Cookie가 포함되어 들어온 상태 변경 요청이 정상적인 클라이언트에서 만들어졌는지를 확인하기 위한 값이다.
SameSite와 CSRF Token도 서로 다른 역할을 한다.
SameSite는 브라우저가 Cross-Site 요청에 Cookie를 전송할지 제한하는 정책이고, CSRF Token은 Cookie가 포함되어 들어온 상태 변경 요청이 정상적인 클라이언트에서 만들어졌는지를 확인하기 위한 값이다.
또한 SameSite는 Origin이 아니라 Site를 기준으로 판단하므로, Origin은 다르지만 같은 Site에 속하는 요청도 존재할 수 있다.
### 3. session과 authorized client의 수명주기를 따로 설계한다
Application Session과 Authorized Client는 서로 다른 값을 저장하고 조회한다.
Session은 `session ID`를 기준으로 조회하지만, Authorized Client는 `client registration 이름``principal name`을 기준으로 조회한다.
Session은 session ID를 기준으로 조회하지만, Authorized Client는 client registration 이름principal name을 기준으로 조회한다.
따라서 여러 인스턴스에서 상태를 공유하기 위해 Shared Store를 도입할 때도 Session 저장소와 Authorized Client 저장소를 각각 어떻게 구성할지 확인해야 한다.
특히 Authorized Client의 조회 기준에는 `session ID`가 포함되지 않는다.
특히 Authorized Client의 조회 기준에는 session ID가 포함되지 않는다.
그래서 같은 사용자가 두 브라우저에서 동일한 Client로 로그인하면 두 Session이 같은 Authorized Client 정보를 사용하거나, 나중에 로그인하면서 저장된 Token 정보가 갱신될 수 있다.
브라우저나 Session마다 서로 다른 Token을 유지해야 한다면 `session ID`까지 포함해 Token을 구분할 수 있도록 별도의 저장 구조를 설계해야 한다.
브라우저나 Session마다 서로 다른 Token을 유지해야 한다면 session ID까지 포함해 Token을 구분할 수 있도록 별도의 저장 구조를 설계해야 한다.
운영 환경에서는 서버가 재시작되거나 요청이 다른 Replica로 전달되더라도 로그인 상태와 Token을 계속 사용할 수 있는지도 고려해야 한다. 이를 위해 Session과 Authorized Client를 공유 저장소에 보관할지, Session Affinity를 사용할지 등을 결정해야 한다.
Token을 외부 저장소에 보관한다면 Access Token과 Refresh Token을 어떻게 보호할지도 정해야 하며, 저장 시 암호화한다면 암호화 Key의 보관 위치와 교체 방법까지 함께 설계해야 한다.
@@ -81,8 +82,8 @@ Session과 Authorized Client는 조회 기준과 저장소가 다르므로, Logo
### 4. Downstream 오류를 클라이언트 응답으로 변환한다
BFF가 Resource Server의 오류를 그대로 브라우저에 전달하면 화면에서는 오류의 원인을 일관되게 판단하기 힘들다.
예를 들어 Resource Server에서 `401 Unauthorized`가 발생했다면 Access Token이 만료되었거나 더 이상 유효하지 않은 상황인지 확인하고, 필요한 경우 Token 갱신이나 재로그인으로 연결해야 한다.
하지만 인증은 정상적으로 되었지만 해당 기능을 사용할 권한이 없어 `403 Forbidden`이 발생한 경우에는 권한 부족으로 처리해야 한다.
예를 들어 Resource Server에서 401 Unauthorized가 발생했다면 Access Token이 만료되었거나 더 이상 유효하지 않은 상황인지 확인하고, 필요한 경우 Token 갱신이나 재로그인으로 연결해야 한다.
하지만 인증은 정상적으로 되었지만 해당 기능을 사용할 권한이 없어 403 Forbidden이 발생한 경우에는 권한 부족으로 처리해야 한다.
Resource Server가 응답하지 않거나 처리가 지연되는 경우도 별도의 규칙이 필요하다.
요청을 얼마 동안 기다릴지 Timeout을 정하고, 실패한 요청을 다시 시도할 수 있는 경우에는 Retry 정책을 적용한다.
@@ -3,7 +3,8 @@ id: 004dd0a2-5fb3-4f25-80c9-576f709de331
kind: REFERENCE
slug: forward-auth-identity-header-trust
title: Forward-Auth에서 Identity Header를 신뢰하기 위한 조건
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 29
@@ -59,20 +60,20 @@ public: "https://hyeonworks.com/references/forward-auth-identity-header-trust"
또한 신뢰할 수 있는 프록시의 범위도 필요한 대상만 포함하도록 제한한다.
이 범위를 너무 넓게 설정하면 같은 내부 네트워크에 있는 다른 서비스가 신뢰받는 프록시처럼 요청을 보낼 수 있다.
특히 `Forwarded``X-Forwarded-*` 헤더를 신뢰하는 구조에서는 어떤 프록시의 요청까지 신뢰할지를 먼저 좁혀 둔다.
특히 ForwardedX-Forwarded-* 헤더를 신뢰하는 구조에서는 어떤 프록시의 요청까지 신뢰할지를 먼저 좁혀 둔다.
### 3. auth endpoint는 subrequest 전용으로 둔다
이 Endpoint는 외부 사용자가 직접 호출하는 API가 아니라, 인증 과정에서 Proxy가 내부적으로 호출하기 위한 Endpoint다.
따라서 외부 요청으로는 접근할 수 없게 하고 Proxy가 생성한 내부 요청만 허용해야 한다.
Nginx에서는 해당 Location에 `internal`을 설정해 외부에서 직접 호출하는 것을 차단할 수 있다.
Nginx에서는 해당 Location에 internal을 설정해 외부에서 직접 호출하는 것을 차단할 수 있다.
### 4. upstream이 헤더 존재만 보지 않는다
요청이 신뢰할 수 있는 Proxy에서 전달된 것인지 확인하기 위해, 배포할 때 설정한 내부용 Credential과 요청에 포함된 Credential을 비교한다. 이때 Credential 값의 일부가 얼마나 일치하는지에 따라 비교 시간이 크게 달라지지 않는 안전한 비교 방식을 사용한다.
이 검증을 각 Controller에서 개별적으로 처리하면 새로운 Endpoint를 추가할 때 검증 로직을 빠뜨릴 수 있기 때문에 운영 환경에서는 `Filter`, `Interceptor`, `Security Chain`과 같은 공통 처리 지점에서 모든 대상 요청에 동일한 검증이 적용되도록 구성해야 한다.
이 검증을 각 Controller에서 개별적으로 처리하면 새로운 Endpoint를 추가할 때 검증 로직을 빠뜨릴 수 있기 때문에 운영 환경에서는 Filter, Interceptor, Security Chain과 같은 공통 처리 지점에서 모든 대상 요청에 동일한 검증이 적용되도록 구성해야 한다.
### 5. Network 격리와 헤더 검증을 모두 적용한다
@@ -91,7 +92,7 @@ Role을 이용해 인가까지 처리하려면 Role 정보를 어떤 방식으
### 7. 요청 성공 여부가 아니라 전달된 사용자 정보를 확인한다
정상적으로 로그인된 세션에서 사용자 정보 헤더만 위조해 요청했다면, 세션 자체는 유효하므로 요청이 `200 OK`로 처리되는 것은 정상이다.
정상적으로 로그인된 세션에서 사용자 정보 헤더만 위조해 요청했다면, 세션 자체는 유효하므로 요청이 200 OK로 처리되는 것은 정상이다.
테스트에서 확인해야 하는 것은 요청의 성공이나 실패가 아니라 애플리케이션이 어떤 사용자를 인증된 사용자로 인식했는지다.
공격자가 임의로 넣은 사용자 정보가 아니라, 인증 프록시가 확인한 실제 사용자 정보가 사용되어야 한다.
@@ -3,7 +3,8 @@ id: 1a00a640-8987-4075-a9e4-7ec023cdffbb
kind: REFERENCE
slug: external-idp-federation-application-boundary
title: 외부 IdP 연동과 Application 인증 구조의 경계
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 27
@@ -51,12 +52,12 @@ Resource Server 역시 Keycloak이 발급한 Token을 검증한다.
로그인 화면에서 Google이나 다른 Provider를 선택하게 하거나, Provider별 계정을 Keycloak 사용자와 어떻게 연결할지를 별도로 처리하는 것은 자연스럽다.
하지만 Resource Server가 Google과 Keycloak의 Token을 각각 다르게 검증하거나, 애플리케이션의 인가 로직이 로그인에 사용한 Provider에 따라 달라지기 시작한다면 외부 IdP와 애플리케이션 사이를 분리하던 Keycloak의 역할이 제대로 유지되고 있는지 확인할 필요가 있다.
### 2. 외부 계정은 provider와 `subject` 조합으로 식별한다
### 2. 외부 계정은 provider와 subject 조합으로 식별한다
이메일 주소는 변경될 수 있고 다른 계정과 중복될 가능성도 있기 때문에 외부 계정을 식별하고 연결하는 기준으로 사용하기에는 적절하지 않다.
대신 어떤 Provider에서 인증했는지와 해당 Provider가 사용자에게 부여한 고유 식별자(`subject`)를 함께 사용해 외부 계정을 식별한다.
예를 들어 Google 사용자는 `Google + subject`의 조합으로 구분한다.
대신 어떤 Provider에서 인증했는지와 해당 Provider가 사용자에게 부여한 고유 식별자(subject)를 함께 사용해 외부 계정을 식별한다.
예를 들어 Google 사용자는 Google + subject의 조합으로 구분한다.
이메일만을 기준으로 계정을 연결하면 사용자가 이메일 주소를 변경했을 때 기존 계정과의 연결을 찾지 못하거나, 동일한 이메일을 가진 다른 계정을 잘못 연결할 수 있다.
@@ -93,6 +94,6 @@ Resource Server 역시 Keycloak이 발급한 Token을 검증한다.
## 예시
- Google 로그인을 추가해도 애플리케이션이 고르는 것은 여전히 4가지 구조 중 하나다
- 브로커는 `provider alias + upstream subject` 조합을 기준으로 외부 계정을 식별한다.
- 브로커는 provider alias + upstream subject 조합을 기준으로 외부 계정을 식별한다.
- 애플리케이션이 신뢰하는 issuer는 외부 IdP가 아니라 브로커다
- mock OIDC provider로 확인한 것은 브로커와 claim mapping 계약까지다
@@ -3,7 +3,8 @@ id: 3f886154-1b85-407b-bda4-57d28370e745
kind: REFERENCE
slug: oauth-oidc-pattern-selection-criteria
title: OAuth/OIDC 인증 패턴 선택 기준
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 23
@@ -48,7 +49,7 @@ Forward-Auth 구조에서는 애플리케이션이 OAuth Token을 직접 관리
구조를 비교할 때는 브라우저의 Access Token 사용 여부, Resource Server 호출 주체, 서버에서 관리하는 인증 상태, Resource Server가 검증하는 Credential, CSRF 처리 위치를 확인한다.
SPA는 Bearer Access Token을 직접 `Authorization` Header에 넣어 Resource Server를 호출하고, 인증에 Cookie를 사용하지 않는다.
SPA는 Bearer Access Token을 직접 Authorization Header에 넣어 Resource Server를 호출하고, 인증에 Cookie를 사용하지 않는다.
SPA와 Mediator에서는 브라우저가 Access Token을 사용해 Resource Server를 직접 호출한다.
차이는 Mediator가 로그인 Session과 OAuth Token을 서버에서도 관리하고, 로그인 이후 브라우저에 Access Token을 전달한다는 점이다.
@@ -3,7 +3,8 @@ id: ede6b9ce-eeed-40c8-9175-9e8116029395
kind: REFERENCE
slug: public-confidential-client-boundary
title: Public Client와 Confidential Client 구분 기준
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 27
@@ -46,26 +47,26 @@ Client 종류는 secret을 안전하게 보관할 수 있는지로 정하고, To
### 1. secret을 숨길 수 있는지로 종류를 정한다
애플리케이션의 배포 파일이나 실행 중인 메모리에서 사용자가 `client secret`을 확인할 수 있다면 이를 안전하게 보관할 수 없으므로 Public Client로 본다.
반대로 `client secret`을 서버 내부에만 보관하고 사용자에게 전달되지 않도록 통제할 수 있다면 Confidential Client로 구성할 수 있다.
애플리케이션의 배포 파일이나 실행 중인 메모리에서 사용자가 client secret을 확인할 수 있다면 이를 안전하게 보관할 수 없으므로 Public Client로 본다.
반대로 client secret을 서버 내부에만 보관하고 사용자에게 전달되지 않도록 통제할 수 있다면 Confidential Client로 구성할 수 있다.
Native App도 브라우저에서 실행되는 것은 아니지만 애플리케이션이 사용자 기기에 설치되기 때문에, 배포 파일을 분석하면 내부에 포함된 `client secret`을 확인할 수 있다. 따라서 Native App 역시 일반적으로 Public Client로 다룬다.
Native App도 브라우저에서 실행되는 것은 아니지만 애플리케이션이 사용자 기기에 설치되기 때문에, 배포 파일을 분석하면 내부에 포함된 client secret을 확인할 수 있다. 따라서 Native App 역시 일반적으로 Public Client로 다룬다.
### 2. public client에서 Authorization Code Flow에 PKCE를 함께 쓴다
PKCE는 `client secret`을 대신해서 Client를 인증하는 방식이 아니다.
PKCE는 client secret을 대신해서 Client를 인증하는 방식이 아니다.
Authorization Code가 중간에 탈취되더라도 다른 사람이 그 Code를 Token으로 교환하기 어렵게 만드는 보호 장치다.
로그인을 시작할 때 Client는 임의의 `code_verifier`를 만들고, 이를 변환한 `code_challenge`를 Authorization Request에 함께 보낸다. 이후 Authorization Code를 Token으로 교환할 때 원래의 `code_verifier`를 제출한다.
Authorization Server는 처음 받은 `code_challenge`와 비교하여 같은 요청에서 시작된 교환인지 확인한다.
로그인을 시작할 때 Client는 임의의 code_verifier를 만들고, 이를 변환한 code_challenge를 Authorization Request에 함께 보낸다. 이후 Authorization Code를 Token으로 교환할 때 원래의 code_verifier를 제출한다.
Authorization Server는 처음 받은 code_challenge와 비교하여 같은 요청에서 시작된 교환인지 확인한다.
이때 `S256` 방식을 사용한다. `plain` 방식은 `code_verifier` 자체가 `code_challenge`로 전달되기 때문에 Authorization Request를 관찰한 사람이 그 값을 그대로 알 수 있다. 반면 `S256``code_verifier`를 SHA-256으로 변환한 값을 전달하므로 Authorization Request에 원래의 `code_verifier`가 노출되지 않는다.
이때 S256 방식을 사용한다. plain 방식은 code_verifier 자체가 code_challenge로 전달되기 때문에 Authorization Request를 관찰한 사람이 그 값을 그대로 알 수 있다. 반면 S256code_verifier를 SHA-256으로 변환한 값을 전달하므로 Authorization Request에 원래의 code_verifier가 노출되지 않는다.
### 3. confidential client에도 PKCE를 함께 쓸 수 있다
Client 인증을 사용하는 Confidential Client에서도 PKCE는 함께 사용할 수 있다.
Client 인증과 PKCE는 보호하는 대상이 다르기 때문이다.
Client 인증은 Token Endpoint에 요청한 Client가 올바른 Client인지 확인하고, PKCE는 Authorization Code를 받은 주체가 로그인 시작 시 생성한 `code_verifier`를 가지고 있는지 확인한다. 그래서 두 방식을 같이 사용하면 서로 다른 구간을 각각 보호할 수 있다.
Client 인증은 Token Endpoint에 요청한 Client가 올바른 Client인지 확인하고, PKCE는 Authorization Code를 받은 주체가 로그인 시작 시 생성한 code_verifier를 가지고 있는지 확인한다. 그래서 두 방식을 같이 사용하면 서로 다른 구간을 각각 보호할 수 있다.
### 4. public client에서는 implicit flow와 direct access grant를 끈다
@@ -82,7 +83,7 @@ Direct Access Grant는 애플리케이션이 사용자의 아이디와 비밀번
Confidential Client가 Authorization Code를 Token으로 교환하더라도, 그 결과로 받은 Access Token을 다시 브라우저에 전달하는 구조를 만들 수 있다.
즉, Confidential Client라고 해서 Token이 반드시 서버 내부에만 있는 것은 아니다.
Client 종류는 `client secret`을 어디에 안전하게 보관할 수 있는지를 나타낸다.
Client 종류는 client secret을 어디에 안전하게 보관할 수 있는지를 나타낸다.
반면 Access Token이 브라우저까지 전달되는지는 어느 계층이 실제 API 호출을 담당하도록 설계했는지에 따라 별도로 결정된다.
## 적용 조건
@@ -3,7 +3,8 @@ id: 66c18e42-116c-459f-86bd-b7e4bf394866
kind: REFERENCE
slug: oauth-token-application-session-boundary
title: OAuth Token과 Application Session을 구분하는 기준
topic: OAuth/OIDC 인증 경계
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 33
@@ -48,7 +49,7 @@ Logout과 만료 처리에서도 같은 구분이 필요하다. IdP의 SSO Sessi
IdP SSO Session, OAuth Access Token, OAuth Refresh Token, Application Session Cookie, Proxy Session Cookie는 각각 생성하는 주체와 사용하는 목적이 다른 별개의 상태다.
로그와 진단 정보에서도 어떤 상태를 확인한 것인지 구체적으로 기록한다.
예를 들어 단순히 `로그인 상태가 만료되었다`고 남기는 대신 `Application Session이 만료되었다`, `Access Token이 만료되었다`, `Proxy Session이 존재하지 않는다`처럼 실제 Session이나 Token의 종류를 명시한다.
예를 들어 단순히 로그인 상태가 만료되었다고 남기는 대신 Application Session이 만료되었다, Access Token이 만료되었다, Proxy Session이 존재하지 않는다처럼 실제 Session이나 Token의 종류를 명시한다.
이렇게 이름을 구분해야 장애를 분석하거나 Logout과 만료 동작을 확인할 때 어떤 상태가 남아 있고 어떤 상태를 삭제하거나 갱신해야 하는지 정확하게 판단할 수 있다.
@@ -64,20 +65,20 @@ Proxy Session Cookie는 인증 Proxy가 발급하고, 이후 Proxy가 인증 상
### 3. 같은 사용자라도 Credential은 서로 다른 상태를 나타낸다
Application Session Cookie는 서버에 저장된 Session을 찾기 위한 `session ID`를 브라우저에 전달하는 데 사용한다.
Application Session Cookie는 서버에 저장된 Session을 찾기 위한 session ID를 브라우저에 전달하는 데 사용한다.
실제 Access Token과 Refresh Token은 Cookie 안에 들어 있는 것이 아니라 Authorized Client와 같은 별도의 서버 저장소에 보관된다. 따라서 Session Cookie와 OAuth Token 저장소는 서로 구분해서 봐야 한다.
Proxy Session Cookie는 반드시 같은 방식으로 동작하는 것은 아니다.
별도의 서버 Session Store를 두지 않고, 인증 상태를 확인하는 데 필요한 정보를 Cookie 자체에 담은 뒤 Proxy가 Cookie의 유효성을 검증하는 방식으로 구성할 수도 있다.
이 경우 Cookie는 서버에 저장된 Session을 조회하기 위한 `session ID`와는 역할이 다르다.
이 경우 Cookie는 서버에 저장된 Session을 조회하기 위한 session ID와는 역할이 다르다.
### 4. 브라우저에 없는 것을 범위까지 적는다
브라우저 JavaScript에 OAuth Token을 전달하지 않는 구조에서도 브라우저에 인증과 관련된 상태는 남아 있을 수 있다.
예를 들어 BFF 구조에서는 애플리케이션의 `HttpOnly` Session Cookie가 유지될 수 있고, IdP에서는 자신의 도메인에 SSO Session Cookie를 유지할 수 있다.
예를 들어 BFF 구조에서는 애플리케이션의 HttpOnly Session Cookie가 유지될 수 있고, IdP에서는 자신의 도메인에 SSO Session Cookie를 유지할 수 있다.
따라서 단순히 `브라우저에 인증 정보가 없다`거나 `브라우저에 Credential이 없다`고 표현하면 안 된다.
`브라우저 JavaScript에 Access Token과 Refresh Token을 노출하지 않는다`처럼 무엇이 없고 어느 범위에서 접근할 수 없는지를 적는다.
따라서 단순히 브라우저에 인증 정보가 없다거나 브라우저에 Credential이 없다고 표현하면 안 된다.
브라우저 JavaScript에 Access Token과 Refresh Token을 노출하지 않는다처럼 무엇이 없고 어느 범위에서 접근할 수 없는지를 적는다.
### 5. 영구 저장과 메모리 보관을 구분한다.
@@ -119,7 +120,7 @@ Application Session을 무효화하는 것과 Authorized Client에 저장된 Acc
- 하나의 요청 흐름 안에서 어떤 Session이나 Token을 의미하는지가 이미 명확한 경우에는 짧은 이름을 사용할 수 있다.
다만 문서에서 처음 등장할 때는 전체 이름을 먼저 적어 어떤 상태를 의미하는지 명확하게 정의한다.
이후 같은 문맥에서는 의미가 달라지지 않는 범위에서 `Session`, `Access Token`, `Refresh Token`처럼 줄여서 표현할 수 있다.
이후 같은 문맥에서는 의미가 달라지지 않는 범위에서 Session, Access Token, Refresh Token처럼 줄여서 표현할 수 있다.
- IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO session과 access token, refresh token이 없다.
## 예시
@@ -81,7 +81,9 @@
"readiness": "READY",
"status": "게시 중",
"studioId": "bf675775-4f3e-4744-8014-f0efff51422a",
"assets": [],
"assets": [
"ap1-custody-v3-6e0376d2"
],
"evidence": [],
"relations": [
"Authorization Code Flow의 Endpoint와 Credential 이동 기준",