블로그

2026년 8월 15일 · 9분 읽기

oauth2-proxy 공유 게이트: Traefik forwardAuth 인증 붙이기

oauth2-proxy 하나와 Traefik forwardAuth로 내부 서비스 넷에 Google 인증을 붙였습니다. allowlist 한 줄이 네 사이트를 동시에 여는 구조와 그 이유 두 층을 다룹니다.

  • oauth2-proxy
  • Traefik
  • cert-manager
  • 쿠버네티스

2026년 8월 23일 업데이트

파일 한 줄을 더했습니다. 이메일 주소 하나입니다.

그 한 줄로 네 사이트가 열렸습니다. baguette, Grafana, Alertmanager, Prometheus입니다. 한 곳만 열고 나머지를 닫는 방법이 이 구조에는 없습니다.

한눈에 보기

문제 파일 한 줄을 더했습니다.
결정 그 한 줄로 네 사이트가 열렸습니다.
결과 검증 방법을 실행 환경이 정합니다.
제약 헤더를 믿는 서비스와 헤더를 만드는 게이트가 둘 다 필요합니다.

게이트가 하나여서 allowlist도 하나입니다

각 서비스 앞에 인증을 붙이는 방법이 둘입니다. 서비스마다 인증을 넣거나, 앞단에서 한 번 확인하고 통과시키거나.

후자입니다. Traefik이 요청을 받으면 원래 서비스로 보내기 전에 oauth2-proxy에게 물어봅니다. 이 방식을 forwardAuth라고 부릅니다. 인증 서버가 200을 주면 통과시키고, 401을 주면 로그인으로 보냅니다.

oauth2-proxy는 Google을 provider로 씁니다. 도메인 제한은 열어 두고(email_domains = [ "*" ]) 실제 허용은 이메일 목록으로 합니다.

# clab-cluster/argocd/applications/oauth2-proxy.yaml
authenticatedEmailsFile:
  enabled: true
  restricted_access: |-      # 이 목록이 전부다
    owner@example.com
    member@example.com

두 줄입니다. 이번 변경이 둘째 줄을 더한 것이고 커밋은 4ab8d80입니다.

같은 파일에 판정자의 동작을 정하는 값도 몇 개 있습니다.

# 같은 Application의 인라인 Helm values
upstreams        = [ "static://202" ]                 # 뒤에 보낼 서비스가 없다
cookie_domains   = [ ".example.net", ".example.com" ]
whitelist_domains= [ ".example.net", ".example.com" ] # 이 밖으로는 리다이렉트 금지
cookie_expire    = "168h0m0s"                         # 7일
set_xauthrequest = true                               # 헤더에 사용자 정보를 실는다
skip-provider-button = "true"                         # Google 선택 화면을 건너뛴다

첫 줄이 이 파드의 성격입니다. static://202는 프록시할 뒤쪽 서비스가 없다는 뜻입니다. 이 파드는 트래픽을 나르지 않고 판정만 합니다. 요청 본문은 Traefik이 원래 서비스로 직접 보냅니다.

cookie_expire가 7일입니다. 이메일을 목록에서 지워도 이미 받은 쿠키는 최대 7일 동안 유효합니다. 접근을 끊으려면 목록에서 지우는 것으로는 부족하고 쿠키 시크릿을 갈아야 합니다.

미들웨어는 세 벌인데 게이트는 한 개입니다

Traefik의 Middleware는 네임스페이스 스코프 리소스입니다. 다른 네임스페이스의 미들웨어를 Ingress에 붙일 수 없습니다. 그래서 보호할 서비스가 있는 네임스페이스마다 한 벌씩 만들어야 합니다.

오늘 클러스터에 물어보니 세 벌이었습니다. baguette, monitoring, justsend-platform입니다. 이 원고를 처음 쓸 때는 두 벌이었고 그 사이에 백오피스 콘솔이 이 게이트 뒤로 들어왔습니다.

세 벌이 같은 주소를 부릅니다.

