kubernetes 인증/인가 - OIDC (로컬 환경 실습)
지난 글에서는 k8s의 인증(Authentication)과 인가(Authorization)에 대해 개념을 공부하고 실습을 진행했습니다.
하지만 실제 업무에서는 개발자가 `kubectl`로 pod에 접근하거나 Devops 엔지니어가 `cluster` 상태를 확인한다거나, 여러 `cluster`를 관리한다거나 처럼 사람이 k8s에 접근하는 경우가 많습니다.
이때, k8s는 OIDC(OpenID Connect)를 이용하여 외부 인증 시스템과 연동할 수 있습니다.
OIDC(OpenID Connect)

`OIDC`는 `OAuth 2.0` 기반의 인증 프로토콜 입니다.
OAuth 2.0과 OIDC의 관계
`OIDC`를 이해하려면 먼저 `OAuth 2.0` 을 알아야 합니다.
`OAuth 2.0`은 인가 프로토콜로, "이 앱이 내 구글 캘린더에 접근해도 좋다"처럼 리소스 접근 권한을 위임하는 것이 목적입니다. 하지만 "지금 로그인한 사람이 누구인가?"는 알려주지 않습니다.
여기서 `OIDC`는 `OAuth 2.0` 위에 인증 레이어를 얹은 프로토콜인데, `OAuth 2.0 Authorization Code Flow`를 그대로 사용하면서, 응답에 `ID Token(JWT)` 을 추가로 발급합니다. 그리고 이 `JWT Token`에 `OAuth2.0` 프로토콜에서 알 수 없었던 "누가 로그인했는지"에 대한 정보가 담기게 됩니다.
- OAuth 2.0 → 인가 (Authorization) : "무엇을 할 수 있는가"
- OIDC → 인증 (Authentication) : "누구인가" + OAuth 2.0
OIDC Flow

여기서 중요한 점은 k8s가 직접 로그인을 처리하지 않는다는 것입니다.
로그인 → IdP
권한 → Kubernetes
즉, k8s는 IdP를 통해 발급된 JWT 토큰을 검증하는 역할만 합니다.
토큰 정보(claim)
{
"iss": "http://127.0.0.1:5556",
"sub": "CmMxMjMSBWxvY2Fs",
"aud": "kubernetes",
"exp": 1773479527,
"iat": 1773393127,
"email": "admin@example.com"
}
OIDC 인증 후 받은 JWT 토큰을 디코딩하면 위와 같은 정보를 볼 수 있는데, 이를 `claim`이라고 합니다.
| Claim | 설명 |
| iss (Issuer) | 토큰을 발급한 OIDC 제공자(IdP) 를 의미합니다. `--oidc-issuer-url` 설정과 일치하는지 확인하여 토큰을 검증합니다. |
| sub (Subject) | 사용자의 고유 식별자입니다. IdP 내부에서 사용자를 구분하기 위한 값으로, 일반적으로 변하지 않는 ID입니다. |
| 사용자의 이메일 정보입니다. `--oidc-username-claim` 설정을 통해 사용자 이름으로 사용할 수 있습니다. |
|
| groups | 사용자가 속한 그룹 정보입니다. `--oidc-groups-claim` 설정을 통해 RBAC 그룹 권한과 매핑할 수 있습니다. |
| aud (Audience) | 이 토큰이 어떤 서비스(Client)를 대상으로 발급되었는지를 나타냅니다. `--oidc-client-id` 값과 일치해야 합니다. |
즉, OIDC의 claim은 토큰 내부에 포함된 사용자 및 토큰 메타데이터로, k8s는 iss, aud 등을 통해 토큰을 검증하고 email, groups 등을 이용해 사용자와 RBAC 권한을 매핑합니다.
OIDC 도구
대표적인 OIDC 도구들은 아래와 같습니다.
| 도구 | 설명 |
| Dex | Kubernetes 환경에서 많이 사용하는 경량 OIDC Provider 링크 |
| Keycloak | RedHat에서 개발한 오픈소스 IdP 링크 |
| Okta | SaaS 형태의 상용 IdP 서비스 링크 |
| Google / Azure AD | 클라우드 계정 기반 OIDC 인증 제공 |
Dex

