soobook
KUBERNETES

OIDC and Kubernetes Authentication

OIDC는 OAuth 2.0 위에 신원을 얹고, kube-apiserver는 그 서명된 토큰 하나로 나를 알아본다

OIDC는 OAuth 2.0 위에 “이 사람이 누구인가”를 표준화해 얹은 계층이다.

Intro

Kubernetes에는 사용자 데이터베이스가 없다.

kubectl get pods를 치면 명령어를 실행하는게 나라는 걸 클러스터가 알 수가 없다.

그럼에도 클러스터는 나를 특정 사용자로, 특정 그룹의 일원으로 인식하고 RBAC을 건다. 이 마법의 상당 부분이 OIDC(OpenID Connect)에서 온다.

OAuth 2.0 in One Breath

OIDC를 이해하려면 OAuth 2.0을 딱 한 문단만 알면 된다.

인증과 인가는 다른 개념이다.

  • 인증(authentication): 누구세요?
  • 인가(authorization): 권한이 있으세요?

OAuth 2.0은 이 중 인가만 표준화한 프로토콜이다. 어떤 앱이 내 리소스에 접근하도록 access token을 발급하는 절차를 정의한다. 그런데 OpenID Connect 스펙은 이 지점을 분명히 못박는다.

Notably, without profiling OAuth 2.0, it is incapable of providing information about the authentication of an End-User.

OAuth 2.0만으로는 “이 토큰을 들고 온 사람이 누구인지”를 표준적으로 알 방법이 없다. access token은 원래 리소스 접근권을 담는 물건이지, 신원 증명서가 아니다.

OIDC는 바로 이 빈칸을 채운다. OpenID Connect 1.0은 스스로를 이렇게 소개한다.

OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol.

OAuth 2.0의 흐름은 그대로 빌려 쓰되, 그 위에 신원 정보를 담은 새 토큰을 하나 더 얹는 것이다.

What OIDC Adds: the ID Token

OIDC가 OAuth 2.0에 더하는 건 사실상 ID Token 하나다.

ID Token은 JWT(JSON Web Token)다. JWT란 두 당사자 사이에 주고받는 claim을 압축된 URL-safe 문자열로 표현하는 방식을 말한다. 점(.)으로 나뉜 세 조각, 즉 header, payload, signature로 이루어진다.

여기서 payload에 담기는 각 필드를 claim이라 부른다. 신원을 구성하는 사실 조각들이다. ID Token에서 중요한 것 몇 개만 보면 이렇다.

claim의미
ississuer. 이 토큰을 발급한 주체의 식별자
subsubject. 발급자 안에서 이 사용자를 가리키는 고유 ID
audaudience. 이 토큰을 받기로 되어 있는 대상(client)
expexpiry. 만료 시각(Unix time)
nonce요청과 응답을 묶어 재생 공격을 막는 일회성 값

iss는 case-sensitive한 문자열이고, 나중에 kube-apiserver가 --oidc-issuer-url과 정확히 대조하는 값이 바로 이것이다. aud도 마찬가지다. JWT 스펙은 이렇게 규정한다.

Each principal intended to process the JWT MUST identify itself with a value in the audience claim. If the principal processing the claim does not identify itself with a value in the “aud” claim when this claim is present, then the JWT MUST be rejected.

즉 토큰을 받은 쪽은 aud에서 자기 자신을 찾지 못하면 그 토큰을 거부해야 한다. “이 토큰은 너 쓰라고 만든 게 아니야”를 검증하는 장치다.

그리고 결정적으로, ID Token은 서명되어 있다. OIDC 스펙은 ID Token을 JWS(JSON Web Signature)로 서명하도록 요구하고, algnone으로 두는 것을 원칙적으로 금지한다. 서명이 있기 때문에, 토큰을 받은 쪽은 발급자에게 되묻지 않고도 이 토큰이 위조되지 않았음을 스스로 확인할 수 있다.

aud 검증. 같은 IdP가 서명해 서명 검증은 둘 다 통과하는 두 ID Token 중, kube-apiserver는 aud에 자신의 client id인 kubernetes가 들어 있는 토큰만 받아들이고 grafana를 대상으로 발급된 토큰은 서명이 유효해도 거부한다

The Handshake: Code Flow, PKCE, Discovery

그러면 이 ID Token은 어떻게 손에 들어오는가.