forwardAuth:
  address: <in-cluster oauth2-proxy>/oauth2/auth

미들웨어는 셋인데 그 뒤의 판정자는 하나입니다. auth 네임스페이스의 파드 한 개이고, 오늘 24일째 Running이며 재시작이 0입니다.

그래서 allowlist도 하나입니다. baguette만 열고 Grafana는 닫으려면 판정자를 둘로 만들어야 합니다. 지금 구조에서는 파일을 어떻게 나눠도 안 됩니다.

세 벌의 내용도 오늘 하나씩 읽어 비교했습니다. authResponseHeaders 네 개와 errors 블록이 세 네임스페이스에서 글자까지 같습니다. 복사해서 붙인 것이고, 앞으로 헤더를 하나 더할 일이 생기면 세 곳을 고쳐야 합니다.

보호되는 호스트 미들웨어가 있는 곳 판정자
baguette.example.net baguette 네임스페이스 oauth2-proxy.auth
grafana.example.net monitoring 같은 파드
alertmanager.example.net monitoring 같은 파드
prometheus.example.net monitoring 같은 파드
admin.example.com justsend-platform 같은 파드

클러스터 전체로 세면 게이트 뒤가 소수입니다. 오늘 Ingress가 24장이었습니다.

종류 장수 무엇
게이트 뒤 5 baguette, Grafana, Alertmanager, Prometheus, 백오피스 콘솔
로그인 콜백 5 위 다섯의 /oauth2 경로
자체 로그인 1 Argo CD
공개 13 제품 웹, API, 커뮤니티, 메일 콘솔, 데모 등

공개가 열셋입니다. 이 게이트가 클러스터의 문을 지키는 것이 아니라 화면 다섯 개를 지킵니다. 그 다섯이 관측 도구와 백오피스라서 중요한 것이고, 나머지 열셋은 원래 열려 있어야 하는 것들입니다.

argocd.example.net이 이 미들웨어를 안 지납니다. Argo CD는 Google OIDC를 직접 붙이고 argocd-rbac-cm으로 권한을 정합니다. 같은 디렉터리에 Ingress가 있어도 게이트는 다릅니다. 디렉터리에 파일이 함께 있는 것과 같은 문을 지나는 것은 다릅니다.

오늘 그 ConfigMap을 읽어 보니 policy.default가 빈 문자열이고 policy.csv에 계정 하나만 role:admin입니다. 로그인은 누구나 되고 권한은 한 명입니다.

거기에 한 줄이 더 있습니다. scopes: "[groups, email]"입니다. Argo CD는 기본으로 JWT의 groups만 보고 email을 보지 않습니다. 이 줄이 없으면 로그인은 되는데 Application 목록이 통째로 비어 보입니다. 권한이 없는 것이 아니라 매칭할 claim을 안 읽는 것입니다.

표가 넷에서 다섯으로 늘어난 것이 이 구조의 성질입니다. 게이트를 공유하면 서비스를 추가하는 비용이 낮습니다. Ingress에 어노테이션 두 줄을 붙이면 끝입니다. 그 대신 이메일 한 줄의 범위가 그만큼 넓어집니다. 다음에 서비스가 하나 더 들어오면 같은 두 줄이 여섯 사이트를 엽니다.

쿠키 도메인은 .example.net.example.com입니다. 한 번 로그인하면 그 도메인 아래에서 다시 묻지 않습니다. 편한 만큼 범위가 넓습니다.

미들웨어를 둘로 나눈 이유는 401입니다

Ingress 하나에 미들웨어가 두 개 붙어 있습니다. 이름이 oauth2-errorsoauth2-auth입니다.

oauth2-auth가 판정을 물어봅니다. 통과하면 응답 헤더 넷을 원래 서비스로 넘깁니다. 누가 로그인했는지 서비스가 알 수 있게 하는 값들입니다.

