Every consumer SSO login was broken for two independent reasons, and neither was recorded in git. This PR makes both IaC-owned.
1. OIDC client secrets lived only in the cluster
scripts/iam/zitadel-provision.py calls _generate_client_secret for every app on everyapply run. A rotation therefore invalidated the stored secret in ArgoCD, Grafana, MinIO, Immich and Paperless, with no git value to compare against.
Verified against ZITADEL's token endpoint. A valid client with a bogus code returns Errors.User.Code.Invalid; a bad secret returns invalid_client. Before this change six apps returned invalid secret.
Declaring the five Secrets as SOPS manifests makes ArgoCD the owner, so the next rotation is a reviewable diff instead of silent drift.
2. Identity links are stored inside three application databases
Forgejo, Paperless and Immich keep the IdP link in their own database, not in Kubernetes. Those rows still carried the Authentik subject and provider. One cause, three different symptoms:
App
Symptom
Forgejo
never left /user/link_account
Paperless
showed the social signup page
Immich
400 email already linked to different account
The PostSync Job re-points each row at the ZITADEL sub, matching on email/username rather than the numeric ids this was first observed on, so a re-run converges and a fresh database works.
3. MinIO shipped with OIDC disabled
identity_openid defaults to off. The tenant had all eight MINIO_IDENTITY_OPENID_* values but not MINIO_IDENTITY_OPENID_ENABLE, so the whole block was inert. Also drops MINIO_IDENTITY_OPENID_CLAIM_PREFIX, which was declared with an empty value.
Verification
Real browser logins against a live ZITADEL session (password + TOTP):
App
Result
ArgoCD
✅/applications
Grafana
✅ dashboards home
Forgejo
✅ "Rock - Dashboard"
Paperless
✅ "Dashboard - Paperless-ngx"
Immich
✅/photos, lands on the admin account
Vault
n/a — no UI by design
MinIO
⚠️ server OIDC now enabled; the console is the operator sidecar, which carries no OIDC env — see below
Known limitation (not fixed here)
The MinIO console at minio.riotpiao.com is the operator sidecar (minio/operator:v5.0.0), which has zero environment variables and does not read MINIO_IDENTITY_OPENID_*. Upstream docs require CONSOLE_IDP_* for a separately-run console, and MinIO removed console SSO from the free build after RELEASE.2025-04-22T22-12-26Z (issues #21311, #21415). Our release is 2025-10-15. Server-side OIDC is enabled and usable via STS; browser SSO needs a separate decision.
Safety
Every SOPS file decrypts.
kubectl kustomize builds; kubectl apply --dry-run=server passes for both k8s/infra/iam and the Tenant.
The repair Job is idempotent and prunes itself via hook-delete-policy: BeforeHookCreation.
## Why
Every consumer SSO login was broken for two independent reasons, and neither was recorded in git. This PR makes both IaC-owned.
### 1. OIDC client secrets lived only in the cluster
`scripts/iam/zitadel-provision.py` calls `_generate_client_secret` for **every** app on **every** `apply` run. A rotation therefore invalidated the stored secret in ArgoCD, Grafana, MinIO, Immich and Paperless, with no git value to compare against.
Verified against ZITADEL's token endpoint. A valid client with a bogus code returns `Errors.User.Code.Invalid`; a bad secret returns `invalid_client`. Before this change six apps returned `invalid secret`.
Declaring the five Secrets as SOPS manifests makes ArgoCD the owner, so the next rotation is a reviewable diff instead of silent drift.
### 2. Identity links are stored inside three application databases
Forgejo, Paperless and Immich keep the IdP link in their own database, not in Kubernetes. Those rows still carried the **Authentik** subject and provider. One cause, three different symptoms:
| App | Symptom |
|---|---|
| Forgejo | never left `/user/link_account` |
| Paperless | showed the social signup page |
| Immich | `400 email already linked to different account` |
The PostSync Job re-points each row at the ZITADEL `sub`, matching on **email/username** rather than the numeric ids this was first observed on, so a re-run converges and a fresh database works.
### 3. MinIO shipped with OIDC disabled
`identity_openid` defaults to `off`. The tenant had all eight `MINIO_IDENTITY_OPENID_*` values but not `MINIO_IDENTITY_OPENID_ENABLE`, so the whole block was inert. Also drops `MINIO_IDENTITY_OPENID_CLAIM_PREFIX`, which was declared with an empty value.
## Verification
Real browser logins against a live ZITADEL session (password + TOTP):
| App | Result |
|---|---|
| ArgoCD | ✅ `/applications` |
| Grafana | ✅ dashboards home |
| Forgejo | ✅ "Rock - Dashboard" |
| Paperless | ✅ "Dashboard - Paperless-ngx" |
| Immich | ✅ `/photos`, lands on the **admin** account |
| Vault | n/a — no UI by design |
| MinIO | ⚠️ server OIDC now enabled; the console is the operator sidecar, which carries no OIDC env — see below |
## Known limitation (not fixed here)
The MinIO console at `minio.riotpiao.com` is the **operator sidecar** (`minio/operator:v5.0.0`), which has zero environment variables and does not read `MINIO_IDENTITY_OPENID_*`. Upstream docs require `CONSOLE_IDP_*` for a separately-run console, and MinIO removed console SSO from the free build after `RELEASE.2025-04-22T22-12-26Z` (issues #21311, #21415). Our release is `2025-10-15`. Server-side OIDC is enabled and usable via STS; browser SSO needs a separate decision.
## Safety
- Every SOPS file decrypts.
- `kubectl kustomize` builds; `kubectl apply --dry-run=server` passes for both `k8s/infra/iam` and the Tenant.
- The repair Job is idempotent and prunes itself via `hook-delete-policy: BeforeHookCreation`.
Every consumer SSO login failed for two independent reasons, and neither was
recorded in git.
1. The OIDC client secrets lived only in the cluster. They are written by
scripts/iam/zitadel-provision.py, whose `apply` phase calls
_generate_client_secret for every app on every run. A rotation therefore
invalidated the stored secret in ArgoCD, Grafana, MinIO, Immich and
Paperless, with no git value to compare against. Declaring the five Secrets
as SOPS manifests makes ArgoCD the owner, so the next rotation is a
reviewable diff instead of silent drift.
2. Forgejo, Paperless and Immich store the identity-provider link inside their
own databases, not in Kubernetes. Those rows still carried the Authentik
subject and provider, which produced three different symptoms from one
cause: Forgejo never left /user/link_account, Paperless showed the social
signup page, and Immich answered 400 "email already linked to different
account". The PostSync Job re-points each row at the ZITADEL `sub`,
matching on email or username rather than the numeric ids this was first
observed on, so a re-run converges.
Verified with a real browser login against a live ZITADEL session: Forgejo,
Paperless, Immich, Grafana and ArgoCD all reach their authenticated landing
page. Vault has no UI by design. MinIO remains open: the operator sidecar
serves its console with no OIDC environment, so it advertises form login only.
MinIO ships identity_openid disabled by default. Every MINIO_IDENTITY_OPENID_*
value was already correct, but without MINIO_IDENTITY_OPENID_ENABLE=on the
whole block is inert: the console answered /api/v1/login with
{"loginStrategy":"form"} and never rendered an SSO button.
Also drop MINIO_IDENTITY_OPENID_CLAIM_PREFIX, which was declared with an empty
value. An empty prefix makes the claim resolver prepend nothing and then look
for a nested property that cannot exist, so omitting the key is correct.
ArgoCD owns this Tenant with selfHeal, so the change has to land in git to
survive; a direct kubectl apply was reverted within seconds.
rock
merged commit 4e84571f0c into main2026-10-31 20:53:57 +00:00
rock
deleted branch fix/zitadel-oidc-iac2026-10-31 20:54:23 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Why
Every consumer SSO login was broken for two independent reasons, and neither was recorded in git. This PR makes both IaC-owned.
1. OIDC client secrets lived only in the cluster
scripts/iam/zitadel-provision.pycalls_generate_client_secretfor every app on everyapplyrun. A rotation therefore invalidated the stored secret in ArgoCD, Grafana, MinIO, Immich and Paperless, with no git value to compare against.Verified against ZITADEL's token endpoint. A valid client with a bogus code returns
Errors.User.Code.Invalid; a bad secret returnsinvalid_client. Before this change six apps returnedinvalid secret.Declaring the five Secrets as SOPS manifests makes ArgoCD the owner, so the next rotation is a reviewable diff instead of silent drift.
2. Identity links are stored inside three application databases
Forgejo, Paperless and Immich keep the IdP link in their own database, not in Kubernetes. Those rows still carried the Authentik subject and provider. One cause, three different symptoms:
/user/link_account400 email already linked to different accountThe PostSync Job re-points each row at the ZITADEL
sub, matching on email/username rather than the numeric ids this was first observed on, so a re-run converges and a fresh database works.3. MinIO shipped with OIDC disabled
identity_openiddefaults tooff. The tenant had all eightMINIO_IDENTITY_OPENID_*values but notMINIO_IDENTITY_OPENID_ENABLE, so the whole block was inert. Also dropsMINIO_IDENTITY_OPENID_CLAIM_PREFIX, which was declared with an empty value.Verification
Real browser logins against a live ZITADEL session (password + TOTP):
/applications/photos, lands on the admin accountKnown limitation (not fixed here)
The MinIO console at
minio.riotpiao.comis the operator sidecar (minio/operator:v5.0.0), which has zero environment variables and does not readMINIO_IDENTITY_OPENID_*. Upstream docs requireCONSOLE_IDP_*for a separately-run console, and MinIO removed console SSO from the free build afterRELEASE.2025-04-22T22-12-26Z(issues #21311, #21415). Our release is2025-10-15. Server-side OIDC is enabled and usable via STS; browser SSO needs a separate decision.Safety
kubectl kustomizebuilds;kubectl apply --dry-run=serverpasses for bothk8s/infra/iamand the Tenant.hook-delete-policy: BeforeHookCreation.MinIO ships identity_openid disabled by default. Every MINIO_IDENTITY_OPENID_* value was already correct, but without MINIO_IDENTITY_OPENID_ENABLE=on the whole block is inert: the console answered /api/v1/login with {"loginStrategy":"form"} and never rendered an SSO button. Also drop MINIO_IDENTITY_OPENID_CLAIM_PREFIX, which was declared with an empty value. An empty prefix makes the claim resolver prepend nothing and then look for a nested property that cannot exist, so omitting the key is correct. ArgoCD owns this Tenant with selfHeal, so the change has to land in git to survive; a direct kubectl apply was reverted within seconds.