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>
6.5 KiB
id, kind, slug, title, topic, topicName, project, status, version, basisVersion, studio
| id | kind | slug | title | topic | topicName | project | status | version | basisVersion | studio |
|---|---|---|---|---|---|---|---|---|---|---|
| a3493786-d3fb-4b01-b1c5-ecb23c3d5497 | CONCEPT | forward-auth-and-auth-request | Forward-Auth와 Nginx auth_request의 동작 | oauth-oidc-auth-boundary | OAuth/OIDC 인증 경계 | KeyCloak Patterns | 게시 전 | 4 | oauth2-proxy 7.15.2 · Nginx auth_request module | https://hyeonworks.com/studio/documents/a3493786-d3fb-4b01-b1c5-ecb23c3d5497/edit |
Forward-Auth와 Nginx auth_request의 동작
forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 endpoint에 허용 여부를 묻는 방식이다. Nginx에서는 auth_request directive가 그 질문을 subrequest로 만든다. 인증 결과는 upstream 요청의 헤더로 바뀌고, upstream은 JWT 대신 그 헤더를 입력으로 받는다.
관계
- Forward-Auth에서 Identity Header를 신뢰하기 위한 조건 이 동작을 운영에서 신뢰하려면 무엇이 필요한지 정리한 기준이다.
- Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유 헤더 위조를 실제로 재현한 기록이다.
- OAuth/OIDC 인증 패턴 선택 기준 이 구조를 언제 고르는지 비교한 기준이다.
본문
요청 하나가 두 번 평가된다
브라우저 요청이 들어오면 Nginx는 바로 upstream을 호출하지 않는다. location /에 다음 directive가 있다.
auth_request /oauth2/auth;
Nginx는 먼저 /oauth2/auth로 subrequest를 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 한 번의 외부 요청이 인증 판단과 upstream 전달 두 단계로 나뉜다.
location = /oauth2/auth는 internal로 선언한다. Nginx가 만드는 subrequest만 들어갈 수 있고 브라우저가 같은 URL을 직접 호출하면 정상 auth endpoint로 쓸 수 없다. 외부에서 이 경로를 부르면 404가 된다.
subrequest가 실어 보내는 것
subrequest는 body를 보내지 않고 Content-Length를 비운다. 원래 요청의 문맥은 헤더로 바뀐다.
| subrequest 헤더 | 값의 출처 |
|---|---|
X-Original-URL |
scheme, host와 original request URI |
X-Real-IP |
client address |
X-Forwarded-For |
proxy chain |
X-Forwarded-Host |
original host |
X-Forwarded-Proto |
original scheme |
X-Forwarded-Uri |
original request URI |
Cookie |
브라우저에 cookie가 있을 때 원래 요청의 값 |
oauth2-proxy는 이 정보로 session이 유효한지 판단한다.
미인증 401의 응답이 경로마다 다르다
인증 결과가 401일 때 무엇을 돌려줄지는 location마다 다르다.
| 외부 입력 | 인증 상태 | 결과 |
|---|---|---|
GET / |
미인증 | /oauth2/start로 302 |
GET /api/edge |
미인증 | Location 없는 401 JSON |
general location은 @oauth2_signin으로 이동해 로그인을 시작한다.
HTTP/1.1 302 Found
Location: http://localhost:8088/oauth2/start?rd=http://localhost:8088/
exact API location은 redirect 없이 401을 만든다. 브라우저 UX와 프로그램이 부르는 API UX를 나눈 구성이다. 이 분리는 해당 path에만 구성돼 있고 다른 path는 general location 규칙을 따른다.
인증 결과를 변수로 옮긴다
oauth2-proxy가 session을 유효하다고 판단하면 auth 응답에 사용자와 이메일이 들어 있다. Nginx는 auth_request_set으로 그 값을 local 변수에 복사한다.
$auth_user ← oauth2-proxy X-Auth-Request-User
$auth_email ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie
upstream 요청을 새로 만든다
원래 요청을 그대로 전달하지 않는다. 외부 /api/edge는 내부 /edge/me로 다시 매핑되고 헤더는 Nginx가 만든 값으로 채워진다.
GET http://app:8081/edge/me
X-Auth-Request-User: <oauth2-proxy-authenticated-user>
X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
X-Internal-Auth-Token: <nginx-environment-secret>
client가 보낸 같은 이름의 헤더를 merge하지 않고 덮어쓴다. 공격자가 X-Auth-Request-User: spoofed-admin을 보내도 upstream 입력은 oauth2-proxy가 확인한 실제 user가 된다.
upstream이 받는 요청에서 브라우저가 보낸 헤더와 edge가 만든 헤더는 구분되지 않는다. 그래서 이 덮어쓰기가 edge에서 끝나야 한다.
upstream은 두 겹을 확인한다
Spring controller는 헤더 두 개를 함께 본다.
1. X-Auth-Request-User가 blank인지 확인
2. X-Internal-Auth-Token을 읽는다
3. 설정된 token과 MessageDigest.isEqual로 비교
4. 둘 다 유효하면 allowlist된 identity field만 응답에 넣는다
MessageDigest.isEqual은 입력값의 일치 길이에 따라 실행 시간이 크게 달라지지 않는 비교다.
user 헤더가 없거나 internal token이 틀리면 401이다.
{
"error": "trusted edge authentication is required"
}
이 검사는 Spring Security의 /edge/** rule이 아니라 controller가 직접 한다. 현재 SecurityConfig는 /edge/**를 permitAll로 두고 있어서, 새 edge endpoint를 추가하면서 같은 검사를 부르지 않으면 보호가 자동으로 따라오지 않는다.
세 방어선이 각각 막는 것
network isolation : app 8081과 oauth2-proxy 4180을 host에 publish하지 않는다
header overwrite : client가 보낸 동명 헤더를 Nginx 값으로 덮어쓴다
internal token : upstream이 edge를 거쳤다는 추가 신호를 확인한다
controller의 shared token만으로는 외부에서 app과 oauth2-proxy에 직접 닿지 못하게 할 수 없다. network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 경우를 걸러 내지 못한다.
지금 구성이 보여 주지 않는 것
general location /도 proxy_pass http://app:8081/edge/me를 쓴다. /orders/123 같은 임의 upstream path를 보존하는 범용 reverse proxy가 아니다. auth-request와 header trust를 관찰하는 fixture다.
실제 upstream을 붙이면 URI rewrite, request body, timeout, retry, response header, logout, 상태 변경 요청 보호를 따로 설계해야 한다. 현재 edge 응답은 user와 email만 전달하고 role, groups, tenant, token expiry는 전달하지 않는다.