# infra/oauth-gateway/monitoring-middleware.yaml
forwardAuth:
  address: <in-cluster oauth2-proxy>/oauth2/auth
  trustForwardHeader: true
  authResponseHeaders:            # 서비스가 받을 값 넷
    - X-Auth-Request-User
    - X-Auth-Request-Email
    - X-Auth-Request-Access-Token
    - Authorization

헤더 넷 중 하나는 실제로 쓰이는 곳이 있습니다. Grafana입니다.

# monitoring/kube-prometheus-stack-grafana ConfigMap의 grafana.ini
[auth.proxy]
enabled = true
header_name = X-Auth-Request-Email   # 이 헤더의 값을 사용자로 믿는다
header_property = username
auto_sign_up = true                  # 처음 온 사람은 계정을 만든다
whitelist = 10.42.0.0/16             # 클러스터 내부에서 온 요청만
sync_ttl = 60

Grafana는 자기 로그인 화면을 띄우지 않습니다. 앞에서 판정이 끝났다고 믿고 헤더의 이메일을 그대로 사용자 이름으로 씁니다.

whitelist가 이 설정의 안전장치입니다. 10.42.0.0/16은 이 클러스터의 파드 네트워크입니다. 그 대역에서 온 요청만 헤더를 믿습니다. 이것이 없으면 누구든 X-Auth-Request-Email 헤더를 손으로 붙여서 아무 사람으로 로그인합니다.

헤더를 믿는 서비스와 헤더를 만드는 게이트가 둘 다 필요합니다. 게이트를 지나지 않고 Grafana에 직접 닿는 경로가 하나라도 생기면 인증이 없는 것과 같습니다. 그래서 Grafana Service는 ClusterIP이고 밖으로 나가는 문이 Ingress 하나입니다.

Alertmanager와 Prometheus는 이 층이 없습니다. 자체 인증이 아예 없어서 게이트가 유일한 방어입니다. 같은 미들웨어를 붙였는데 뒤에 있는 것의 성질이 다릅니다.

oauth2-errors는 실패를 받습니다. 401부터 403까지를 로그인 화면으로 보냅니다.

errors:
  status: ["401-403"]
  service:
    name: oauth2-proxy-local        # ExternalName 서비스
  query: /oauth2/sign_in?rd={url}   # 원래 가려던 주소를 들고 간다
  statusRewrites:
    "401": 302                      # 브라우저가 따라가도록 바꾼다

이 미들웨어가 있는 이유가 마지막 줄입니다. 401은 브라우저가 화면을 갈아 주지 않는 응답입니다. 302로 바꾸면 브라우저가 로그인 주소로 이동합니다.

rd={url}도 같은 이유입니다. 원래 가려던 주소를 들고 가야 로그인 뒤에 그 자리로 돌아옵니다. 없으면 로그인하고 나서 첫 화면에 떨어집니다.

ExternalName 서비스 때문에 Traefik에 수동 patch가 하나 남았습니다

oauth2-errors가 가리키는 oauth2-proxy-local은 실제 파드가 없는 Service입니다. auth 네임스페이스의 이름을 가리키는 ExternalName입니다.

미들웨어가 네임스페이스 안의 Service만 참조할 수 있어서 만든 우회입니다. 그리고 Traefik은 ExternalName 서비스를 기본으로 거부합니다. 그래서 allowExternalNameServices: true를 한 번 손으로 patch했습니다.

그 patch는 지금도 깃옵스 밖에 있습니다. 클러스터를 다시 세우면 이 게이트가 502를 냅니다. 원인이 미들웨어 설정에 없고 Traefik 프로바이더 옵션에 있습니다.

로그인 경로는 게이트 밖에 따로 둡니다

보호되는 호스트마다 Ingress가 두 장입니다. 오늘 세어 보니 보호 호스트 다섯 개에 콜백 Ingress가 다섯 장 있습니다.

# monitoring/grafana-oauth2-callback (라이브 읽기)
rules:
  - host: grafana.example.net
    http:
      paths:
        - path: /oauth2                 # 이 접두사만
          backend: <in-cluster oauth2-proxy>
