feat(talos): authenticate kube-apiserver against ZITADEL #87

Merged
rock merged 1 commits from feat/apiserver-zitadel-oidc into main 2026-11-01 00:01:07 +00:00
Owner

Why

The kube-apiserver still authenticates OIDC against Authentik, which now runs zero pods. That issuer cannot be discovered, so token-based kubectl login is broken today. The template carried no OIDC block at all, so a render would have removed the stale config rather than replaced it.

This adds the ZITADEL issuer, client id and claims.

Three details verified against real tokens first (not assumed)

# Finding Consequence
1 Issuer must be https://zitadel.riotpiao.com — no trailing slash, no /application/o/<app>/ suffix That suffix was the Authentik scheme. The apiserver compares iss by exact string equality
2 ZITADEL does not put profile/email claims in the id_token by default oidc-username-claim: email resolved to nothing. idTokenUserinfoAssertion is now enabled on the kubernetes app
3 ZITADEL asserts roles as a nested urn:...:project:roles claim the apiserver cannot read The groupsClaim action mirrors bare role names into a flat groups claim

Proof

A full PKCE authorization-code flow against the kubernetes client returns an id_token containing:

iss                = https://zitadel.riotpiao.com
sub                = 396924923823522416
email              = [email protected]
preferred_username = riotpiao
groups             = [homelab-admins, llm:inference, memory:read, ...]

With oidc-groups-prefix: "oidc:" that maps to the existing ClusterRoleBinding homelab-admin-oidc → cluster-admin.

Also in this change

groupsClaim action created and bound to the Complement Token flow (triggers 4 and 5), with allowedToFail: true.

Note: an earlier draft of that script read ctx.v1.user.grants.count unguarded. Machine users have no grants, so it threw and ZITADEL answered 500 to every /oauth/v2/token request — a full auth outage. The committed script guards every field and is wrapped in try/catch, and allowedToFail ensures a future script bug degrades to "no groups claim" instead of blocking token issuance.

DNS

The issuer hostname is pinned in extraHostEntries to the ingress LoadBalancer. The apiserver's static pod resolves through the node's DNS, not cluster CoreDNS, and the LB serves the publicly-trusted Let's Encrypt *.riotpiao.com certificate (verified Verify return code: 0).

Safety

  • Client-certificate auth is unchanged — a wrong value degrades OIDC logins, it cannot lock anyone out.
  • terraform plan touches only local_file resources (renders cluster-config/*.yaml); it does not contact the nodes.
  • The rolling control-plane reboot (make apply-cp) is a separate, deliberate step and is not run by this PR.
## Why The kube-apiserver still authenticates OIDC against **Authentik**, which now runs zero pods. That issuer cannot be discovered, so token-based `kubectl` login is broken today. The template carried no OIDC block at all, so a render would have *removed* the stale config rather than replaced it. This adds the ZITADEL issuer, client id and claims. ## Three details verified against real tokens first (not assumed) | # | Finding | Consequence | |---|---|---| | 1 | Issuer must be `https://zitadel.riotpiao.com` — no trailing slash, **no** `/application/o/<app>/` suffix | That suffix was the Authentik scheme. The apiserver compares `iss` by exact string equality | | 2 | ZITADEL does **not** put profile/email claims in the id_token by default | `oidc-username-claim: email` resolved to nothing. `idTokenUserinfoAssertion` is now enabled on the `kubernetes` app | | 3 | ZITADEL asserts roles as a nested `urn:...:project:roles` claim the apiserver cannot read | The `groupsClaim` action mirrors bare role names into a flat `groups` claim | ## Proof A full PKCE authorization-code flow against the `kubernetes` client returns an id_token containing: ``` iss = https://zitadel.riotpiao.com sub = 396924923823522416 email = [email protected] preferred_username = riotpiao groups = [homelab-admins, llm:inference, memory:read, ...] ``` With `oidc-groups-prefix: "oidc:"` that maps to the existing ClusterRoleBinding `homelab-admin-oidc` → `cluster-admin`. ## Also in this change `groupsClaim` action created and bound to the Complement Token flow (triggers 4 and 5), with `allowedToFail: true`. > Note: an earlier draft of that script read `ctx.v1.user.grants.count` unguarded. Machine users have no `grants`, so it threw and ZITADEL answered **500 to every** `/oauth/v2/token` request — a full auth outage. The committed script guards every field and is wrapped in try/catch, and `allowedToFail` ensures a future script bug degrades to "no groups claim" instead of blocking token issuance. ## DNS The issuer hostname is pinned in `extraHostEntries` to the ingress LoadBalancer. The apiserver's static pod resolves through the **node's** DNS, not cluster CoreDNS, and the LB serves the publicly-trusted Let's Encrypt `*.riotpiao.com` certificate (verified `Verify return code: 0`). ## Safety - Client-certificate auth is unchanged — a wrong value degrades OIDC logins, it cannot lock anyone out. - `terraform plan` touches only `local_file` resources (renders `cluster-config/*.yaml`); it does **not** contact the nodes. - The rolling control-plane reboot (`make apply-cp`) is a separate, deliberate step and is **not** run by this PR.
rock added 1 commit 2026-10-31 21:27:49 +00:00
The apiserver's live OIDC config still pointed at
https://authentik.riotpiao.com/application/o/kubernetes/. Authentik now runs
zero pods, so that issuer cannot be discovered and every attempt to login with
a token instead of a client certificate fails. The template itself carried no
OIDC block at all, so a render would have removed the stale config rather than
replace it.

Add the ZITADEL issuer, the `kubernetes` client id, and the groups/username
claims. Three details had to be verified against a real token first:

1. The issuer is https://zitadel.riotpiao.com with no trailing slash and no
   /application/o/<app>/ suffix. That suffix belonged to the Authentik scheme;
   the apiserver compares `iss` by exact string equality.
2. ZITADEL does NOT put profile/email claims in the id_token by default, so
   `oidc-username-claim: email` resolved to nothing. idTokenUserinfoAssertion is
   now enabled on the `kubernetes` application.
3. ZITADEL asserts roles as a nested urn claim the apiserver cannot read. The
   `groupsClaim` action (created alongside this change) mirrors the bare role
   names into a flat `groups` claim.

Verified with a full PKCE authorization-code flow against this client: the
id_token carries iss=https://zitadel.riotpiao.com, email, preferred_username and
groups=[homelab-admins, ...]. With oidc-groups-prefix=oidc: that maps to the
existing ClusterRoleBinding homelab-admin-oidc, which grants cluster-admin.

The issuer hostname is pinned in extraHostEntries because the apiserver's static
pod resolves through the node's DNS, not cluster CoreDNS; the ingress
LoadBalancer serves the publicly-trusted Let's Encrypt wildcard certificate.

Client-certificate auth is unchanged, so a wrong value degrades OIDC logins
without locking anyone out.
rock merged commit d5487c708a into main 2026-11-01 00:01:07 +00:00
rock deleted branch feat/apiserver-zitadel-oidc 2026-11-01 00:01:11 +00:00
Sign in to join this conversation.