- 계약 채택 — 독자 질문, 후보 29건(PROMOTE 24 · MERGE_INTO 4 · KEEP_IN_SSOT 1). 게시 중 17건은 전부 유지. 저장소 keycloak-pattern 은 패턴 넷이 브랜치로 갈라져 있어 revisions 로 tip 넷을 적었다. keycloak-session-store 는 같은 저장소 @ cdac9b8 - 게시된 기록의 redirect_uri 가 SSOT·코드와 달랐다 — OAuth2callback.html → callback.html (frontend/src/app.js 에서 확인). 계약 title 이 기록과 다른 7건도 기록 쪽으로 맞췄다 - 미작성 1건 작성 — 패턴 검증을 실제로 돌릴 때의 안전한 순서(Reference) - 리뷰 100건 반영 — 설명 뒤에 붙은 평가·차례 예고·독자 오해 가정·작성 지시를 지웠다. 삭제가 남긴 조각 4건을 고치고, 원래부터 잘려 있던 로컬 미리보기 라벨 1건도 닫았다 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8.5 KiB
id, kind, slug, title, topic, topicName, project, status, version, basisVersion, studio, sourceRevision, source
| id | kind | slug | title | topic | topicName | project | status | version | basisVersion | studio | sourceRevision | source | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 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 | keycloak-patterns-lab@2026-08 |
|
Forward-Auth와 Nginx auth_request의 동작
forward-auth는 실제 요청을 업스트림으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 먼저 묻는 방식이다. Nginx에서는 auth_request 지시어가 그 질문을 하위 요청(subrequest)으로 만든다. 인증 결과는 업스트림으로 나가는 요청의 헤더로 바뀌기 때문에, 업스트림은 JWT 대신 그 헤더를 입력으로 받는다.
관계
- Forward-Auth에서 Identity Header를 신뢰하기 위한 조건 이 동작을 운영에서 신뢰하려면 무엇이 갖춰져야 하는지 적은 기록이다.
- Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유 헤더 위조를 실제로 재현한 기록이다.
- OAuth/OIDC 인증 패턴 선택 기준 이 구조를 언제 고르는지 다른 패턴과 비교한 기록이다.
본문
요청 하나가 두 번 평가된다
브라우저 요청이 들어와도 Nginx는 업스트림을 바로 호출하지 않는다. 업스트림은 Nginx가 요청을 최종으로 넘기는 뒤쪽 서버이고, 이 구성에서는 app:8081의 Spring 애플리케이션이다. 바로 넘기지 않는 것은 location /에 다음 지시어가 있기 때문이다.
auth_request /oauth2/auth;
auth_request는 원래 요청을 처리하기 전에 지정한 경로로 인증용 하위 요청을 한 번 보내는 지시어다. 하위 요청은 Nginx가 원래 요청과 별개로 내부에서 만들어 보내는 요청을 말한다. 그래서 Nginx는 먼저 /oauth2/auth로 하위 요청을 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 밖에서 들어온 요청 한 번이 인증 판단과 업스트림 전달 두 단계로 나뉜다.
location = /oauth2/auth는 internal로 선언한다. Nginx가 만든 하위 요청만 들어갈 수 있어서 브라우저가 같은 URL을 직접 호출하면 정상 인증 엔드포인트로 쓸 수 없고, 밖에서 이 경로를 부르면 404가 된다.
subrequest가 실어 보내는 것
하위 요청은 본문을 보내지 않고 Content-Length를 비우기 때문에, 원래 요청의 문맥은 헤더로 바뀌어 실린다.
| subrequest 헤더 | 값의 출처 |
|---|---|
X-Original-URL |
스킴과 호스트, 원래 요청의 URI |
X-Real-IP |
클라이언트 주소 |
X-Forwarded-For |
프록시 체인 |
X-Forwarded-Host |
원래 요청의 호스트 |
X-Forwarded-Proto |
원래 요청의 스킴 |
X-Forwarded-Uri |
원래 요청의 URI |
Cookie |
브라우저에 쿠키가 있을 때 원래 요청의 값 |
oauth2-proxy는 이렇게 넘어온 헤더를 보고 세션이 유효한지 판단한다.
미인증 401의 응답이 경로마다 다르다
인증 결과가 401일 때 그다음에 무엇을 돌려주는지는 요청이 걸린 location마다 다르다.
| 외부 입력 | 인증 상태 | 결과 |
|---|---|---|
GET / |
미인증 | /oauth2/start로 302 |
GET /api/edge |
미인증 | Location 없는 401 JSON |
location /으로 들어온 요청은 @oauth2_signin으로 이동해 로그인을 시작한다.
HTTP/1.1 302 Found
Location: http://localhost:8088/oauth2/start?rd=http://localhost:8088/
/api/edge를 정확히 일치로 잡은 location은 리다이렉트 없이 401을 만든다. 브라우저로 화면을 여는 쪽과 프로그램이 API를 부르는 쪽에 서로 다른 응답을 주려고 나눈 구성이다. 다만 이 분리는 /api/edge 경로에만 구성돼 있어서, 다른 경로로 들어온 미인증 요청은 location /의 로그인 리다이렉트 규칙을 따른다.
인증 결과를 변수로 옮긴다
oauth2-proxy가 세션을 유효하다고 판단하면 인증 응답에 사용자와 이메일이 담겨 온다. Nginx는 auth_request_set으로 그 값을 지역 변수에 복사한다. 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
여기 복사한 사용자와 이메일이 다음 절에서 업스트림 요청의 헤더가 된다.
upstream 요청을 새로 만든다
Nginx는 원래 요청을 그대로 전달하지 않는다. 밖에서 들어온 /api/edge는 안쪽 /edge/me로 다시 매핑되고, 헤더는 앞에서 변수에 담아 둔 값으로 채워진다.
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>
클라이언트가 보낸 같은 이름의 헤더는 합치지 않고 덮어쓴다. 그래서 공격자가 X-Auth-Request-User: spoofed-admin을 보내도 업스트림이 받는 값은 oauth2-proxy가 확인한 실제 사용자가 된다.
업스트림에 도착한 요청만 봐서는 브라우저가 보낸 헤더와 바깥과 맞닿은 Nginx, 즉 엣지가 만든 헤더를 구분할 수 없다. 덮어쓰기를 엣지에서 끝내야 하는 이유가 여기에 있다.
upstream은 두 겹을 확인한다
Spring 컨트롤러는 헤더 두 개를 함께 본다.
1. X-Auth-Request-User가 blank인지 확인
2. X-Internal-Auth-Token을 읽는다
3. 설정된 token과 MessageDigest.isEqual로 비교
4. 둘 다 유효하면 allowlist된 identity field만 응답에 넣는다
MessageDigest.isEqual은 두 값이 앞에서부터 얼마나 일치하는지에 따라 실행 시간이 크게 달라지지 않는 비교 메서드다.
사용자 헤더가 없거나 내부 토큰이 틀리면 401을 돌려준다.
{
"error": "trusted edge authentication is required"
}
이 검사는 Spring Security의 /edge/** 규칙이 아니라 컨트롤러가 직접 한다. 현재 SecurityConfig는 /edge/**를 permitAll로 두고 있어서, 엣지 엔드포인트를 새로 만드는 사람이 같은 검사를 직접 불러 주지 않으면 그 엔드포인트에는 검사가 붙지 않는다.
세 방어선이 각각 막는 것
network isolation : app 8081과 oauth2-proxy 4180을 host에 publish하지 않는다
header overwrite : client가 보낸 동명 헤더를 Nginx 값으로 덮어쓴다
internal token : upstream이 edge를 거쳤다는 추가 신호를 확인한다
컨트롤러가 확인하는 공유 토큰만으로는 밖에서 app의 8081과 oauth2-proxy의 4180에 직접 닿는 것을 막지 못한다. 두 포트를 호스트에 열지 않는 것만으로는 안쪽 워크로드가 보냈거나 잘못 붙은 프록시 헤더를 걸러 내지 못한다.
세 방어선을 함께 두어도 신뢰할 프록시 범위를 넓게 잡거나 공유 시크릿이 노출되면 다른 사용자를 흉내 낼 수 있다.
지금 구성이 보여 주지 않는 것
location /도 proxy_pass http://app:8081/edge/me를 쓰기 때문에, /orders/123 같은 임의 경로를 그대로 뒤로 넘기는 범용 리버스 프록시가 아니다. /와 /api/edge를 같은 사용자 응답으로 이어 붙여 auth_request와 헤더 신뢰만 관찰하려고 만든 실험 구성이다.
실제 업스트림을 붙이려면 URI 재작성, 요청 본문, 타임아웃, 재시도, 응답 헤더, 로그아웃, 상태를 바꾸는 요청의 보호를 하나씩 따로 설계해야 한다. 지금 엣지 응답이 넘기는 값은 사용자와 이메일 둘뿐이고 역할·그룹·테넌트·토큰 만료는 넘기지 않는다. 이 값이 필요한 업스트림을 붙이려면 무엇을 어디서 읽어 어떤 헤더로 넘길지 먼저 정해야 한다. 지금 /edge/me는 역할 판단을 하지 않는다.