# 미들웨어 어노테이션이 없다

미들웨어가 없는 것이 이 파일의 요점입니다. /oauth2 아래에 로그인 화면과 Google 콜백이 있습니다. 그 경로에 인증을 걸면 로그인하려면 먼저 로그인해야 하는 상태가 됩니다.

경로 미들웨어 가는 곳
grafana.example.net/ oauth2-errors, oauth2-auth Grafana
grafana.example.net/oauth2/… 없음 판정자 파드

같은 호스트 이름이 경로에 따라 다른 곳으로 갑니다. 쿠키가 호스트 단위로 붙으니 로그인 화면과 보호되는 화면이 같은 호스트에 있어야 합니다. 그래서 판정자를 별도 도메인에 두지 않고 호스트마다 경로 하나를 내줬습니다.

호스트를 하나 추가할 때 붙는 것이 어노테이션 두 줄과 Ingress 한 장입니다. 콜백 Ingress를 빠뜨리면 화면은 로그인으로 가는데 그 로그인 주소에서 404가 납니다.

인증서는 호스트마다 따로 받습니다

Ingress에 어노테이션 한 줄을 붙이면 cert-manager가 인증서를 받아 옵니다.

annotations:
  cert-manager.io/cluster-issuer: letsencrypt-prod
tls:
  - hosts: [grafana.example.net]
    secretName: grafana-example-net-tls   # 호스트 하나에 secret 하나

ClusterIssuer는 HTTP-01 solver를 씁니다. Let's Encrypt가 http://호스트/.well-known/acme-challenge/...로 되물어 보고, 그 요청이 우리 Traefik에 닿아야 발급됩니다.

그래서 이 방식은 호스트가 공개 DNS로 우리를 가리키고 80번이 열려 있어야 됩니다. 호스트가 늘면 그만큼 발급이 늘어납니다.

오늘 클러스터의 Certificate를 다 세어 봤습니다. 열여덟 장이고 전부 letsencrypt-prod이며 전부 Ready입니다. 한 장이 호스트 하나를 담당하고, 이름에 호스트가 그대로 들어 있습니다.

와일드카드는 다른 경로입니다

같은 클러스터에 와일드카드 인증서를 쓰는 Ingress가 하나 있습니다. *.rpc.example.org입니다.

호스트 secret 만드는 주체
cert-manager Certificate 18장 grafana.example.net 호스트별로 하나씩 cert-manager, HTTP-01
RPC 게이트웨이 *.rpc.example.org rpc-wildcard-tls 하나 사람이 kubectl apply

RPC 쪽은 앱마다 서브도메인이 생깁니다. 게이트웨이가 호스트를 보지 않고 bearer key로 인증하므로 어느 서브도메인이 와도 같은 서비스로 갑니다. 호스트가 계속 늘어나는 구조에서 호스트별 발급을 쓰면 새 앱마다 인증서를 받아야 합니다.

두 경로를 섞으면 헷갈립니다. 호스트가 고정이면 호스트별로 받고, 호스트가 늘어나면 와일드카드를 받습니다.

와일드카드 쪽에는 갱신할 주체가 없습니다

와일드카드를 확인하다가 Certificate 목록에 그것이 없는 것을 봤습니다. secret만 있고 cert-manager가 아는 Certificate가 없습니다.

secret에서 인증서를 꺼내 읽었습니다.

# kubectl -n fp-router get secret rpc-wildcard-tls → openssl x509 (2026-08-15)
subject   CN=*.rpc.example.org
issuer    Let's Encrypt YE2
notBefore Jul 15 08:12:12 2026 GMT
notAfter  Oct 13 08:12:11 2026 GMT

Let's Encrypt이니 90일입니다. 그리고 이 secret은 2026-07-15에 사람이 kubectl apply로 넣었습니다. last-applied-configuration 어노테이션이 붙어 있고 cert-manager의 라벨이 없습니다.