`Dex`는 `OpenID Connect`를 사용하여 다른 앱의 인증을 지원하는 경량화 오픈소스입니다. `Google`, `GitHub` 등 다양한 `IdP` 앞에 위치하여 `Kubernetes`가 이해할 수 있는 `JWT` 토큰으로 변환해주는 역할을 합니다.
실습 - Dex를 활용한 OIDC 인증
로컬 실행이 간편하고, static password 지원하기 때문에 `Google` 계정이나 `LDAP` 없이도 동작하기 때문에 `Dex`를 통한 `OIDC` 실습을 진행합니다.
인증서 구성
`kube-apiserver`는 `--oidc-issuer-url`이 `HTTPS`여야만 정상적으로 동작합니다. (`cluster`를 생성할 때 `HTTP`라면 생성이 안됩니다.) 그렇기 때문에 `Dex`를 `HTTPS`로 띄워야 하는데, 로컬 환경에서는 `Let's Encrypt` 같은 공인 `CA`에서 인증서를 받을 수 없습니다. (`host.docker.internal`은 공인 도메인이 아니기 때문)
그렇기 때문에 local에서 실습을 하기 위해선 직접 `CA`를 만들고, 그 `CA`로 `Dex` 서버 인증서에 서명하는 방식을 써야 합니다.
인증서 디렉토리 생성
mkdir ./certs
CA 키 생성
서버 인증서에 서명할 때 사용하기 위해 `ca.key`를 생성합니다.
openssl genrsa -out certs/ca.key 2048
`genrsa` 명령을 통해 `RSA` 개인키를 생성하고, 로컬 실습용이기 때문에 길이는 2048로 설정합니다.
자체 서명된 CA 인증서 생성
`ca.key`로 서명한 인증서를 신뢰할 수 있게 하는 `ca.crt`를 생성합니다.
openssl req -x509 -new -nodes \
-key certs/ca.key \
-sha256 -days 3650 \
-subj "/CN=dex-ca" \
-out certs/ca.crt
Dex 서버 개인키 생성
openssl genrsa -out certs/tls.key 2048
`Dex`가 `HTTPS` 통신에 사용할 개인키로, `CA`와 동일한 방식으로 생성합니다.
CSR 생성
생성한 공개키에 대한 인증서를 발급하기 위한 `CSR`을 생성합니다.
openssl req -new \
-key certs/tls.key \
-subj "/CN=host.docker.internal" \
-out certs/tls.csr
`/CN=host.docker.internal` 을 통해 `docker` 기반의 `kind`에서 해당 도메인을 인증서에 사용하도록 합니다.
SAN 파일 생성
cat > certs/tls.ext <<EOF
[v3_req]
subjectAltName = DNS:host.docker.internal,IP:127.0.0.1
EOF
브라우저와 `TLS` 클라이언트는 `CN` 대신 `SAN`을 기준으로 도메인을 검증 합니다.
CA로 서버 인증서 서명
openssl x509 -req \
-in certs/tls.csr \
-CA certs/ca.crt \
-CAkey certs/ca.key \
-CAcreateserial \
-out certs/tls.crt \
-days 3650 \
-sha256 \
-extfile certs/tls.ext \
-extensions v3_req
위에서 만든 `SAN` 파일을 적용하여 생성합니다.
서명 검증 확인
openssl verify -CAfile certs/ca.crt certs/tls.crt
`verify` 명령을 통해 생성한 인증서들에 대한 검증을 진행합니다.

이 검증이 통과해야 `kube-apiserver`와 `kubelogin`이 `Dex` 인증서를 신뢰할 수 있습니다.
Kind로 Cluster 생성
kind 설정 파일
cat > kind-oidc.yaml << 'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
image: kindest/node:v1.34.0
extraMounts:
- hostPath: ./certs/ca.crt
containerPath: /etc/kubernetes/pki/dex-ca.crt
readOnly: true
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs:
oidc-issuer-url: "https://host.docker.internal:5556"
oidc-client-id: "kubernetes"
oidc-username-claim: "email"
oidc-groups-claim: "groups"
oidc-ca-file: "/etc/kubernetes/pki/dex-ca.crt"
- role: worker
image: kindest/node:v1.34.0
EOF
여기서 특이한 점은 `nodes.extraMounts` 부분입니다.
`kube-apiserver`가 `Dex`의 `self-signed` 인증서를 신뢰하려면`CA`가 필요하기 때문에 위에서 생성한 CA 인증서를 control plane 안으로 마운트 시킵니다.
kind cluster 생성
kind create cluster --config kind-oidc.yaml --name oidc-study
위에서 생성한 kind config 파일로 oidc-study 라는 cluster를 생성합니다.

node 확인
kubectl get nodes