표준 경로는 Authorization Code Flow다. 사용자가 브라우저로 IdP(Identity Provider)에 로그인하면, IdP는 먼저 짧은 authorization code를 돌려주고, 클라이언트가 그 code를 토큰 엔드포인트에서 실제 토큰으로 교환한다. 로그인이 일어나는 채널(브라우저)과 토큰을 받는 채널(백채널)을 분리하는 게 요지다.

여기에 거의 항상 PKCE(Proof Key for Code Exchange, “픽시”라 읽는다)가 붙는다. kubectl 같은 CLI나 SPA처럼 비밀을 안전하게 보관할 수 없는 public client는, 중간에서 authorization code를 가로채면 그대로 털린다. PKCE는 이 code 가로채기 공격을 막는다.

원리는 단순하다. 클라이언트가 매번 임의의 code_verifier를 만들고, 그 SHA-256 해시를 BASE64URL로 인코딩한 값을 code_challenge로 먼저 보낸다. 나중에 code를 토큰으로 교환할 때 원본 code_verifier를 함께 제출한다. IdP는 받은 verifier를 같은 방식으로 해시해 앞서 온 code_challenge와 맞는지 확인한다. code만 훔친 공격자는 원본 verifier를 모르니 교환에 실패한다.

클라이언트는 IdP의 로그인 엔드포인트가 어디인지, 서명 검증용 공개키가 어디 있는지를 Discovery로 알아낸다.

OIDC provider는 자신의 issuer URL 뒤에 /.well-known/openid-configuration을 붙인 경로에 JSON 설정 문서를 반드시 제공해야 한다. 이 문서 안에 authorization 엔드포인트, token 엔드포인트, 그리고 jwks_uri가 들어 있다.

jwks_uri는 그 provider의 공개 서명 키 묶음(JWK Set)을 가리킨다. 검증하는 쪽은 여기서 공개키를 받아 ID Token의 서명을 확인한다. 개인키나 대칭키는 이 문서에 들어가지 않는다. 공개키만 나눠 주고, 서명은 IdP만 만들 수 있으니, “이 토큰은 진짜 저 IdP가 발급했다”가 증명된다.

OIDC 핸드셰이크 여정. Discovery로 엔드포인트와 jwks_uri를 알아낸 뒤, 프런트 채널의 브라우저 로그인이 code_challenge를 보내고 짧은 authorization code를 받아 오고, 백채널에서 code와 code_verifier를 제출해 IdP의 해시 대조를 통과하면 서명된 ID Token이 손에 들어온다

Kubernetes Has No Users

이제 다시 Kubernetes로 돌아온다.

kube-apiserver는 OIDC의 관점에서 보면 토큰을 받아 검증하는 쪽이다. 기억할만한 점은 apiserver가 access token이 아니라 ID Token을 bearer token으로 쓴다는 것이다.

To identify the user, the authenticator uses the id_token (not the access_token) from the OAuth2 token response as a bearer token.

인가가 아니라 인증이 목적이니, 신원이 담긴 ID Token을 쓰는 게 자연스럽다.

Authorization 이 아닌 Authentication 이 목적이라는 점에 유의하자!

여기서 등장인물 전체를 한 번 정리하고 가자. 사용자, 클라이언트(kubectl oidc-login), IdP, 그리고 kube-apiserver.

  1. Setup(1회): 운영자가 IdP에 OIDC application을 등록해 client id를 얻고, kube-apiserver에는 issuer URL과 그 client id를 신뢰하도록 설정한다.
  2. Discover: 클라이언트가 IdP의 /.well-known/openid-configuration을 읽어 엔드포인트와 jwks_uri를 알아낸다.
  3. Authorize: 사용자가 브라우저로 IdP에 로그인하고, IdP가 짧은 authorization code를 돌려준다.
  4. Exchange: 클라이언트가 code와 code_verifier를 IdP의 token 엔드포인트에 내고 ID Token을 받는다. 여기까지 토큰을 만들고 서명하고 발급하는 주체는 전부 IdP다.
  5. Use: 클라이언트가 그 ID Token을 bearer로 apiserver에 제출한다. Kubernetes 하는 일은 발급이 아니라 검증이다.

apiserver가 IdP에 자신을 등록하는 절차는 없다는 점에 유의하자.

sequenceDiagram
    autonumber
    actor U as User
    participant C as Client (kubectl oidc-login)
    participant I as IdP (Okta)
    participant K as kube-apiserver

    Note over U,K: Setup, once
    U->>I: register OIDC app
    I-->>U: client_id
    U->>K: trust issuer URL + client_id

    Note over U,I: Login, per session
    U->>C: kubectl get pods
    C->>I: GET /.well-known/openid-configuration
    C->>I: authorize + code_challenge (browser login)
    I-->>C: authorization code
    C->>I: code + code_verifier
    I-->>C: ID Token, signed by IdP

    Note over C,K: Request, per call
    C->>K: Authorization: Bearer id_token
    K->>K: verify locally (sig, iss, aud, exp), then RBAC

