Sougen Blog

authentik を自宅ラボの IdP にして kube-apiserver・Headlamp・Open WebUI を OIDC 化する

authentikkubernetesoidcheadlamphomelab

自宅ラボ環境の Kubernetes クラスタ(kubeadm、v1.36.4、コントロールプレーン 3 + ワーカー 3)で、認証基盤を authentik に一本化しました。
管理 UI の Headlamp、kube-apiserver 自体、ローカル LLM の Open WebUI の三つを、authentik の OpenID Connect(OIDC)でつないでいます。
この記事は、その三つを「同じ OIDC」とひとくくりにできない理由と、途中で踏んだ設定の罠をまとめたものです。
構築の全手順ではないので、authentik と Headlamp の基本的な設定は公式のドキュメントに譲ります。
また、手元の kubectl は従来どおりクライアント証明書の kubeconfig で使っていて、kubectl の OIDC ログイン(kubelogin など)は扱いません。

三つの OIDC 経路は別物

最初に押さえておきたいのは、authentik につなぐ三つのクライアントでは、id_token を検証する主体がそれぞれ違うことです。

したがって手間がかかるのは、Headlamp の設定ではなく kube-apiserver に OIDC 検証を入れるところです。
Headlamp 側は、クライアント ID や issuer URL などを渡せば済みます。

authentik 本体には 2026.8.3 を使い、helm で入れました。
高可用性は組まず、データベースは CloudNativePG(CNPG)の 1 インスタンスです。
authentik は 2025.10 で Redis 依存をやめたので、動く Pod は server・worker・PostgreSQL の三種類だけになります。

管理画面に入らず blueprint で設定する

authentik の設定は管理 UI で手作業で作らず、blueprint(宣言的な YAML)で与えました。
blueprint を Secret にして chart の blueprints.secrets にその名前を並べると、Secret がマウントされ、worker がそれを読んで適用します。
設定がリポジトリの YAML に残るので、何をどう作ったかが後から追えます。

authentik 2026.8.3 では、OAuth2 プロバイダを blueprint で新規に作るときに grant_types を必ず書きます。
省くと空の {} になり、authorize の要求が Invalid grant_type for provider で弾かれます。
クライアントには invalid_request: The request is otherwise malformed としか返らないので、原因が grant_types だとは気付きにくいところです。

# authentik blueprint: Open WebUI 用 OAuth2 provider(抜粋)
entries:
  - model: authentik_providers_oauth2.oauth2provider
    id: provider
    identifiers:
      name: open-webui
    attrs:
      client_type: confidential
      # 書かないと空({})になり、authorize が invalid_request で返る
      grant_types:
        - authorization_code
        - refresh_token
      client_id: open-webui
      client_secret: __CLIENT_SECRET__
      redirect_uris:
        - matching_mode: strict
          url: https://ai.example.com/oauth/oidc/login/callback
      property_mappings:
        - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
        - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
        - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]

client_secret はリポジトリに置きません。
blueprint 内はプレースホルダ(__CLIENT_SECRET__)のままにしておき、ノード上で乱数を作って該当行だけ置換し、Secret にだけ実値を入れます。

rollout 中に worker が新しい blueprint を見落とす

blueprint の Secret を追加して helm upgrade すると、適用されないことがあります。
helm の rollout 中は、まだマウントの無い古い worker が discovery 処理を拾ってしまい、新しいファイルを読まないまま処理を終えます。
このとき discovery のログには Successfully imported 45 files. と出ていました。
実際のファイルは 47 個あり、2 件足りません。
エラーは出ないので、手元で気付けた手がかりは、ログに出る件数と実際のファイル数の食い違いでした。

対処は、upgrade のあとで worker を rollout restart し、適用状況をデータベースで確かめることです。

kubectl -n authentik rollout restart deploy/authentik-worker

# blueprint インスタンスの適用状況を直接見る
kubectl -n authentik exec authentik-postgresql-1 -c postgres -- \
  psql -d authentik -Atc \
  "select status, last_applied from authentik_blueprints_blueprintinstance where name='headlamp-oidc';"

successful と適用時刻が返れば通っています。

Secret を差し替えたら明示的に適用する

client_secret を作り直すなど、マウント済みの blueprint Secret を差し替えたときも、worker の再起動に頼らない方が安全でした。
再起動の直後に、終了しかけた古い worker が古いファイルで適用してしまい、新しい worker から見えるファイルは正しいのにデータベースの値だけ古い、という中間状態になったことがあります。

Secret の変更がボリュームに届くまでには時間差があるので、worker の中のファイルが新しくなったことを sha256sum などで確かめてから、ak apply_blueprint で明示的に適用します。
適用後は、データベースの値(client_secret はハッシュ)が変わったことを確かめます。

kubectl -n authentik exec deploy/authentik-worker -- \
  ak apply_blueprint /blueprints/mounted/secret-authentik-blueprint-open-webui/open-webui.yaml

秘密値を埋める置換には注意が要ります。
置換はプレースホルダの行だけを対象にし、コメント行のプレースホルダまで置換して画面に値を出してしまわないようにします。
macOS の BSD 版 sed は \s を解釈せず、エラーも出さずに何も置換しないので、空白は [[:space:]] で書きます。