plugin 설치
OIDC 로그인은 `kubectl` 기본 기능이 아니라 외부 인증 플러그인 방식으로 동작하기 때문에 `external credential plugin` 이 필요합니다.
인증 방식
k8s에서 `kubectl`이 `api server` 에 요청을 할 때는 반드시 인증 정보가 필요합니다.
| 인증 방식 | 동작 |
| client certificate | kubeconfig에 인증서 저장 |
| serviceaccount token | token 사용 |
| static token | kubeconfig token |
| OIDC | external credential plugin 필요 |
OIDC의 경우 사용자가 IdP에 로그인해야 JWT 토큰을 발급받을 수 있기 때문에 `kubectl` 내부에서 직접 처리하지 않습니다. 그렇기 때문에 `external credential plugin` 이 필요합니다.
kubelogin plugin 설치
kubelogin(kubectl-oidc-login)은 int128/kubelogin에서 개발된 오픈소스 kubectl plugin입니다.
`OIDC` 인증에서 브라우저 로그인 → `Authorization Code` 교환 → Token 발급 과정을 자동화해주며, `kubectl`은 `exec credential plugin` 방식으로 이 플러그인을 호출하여, 토큰을 `stdout JSON`으로 반환합니다.
brew install kubelogin
plugin 확인
kubectl plugin list

Dex 생성
namespace 생성
kubectl create namespace dex
TLS Secret 생성
kubectl create secret tls dex-tls \
--cert=certs/tls.crt \
--key=certs/tls.key \
-n dex
위에서 생성한 인증서 파일을 컨테이너 이미지에 포함시키거나 `configMap`에 평문으로 넣지 않기 위해서 `secret`을 생성합니다.

Dex configmap 파일 생성
cat > dex-config.yaml << 'EOF'
issuer: https://host.docker.internal:5556
storage:
type: memory
web:
http: 0.0.0.0:5556
enablePasswordDB: true
staticClients:
- id: kubernetes
redirectURIs:
- 'http://localhost:8000'
name: 'Kubernetes'
secret: kubernetes-secret
staticPasswords:
- email: "admin@example.com"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
userID: "123"
EOF
`issuer`는 `kube-apiserver`의 `--oidc-issuer-url`과 반드시 동일해야 합니다.
추가로 위로 생성된 dex의 인증 정보는 아래와 같습니다.
ID : admin@example.com
PW : password
Dex configmap 생성
kubectl create configmap dex-config \
--from-file=config.yaml=dex-config.yaml \
-n dex

Dex Pod 생성
cat > dex-deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: dex
namespace: dex
spec:
replicas: 1
selector:
matchLabels:
app: dex
template:
metadata:
labels:
app: dex
spec:
containers:
- name: dex
image: ghcr.io/dexidp/dex:v2.37.0
command: ["dex", "serve", "/etc/dex/config.yaml"]
ports:
- containerPort: 5556
volumeMounts:
- name: config
mountPath: /etc/dex
- name: tls
mountPath: /etc/dex/tls
readOnly: true
volumes:
- name: config
configMap:
name: dex-config
- name: tls
secret:
secretName: dex-tls
EOF
Dex 배포
kubectl apply -f dex-deployment.yaml

생성된 Dex Pod 확인
kubectl get pods -n dex

Dex Service 생성
cat > dex-service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
name: dex
namespace: dex
spec:
ports:
- port: 5556
targetPort: 5556
selector:
app: dex
EOF
kubectl apply -f dex-service.yaml

