--- id: a3493786-d3fb-4b01-b1c5-ecb23c3d5497 kind: CONCEPT slug: forward-auth-and-auth-request title: Forward-Auth와 Nginx auth_request의 동작 topic: oauth-oidc-auth-boundary topicName: OAuth/OIDC 인증 경계 project: KeyCloak Patterns status: 게시 전 version: 4 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 sourceRevision: keycloak-patterns-lab@2026-08 source: - final/document.md#선택의-이유와-지킨-경계-ap4 --- # Forward-Auth와 Nginx auth_request의 동작 forward-auth는 실제 요청을 업스트림으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 먼저 묻는 방식이다. Nginx에서는 `auth_request` 지시어가 그 질문을 하위 요청(subrequest)으로 만든다. 인증 결과는 업스트림으로 나가는 요청의 헤더로 바뀌기 때문에, 업스트림은 JWT 대신 그 헤더를 입력으로 받는다. ## 관계 - **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건** 이 동작을 운영에서 신뢰하려면 무엇이 갖춰져야 하는지 적은 기록이다. - **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유** 헤더 위조를 실제로 재현한 기록이다. - **OAuth/OIDC 인증 패턴 선택 기준** 이 구조를 언제 고르는지 다른 패턴과 비교한 기록이다. ## 본문 ## 요청 하나가 두 번 평가된다 :::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 /`에 다음 지시어가 있기 때문이다. ```nginx label="general location의 auth_request" 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 label="미인증 navigation의 응답" 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`은 인증 하위 요청의 응답에서 값을 꺼내 변수에 담아 두는 지시어다. ```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 ``` 여기 복사한 사용자와 이메일이 다음 절에서 업스트림 요청의 헤더가 된다. ## upstream 요청을 새로 만든다 Nginx는 원래 요청을 그대로 전달하지 않는다. 밖에서 들어온 `/api/edge`는 안쪽 `/edge/me`로 다시 매핑되고, 헤더는 앞에서 변수에 담아 둔 값으로 채워진다. ```http label="Nginx가 만드는 upstream 요청" GET http://app:8081/edge/me X-Auth-Request-User: X-Auth-Request-Email: X-Internal-Auth-Token: ``` 클라이언트가 보낸 같은 이름의 헤더는 합치지 않고 덮어쓴다. 그래서 공격자가 `X-Auth-Request-User: spoofed-admin`을 보내도 업스트림이 받는 값은 oauth2-proxy가 확인한 실제 사용자가 된다. 업스트림에 도착한 요청만 봐서는 브라우저가 보낸 헤더와 바깥과 맞닿은 Nginx, 즉 엣지가 만든 헤더를 구분할 수 없다. 덮어쓰기를 엣지에서 끝내야 하는 이유가 여기에 있다. ## upstream은 두 겹을 확인한다 Spring 컨트롤러는 헤더 두 개를 함께 본다. ```text label="/edge/me의 확인 순서" 1. X-Auth-Request-User가 blank인지 확인 2. X-Internal-Auth-Token을 읽는다 3. 설정된 token과 MessageDigest.isEqual로 비교 4. 둘 다 유효하면 allowlist된 identity field만 응답에 넣는다 ``` `MessageDigest.isEqual`은 두 값이 앞에서부터 얼마나 일치하는지에 따라 실행 시간이 크게 달라지지 않는 비교 메서드다. 사용자 헤더가 없거나 내부 토큰이 틀리면 401을 돌려준다. ```json label="신뢰 조건을 만족하지 못한 응답" { "error": "trusted edge authentication is required" } ``` 이 검사는 Spring Security의 `/edge/**` 규칙이 아니라 컨트롤러가 직접 한다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 있어서, 엣지 엔드포인트를 새로 만드는 사람이 같은 검사를 직접 불러 주지 않으면 그 엔드포인트에는 검사가 붙지 않는다. ## 세 방어선이 각각 막는 것 ```text label="AP4가 사용하는 세 방어선" 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`는 역할 판단을 하지 않는다.