- generic [ref=f3e3]: - link "본문으로 건너뛰기" [ref=f3e4] [cursor=pointer]: - /url: "#main-content" - banner [ref=f3e5]: - generic [ref=f3e6]: - link "TechLog Studio" [ref=f3e7] [cursor=pointer]: - /url: /studio - text: TechLog - generic [ref=f3e8]: Studio - navigation "Studio 주 탐색" [ref=f3e10]: - link "작업본" [ref=f3e11] [cursor=pointer]: - /url: /studio/documents - link "게시 기록" [ref=f3e12] [cursor=pointer]: - /url: /studio/publications - link "새 문서" [ref=f3e13] [cursor=pointer]: - /url: /studio/documents/new - link "주제·프로젝트" [ref=f3e14] [cursor=pointer]: - /url: /studio/taxonomy - link "릴리즈" [ref=f3e15] [cursor=pointer]: - /url: /studio/releases - link "공개 사이트 보기" [ref=f3e16] [cursor=pointer]: - /url: / - button "로그아웃" [ref=f3e17] - main [ref=f3e18]: - generic [ref=f3e19]: - generic [ref=f3e20]: - region [ref=f3e21]: - generic [ref=f3e22]: - paragraph [ref=f3e23]: CASE · VERSION 43 - heading "문서 편집" [level=1] [ref=f3e24] - paragraph [ref=f3e25]: Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유 - region [ref=f3e26]: - generic [ref=f3e27]: - paragraph [ref=f3e28]: DOCUMENT - heading "기본 정보" [level=2] [ref=f3e29] - generic [ref=f3e30]: - generic [ref=f3e31]: - generic [ref=f3e32]: 제목 - textbox "제목" [ref=f3e33]: Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유 - generic [ref=f3e34]: - generic [ref=f3e35]: slug - textbox "slug" [ref=f3e36]: - /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈) - text: identity-header-trust - generic [ref=f3e37]: - generic [ref=f3e38]: 요약 - textbox "요약" [ref=f3e39]: X-Auth-Request-User는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다. upstream이 받는 요청에서는 동일한 구조. 그래서 header overwrite, backend direct path 차단, internal credential 검증을 서로 독립된 세 곳에서 방어할 수 있도록 해야 한다. - generic [aria-hidden] [ref=f3e40]: 목록 카드에는 약 90자까지 보입니다 · 192 / 2000 - generic [ref=f3e41]: - generic [ref=f3e42]: Topic - combobox "Topic" [ref=f3e43]: - option "선택하지 않음" - option "JPA 피드 조회 성능" - option "OAuth/OIDC 인증 경계" [selected] - generic [ref=f3e44]: - generic [ref=f3e45]: Project - combobox "Project" [ref=f3e46]: - option "미지정" - option "Backend Clean Architecture" - option "KeyCloak Patterns" [selected] - option "Liner N + 1문제" - status [ref=f3e47] - group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f3e48]: - generic [ref=f3e50] [cursor=pointer]: - checkbox "SPA" [ref=f3e51] - generic [ref=f3e52]: SPA - generic [ref=f3e53] [cursor=pointer]: - checkbox "Mediator" [ref=f3e54] - generic [ref=f3e55]: Mediator - generic [ref=f3e56] [cursor=pointer]: - checkbox "BFF" [ref=f3e57] - generic [ref=f3e58]: BFF - generic [ref=f3e59] [cursor=pointer]: - checkbox "Forward-Auth" [checked] [ref=f3e60] - generic [ref=f3e61]: Forward-Auth - group "관계" [ref=f3e62]: - generic [ref=f3e64]: - generic [ref=f3e65]: - generic [ref=f3e66]: 관계 1 대상 - combobox "관계 1 대상" [ref=f3e67]: - option "대상 선택" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" - option "Collection Fetch Join Pagination의 In-memory Paging" - option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Projection 이후에도 1,509행을 읽은 Row Over-fetch" - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" - option "Visibility OR이 Keyset Index를 깨뜨린 문제" - option "Authorization Code와 PKCE가 보호하는 구간" - option "Bearer JWT가 인증된 principal이 되기까지" - option "Cookie로 인증하는 요청에서 CSRF token이 하는 일" - option "브라우저가 credential을 보관하는 위치와 그 성질" - option "Forward-Auth와 Nginx auth_request의 동작" - option "외부 IdP Brokering의 동작" - option "패턴 검증을 실제로 돌릴 때의 안전한 순서" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" - option "Feed Visibility Query Pattern" - option "Fetch Join · Batch · Projection 선택 기준" - option "Fetch Type과 Fetch Strategy 구분" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" [selected] - option "외부 IdP 연동과 Application 인증 구조의 경계" - option "JPA N+1 정량 진단 기준" - option "Keyset Pagination 설계 기준" - option "OAuth/OIDC 인증 패턴 선택 기준" [disabled] - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "PostgreSQL Query Plan 측정 기준" - option "Public Client와 Confidential Client 구분 기준" - option "Top-N-per-group 선택 기준" - option "실제 동시 트래픽에서도 이 구조가 안정적인가" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "feed_visible을 Production CQRS로 승격할 것인가" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" [disabled] - option "Highlight 없는 FeedItem을 허용할 것인가" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "Round Trip과 Row Volume을 독립 측정할 것인가" - option "BFF가 OAuth Token을 관리하는 조건" - option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" - option "Entity Graph 조회에는 Batch Fetch를 사용한다" - option "Feed Pagination은 Keyset을 사용한다" - option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다." - option "Query Plan은 실제 PostgreSQL에서 측정한다" - option "Query Strategy는 FeedQueryPort 뒤에서 소유한다" - option "현재 Read Model은 CQRS-lite로 유지한다" - option "화면 조회는 Read Projection을 사용한다" - generic [ref=f3e68]: - generic [ref=f3e69]: 관계 1 이유 - textbox "관계 1 이유" [ref=f3e70]: 이 기준의 다섯 조건이 실제로 어떻게 구성되는지 코드와 설정으로 확인한 자리다. - generic [aria-hidden] [ref=f3e71]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다. - generic [ref=f3e72]: - button "위로" [disabled] [ref=f3e73] - button "아래로" [ref=f3e74] - button "삭제" [ref=f3e75] - generic [ref=f3e76]: - generic [ref=f3e77]: - generic [ref=f3e78]: 관계 2 대상 - combobox "관계 2 대상" [ref=f3e79]: - option "대상 선택" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" - option "Collection Fetch Join Pagination의 In-memory Paging" - option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Projection 이후에도 1,509행을 읽은 Row Over-fetch" - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" - option "Visibility OR이 Keyset Index를 깨뜨린 문제" - option "Authorization Code와 PKCE가 보호하는 구간" - option "Bearer JWT가 인증된 principal이 되기까지" - option "Cookie로 인증하는 요청에서 CSRF token이 하는 일" - option "브라우저가 credential을 보관하는 위치와 그 성질" - option "Forward-Auth와 Nginx auth_request의 동작" - option "외부 IdP Brokering의 동작" - option "패턴 검증을 실제로 돌릴 때의 안전한 순서" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" - option "Feed Visibility Query Pattern" - option "Fetch Join · Batch · Projection 선택 기준" - option "Fetch Type과 Fetch Strategy 구분" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" [disabled] - option "외부 IdP 연동과 Application 인증 구조의 경계" - option "JPA N+1 정량 진단 기준" - option "Keyset Pagination 설계 기준" - option "OAuth/OIDC 인증 패턴 선택 기준" [disabled] - option "OAuth Token과 Application Session을 구분하는 기준" [selected] - option "PostgreSQL Query Plan 측정 기준" - option "Public Client와 Confidential Client 구분 기준" - option "Top-N-per-group 선택 기준" - option "실제 동시 트래픽에서도 이 구조가 안정적인가" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "feed_visible을 Production CQRS로 승격할 것인가" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" [disabled] - option "Highlight 없는 FeedItem을 허용할 것인가" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "Round Trip과 Row Volume을 독립 측정할 것인가" - option "BFF가 OAuth Token을 관리하는 조건" - option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" - option "Entity Graph 조회에는 Batch Fetch를 사용한다" - option "Feed Pagination은 Keyset을 사용한다" - option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다." - option "Query Plan은 실제 PostgreSQL에서 측정한다" - option "Query Strategy는 FeedQueryPort 뒤에서 소유한다" - option "현재 Read Model은 CQRS-lite로 유지한다" - option "화면 조회는 Read Projection을 사용한다" - generic [ref=f3e80]: - generic [ref=f3e81]: 관계 2 이유 - textbox "관계 2 이유" [ref=f3e82]: proxy session cookie와 identity 헤더를 JWT와 구분해야 하는 실례다. - generic [aria-hidden] [ref=f3e83]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다. - generic [ref=f3e84]: - button "위로" [ref=f3e85] - button "아래로" [ref=f3e86] - button "삭제" [ref=f3e87] - generic [ref=f3e88]: - generic [ref=f3e89]: - generic [ref=f3e90]: 관계 3 대상 - combobox "관계 3 대상" [ref=f3e91]: - option "대상 선택" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" - option "Collection Fetch Join Pagination의 In-memory Paging" - option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Projection 이후에도 1,509행을 읽은 Row Over-fetch" - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" - option "Visibility OR이 Keyset Index를 깨뜨린 문제" - option "Authorization Code와 PKCE가 보호하는 구간" - option "Bearer JWT가 인증된 principal이 되기까지" - option "Cookie로 인증하는 요청에서 CSRF token이 하는 일" - option "브라우저가 credential을 보관하는 위치와 그 성질" - option "Forward-Auth와 Nginx auth_request의 동작" - option "외부 IdP Brokering의 동작" - option "패턴 검증을 실제로 돌릴 때의 안전한 순서" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" - option "Feed Visibility Query Pattern" - option "Fetch Join · Batch · Projection 선택 기준" - option "Fetch Type과 Fetch Strategy 구분" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" [disabled] - option "외부 IdP 연동과 Application 인증 구조의 경계" - option "JPA N+1 정량 진단 기준" - option "Keyset Pagination 설계 기준" - option "OAuth/OIDC 인증 패턴 선택 기준" [selected] - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "PostgreSQL Query Plan 측정 기준" - option "Public Client와 Confidential Client 구분 기준" - option "Top-N-per-group 선택 기준" - option "실제 동시 트래픽에서도 이 구조가 안정적인가" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "feed_visible을 Production CQRS로 승격할 것인가" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" [disabled] - option "Highlight 없는 FeedItem을 허용할 것인가" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "Round Trip과 Row Volume을 독립 측정할 것인가" - option "BFF가 OAuth Token을 관리하는 조건" - option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" - option "Entity Graph 조회에는 Batch Fetch를 사용한다" - option "Feed Pagination은 Keyset을 사용한다" - option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다." - option "Query Plan은 실제 PostgreSQL에서 측정한다" - option "Query Strategy는 FeedQueryPort 뒤에서 소유한다" - option "현재 Read Model은 CQRS-lite로 유지한다" - option "화면 조회는 Read Projection을 사용한다" - generic [ref=f3e92]: - generic [ref=f3e93]: 관계 3 이유 - textbox "관계 3 이유" [ref=f3e94]: OAuth를 모르는 upstream 앞의 공통 관문을 얻고 network·헤더 신뢰 계약을 내주는 경우다. - generic [aria-hidden] [ref=f3e95]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다. - generic [ref=f3e96]: - button "위로" [ref=f3e97] - button "아래로" [ref=f3e98] - button "삭제" [ref=f3e99] - generic [ref=f3e100]: - generic [ref=f3e101]: - generic [ref=f3e102]: 관계 4 대상 - combobox "관계 4 대상" [ref=f3e103]: - option "대상 선택" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" - option "Collection Fetch Join Pagination의 In-memory Paging" - option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Projection 이후에도 1,509행을 읽은 Row Over-fetch" - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" - option "Visibility OR이 Keyset Index를 깨뜨린 문제" - option "Authorization Code와 PKCE가 보호하는 구간" - option "Bearer JWT가 인증된 principal이 되기까지" - option "Cookie로 인증하는 요청에서 CSRF token이 하는 일" - option "브라우저가 credential을 보관하는 위치와 그 성질" - option "Forward-Auth와 Nginx auth_request의 동작" - option "외부 IdP Brokering의 동작" - option "패턴 검증을 실제로 돌릴 때의 안전한 순서" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" - option "Feed Visibility Query Pattern" - option "Fetch Join · Batch · Projection 선택 기준" - option "Fetch Type과 Fetch Strategy 구분" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" [disabled] - option "외부 IdP 연동과 Application 인증 구조의 경계" - option "JPA N+1 정량 진단 기준" - option "Keyset Pagination 설계 기준" - option "OAuth/OIDC 인증 패턴 선택 기준" [disabled] - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "PostgreSQL Query Plan 측정 기준" - option "Public Client와 Confidential Client 구분 기준" - option "Top-N-per-group 선택 기준" - option "실제 동시 트래픽에서도 이 구조가 안정적인가" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "feed_visible을 Production CQRS로 승격할 것인가" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" [selected] - option "Highlight 없는 FeedItem을 허용할 것인가" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "Round Trip과 Row Volume을 독립 측정할 것인가" - option "BFF가 OAuth Token을 관리하는 조건" - option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" - option "Entity Graph 조회에는 Batch Fetch를 사용한다" - option "Feed Pagination은 Keyset을 사용한다" - option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다." - option "Query Plan은 실제 PostgreSQL에서 측정한다" - option "Query Strategy는 FeedQueryPort 뒤에서 소유한다" - option "현재 Read Model은 CQRS-lite로 유지한다" - option "화면 조회는 Read Projection을 사용한다" - generic [ref=f3e104]: - generic [ref=f3e105]: 관계 4 이유 - textbox "관계 4 이유" [ref=f3e106]: edge가 user와 email만 전달한다는 사실이 이 질문의 출발점이다. - generic [aria-hidden] [ref=f3e107]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다. - generic [ref=f3e108]: - button "위로" [ref=f3e109] - button "아래로" [disabled] [ref=f3e110] - button "삭제" [ref=f3e111] - button "관계 추가" [ref=f3e112] - region [ref=f3e113]: - generic [ref=f3e114]: - paragraph [ref=f3e115]: CASE - heading "문제와 검증" [level=2] [ref=f3e116] - generic [ref=f3e117]: - generic [ref=f3e118]: - generic [ref=f3e119]: 문제 - textbox "문제" [ref=f3e120]: "앞단 proxy가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다. 대신 upstream은 X-Auth-Request-User 하나로 사용자를 판단하게 된다. 이 헤더는 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다. upstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문제가 된다. backend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 보낼 수 있다. 그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다." - generic [ref=f3e121]: - generic [ref=f3e122]: 결론 - textbox "결론" [ref=f3e123]: "헤더를 믿으려면 서로 독립된 곳에서 방어를 해야 된다. host port 닫힘 : 외부에서 upstream·proxy로 바로 가는 경로를 막는다 Nginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다 upstream internal token : edge를 거치지 않은 내부 요청을 막는다 network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 문제를 막지 못한다. controller의 공유 token만으로는 외부 직접 접근이 어려워지는 network 속성을 대신할 수 없다." - generic [ref=f3e124]: - generic [ref=f3e125]: 검증 환경 - textbox "검증 환경" [ref=f3e126]: "Keycloak 26.7.0, oauth2-proxy 7.15.2 client : edge-proxy confidential, PKCE S256 : o 외부 공개 Nginx : 8088 app 8081, oauth2-proxy 4180 : Compose network에 expose만, host publish x Nginx auth_request /oauth2/auth location = /oauth2/auth : internal auth_request_set으로 user, email, Set-Cookie 복사 client 제공 동명 헤더 : 덮어쓰기 trusted proxy : 단일 IP upstream EdgeIdentityController.currentUser(HttpServletRequest) X-Internal-Auth-Token 비교 : MessageDigest.isEqual SecurityConfig의 /edge/** : permitAll AP4_SESSION HttpOnly : true SameSite : Lax Secure : false in local HTTP fixture expire : 1 hour in proxy configuration session-cookie-minimal : true server-side session store : x automatic discovery : x login, token, JWKS, userinfo URL을 각각 관리. HTTP : o" - generic [ref=f3e127]: - generic [ref=f3e128]: 재현 조건 - textbox "재현 조건" [ref=f3e129]: "1. cookie 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인. 2. cookie 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인. 3. authorization request에 client_id=edge-proxy와 code_challenge_method=S256이 있는지 확인. 4. 로그인 뒤 cookie가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인. 브라우저 요청 목록에 Keycloak token endpoint가 없어야 함. Web Storage가 비어 있고 document.cookie로 session cookie를 읽을 수 없어야 함. 5. 정상 session에 다음 헤더를 얹어 GET /api/edge를 보냄. X-Auth-Request-User : spoofed-admin X-Auth-Request-Email : spoofed-admin@example.test X-Internal-Auth-Token : attacker-controlled-token 응답은 200이고 user는 spoofed-admin이 아니라 실제 authenticated user여야 함. 6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인. 7. host의 4180과 8081에 접근할 수 없는지 확인. 8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고, 둘 다 맞으면 200인지 확인." - generic [ref=f3e130]: - generic [ref=f3e131]: 마지막 검증일 - textbox "마지막 검증일" [ref=f3e132]: 2026-08-25 - generic [ref=f3e133]: - generic [ref=f3e134]: 본문 Markdown - group "Markdown 삽입" [ref=f3e135]: - button "코드" [ref=f3e136] [cursor=pointer] - button "표" [ref=f3e137] [cursor=pointer] - button "목록" [ref=f3e138] [cursor=pointer] - textbox "본문 Markdown" [ref=f3e139]: "## 같은 이름의 헤더 `X-Auth-Request-User`는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다. :::evidence key=\"ap4-edge-trust-architecture-1a916e10\" alt=\"외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처.\" caption=\"\" zoom=\"true\" ::: 그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다. ## 위조 요청의 모양 로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자. ```http label=\"공격자가 보낸 요청\" GET http://localhost:8088/api/edge Cookie: AP4_SESSION= X-Auth-Request-User: spoofed-admin X-Auth-Request-Email: spoofed-admin@example.test X-Internal-Auth-Token: attacker-controlled-token ``` 이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다. ## 세 개의 독립된 경계 :::evidence key=\"ap4-edge-forward-auth-flow-a6ec423a\" alt=\"브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도.\" caption=\" \" zoom=\"true\" ::: 현재 OAuth2-Proxy구조에선 이 문제를 서로 독립된 세 곳에서 막는다. | 위치 | 여기서 어떻게 막지? | |---|---| | host port 닫힘 | 외부에서 upstream·proxy로 가는 직접 경로 | | Nginx header 덮어쓰기 | client가 보낸 동명 헤더 | | upstream internal token | edge를 거치지 않은 내부 요청 | 이 중 하나라도 막지 않는다면 안된다. host port가 열려 있으면 헤더 검사만으로 막을 수 없고, 덮어쓰기가 없으면 인증을 안 거친 헤더가 그대로 upstream에 들어가고, internal token이 없으면 내부 workload가 edge처럼 동작할 수 있는 여지가 생긴다. **network isolation만으로는 내부 위조를 막지 못한다. controller의 공유 token만으로는 외부 직접 접근을 막지 못한다.** ## Nginx가 헤더를 만드는 경계 Nginx는 먼저 internal subrequest를 만든다. `location = /oauth2/auth`는 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있다. ```nginx label=\"upstream을 부르기 전에 먼저 물어본다\" auth_request /oauth2/auth; ``` oauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다. ```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가 아니라 덮어쓰기**로 채워진다. ```http label=\"upstream이 실제로 받는 요청\" GET http://app:8081/edge/me X-Auth-Request-User: X-Auth-Request-Email: X-Internal-Auth-Token: ``` 그럼 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다. ## upstream은 무엇을 확인하나 `EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는다. 1. `X-Auth-Request-User`를 읽고 비어 있는지 확인한다. 2. `X-Internal-Auth-Token`을 읽어 설정값과 `MessageDigest.isEqual`로 비교한다. 두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다. ```json label=\"정상 응답 — 4가지 필드\" { \"pattern\": \"AP4-edge-forward-auth\", \"user\": \"regular-user\", \"email\": \"regular-user@example.test\", \"identityHeader\": \"X-Auth-Request-User\" } ``` 하나라도 다르면 401이 된다. ```json label=\"user 헤더가 없거나 internal token이 틀릴 때\" { \"error\": \"trusted edge authentication is required\" } ``` internal token 비교에는 일반 문자열 비교 대신 `MessageDigest.isEqual`을 썼다. 비교 시간 차이로 값이 어디까지 맞았는지 새어 나가는 것을 줄이려는 선택이다. :::danger 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 보호 되지 않는다. ::: 운영으로 넘어갈 때는 이 검사를 filter나 interceptor, security chain처럼 **대상 endpoint 전체에 걸리는 공통 경계**로 옮겨야 한다. ## 경로마다 달라지는 결과 같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다. | 외부 입력 | 인증 상태 | 결과 | |---|---|---| | `GET /` | 미인증 | `/oauth2/start` 302 | | `GET /api/edge` | 미인증 | redirect 없는 401 | | `GET /oauth2/auth` | 무관 | 404 | | `GET /` + 위조 헤더 | 정상 session | 실제 user 200 | | `/edge/me` + user 헤더만 | edge token 없음 | 401 | | `/edge/me` + 틀린 token | token 불일치 | 401 | 아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 `Location` 없는 401을 받아야 한다. **redirect 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.** 다른 경로는 로그인 redirect 규칙을 따른다. 셋째 줄도 중요하다. 외부에서 `/oauth2/auth`를 직접 부르면 404다. `internal` 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다. ## 브라우저가 가지고 있는 것 OAuth2-Proxy 구조는 server-side session store를 두지 않는다. ```text label=\"AP4_SESSION cookie 설정\" name = AP4_SESSION HttpOnly = true SameSite = Lax Secure = false in local HTTP fixture expire = 1 hour in proxy configuration ``` `session-cookie-minimal=true`를 쓰면 cookie에는 access·refresh·ID token 대신 edge가 필요한 최소 정보만 남는다. 브라우저에 남은 부분은 cookie를 JavaScript로 읽을 수 없고 다음 요청에 자동으로 붙는 opaque cookie뿐이다. 지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다. ## endpoint를 외부용과 내부용으로 나눈 이유 브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다. 그래서 자동 discovery를 끄고 네 주소를 각각 관리한다. ```text label=\"issuer는 브라우저가 접속하는 부분\" 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가 요청을 보내기 위해 사용되는 내부 네트워크 주소다. 따라서 둘다 keycloak realm을 가리키지만 용도가 다르고 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없기에 로그인에는 `localhost:8080`을 사용하고 컨테이너는 자신의 `localhost:8080`이 keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 사용한다. ## upstream이 JWT를 받지 않는다 앞의 3가지 구조에서는 Resource Server는 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 `/edge/me`는 **JWT를 입력으로 받지 않는다.** | 무엇을 믿나 | AP1~AP3 | AP4 | |---|---|---| | 서명된 JWT | o | x | | network topology | x | o | | internal token | x | o | | edge의 user·email | x | o | 오른쪽 열이 이 패턴이 신뢰 하는 부분이다. 그래서 edge가 인증 경계 자체가 되고, backend 직접 경로나 사용자 제공 헤더를 허용하는 순간 다른 사용자처럼 보낼 수 있게 된다. ## 헤더를 늘릴 때 정해야 하는 것 현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다. - claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가 - allowlist : Nginx가 어느 응답 헤더만 복사하는가 - 덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가 - 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가 - upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지 - 갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가 ## 확인한 것과 확인하지 않은 것 아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분** 이다. | 항목 | 확인한 부분 | |---|---| | 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 | 일곱째 줄이 핵심이다. 요청이 실패하는지 보는 것이 아니라, **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다. ## 증명하지 않는 것 현재 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾼다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy가 아니다. 그래서 path, method, body, streaming, websocket 같은 큰 헤더 동작은 입증하지 못했다." - group [ref=f3e140]: - paragraph [ref=f3e141]: EVIDENCE - heading "본문에 Asset 삽입" [level=3] [ref=f3e142] - paragraph [ref=f3e143]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다. - generic [ref=f3e144]: - generic [ref=f3e145]: - generic [ref=f3e146]: 업로드 종류 - combobox "업로드 종류" [ref=f3e147]: - option "이미지" [selected] - option "다이어그램" - option "첨부파일" - button "Asset 업로드" [ref=f3e148] - generic [ref=f3e149]: - search [ref=f3e150]: - generic [ref=f3e151]: Asset 검색 - generic [ref=f3e152]: - searchbox "Asset 검색" [ref=f3e153] - button "검색" [ref=f3e154] - generic [ref=f3e155]: - checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f3e156] - generic [ref=f3e157]: 삽입할 때 크게 보기 허용 - status [ref=f3e158]: 삽입할 수 있는 Asset 26개 - list [ref=f3e159]: - listitem [ref=f3e160]: - button "ap4-edge-trust-architecture-8125e9e8" [ref=f3e161] - button "삭제" [ref=f3e162] - listitem [ref=f3e163]: - button "batch-fetch-in-clause-cb44066a" [ref=f3e164] - button "삭제" [ref=f3e165] - listitem [ref=f3e166]: - button "ap4-edge-trust-architecture-1a916e10" [ref=f3e167] - button "삭제" [ref=f3e168] - listitem [ref=f3e169]: - button "ap3-bff-session-flow-1b005e15" [ref=f3e170] - button "삭제" [ref=f3e171] - listitem [ref=f3e172]: - button "ap3-bff-architecture-a27ea91c" [ref=f3e173] - button "삭제" [ref=f3e174] - listitem [ref=f3e175]: - button "ap2-mediator-handoff-flow-efe7039c" [ref=f3e176] - button "삭제" [ref=f3e177] - listitem [ref=f3e178]: - button "ap2-mediator-architecture-c95ed25f" [ref=f3e179] - button "삭제" [ref=f3e180] - listitem [ref=f3e181]: - button "projection-row-over-fetch-f2b1943b" [ref=f3e182] - button "삭제" [ref=f3e183] - listitem [ref=f3e184]: - button "cartesian-row-multiplication-dce2e166" [ref=f3e185] - button "삭제" [ref=f3e186] - listitem [ref=f3e187]: - button "eager-lazy-query-sequence-47c12bda" [ref=f3e188] - button "삭제" [ref=f3e189] - listitem [ref=f3e190]: - button "ap3-bff-session-flow-a8dfff6f" [ref=f3e191] - button "삭제" [ref=f3e192] - listitem [ref=f3e193]: - button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f3e194] - button "삭제" [ref=f3e195] - listitem [ref=f3e196]: - button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f3e197] - button "삭제" [ref=f3e198] - listitem [ref=f3e199]: - button "ap3-csrf-boundary-971df81c" [ref=f3e200] - button "삭제" [ref=f3e201] - listitem [ref=f3e202]: - button "login-api-phase-split-3e354274" [ref=f3e203] - button "삭제" [ref=f3e204] - listitem [ref=f3e205]: - button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f3e206] - button "삭제" [ref=f3e207] - listitem [ref=f3e208]: - button "ap1-direct-architecture-0adf4199" [ref=f3e209] - button "삭제" [ref=f3e210] - listitem [ref=f3e211]: - button "nplus1-query-fanout-644febe6" [ref=f3e212] - button "삭제" [ref=f3e213] - listitem [ref=f3e214]: - button "ap4-edge-trust-1cff2399" [ref=f3e215] - button "삭제" [ref=f3e216] - listitem [ref=f3e217]: - button "ap3-csrf-split-501dd1f7" [ref=f3e218] - button "삭제" [ref=f3e219] - listitem [ref=f3e220]: - button "ap3-bff-custody-82fa18bd" [ref=f3e221] - button "삭제" [ref=f3e222] - listitem [ref=f3e223]: - button "ap2-split-custody-779cb791" [ref=f3e224] - button "삭제" [ref=f3e225] - listitem [ref=f3e226]: - button "ap1-custody-v3-6e0376d2" [ref=f3e227] - button "삭제" [ref=f3e228] - listitem [ref=f3e229]: - button "ap1-custody-v2-e110bd98" [ref=f3e230] - button "삭제" [ref=f3e231] - listitem [ref=f3e232]: - button "ap1-credential-custody-f5e0c027" [ref=f3e233] - button "삭제" [ref=f3e234] - listitem [ref=f3e235]: - button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f3e236] - button "삭제" [ref=f3e237] - region [ref=f3e238]: - generic [ref=f3e239]: - paragraph [ref=f3e240]: LIVE - heading "즉시 미리보기" [level=2] [ref=f3e241] - generic [ref=f3e244]: - generic [ref=f3e245]: - navigation "문서 경로" [ref=f3e246]: - link "검증 기록" [ref=f3e247] [cursor=pointer]: - /url: /explore/cases - generic [aria-hidden] [ref=f3e248]: / - generic [ref=f3e249]: OAuth/OIDC 인증 경계 - generic [aria-hidden] [ref=f3e250]: / - link "KeyCloak Patterns" [ref=f3e251] [cursor=pointer]: - /url: /projects/keycloak-patterns - heading "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" [level=1] [ref=f3e252] - paragraph [ref=f3e253]: X-Auth-Request-User는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다. upstream이 받는 요청에서는 동일한 구조. 그래서 header overwrite, backend direct path 차단, internal credential 검증을 서로 독립된 세 곳에서 방어할 수 있도록 해야 한다. - region "문제와 결론" [ref=f3e254]: - generic [ref=f3e255]: - paragraph [ref=f3e256]: 문제 - paragraph [ref=f3e257]: 앞단 proxy가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다. - paragraph [ref=f3e258]: 대신 upstream은 X-Auth-Request-User 하나로 사용자를 판단하게 된다.이 헤더는 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다.upstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문제가 된다. - paragraph [ref=f3e259]: backend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면공격자가 인증된 사용자처럼 보낼 수 있다. - paragraph [ref=f3e260]: - text: 그래서 이 구조의 문제는 upstream이 - code [ref=f3e261]: X-Auth-Request-User - text: 의 출처를 구분할 수 없다는 점이다. - generic [ref=f3e262]: - paragraph [ref=f3e263]: 결론 - paragraph [ref=f3e264]: 헤더를 믿으려면 서로 독립된 곳에서 방어를 해야 된다. - paragraph [ref=f3e265]: "host port 닫힘 : 외부에서 upstream·proxy로 바로 가는 경로를 막는다Nginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다upstream internal token : edge를 거치지 않은 내부 요청을 막는다" - paragraph [ref=f3e266]: network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 문제를 막지 못한다.controller의 공유 token만으로는 외부 직접 접근이 어려워지는 network 속성을 대신할 수 없다. - generic [ref=f3e267]: - generic [ref=f3e268]: - term [ref=f3e269]: 검증 환경 - definition [ref=f3e270]: - paragraph [ref=f3e271]: Keycloak 26.7.0, oauth2-proxy 7.15.2 - paragraph [ref=f3e272]: "client : edge-proxyconfidential, PKCE S256 : o" - paragraph [ref=f3e273]: "외부 공개Nginx : 8088app 8081, oauth2-proxy 4180 : Compose network에 expose만, host publish x" - paragraph [ref=f3e274]: "Nginxauth_request /oauth2/authlocation = /oauth2/auth : internalauth_request_set으로 user, email, Set-Cookie 복사client 제공 동명 헤더 : 덮어쓰기trusted proxy : 단일 IP" - paragraph [ref=f3e275]: "upstreamEdgeIdentityController.currentUser(HttpServletRequest)X-Internal-Auth-Token 비교 : MessageDigest.isEqualSecurityConfig의 /edge/** : permitAll" - paragraph [ref=f3e276]: "AP4_SESSIONHttpOnly : trueSameSite : LaxSecure : false in local HTTP fixtureexpire : 1 hour in proxy configurationsession-cookie-minimal : true" - paragraph [ref=f3e277]: "server-side session store : xautomatic discovery : xlogin, token, JWKS, userinfo URL을 각각 관리." - paragraph [ref=f3e278]: "HTTP : o" - generic [ref=f3e279]: - term [ref=f3e280]: 검증 데이터 - definition [ref=f3e281]: - paragraph [ref=f3e282]: 1. cookie 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인. - paragraph [ref=f3e283]: 2. cookie 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인. - paragraph [ref=f3e284]: 3. authorization request에 client_id=edge-proxy와 code_challenge_method=S256이 있는지 확인. - paragraph [ref=f3e285]: 4. 로그인 뒤 cookie가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인.브라우저 요청 목록에 Keycloak token endpoint가 없어야 함.Web Storage가 비어 있고 document.cookie로 session cookie를 읽을 수 없어야 함. - paragraph [ref=f3e286]: "5. 정상 session에 다음 헤더를 얹어 GET /api/edge를 보냄.X-Auth-Request-User : spoofed-adminX-Auth-Request-Email : spoofed-admin@example.testX-Internal-Auth-Token : attacker-controlled-token" - paragraph [ref=f3e287]: 응답은 200이고 user는 spoofed-admin이 아니라 실제 authenticated user여야 함. - paragraph [ref=f3e288]: 6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인. - paragraph [ref=f3e289]: 7. host의 4180과 8081에 접근할 수 없는지 확인. - paragraph [ref=f3e290]: 8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고,둘 다 맞으면 200인지 확인. - generic [ref=f3e291]: - term [ref=f3e292]: 기록 - definition [ref=f3e293]: 게시 2026.08.25 · 마지막 검증 2026.08.25 - group [ref=f3e295]: - generic "목차 · 같은 이름의 헤더" [ref=f3e296] [cursor=pointer] - article [ref=f3e298]: - region [ref=f3e299]: - heading [level=2] [ref=f3e300]: - link "같은 이름의 헤더 바로가기" [ref=f3e301] [cursor=pointer]: - /url: "#같은-이름의-헤더" - text: 같은 이름의 헤더 - generic [aria-hidden] [ref=f3e302]: "#" - paragraph [ref=f3e303]: - code [ref=f3e304]: X-Auth-Request-User - text: 는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다. - figure [ref=f3e305]: - button "ap4-edge-trust-architecture-1a916e10 이미지 크게 보기" [ref=f3e306]: - img "외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처." [ref=f3e307] - generic [ref=f3e308]: 크게 보기 - generic [ref=f3e309]: 외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처. - paragraph [ref=f3e310]: - text: 그래서 이 구조의 문제는 upstream이 - code [ref=f3e311]: X-Auth-Request-User - text: 의 출처를 구분할 수 없다는 점이다. - region [ref=f3e312]: - heading [level=2] [ref=f3e313]: - link "위조 요청의 모양 바로가기" [ref=f3e314] [cursor=pointer]: - /url: "#위조-요청의-모양" - text: 위조 요청의 모양 - generic [aria-hidden] [ref=f3e315]: "#" - paragraph [ref=f3e316]: 로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자. - figure "HTTP ·공격자가 보낸 요청 코드 복사" [ref=f3e317]: - generic [ref=f3e318]: - generic [ref=f3e319]: HTTP - generic [ref=f3e320]: ·공격자가 보낸 요청 - button "코드 복사" [ref=f3e321] [cursor=pointer]: 복사 - region "공격자가 보낸 요청 코드" [ref=f3e322]: - code [ref=f3e323]: "GET http://localhost:8088/api/edge Cookie: AP4_SESSION= X-Auth-Request-User: spoofed-admin X-Auth-Request-Email: spoofed-admin@example.test X-Internal-Auth-Token: attacker-controlled-token" - paragraph [ref=f3e325]: - text: 이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 - code [ref=f3e326]: user - text: 가 - code [ref=f3e327]: spoofed-admin - text: 으로 바뀌지 않았는지다. - region [ref=f3e328]: - heading [level=2] [ref=f3e329]: - link "세 개의 독립된 경계 바로가기" [ref=f3e330] [cursor=pointer]: - /url: "#세-개의-독립된-경계" - text: 세 개의 독립된 경계 - generic [aria-hidden] [ref=f3e331]: "#" - figure [ref=f3e332]: - button "ap4-edge-forward-auth-flow-a6ec423a 이미지 크게 보기" [ref=f3e333]: - img "브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도." [ref=f3e334] - generic [ref=f3e335]: 크게 보기 - generic [ref=f3e336]: 브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도. - paragraph [ref=f3e337]: 현재 OAuth2-Proxy구조에선 이 문제를 서로 독립된 세 곳에서 막는다. - region "표" [ref=f3e338]: - table [ref=f3e339]: - caption [ref=f3e340] - rowgroup [ref=f3e341]: - row [ref=f3e342]: - columnheader "위치" [ref=f3e343] - columnheader "여기서 어떻게 막지?" [ref=f3e344] - rowgroup [ref=f3e345]: - row [ref=f3e346]: - cell "host port 닫힘" [ref=f3e347] - cell "외부에서 upstream·proxy로 가는 직접 경로" [ref=f3e348] - row [ref=f3e349]: - cell "Nginx header 덮어쓰기" [ref=f3e350] - cell "client가 보낸 동명 헤더" [ref=f3e351] - row [ref=f3e352]: - cell "upstream internal token" [ref=f3e353] - cell "edge를 거치지 않은 내부 요청" [ref=f3e354] - paragraph [ref=f3e355]: 이 중 하나라도 막지 않는다면 안된다. host port가 열려 있으면 헤더 검사만으로 막을 수 없고, 덮어쓰기가 없으면 인증을 안 거친 헤더가 그대로 upstream에 들어가고, internal token이 없으면 내부 workload가 edge처럼 동작할 수 있는 여지가 생긴다. - paragraph [ref=f3e356]: - strong [ref=f3e357]: network isolation만으로는 내부 위조를 막지 못한다. controller의 공유 token만으로는 외부 직접 접근을 막지 못한다. - region [ref=f3e358]: - heading [level=2] [ref=f3e359]: - link "Nginx가 헤더를 만드는 경계 바로가기" [ref=f3e360] [cursor=pointer]: - /url: "#nginx가-헤더를-만드는-경계" - text: Nginx가 헤더를 만드는 경계 - generic [aria-hidden] [ref=f3e361]: "#" - paragraph [ref=f3e362]: - text: Nginx는 먼저 internal subrequest를 만든다. - code [ref=f3e363]: location = /oauth2/auth - text: 는 - code [ref=f3e364]: internal - text: 이라 Nginx가 만든 subrequest만 들어갈 수 있다. - figure "NGINX ·upstream을 부르기 전에 먼저 물어본다 코드 복사" [ref=f3e365]: - generic [ref=f3e366]: - generic [ref=f3e367]: NGINX - generic [ref=f3e368]: ·upstream을 부르기 전에 먼저 물어본다 - button "코드 복사" [ref=f3e369] [cursor=pointer]: 복사 - region "upstream을 부르기 전에 먼저 물어본다 코드" [ref=f3e370]: - code [ref=f3e371]: auth_request /oauth2/auth; - paragraph [ref=f3e373]: oauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다. - figure "TEXT ·auth_request_set — 값의 출처가 여기서 고정 코드 복사" [ref=f3e374]: - generic [ref=f3e375]: - generic [ref=f3e376]: TEXT - generic [ref=f3e377]: ·auth_request_set — 값의 출처가 여기서 고정 - button "코드 복사" [ref=f3e378] [cursor=pointer]: 복사 - region "auth_request_set — 값의 출처가 여기서 고정 코드" [ref=f3e379]: - code [ref=f3e380]: $auth_user ← oauth2-proxy X-Auth-Request-User $auth_email ← oauth2-proxy X-Auth-Request-Email $auth_cookie ← oauth2-proxy Set-Cookie - paragraph [ref=f3e382]: - text: 그 다음 원래 요청을 그대로 넘기지 않는다. 외부 - code [ref=f3e383]: /api/edge - text: 는 내부 - code [ref=f3e384]: /edge/me - text: 로 다시 매핑되고, 세 헤더는 - strong [ref=f3e385]: merge가 아니라 덮어쓰기 - text: 로 채워진다. - figure "HTTP ·upstream이 실제로 받는 요청 코드 복사" [ref=f3e386]: - generic [ref=f3e387]: - generic [ref=f3e388]: HTTP - generic [ref=f3e389]: ·upstream이 실제로 받는 요청 - button "코드 복사" [ref=f3e390] [cursor=pointer]: 복사 - region "upstream이 실제로 받는 요청 코드" [ref=f3e391]: - code [ref=f3e392]: "GET http://app:8081/edge/me X-Auth-Request-User: X-Auth-Request-Email: X-Internal-Auth-Token: " - paragraph [ref=f3e394]: 그럼 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다. - region [ref=f3e395]: - heading [level=2] [ref=f3e396]: - link "upstream은 무엇을 확인하나 바로가기" [ref=f3e397] [cursor=pointer]: - /url: "#upstream은-무엇을-확인하나" - text: upstream은 무엇을 확인하나 - generic [aria-hidden] [ref=f3e398]: "#" - paragraph [ref=f3e399]: - code [ref=f3e400]: EdgeIdentityController.currentUser(HttpServletRequest) - text: 가 - code [ref=f3e401]: /edge/me - text: 를 받는다. - list [ref=f3e402]: - listitem [ref=f3e403]: - code [ref=f3e404]: X-Auth-Request-User - text: 를 읽고 비어 있는지 확인한다. - listitem [ref=f3e405]: - code [ref=f3e406]: X-Internal-Auth-Token - text: 을 읽어 설정값과 - code [ref=f3e407]: MessageDigest.isEqual - text: 로 비교한다. - paragraph [ref=f3e408]: 두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다. - figure "JSON ·정상 응답 — 4가지 필드 코드 복사" [ref=f3e409]: - generic [ref=f3e410]: - generic [ref=f3e411]: JSON - generic [ref=f3e412]: ·정상 응답 — 4가지 필드 - button "코드 복사" [ref=f3e413] [cursor=pointer]: 복사 - region "정상 응답 — 4가지 필드 코드" [ref=f3e414]: - code [ref=f3e415]: "{ \"pattern\": \"AP4-edge-forward-auth\", \"user\": \"regular-user\", \"email\": \"regular-user@example.test\", \"identityHeader\": \"X-Auth-Request-User\" }" - paragraph [ref=f3e417]: 하나라도 다르면 401이 된다. - figure "JSON ·user 헤더가 없거나 internal token이 틀릴 때 코드 복사" [ref=f3e418]: - generic [ref=f3e419]: - generic [ref=f3e420]: JSON - generic [ref=f3e421]: ·user 헤더가 없거나 internal token이 틀릴 때 - button "코드 복사" [ref=f3e422] [cursor=pointer]: 복사 - region "user 헤더가 없거나 internal token이 틀릴 때 코드" [ref=f3e423]: - code [ref=f3e424]: "{ \"error\": \"trusted edge authentication is required\" }" - paragraph [ref=f3e426]: - text: internal token 비교에는 일반 문자열 비교 대신 - code [ref=f3e427]: MessageDigest.isEqual - text: 을 썼다. 비교 시간 차이로 값이 어디까지 맞았는지 새어 나가는 것을 줄이려는 선택이다. - complementary "위험" [ref=f3e428]: - paragraph [ref=f3e429]: 위험 - paragraph [ref=f3e430]: - text: 현재 - code [ref=f3e431]: SecurityConfig - text: 는 - code [ref=f3e432]: /edge/** - text: 를 - code [ref=f3e433]: permitAll - text: 로 두고 - code [ref=f3e434]: /edge/me - text: controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 보호 되지 않는다. - paragraph [ref=f3e435]: - text: 운영으로 넘어갈 때는 이 검사를 filter나 interceptor, security chain처럼 - strong [ref=f3e436]: 대상 endpoint 전체에 걸리는 공통 경계 - text: 로 옮겨야 한다. - region [ref=f3e437]: - heading [level=2] [ref=f3e438]: - link "경로마다 달라지는 결과 바로가기" [ref=f3e439] [cursor=pointer]: - /url: "#경로마다-달라지는-결과" - text: 경로마다 달라지는 결과 - generic [aria-hidden] [ref=f3e440]: "#" - paragraph [ref=f3e441]: 같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다. - region "표" [ref=f3e442]: - table [ref=f3e443]: - caption [ref=f3e444] - rowgroup [ref=f3e445]: - row [ref=f3e446]: - columnheader "외부 입력" [ref=f3e447] - columnheader "인증 상태" [ref=f3e448] - columnheader "결과" [ref=f3e449] - rowgroup [ref=f3e450]: - row [ref=f3e451]: - cell [ref=f3e452]: - code [ref=f3e453]: GET / - cell "미인증" [ref=f3e454] - cell [ref=f3e455]: - code [ref=f3e456]: /oauth2/start - text: "302" - row [ref=f3e457]: - cell [ref=f3e458]: - code [ref=f3e459]: GET /api/edge - cell "미인증" [ref=f3e460] - cell "redirect 없는 401" [ref=f3e461] - row [ref=f3e462]: - cell [ref=f3e463]: - code [ref=f3e464]: GET /oauth2/auth - cell "무관" [ref=f3e465] - cell "404" [ref=f3e466] - row [ref=f3e467]: - cell [ref=f3e468]: - code [ref=f3e469]: GET / - text: + 위조 헤더 - cell "정상 session" [ref=f3e470] - cell "실제 user 200" [ref=f3e471] - row [ref=f3e472]: - cell [ref=f3e473]: - code [ref=f3e474]: /edge/me - text: + user 헤더만 - cell "edge token 없음" [ref=f3e475] - cell "401" [ref=f3e476] - row [ref=f3e477]: - cell [ref=f3e478]: - code [ref=f3e479]: /edge/me - text: + 틀린 token - cell "token 불일치" [ref=f3e480] - cell "401" [ref=f3e481] - paragraph [ref=f3e482]: - text: 아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 - code [ref=f3e483]: Location - text: 없는 401을 받아야 한다. - paragraph [ref=f3e484]: - strong [ref=f3e485]: - text: redirect 없는 JSON 401은 정확히 - code [ref=f3e486]: /api/edge - text: 경로에만 구성돼 있다. - text: 다른 경로는 로그인 redirect 규칙을 따른다. - paragraph [ref=f3e487]: - text: 셋째 줄도 중요하다. 외부에서 - code [ref=f3e488]: /oauth2/auth - text: 를 직접 부르면 404다. - code [ref=f3e489]: internal - text: 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다. - region [ref=f3e490]: - heading [level=2] [ref=f3e491]: - link "브라우저가 가지고 있는 것 바로가기" [ref=f3e492] [cursor=pointer]: - /url: "#브라우저가-가지고-있는-것" - text: 브라우저가 가지고 있는 것 - generic [aria-hidden] [ref=f3e493]: "#" - paragraph [ref=f3e494]: OAuth2-Proxy 구조는 server-side session store를 두지 않는다. - figure "TEXT ·AP4_SESSION cookie 설정 코드 복사" [ref=f3e495]: - generic [ref=f3e496]: - generic [ref=f3e497]: TEXT - generic [ref=f3e498]: ·AP4_SESSION cookie 설정 - button "코드 복사" [ref=f3e499] [cursor=pointer]: 복사 - region "AP4_SESSION cookie 설정 코드" [ref=f3e500]: - code [ref=f3e501]: name = AP4_SESSION HttpOnly = true SameSite = Lax Secure = false in local HTTP fixture expire = 1 hour in proxy configuration - paragraph [ref=f3e503]: - code [ref=f3e504]: session-cookie-minimal=true - text: 를 쓰면 cookie에는 access·refresh·ID token 대신 edge가 필요한 최소 정보만 남는다. 브라우저에 남은 부분은 cookie를 JavaScript로 읽을 수 없고 다음 요청에 자동으로 붙는 opaque cookie뿐이다. - paragraph [ref=f3e505]: - text: 지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 - code [ref=f3e506]: Secure = true - text: 로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다. - region [ref=f3e507]: - heading [level=2] [ref=f3e508]: - link "endpoint를 외부용과 내부용으로 나눈 이유 바로가기" [ref=f3e509] [cursor=pointer]: - /url: "#endpoint를-외부용과-내부용으로-나눈-이유" - text: endpoint를 외부용과 내부용으로 나눈 이유 - generic [aria-hidden] [ref=f3e510]: "#" - paragraph [ref=f3e511]: 브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다. 그래서 자동 discovery를 끄고 네 주소를 각각 관리한다. - figure "TEXT ·issuer는 브라우저가 접속하는 부분 코드 복사" [ref=f3e512]: - generic [ref=f3e513]: - generic [ref=f3e514]: TEXT - generic [ref=f3e515]: ·issuer는 브라우저가 접속하는 부분 - button "코드 복사" [ref=f3e516] [cursor=pointer]: 복사 - region "issuer는 브라우저가 접속하는 부분 코드" [ref=f3e517]: - code [ref=f3e518]: 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/... - paragraph [ref=f3e520]: issuer는 실제로 요청을 보내기 위한 주소가 아니라 KeyCloak이 발급한 토큰의 iss claim이 우리가 기대한 값과 같은지 검증하기 위한 기준값이다. 반면 token url, userinfo url 같은 경우는 실제로 내부에서 oauth2-proxy가 요청을 보내기 위해 사용되는 내부 네트워크 주소다. - paragraph [ref=f3e521]: - text: 따라서 둘다 keycloak realm을 가리키지만 용도가 다르고 브라우저는 docker 내부 호스트명인 - code [ref=f3e522]: keycloak:8080 - text: 에 접근할 수 없기에 로그인에는 - code [ref=f3e523]: localhost:8080 - text: 을 사용하고 컨테이너는 자신의 - code [ref=f3e524]: localhost:8080 - text: 이 keycloak이 아니므로 내부 통신에는 - code [ref=f3e525]: keycloak:8080 - text: 을 사용한다. - region [ref=f3e526]: - heading [level=2] [ref=f3e527]: - link "upstream이 JWT를 받지 않는다 바로가기" [ref=f3e528] [cursor=pointer]: - /url: "#upstream이-jwt를-받지-않는다" - text: upstream이 JWT를 받지 않는다 - generic [aria-hidden] [ref=f3e529]: "#" - paragraph [ref=f3e530]: - text: 앞의 3가지 구조에서는 Resource Server는 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 - code [ref=f3e531]: /edge/me - text: 는 - strong [ref=f3e532]: JWT를 입력으로 받지 않는다. - region "표" [ref=f3e533]: - table [ref=f3e534]: - caption [ref=f3e535] - rowgroup [ref=f3e536]: - row [ref=f3e537]: - columnheader "무엇을 믿나" [ref=f3e538] - columnheader "AP1~AP3" [ref=f3e539] - columnheader "AP4" [ref=f3e540] - rowgroup [ref=f3e541]: - row [ref=f3e542]: - cell "서명된 JWT" [ref=f3e543] - cell "o" [ref=f3e544] - cell "x" [ref=f3e545] - row [ref=f3e546]: - cell "network topology" [ref=f3e547] - cell "x" [ref=f3e548] - cell "o" [ref=f3e549] - row [ref=f3e550]: - cell "internal token" [ref=f3e551] - cell "x" [ref=f3e552] - cell "o" [ref=f3e553] - row [ref=f3e554]: - cell "edge의 user·email" [ref=f3e555] - cell "x" [ref=f3e556] - cell "o" [ref=f3e557] - paragraph [ref=f3e558]: 오른쪽 열이 이 패턴이 신뢰 하는 부분이다. 그래서 edge가 인증 경계 자체가 되고, backend 직접 경로나 사용자 제공 헤더를 허용하는 순간 다른 사용자처럼 보낼 수 있게 된다. - region [ref=f3e559]: - heading [level=2] [ref=f3e560]: - link "헤더를 늘릴 때 정해야 하는 것 바로가기" [ref=f3e561] [cursor=pointer]: - /url: "#헤더를-늘릴-때-정해야-하는-것" - text: 헤더를 늘릴 때 정해야 하는 것 - generic [aria-hidden] [ref=f3e562]: "#" - paragraph [ref=f3e563]: 현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다. - list [ref=f3e564]: - listitem [ref=f3e565]: "claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가" - listitem [ref=f3e566]: "allowlist : Nginx가 어느 응답 헤더만 복사하는가" - listitem [ref=f3e567]: "덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가" - listitem [ref=f3e568]: "직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가" - listitem [ref=f3e569]: "upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지" - listitem [ref=f3e570]: "갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가" - region [ref=f3e571]: - heading [level=2] [ref=f3e572]: - link "확인한 것과 확인하지 않은 것 바로가기" [ref=f3e573] [cursor=pointer]: - /url: "#확인한-것과-확인하지-않은-것" - text: 확인한 것과 확인하지 않은 것 - generic [aria-hidden] [ref=f3e574]: "#" - paragraph [ref=f3e575]: - text: 아래는 - strong [ref=f3e576]: 커밋된 자동 테스트가 확인하도록 정의한 부분 - text: 이다. - region "표" [ref=f3e577]: - table [ref=f3e578]: - caption [ref=f3e579] - rowgroup [ref=f3e580]: - row [ref=f3e581]: - columnheader "항목" [ref=f3e582] - columnheader "확인한 부분" [ref=f3e583] - rowgroup [ref=f3e584]: - row [ref=f3e585]: - cell "cookie 없는 root의 302" [ref=f3e586] - cell "o" [ref=f3e587] - row [ref=f3e588]: - cell [ref=f3e589]: - text: cookie 없는 - code [ref=f3e590]: /api/edge - text: 의 401 - cell "o" [ref=f3e591] - row [ref=f3e592]: - cell [ref=f3e593]: - code [ref=f3e594]: edge-proxy - text: + S256 challenge - cell "o" [ref=f3e595] - row [ref=f3e596]: - cell [ref=f3e597]: - code [ref=f3e598]: AP4_SESSION - text: HttpOnly · SameSite=Lax - cell "o" [ref=f3e599] - row [ref=f3e600]: - cell "브라우저 요청에 token endpoint 없음" [ref=f3e601] - cell "o" [ref=f3e602] - row [ref=f3e603]: - cell "Web Storage 비어 있고 cookie 읽기 불가" [ref=f3e604] - cell "o" [ref=f3e605] - row [ref=f3e606]: - cell "위조 헤더를 보내도 실제 user로 200" [ref=f3e607] - cell "o" [ref=f3e608] - row [ref=f3e609]: - cell [ref=f3e610]: - text: 외부 - code [ref=f3e611]: /oauth2/auth - text: "404" - cell "o" [ref=f3e612] - row [ref=f3e613]: - cell "host의 4180 · 8081 접근 불가" [ref=f3e614] - cell "o" [ref=f3e615] - row [ref=f3e616]: - cell "user 헤더 없음 · token 없음 · token 불일치 401" [ref=f3e617] - cell "o" [ref=f3e618] - row [ref=f3e619]: - cell "role 전달" [ref=f3e620] - cell "x" [ref=f3e621] - row [ref=f3e622]: - cell "새 endpoint의 공통 강제" [ref=f3e623] - cell "x" [ref=f3e624] - row [ref=f3e625]: - cell "상태 변경 요청의 CSRF" [ref=f3e626] - cell "x" [ref=f3e627] - row [ref=f3e628]: - cell "session 갱신" [ref=f3e629] - cell "x" [ref=f3e630] - row [ref=f3e631]: - cell "replica 간 secret 공유" [ref=f3e632] - cell "x" [ref=f3e633] - row [ref=f3e634]: - cell "internal secret 교체" [ref=f3e635] - cell "x" [ref=f3e636] - paragraph [ref=f3e637]: - text: 일곱째 줄이 핵심이다. 요청이 실패하는지 보는 것이 아니라, - strong [ref=f3e638]: Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지 - text: 를 본다. - region [ref=f3e639]: - heading [level=2] [ref=f3e640]: - link "증명하지 않는 것 바로가기" [ref=f3e641] [cursor=pointer]: - /url: "#증명하지-않는-것" - text: 증명하지 않는 것 - generic [aria-hidden] [ref=f3e642]: "#" - paragraph [ref=f3e643]: - text: 현재 설정은 - code [ref=f3e644]: /api/edge - text: 와 - code [ref=f3e645]: / - text: 를 모두 - code [ref=f3e646]: /edge/me - text: 로 바꾼다. - code [ref=f3e647]: /orders/123 - text: 같은 임의 경로를 보존하는 범용 reverse proxy가 아니다. 그래서 path, method, body, streaming, websocket 같은 큰 헤더 동작은 입증하지 못했다. - region [ref=f3e648]: - paragraph [ref=f3e649]: Next - heading "다음에 읽을 것" [level=2] [ref=f3e650] - list [ref=f3e651]: - listitem [ref=f3e652]: - link "적용 기준 Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" [ref=f3e653] [cursor=pointer]: - /url: /references/forward-auth-identity-header-trust - generic [ref=f3e654]: 적용 기준 - generic [ref=f3e655]: - strong [ref=f3e656]: Forward-Auth에서 Identity Header를 신뢰하기 위한 조건 - paragraph [aria-hidden] [ref=f3e657]: 이 기준의 다섯 조건이 실제로 어떻게 구성되는지 코드와 설정으로 확인한 자리다. - generic [aria-hidden] [ref=f3e658]: ↗ - listitem [ref=f3e659]: - link "적용 기준 OAuth Token과 Application Session을 구분하는 기준" [ref=f3e660] [cursor=pointer]: - /url: /references/oauth-token-application-session-boundary - generic [ref=f3e661]: 적용 기준 - generic [ref=f3e662]: - strong [ref=f3e663]: OAuth Token과 Application Session을 구분하는 기준 - paragraph [aria-hidden] [ref=f3e664]: proxy session cookie와 identity 헤더를 JWT와 구분해야 하는 실례다. - generic [aria-hidden] [ref=f3e665]: ↗ - listitem [ref=f3e666]: - link "적용 기준 OAuth/OIDC 인증 패턴 선택 기준" [ref=f3e667] [cursor=pointer]: - /url: /references/oauth-oidc-pattern-selection-criteria - generic [ref=f3e668]: 적용 기준 - generic [ref=f3e669]: - strong [ref=f3e670]: OAuth/OIDC 인증 패턴 선택 기준 - paragraph [aria-hidden] [ref=f3e671]: OAuth를 모르는 upstream 앞의 공통 관문을 얻고 network·헤더 신뢰 계약을 내주는 경우다. - generic [aria-hidden] [ref=f3e672]: ↗ - listitem [ref=f3e673]: - link "열린 질문 Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" [ref=f3e674] [cursor=pointer]: - /url: /questions/edge-authorization-scope - generic [ref=f3e675]: 열린 질문 - generic [ref=f3e676]: - strong [ref=f3e677]: Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가 - paragraph [aria-hidden] [ref=f3e678]: edge가 user와 email만 전달한다는 사실이 이 질문의 출발점이다. - generic [aria-hidden] [ref=f3e679]: ↗ - complementary [ref=f3e680]: - heading "작업 상태" [level=2] [ref=f3e681] - status "편집 상태" [ref=f3e682]: 저장됨 - generic [ref=f3e683]: - generic [ref=f3e684]: - term [ref=f3e685]: 저장 버전 - definition [ref=f3e686]: "43" - generic [ref=f3e687]: - term [ref=f3e688]: 종류 - definition [ref=f3e689]: 검증 기록 - paragraph [ref=f3e690]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다. - generic [ref=f3e691]: - button "저장" [disabled] [ref=f3e692] - button "게시" [ref=f3e693] - paragraph [ref=f3e694]: 버전 43으로 저장했습니다.