동작은 이렇다. 클라이언트가 ID Token을 Authorization: Bearer <id_token> 헤더에 실어 apiserver를 호출한다. apiserver는 그 JWT의 서명을, issuer의 Discovery로 알아낸 공개키로 검증한다. iss가 설정된 issuer와 일치하는지, aud에 자기 client id가 들어 있는지, exp가 지나지 않았는지를 본다. 통과하면, 지정된 claim을 사용자 이름과 그룹으로 삼아 요청에 붙인다.

여기서 가장 중요한 성질은 이 검증이 stateless라는 점이다. 문서의 표현이 정확하다.

Since all of the data needed to validate who you are is in the id_token, Kubernetes doesn’t need to “phone home” to the identity provider.

토큰 안에 필요한 모든 것이 들어 있고 서명으로 무결성이 보장되니, apiserver는 매 요청마다 IdP에 되물을 필요가 없다. 이 덕에 인증이 빠르고 IdP 장애에 덜 흔들린다.

대신 대가가 있다. 되묻지 않는다는 건 곧 토큰을 회수할 수 없다는 뜻이다. 문서도 못박는다.

The id_token can’t be revoked, it’s like a certificate so it should be short-lived (only a few minutes).

한 번 발급된 ID Token은 exp가 될 때까지 유효하다. 그래서 ID Token은 인증서처럼 다뤄야 하고, 수명을 몇 분 단위로 짧게 잡는 게 정석이다. 유출돼도 창이 짧게.

kube-apiserver의 stateless OIDC 검증. kubectl이 ID Token을 bearer로 실어 apiserver를 호출하면, apiserver는 시작 시 Discovery로 받아 둔 IdP 공개키(JWKS)로 서명을 로컬에서 검증하고 iss, aud, exp를 확인한 뒤 username과 groups claim을 요청에 붙여 RBAC으로 넘긴다. IdP로 되묻는 화살표는 없다

레거시 설정에서는 이 동작을 kube-apiserver 플래그로 켠다.

플래그역할
--oidc-issuer-urlIdP의 issuer URL. https만 허용, 여기서 공개키를 discovery
--oidc-client-id모든 토큰이 대상으로 삼아야 하는 aud
--oidc-username-claim사용자 이름으로 쓸 claim. 기본값 sub
--oidc-groups-claim그룹으로 쓸 claim. 값은 문자열 배열이어야 함
--oidc-signing-algs허용 서명 알고리즘. 기본값 RS256

From Flags to Config

플래그 방식에는 한계가 뚜렷하다. IdP를 하나밖에 못 걸고, aud도 하나로 고정이며, claim을 손보려면 접두어 옵션 정도가 전부다.

그래서 Kubernetes는 파일 기반의 Structured Authentication Configuration으로 옮겨 갔다.

AuthenticationConfiguration이라는 object(apiserver.config.k8s.io/v1)를 파일로 두고 --authentication-config로 넘기는 방식이다. 이 기능은 v1.34에서 stable이 되었고 기본으로 켜져 있다.

특징은 다음과 같다.

  • 다중 authenticator: 서로 다른 issuer를 가진 JWT authenticator를 여러 개(최대 64개) 걸 수 있다. IdP 여러 곳을 동시에 신뢰한다.
  • 다중 audience: 한 issuer에 여러 aud를 허용한다.
  • CEL 표현식: claim을 사용자 속성으로 매핑하거나, claim과 사용자 정보를 검증하는 로직을 CEL(Common Expression Language)로 직접 쓸 수 있다.
  • hot-reload: 파일을 고치면 apiserver가 authenticator를 자동으로 다시 읽는다. 재시작이 필요 없다.

한 가지 주의할 것은 --authentication-config와 기존 --oidc-* 플래그를 동시에 주면 안 된다.

If you specify —authentication-config along with any of the —oidc-* command line arguments, this is a misconfiguration. In this situation, the API server reports an error and then immediately exits.

병용하면 apiserver가 에러를 내고 곧바로 죽는다. 파일 방식으로 넘어가려면 --oidc-*를 전부 걷어내야 한다.

Groups and RBAC

