Set up single sign-on
A freshly deployed cluster has ZITADEL running and nothing configured in it. These are the steps that turn that into a working Google login for Grafana, Harbor, the Flux UI and Headlamp — the same steps on both clouds, with different values.
For what the resulting stack actually does — and why ZITADEL sits in the middle of it — see Authentication.
The values for your cluster
Everything below is the same on both clouds except these:
aws-0 | gcp-0 | |
|---|---|---|
--cluster | aws-0 | gcp-0 |
--cloud | aws | gcp |
| account flag | --region eu-west-3 | --project ogenki-435905 |
IDP_URL | https://auth.cloud.ogenki.io | https://auth.gcp.cloud.ogenki.io |
PRIVATE_DOMAIN | priv.aws.ogenki.io | priv.gcp.ogenki.io |
| step 2 | not needed — External Secrets uses EKS Pod Identity, granted by OpenTofu | required |
OPENBAO | the --openbao-* flags: step 1 re-points OpenBao’s OIDC client too (#2045) | hosting its own directory: the --openbao-* flags plus --mirror-openbao. Consuming aws-0’s: empty |
Set them once and the rest of the page copies straight into a shell:
On aws-0:
CL="--cluster aws-0 --cloud aws --region eu-west-3"
OPENBAO="--openbao-url https://bao.priv.aws.ogenki.io:8200 --openbao-root-token-secret openbao/cloud-native-ref/tokens/root --openbao-ca-file opentofu/aws/openbao/management/.tls/ca.pem"
export IDP_URL=https://auth.cloud.ogenki.io
export PRIVATE_DOMAIN=priv.aws.ogenki.ioOn gcp-0, when it hosts its own directory:
CL="--cluster gcp-0 --cloud gcp --project ogenki-435905"
# gcp-0's ExternalSecrets read OpenBao, so step 1 also mirrors each client there.
OPENBAO="--openbao-url https://bao.priv.gcp.ogenki.io:8200 --openbao-root-token-secret openbao-priv-gcp-root-token --openbao-ca-file opentofu/gcp/gke/configure/.tls/ca.pem --mirror-openbao"
export IDP_URL=https://auth.gcp.cloud.ogenki.io
export PRIVATE_DOMAIN=priv.gcp.ogenki.ioOn gcp-0, when it consumes aws-0’s directory, aws-0’s own run rotates
OpenBao OIDC. Step 1 only registers gcp-0’s clients there, and step 3 is not
run:
CL="--cluster gcp-0 --cloud gcp --project ogenki-435905 --idp-cloud aws --region eu-west-3"
OPENBAO=""
export IDP_URL=https://auth.cloud.ogenki.io
export PRIVATE_DOMAIN=priv.gcp.ogenki.ioThe steps
The order is load-bearing: each one creates what the next reads. Running only the first leaves you with OIDC clients and no way to log in through Google.
Every step is idempotent — re-running prints [skip …] and changes nothing.
# 1. Register the OIDC clients, the project, its roles, and
# projectRoleAssertion. That last one is not optional: with it off ZITADEL
# puts NO roles in any token AND leaves ctx.v1.user.grants empty inside the
# groups action, so every consumer authenticates and then has no groups.
# On a hosting cluster, $OPENBAO points OpenBao at whatever client ZITADEL
# now holds; leaving it out strands OpenBao on a client ZITADEL forgot.
# A client id that REPLACES one the cluster held also restarts the
# Deployments reading it from env (oauth2-proxy, Headlamp, Grafana), once
# their Secret carries it. Only with kubectl on --cluster; otherwise it warns.
./scripts/provision/zitadel-oidc-clients.sh sync $CL $OPENBAO --apply# 2. GCP ONLY. Let External Secrets read what step 1 just created. Those secrets
# did not exist when the OpenTofu stack applied, so nothing granted access to
# them. On AWS this step does not exist — External Secrets authenticates with
# EKS Pod Identity, which OpenTofu already granted.
./scripts/provision/secret-store.sh grant --cloud gcp --project ogenki-435905 --apply# 3. The Google identity provider, the LOGIN POLICY entry that actually enables
# it, and the action that flattens project roles into a `groups` claim.
# Creating the provider without the policy entry gives "User not found".
# With $OPENBAO, it also mirrors the room broker's ZITADEL link reader into
# OpenBao (non-admin room access stays off until it runs).
IDP_URL=$IDP_URL ./scripts/provision/zitadel-idp.sh sync $CL $OPENBAO --mirror-openbao --applyThere is no manual step for Harbor. Step 1 already wrote its client id and
secret to the harbor-oidc store entry; an ExternalSecret syncs that into the
cluster and Harbor’s HelmRelease renders it into CONFIG_OVERWRITE_JSON
declaratively — the same shape as every other consumer. It converges on its own
within the ExternalSecret refresh interval and the next HelmRelease
reconcile. See ADR-0028.
Then log in once through Google, at any consumer. That first login is what CREATES your ZITADEL user — the IdP auto-registers it — so there is nobody to authorise before it. Afterwards:
# 4. Give yourself the admin role. Group-based RBAC (cluster-admin via the
# `admin` group) does nothing until a user actually holds it.
./scripts/provision/zitadel-oidc-clients.sh sync $CL --grant-admin you@example.com --applyThe one step no script can do
The OAuth client’s authorized redirect URI, on the Google side, must list:
https://auth.<public domain>/ui/login/login/externalidp/callbackGoogle accepts many redirect URIs on one client, so a single client serves every
cluster — add both clusters’ URIs once and you are done.
zitadel-idp.sh prints the exact URI on every run and cannot add it for you.
Then check it worked
Open Grafana, Harbor, the Flux UI or Headlamp. You should be offered a Google button rather than a username field, and after logging in your ZITADEL user exists.
The per-cloud verify pages cover the rest of the post-deploy checks: aws-0 · gcp-0.
aws-0 bootstraps its
database from a frozen backup, so a cluster rebuilt today comes back with
whatever OIDC clients and roles existed when that seed was taken — not the ones
this page creates. Re-run steps 1 and 3 after a restore; they are idempotent
and will fill in whatever the seed is missing. Harbor needs no re-run — it
converges from whatever step 1 last wrote to the harbor-oidc store entry. See
Restore a database.iam-admin-pat Secret in the security
namespace, at FirstInstance. On the cloud that hosts the directory, every run
reads that Secret first, and it wins: it overwrites the cloud store’s copy when
they differ, because a fresh FirstInstance minted a new token. The store is
read only when the Secret is gone, after a restore. A cluster consuming another
cloud’s directory reads that cloud’s store and never writes it. Run hosting
syncs with kubectl pointed at the hosting cluster, since a leftover Secret
elsewhere would overwrite the stored token. Nothing here needs a token minted
by hand.Recovery only: a cluster restored before this landed has no PAT anywhere. If
every script above fails with no ZITADEL admin PAT available, the store was
never seeded — this cluster’s seed predates the capture step above, or its store
entry was deleted. Mint a PAT for the iam-admin machine user in the ZITADEL
console, then:
kubectl create secret generic iam-admin-pat \
-n security --from-literal=pat=<token>and re-run step 1 — it persists the token into the store, so this is a one-time recovery rather than a routine step.
Related
- Authentication — what the stack does and where the two clouds genuinely differ
- ADR-0024 — why each cloud runs its own ZITADEL rather than sharing one