HTTP-01로는 와일드카드를 받을 수 없습니다. 와일드카드는 DNS-01이 필요하고, DNS-01은 DNS 제공자의 API 자격증명을 클러스터에 넣어야 합니다. 그것을 넣지 않았으니 손으로 받아 손으로 넣은 상태입니다.

열여덟 장은 저절로 갱신되고 한 장은 사람이 기억해야 합니다. 만료가 10월 13일이고 오늘이 8월 15일입니다.

한 줄이 파드에 닿기까지 다섯 단계입니다

파일을 고치고 push했습니다. 그것으로 끝이 아닙니다.

단계 무엇이 일어나나
1 push가 main에 도착합니다
2 root Application이 4ab8d80으로 sync합니다
3 oauth2-proxy 자식 Application이 인라인 Helm values를 다시 렌더합니다
4 auth 네임스페이스의 ConfigMap oauth2-proxy-accesslist가 갱신됩니다
5 Helm 차트의 config-checksum이 바뀌어 파드가 롤링됩니다

5단계가 있는 이유는 파드가 파일을 시작할 때 읽기 때문입니다. ConfigMap만 갱신되면 이미 뜬 파드는 옛 목록을 들고 있습니다. 차트가 설정 해시를 파드 템플릿에 넣어 두는 것이 그것을 강제합니다.

새 파드가 07:48:01에 떴습니다. 로그에 using authenticated emails file /etc/oauth2-proxy/oauth2-proxy-accesslist와 watcher 등록이 남았습니다.

키 이름이 중간에 한 번 바뀝니다

오늘 그 ConfigMap을 다시 읽었습니다. 목록은 여전히 두 줄입니다. 그런데 키 이름이 values에 적은 것과 다릅니다.

# Helm values에 적은 것
authenticatedEmailsFile.restricted_access: |-
  owner@example.com
  member@example.com

# 클러스터의 ConfigMap oauth2-proxy-accesslist
data.restricted_user_access: "owner@example.com\nmember@example.com"

restricted_access로 적었고 restricted_user_access로 나옵니다. 차트가 이름을 바꿔서 렌더합니다.

검증할 때 걸리는 자리입니다. values에 적은 이름으로 ConfigMap을 조회하면 아무것도 없다고 나옵니다. 반영이 안 된 것처럼 보이는 두 번째 함정이고, 앞의 180초와 원인이 다릅니다.

중간에 거짓으로 보이는 구간이 있습니다

2단계 전에 4단계를 확인하면 옛 목록이 나옵니다. root Application의 polling 간격이 180초입니다.

push했고 파일은 맞고 ConfigMap은 옛것입니다. 이 상태에서 "반영되지 않았다"고 판정하면 없는 문제를 찾기 시작합니다.

root sync가 끝난 뒤 다시 보니 즉시 반영돼 있었습니다. 그래서 확인 순서를 바꿨습니다. ConfigMap보다 root의 revision을 먼저 봅니다.

distroless에서는 컨테이너 밖을 읽었습니다

파드 안에서 파일을 직접 확인하려고 했습니다. exec cat이 되지 않았습니다.

oauth2-proxy 이미지가 distroless입니다. 셸도 cat도 들어 있지 않습니다. 공격 표면을 줄이려고 그렇게 만든 이미지입니다.

그래서 밖에서 셋을 봤습니다. ConfigMap을 다시 읽고, 라이브 Application의 Helm values를 읽고, 파드 로그에서 그 파일을 열었다는 줄을 찾았습니다.

셋이 각각 다른 층입니다. ConfigMap은 선언, Helm values는 렌더 결과, 로그는 프로세스가 실제로 한 일입니다. 셋이 같은 값을 말하면 안에서 cat한 것과 같은 수준입니다.

검증 방법을 실행 환경이 정합니다. 셸이 없는 이미지를 골랐을 때 무엇으로 확인할지도 같이 정했습니다.