RBAC 설정
Role
cat > oidc-role.yaml << 'EOF'
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: oidc-user
namespace: default
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","list"]
EOF
`default namespace`에서 `pods`를 `get`, `list`할 수 있는 `Role`을 생성하기 위해 `yaml` 파일을 생성합니다.
RoleBinding
cat > oidc-rolebinding.yaml << 'EOF'
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: oidc-binding
namespace: default
subjects:
- kind: User
name: admin@example.com
roleRef:
kind: Role
name: oidc-user
apiGroup: rbac.authorization.k8s.io
EOF
위에서 생성한 `oidc-user`라는 `role`과 `kube-apiserver`가 `JWT`에서 추출한 `email claim`을 검증하여 권한을 적용하는 `rolebinding yaml` 파일을 생성합니다.
Role & Rolebinding 생성
kubectl apply -f oidc-role.yaml
kubectl apply -f oidc-rolebinding.yaml
Dex Port-forwad 설정
kubectl -n dex port-forward svc/dex 5556:5556
`kubelogin`이 local에서 `Dex` 에 접근하려면 port-forward가 필요하기 때문에 port-forward를 설정합니다.
/etc/hosts에 host.docker.internal 등록
sudo sh -c 'echo "127.0.0.1 host.docker.internal" >> /etc/hosts'
OIDC 로그인
kubectl oidc-login get-token \
--oidc-issuer-url=https://host.docker.internal:5556 \
--oidc-client-id=kubernetes \
--oidc-client-secret=kubernetes-secret \
--oidc-extra-scope=email \
--certificate-authority=$(pwd)/certs/ca.crt
위 명령어는 Dex 서버에 로그인해서 api server 인증에 사용할 jwt 토큰을 발급받는 명령입니다.
`--oidc-issuer-url=https://host.docker.internal:5556`
위 옵션은 로그인할 OIDC 서버 주소를 의미합니다. 여기서는 Dex의 port를 명시하여 실행합니다.
`--oidc-client-id=kubernetes`
OIDC에서는 사용자가 로그인할 때 어떤 애플리케이션이 인증을 요청했는지 구분해서 명시해야 합니다.
staticClients:
- id: kubernetes
redirectURIs:
- 'http://localhost:8000'
name: 'Kubernetes'
secret: kubernetes-secret
위에서 작성한 dex conifg 파일을 보면 위와 같이 `staticClients` 를 통해 클라이언트를 등록해놨었고, 옵션으로 해당 클라이언트ID를 명시했습니다.
`--oidc-client-secret=kubernetes-secret`
OIDC에서는 단순히 clientID만 전달하면 누구든지 그 ID를 사용할 수 있기 때문에 보안 문제가 발생할 수 있습니다. 그렇기 때문에 client secret을 함께 사용하여 인증을 진행합니다.
마찬가지로 위에서 설정했던 Dex Config 파일에 `secret: kubernetes-secret` 부분을 적어놨고, 옵션으로 해당 값을 입력합니다.

위와 같이 로그인 화면이 나왔다면, 위에서 생성했던 계정 정보를 통해 로그인을 합니다.

위와 같은 화면이 보일텐데, OIDC 허용에 관한 화면입니다.
즉, `kubectl oidc-login get-token`이 Dex에 로그인한 뒤 이 클라이언트(kubernetes)가 토큰을 받아도 되는지 승인받는 단계입니다.
1. Dex가 인증 코드를 발급
2. 브라우저가 http://localhost:8000 쪽으로 리다이렉트
3. kubectl oidc-login이 그 코드를 받아 토큰으로 교환
4. 터미널에 토큰이 출력되거나, 다음 단계로 진행

인증이 완료되면 위와 같이 `Authenticated` 화면이 보이게 됩니다.

그리고 다시 터미널로 돌아와보면 `JWT` 이 발급된것을 확인할 수 있습니다.
kubeconfig 등록
credentials 등록
kubectl config set-credentials oidc-user \
--exec-api-version=client.authentication.k8s.io/v1 \
--exec-command=kubectl \
--exec-arg=oidc-login \
--exec-arg=get-token \
--exec-arg=--oidc-issuer-url=https://host.docker.internal:5556 \
--exec-arg=--oidc-client-id=kubernetes \
--exec-arg=--oidc-client-secret=kubernetes-secret \
--exec-arg=--oidc-extra-scope=email \
--exec-arg=--certificate-authority=$(pwd)/certs/ca.crt \
--exec-interactive-mode=IfAvailable
`kubeconfig context` 에 연결된 사용자가 `exec` 방식으로 설정되어 있으면, `kubectl`은 API 요청 시점에 `kubectl oidc-login get-token` 명령을 실행하여 `OIDC Token`을 발급받을 수 있습니다.

결과적으로 `~/.kube/config`에 아래와 같이 저장됩니다.

context 등록 및 전환
kubectl config set-context oidc-context \
--cluster=kind-oidc-study \
--user=oidc-user

kubectl config use-context oidc-context

이후 모든 `kubectl` 명령이 `kind-oidc-study` 클러스터에 `oidc-user` 자격증명으로 실행됩니다.
로그인 테스트
default namespace
kubectl get pods -n default

로그인 완료 후 `kubectl`이 토큰을 받아 `kube-apiserver`에 요청을 보내고 정상적으로 결과가 반환된 것을 확인할 수 있습니다.
kube-system namespace
kubectl get pods -n kube-system

`RBAC`에서 해당 `user`에게 `kube-system namespace` 접근 권한을 주지 않았기 때문에 거부된 것을 확인할 수 있습니다.