Skip to content
Set up single sign-on

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-0gcp-0
--clusteraws-0gcp-0
--cloudawsgcp
account flag--region eu-west-3--project ogenki-435905
IDP_URLhttps://auth.cloud.ogenki.iohttps://auth.gcp.cloud.ogenki.io
PRIVATE_DOMAINpriv.aws.ogenki.iopriv.gcp.ogenki.io
step 2not needed — External Secrets uses EKS Pod Identity, granted by OpenTofurequired
OPENBAOthe --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.io

On 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.io

On 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.io

The 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 --apply

There 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 --apply

The 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/callback

Google 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.

A restored ZITADEL can predate its own configuration. 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.
The admin PAT every script above needs is automatic, not a prerequisite. The chart writes it once, into the 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