kube-apiserver に structured authentication configuration を足す

kube-apiserver 側は、従来の --oidc-* フラグではなく、structured authentication configuration(--authentication-config)を使いました。
両者は併用できず、どちらか一方を選びます。
設定をフラグではなくファイルに書くので、issuer を複数並べたり claim の対応を細かく指定したりしやすくなります。
この機能は Kubernetes v1.34 で stable になり、apiVersion も v1 で書けるようになりました。
それより前の v1.30〜v1.33 では v1beta1 を使います。

# /etc/kubernetes/authn/authentication-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
jwt:
  - issuer:
      # discovery の "issuer" と末尾スラッシュまで完全一致させる
      url: https://auth.example.com/application/o/headlamp/
      audiences:
        - headlamp          # authentik の provider のクライアント ID(= id_token の aud)
    claimMappings:
      username:
        claim: preferred_username   # authentik の profile スコープで入る
        prefix: "oidc:"
      groups:
        claim: groups               # 同じく profile スコープ。authentik のグループ名の配列
        prefix: "oidc:"

issuer.url は、discovery が返す issuer と末尾のスラッシュまで完全に一致していないと検証に失敗します。
authentik の issuer はプロバイダごとに https://auth.example.com/application/o/<slug>/ の形になり、末尾にスラッシュが付きます。
audiences には Headlamp 用クライアントのクライアント ID を入れます。
Headlamp が転送するのは Headlamp 向けに発行された id_token だからです。
ユーザー名とグループの claim には oidc: の prefix を付け、証明書ベースの既存ユーザー名と衝突しないようにしました。

この設定ファイルは apiserver が自動で読み直します。
ファイルだけの変更なら Pod の作り直しは要らず、フラグとマウントを足す初回だけ static pod の再作成が走ります。
フラグとマウントは /etc/kubernetes/manifests/kube-apiserver.yaml に三か所足します。

# spec.containers[0].command の末尾
    - --authentication-config=/etc/kubernetes/authn/authentication-config.yaml
# spec.containers[0].volumeMounts
    - mountPath: /etc/kubernetes/authn
      name: authn-config
      readOnly: true
# spec.volumes
  - hostPath:
      path: /etc/kubernetes/authn
      type: Directory
    name: authn-config

コントロールプレーンは一台ずつ進めます。
manifest を保存すると kubelet が数十秒で apiserver を作り直すので、その一台の API は一時的に落ちます。
VIP 経由の kubectl は残り二台が受けるため、順に直せば全体は止まりません。
実測では、適用から Pod が 1/1 Ready になるまで各台 65〜75 秒でした。

ここで二つ、気を付けた点があります。

一つ目は、編集前のバックアップを /etc/kubernetes/manifests/ の中に置かないことです。
kubelet はこのディレクトリの YAML をすべて static pod として読むので、kube-apiserver.yaml.bak のようなコピーを同じ場所に残すと、同名の Pod が二つ立とうとして正常に動かなくなります。
バックアップは /root など manifests の外に置きます。

二つ目は、kubeadm-config の apiServer.extraArgs と extraVolumes にも同等の設定を入れておくことです。
extraVolumes は Pod マニフェストの volumes と volumeMounts に分けず、hostPath と mountPath を 1 つの要素にまとめて書きます。
手で manifest に足しただけだと、次の kubeadm upgrade で上書きされて消えてしまいます。

ブラウザを使わずに認証経路を確かめる

ブラウザで実ログインする前に、認証経路だけを切り出して確かめたくなりました。
authentik の ak shell で、プロバイダの encode() を使い、プロバイダ自身の署名鍵で署名したテスト用 id_token を作れます(authentik の内部 API なので、バージョンが変わると使えなくなるかもしれません)。
claim(iss、aud、sub、preferred_username、groups、iat、exp)を自分で詰めて、5 分間だけ有効な token を発行しました。

これを kubectl に --token で渡して auth whoami を叩きます。
ただし、普段の kubeconfig のまま --token を付けると、手元の v1.36.4 ではクライアント証明書で認証され、OIDC を通っていないのに kubernetes-admin と表示されました。
kube-apiserver は複数の認証方式の順序を保証していないので、OIDC だけを試すには証明書を外します。
確かめたつもりになってしまうので、kubeconfig を空にして証明書を除きました。

kubectl --kubeconfig=/dev/null \
  --server=https://api.k8s.example:6443 \
  --certificate-authority=/etc/kubernetes/pki/ca.crt \
  --token="$T" auth whoami

この形にすると、apiserver は id_token だけで判断します。
VIP の振り先がどの apiserver でも oidc:admin として認証され、get nodes も通りました。
末尾を一文字壊した token は Unauthorized になり、署名検証が効いていることも確かめられました。

apiserver のログに出たのは OIDC: No x509 certificates provided, will use host's root CA set の一行だけで、discovery や JWKS の取得失敗はありませんでした。
issuer の証明書が Let's Encrypt なので、ホストのルート CA でそのまま検証できます。

