OIDC kubeconfigs with Zitadel
Kubeconfig that outlive the persons holding it are a compliance anti-pattern.
Zitadel can be the issuer instead: kubectl opens a browser, gets a short-lived token,
and the API server maps a project role to a Kubernetes group.
(Zitadel is a modern replacement for Keycloak. You can use Keycloak for all of this, too.)
From a fresh cluster:
- vendor and deploy Zitadel,
- create one project role,
- point
kubectlat the OIDC app.
Vendor and deploy Zitadel ¶
Vendor the Zitadel chart using the helm-vendor CLI:
$ helm-vendor add zitadel https://charts.zitadel.com 10.0.4
The chart’s bundled PostgreSQL is off by default. Turn it on for a first cluster; use a database you operate for anything real. The chart is rendered with kubenix below.
The values that matter are the external domain and the masterkey Secret.
# services/zitadel.nix
{ kubenix, charts, ... }:
{
kubernetes.resources.namespaces.zitadel = { };
kubernetes.objects = builtins.fromJSON (builtins.readFile (kubenix.lib.helm.chart2json {
chart = charts.zitadel;
name = "zitadel";
namespace = "zitadel";
values = {
postgresql.enabled = true;
zitadel.masterkeySecretName = "zitadel-masterkey";
zitadel.configmapConfig = {
ExternalDomain = "zitadel.example.com";
ExternalSecure = true;
ExternalPort = 443;
};
zitadel.configmapConfig.FirstInstance.Org.Human = {
UserName = "john";
Email = "john@example.com";
Password = "ChangeMe123!";
PasswordChangeRequired = true;
};
gateway.httpRoute = {
enabled = true;
hostnames = [ "zitadel.example.com" ];
parentRefs = [ { name = "main"; namespace = "gateway"; } ];
};
login.gateway.httpRoute = {
enabled = true;
hostnames = [ "zitadel.example.com" ];
parentRefs = [ { name = "main"; namespace = "gateway"; } ];
};
};
}));
}
Create the namespace and masterkey before the first apply. The placeholder password forces a change on first login:
$ kubectl create namespace zitadel
$ kubectl create secret generic zitadel-masterkey -n zitadel \
--from-literal=masterkey="$(openssl rand -hex 16)"
Then apply the service. The setup job creates the first instance and admin user. The two
gateway.httpRoute values assume a Gateway named main; use the chart’s ingress values instead if
that is what you run.
One project role, one group ¶
In the Zitadel console, create a project kubernetes and a role cluster-admin. Create a Native
OIDC app kubernetes-api with PKCE, redirect http://localhost:8000, development mode, JWT access
tokens, both role assertions, and user info inside the ID token. Copy the client ID and project ID.
Kubernetes wants a groups array; Zitadel asserts project roles as a nested object. A Complement
Token action flattens them:
// Zitadel action: groupsClaim
function groupsClaim(ctx, api) {
if (ctx.v1.user.grants == undefined || ctx.v1.user.grants.count == 0) return;
const groups = [];
ctx.v1.user.grants.grants.forEach((grant) => {
grant.roles.forEach((role) => groups.push(role));
});
api.v1.claims.setClaim("groups", groups);
}
Name the action groupsClaim, matching the function. Attach it to both Complement Token triggers:
Pre Userinfo creation and Pre Access Token creation. Check allowed to fail, so a user without a
role can still log in. Grant cluster-admin to the people who should have it.
The client must request the project audience, or the action doesn’t see any grants.
The kubeconfig below makes the client do that.
The cluster repo reconciles the same objects with an in-cluster Job similar to the one described in the article A Vault Job, with Vault calls replaced by Zitadel’s management API.
A kubeconfig that expires ¶
Point kubectl at the OIDC app. The kubeconfig has no client certificate; it runs the
int128/kubelogin plugin, which opens the browser. Set CLIENT_ID and PROJECT_ID, and
point the cluster at your API server and CA:
$ CLIENT_ID=... PROJECT_ID=...
$ kubectl config set-cluster cluster --server=https://kube.example.com:6443 \
--certificate-authority=ca.crt
$ kubectl config set-credentials zitadel \
--exec-api-version=client.authentication.k8s.io/v1 \
--exec-command=kubectl-oidc_login \
--exec-arg=get-token \
--exec-arg=--oidc-issuer-url=https://zitadel.example.com \
--exec-arg=--oidc-client-id="$CLIENT_ID" \
--exec-arg=--oidc-extra-scope=email \
--exec-arg=--oidc-extra-scope="urn:zitadel:iam:org:project:id:$PROJECT_ID:aud" \
--exec-interactive-mode=IfAvailable
$ kubectl config set-context zitadel --cluster=cluster --user=zitadel
$ kubectl config use-context zitadel
Add pkgs.kubelogin-oidc to the devshell; it provides kubectl-oidc_login. On Talos, the API
server learns to trust the issuer with these flags:
# talos/patches/oidc.yaml
cluster:
apiServer:
extraArgs:
oidc-issuer-url: https://zitadel.example.com
oidc-client-id: <client-id>
oidc-username-claim: email
oidc-groups-claim: groups
oidc-username-prefix: "oidc:"
oidc-groups-prefix: "oidc:"
Finally, bind the prefixed group in the same kubenix service:
# services/zitadel.nix (continued)
kubernetes.resources.clusterRoleBindings.zitadel-cluster-admins = {
roleRef = { apiGroup = "rbac.authorization.k8s.io"; kind = "ClusterRole"; name = "cluster-admin"; };
subjects = [ { apiGroup = "rbac.authorization.k8s.io"; kind = "Group"; name = "oidc:cluster-admin"; } ];
};
kubectl get nodes now opens Zitadel. After login, kubectl auth whoami shows
oidc:alice@example.com and oidc:cluster-admin. The token expires; the browser login renews it.