검증을 통과한 요청에는 username과 groups가 붙는다. 이 groups를 채우는 것이 --oidc-groups-claim이다. IdP가 토큰에 그룹 정보를 담아 주면, apiserver가 그것을 사용자의 그룹으로 삼고, RBAC의 RoleBinding이 그 그룹에 권한을 건다.

IdP에서 platform-team 그룹에 속한 사람은 자동으로 그 그룹에 묶인 Role을 받는다. 사람이 클러스터를 떠나도, IdP에서 그룹만 빼면 클러스터 권한이 사라진다.

신원 관리의 무게중심이 클러스터 밖 IdP로 옮겨 간다는 게 OIDC 방식의 핵심 이점이다.

When the Cluster Is the Provider

지금까지는 클러스터가 토큰을 받는 쪽이었다. 그런데 이 방향이 뒤집히는 지점이 있다. 클러스터 자신이 OIDC provider가 되는 경우다.

Kubernetes에서는 ServiceAccount 토큰이 그렇다. 요즘의 ServiceAccount 토큰은 TokenRequest API로 발급되는 bound token이다. 수명이 짧고, 특정 Pod에 묶이며(Pod이 사라지면 만료), 정해진 aud를 가진 JWT다. 즉 이 토큰들도 결국 서명된 OIDC 스타일 토큰이다.

그렇다면 클러스터가 자신의 Discovery 문서와 JWKS를 공개하면, 클러스터 밖의 시스템도 이 토큰의 서명을 검증할 수 있다.

  • AWS IRSA: EKS는 클러스터마다 공개 OIDC discovery 엔드포인트를 띄운다. AWS IAM이 그 엔드포인트로 클러스터가 발급한 ServiceAccount 토큰의 서명을 검증하고, 그에 맞는 IAM 역할을 내준다. Pod이 정적 AWS 키 없이 IAM 권한을 얻는다.
  • GKE Workload Identity Federation: 클러스터를 PROJECT_ID.svc.id.goog라는 workload identity pool에 묶어, ServiceAccount가 Google Cloud 리소스에 접근하도록 신원을 연합한다.

실제 usecase로 클러스터가 클러스터에 접근하는 시나리오로 보자. Argo CD를 management 클러스터에 두고 여러 workload 클러스터에 배포하는 멀티클러스터 GitOps 구성이 대표적이고, EKS에서는 IRSA가 이 연결을 만든다.

  1. Argo CD 컨트롤러 Pod은 management 클러스터가 서명한 ServiceAccount 토큰을 갖는다. audsts.amazonaws.com이다.
  2. 이 토큰으로 AWS STS에 AssumeRoleWithWebIdentity를 호출한다. IAM은 management 클러스터의 공개 OIDC 엔드포인트에서 JWKS를 받아 서명을 검증하고, 임시 자격 증명을 돌려준다. 발급자가 클러스터이고 검증자가 IAM이니, 사람 로그인에서 봤던 역할이 정확히 뒤집혀 있다.
  3. Argo CD는 그 자격 증명으로 workload 클러스터용 EKS 인증 토큰을 만들어 대상 apiserver를 호출한다. 대상 클러스터는 access entry로 그 IAM 역할을 Kubernetes 사용자와 그룹에 매핑하고, 나머지는 늘 하던 RBAC이다.

이 체인 어디에도 kubeconfig에 박아 둔 정적 토큰이 없다. management 클러스터의 서명 키가 신뢰의 뿌리가 되고, 그 위의 자격 증명은 전부 짧게 살다 사라진다. 클라우드 밖이라면 workload 클러스터의 Structured Authentication Configuration에 management 클러스터의 issuer를 JWT authenticator로 직접 등록해 같은 효과를 낼 수도 있다.

management 클러스터의 Argo CD가 workload EKS 클러스터에 접근하는 IRSA 흐름. Argo CD Pod이 management 클러스터가 서명한 ServiceAccount 토큰으로 AWS STS에 role을 요청하면, IAM이 그 클러스터의 공개 JWKS로 서명을 검증해 임시 자격 증명을 내주고, Argo CD는 EKS 인증 토큰으로 대상 apiserver를 호출한다. 대상 클러스터는 access entry로 IAM 신원을 RBAC에 매핑하며, 체인 어디에도 정적 kubeconfig 비밀이 없다

패턴은 같다. 한쪽이 서명하고 공개키를 공개하면, 다른 쪽은 그 공개키로 검증하고 신뢰한다. OIDC의 서명-검증 구조가 클러스터 경계를 넘어 클라우드 IAM까지 확장된 것이다.

References