view ClusterRole には nodes が含まれない

RBAC は、ClusterRoleBinding で authentik のグループを Kubernetes のロールに結ぶ形にしました。
authentication configuration で groups claim に oidc: prefix を付けているので、authentik の admin グループは apiserver からは oidc:admin に見えます。
これを cluster-admin に、読み取りだけ渡したい AD グループを組み込みの view に結びました。

ところが view を結んだユーザーで Headlamp を開くと、クラスタ概要のノード欄が出ませんでした。
組み込みの view ClusterRole は、クラスタスコープのリソースでは namespaces の読み取りしか含まず、nodes が入っていません。
nodes の読み取りだけを足す ClusterRole を別に作って補いました。

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: oidc-node-viewer
rules:
  - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["metrics.k8s.io"]
    resources: ["nodes", "pods"]
    verbs: ["get", "list", "watch"]

metrics.k8s.io も足しているのは、Headlamp がノードや Pod の画面で使用量を読みに行くからです。
metrics-server を入れる前は、この API は本来 404 になるはずでした。
しかし権限が無いと RBAC が先に 403 を返し、それが画面にエラーとして出ます。
権限を足すと、admin と同じく 404 が返り、Headlamp はそれをエラーとして出さずに無視しました。
なお、metrics-server を入れると、同梱の ClusterRole(rbac.authorization.k8s.io/aggregate-to-view: "true" のラベル付き)が view に集約されます。
そのため、入れた後は metrics.k8s.io の 2 行は view と重なり、効いているのは core の nodes だけになります。

Open WebUI は env だけで OIDC 直結できる

Open WebUI(0.11 系)は、OIDC の設定を環境変数で与えるだけでつながります。
ENABLE_OAUTH_PERSISTENT_CONFIG の既定が False なので、oauth.* の設定はデータベースを見ずに env から読み込まれます。
コンテナに env を足して作り直せば反映されます。

OAUTH_PROVIDER_NAME=authentik
OAUTH_CLIENT_ID=open-webui
OAUTH_CLIENT_SECRET=__CLIENT_SECRET__
OPENID_PROVIDER_URL=https://auth.example.com/application/o/open-webui/.well-known/openid-configuration
OPENID_REDIRECT_URI=https://ai.example.com/oauth/oidc/login/callback
OAUTH_SCOPES=openid email profile
ENABLE_OAUTH_SIGNUP=true
ENABLE_OAUTH_EMAIL_FALLBACK=true

いくつか嵌りどころがありました。

リダイレクト URI は OPENID_REDIRECT_URI で明示します。
空にすると Open WebUI が request.url_for で組み立てますが、リバースプロキシの後ろでは http:// になってしまい、authentik 側の strict な照合で一致しません。
値は authentik のプロバイダに登録した redirect URI と一文字単位で一致させます。
0.11 系のコールバックは /oauth/oidc/login/callback で、/oauth/oidc/callback は古い経路です。

既存のローカルアカウントを SSO と同じユーザーにつなぐには、authentik 側の sub を Open WebUI のユーザーレコードに先に書いておきます。
Open WebUI はログイン時にまず user.oauth.oidc.sub で既存ユーザーを探すので、メールアドレスが違っても同じアカウントに入ります。
メールでの突き合わせ(OAUTH_MERGE_ACCOUNTS_BY_EMAIL)は、取り違えが怖いので使いませんでした。
AD にメール属性の無いユーザーもいるので、ENABLE_OAUTH_EMAIL_FALLBACK=true を入れ、メールが無くてもログインが失敗しないようにしてあります。
この場合、Open WebUI は oidc@<sub>.local という代わりのメールアドレスでユーザーを作るので、管理画面にはこのアドレスが並びます。

パスワードでのログインフォームはそのまま残しました。
authentik 側には絞り込みのポリシーを付けず、誰を通すかは Open WebUI の承認(新規ユーザーの既定ロールが pending)で決める運用にしています。

コンテナを作り直すときは、WEBUI_SECRET_KEY に注意します。
既定ではこの鍵がコンテナ層の /app/backend/.webui_secret_key に置かれるので、作り直すと失われて既存のセッションが切れます。
データディレクトリ側へ移し、WEBUI_SECRET_KEY_FILE でそこを指すようにしてから入れ替えました。

CNPG の -app Secret はパスワードを変えると DB に反映される

authentik のデータベースは CNPG で立てています。
CNPG が作る -app という Secret のパスワードを書き換えると、operator がそれを検知してデータベース側のパスワードを更新します。
手元では、Secret の password だけでなく uri や pgpass など関連するキーもまとめて置き換わり、数秒で新しいパスワードでログインできるようになります。
古いパスワードは拒否されます。
アプリケーション側の Pod は env で読んでいるので、反映後に再起動して読み直させる必要があります。

Secret を更新するときは kubectl apply を避けた方がよいです。
apply は last-applied-configuration の注釈に data を base64 のまま複製するので、注釈を表示しただけで秘密値が出てしまいます。
更新は注釈を残さない kubectl replace で行います。