oidc tower on verdandi: keycloak, the lament realm, web-tier SSO + desktop SPNEGO #109

Merged
lament merged 8 commits from oidc into main 2026-07-24 16:51:00 -05:00
Owner

stands up keycloak on verdandi as the fleet's OIDC IdP, federated to the existing krb5+ldap root, and graduates the whole web tier plus ishtar's desktop login onto it. picked keycloak over authelia/kanidm for its native kerberos/SPNEGO brokering, so the silent-TGT endgame landed in-tower rather than as a later bolt-on. the KDC stays the credential root throughout; KC federates to slapd and password auth always rides the {SASL} -> KDC passthrough underneath.

keycloak on verdandi

  • services/identity/keycloak.nix: KC bound to the WG internal addr, plain http behind caddy (TLS terminates at the edge), hostname=https://id.lament.gay, proxy-headers=xforwarded
  • local postgres tenant - verdandi gains the postgres role (its own server, persist + backup); DB via createLocally + a sops passwordFile; the DR dump names the keycloak db explicitly
  • bootstrap admin injected via a sops EnvironmentFile (KC_BOOTSTRAP_ADMIN_*), never baked into the store
  • id.lament.gay public vhost (directory services.id) + a zone CNAME

the lament realm, realm-as-code

  • static/keycloak/realm-lament.json (secret-free) reconciled by a keycloak-config-cli oneshot on every activation; --import.var-substitution + $(env:LDAP_BIND_PW) pulls the bind pw from a sops env template - same idempotent oneshot idiom as the forgejo/wiki-js auth sources
  • LDAP user federation -> slapd (editMode=READ_ONLY); a group-ldap-mapper lifts cn=admins -> /admins -> realm-management:realm-admin
  • edge: id.lament.gay + /admin redirect to the lament realm console, not keycloak's built-in master (which stays break-glass at its explicit path)

web tier graduates to oidc

one shape per app - a realm client + secret, then swap the app's own auth source:

  • grafana (auth.generic_oauth, role from the /admins groups claim) - oauth_allow_insecure_email_lookup needed to link the pre-existing ldap-created admin by email instead of erroring
  • forgejo (DB-resident oauth2 source via admin auth add-oauth/update-oauth, old ldap source deactivated not deleted) - oauth2_client.ACCOUNT_LINKING=auto links the existing admin by email
  • wiki-js (GraphQL updateStrategies, old ldap strategy kept in the array but isEnabled:false - omitting it entirely triggers a delete that a still-linked user blocks via FK) - wiki-js doesn't email-link across providers at all, so its existing admin needed a one-time manual db re-point (providerKey='oidc', providerId=NULL) into its "pending social user" shape

desktop SPNEGO on ishtar

  • ishtar drops greetd autologin; security.pam.krb5.enable wires pam-krb5 into the PAM stack ahead of the local password check, so a normal login does a real kinit and drops a TGT into the session ticket cache
  • keycloak's kerberos integration rides the existing ldap federation component (serverPrincipal=HTTP/id.lament.gay, its own service keytab); the browser flow's auth-spnego execution (disabled by default, not a component-config field) gets flipped to ALTERNATIVE over the admin REST API in the same realm oneshot
  • firefox + zen (ishtar's actual default) both get SPNEGO trust for id.lament.gay - zen needed the enterprise Authentication.SPNEGO policy instead of profile prefs, since its profile is deliberately left unmanaged for noctalia's theming
  • one negotiation now covers grafana/forgejo/wiki-js too, since they all delegate login to the same realm session

mobile deliberately gets no kerberos equivalent - it already inherits the SSO payoff from the web-tier work (one password, one realm session, all three apps) and there's no sane device-side ticket cache to hook into on a phone.

tower is functionally complete; the one open thread is verifying the SPNEGO negotiation live from ishtar.

stands up **keycloak** on `verdandi` as the fleet's OIDC IdP, federated to the existing krb5+ldap root, and graduates the whole web tier plus ishtar's desktop login onto it. picked keycloak over authelia/kanidm for its native kerberos/SPNEGO brokering, so the silent-TGT endgame landed in-tower rather than as a later bolt-on. the KDC stays the credential root throughout; KC federates to slapd and password auth always rides the `{SASL}` -> KDC passthrough underneath. ### `keycloak` on `verdandi` - `services/identity/keycloak.nix`: KC bound to the WG internal addr, plain http behind caddy (TLS terminates at the edge), `hostname=https://id.lament.gay`, `proxy-headers=xforwarded` - local postgres tenant - `verdandi` gains the `postgres` role (its own server, persist + backup); DB via `createLocally` + a sops `passwordFile`; the DR dump names the `keycloak` db explicitly - bootstrap admin injected via a sops `EnvironmentFile` (`KC_BOOTSTRAP_ADMIN_*`), never baked into the store - `id.lament.gay` public vhost (directory `services.id`) + a zone `CNAME` ### the `lament` realm, realm-as-code - `static/keycloak/realm-lament.json` (secret-free) reconciled by a `keycloak-config-cli` oneshot on every activation; `--import.var-substitution` + `$(env:LDAP_BIND_PW)` pulls the bind pw from a sops env template - same idempotent oneshot idiom as the forgejo/wiki-js auth sources - LDAP user federation -> slapd (`editMode=READ_ONLY`); a `group-ldap-mapper` lifts `cn=admins` -> `/admins` -> `realm-management:realm-admin` - edge: `id.lament.gay` + `/admin` redirect to the `lament` realm console, not keycloak's built-in `master` (which stays break-glass at its explicit path) ### web tier graduates to oidc one shape per app - a realm client + secret, then swap the app's own auth source: - **grafana** (`auth.generic_oauth`, role from the `/admins` groups claim) - `oauth_allow_insecure_email_lookup` needed to link the pre-existing ldap-created admin by email instead of erroring - **forgejo** (DB-resident oauth2 source via `admin auth add-oauth`/`update-oauth`, old ldap source deactivated not deleted) - `oauth2_client.ACCOUNT_LINKING=auto` links the existing admin by email - **wiki-js** (GraphQL `updateStrategies`, old `ldap` strategy kept in the array but `isEnabled:false` - omitting it entirely triggers a delete that a still-linked user blocks via FK) - wiki-js doesn't email-link across providers at all, so its existing admin needed a one-time manual db re-point (`providerKey='oidc', providerId=NULL`) into its "pending social user" shape ### desktop SPNEGO on ishtar - ishtar drops greetd autologin; `security.pam.krb5.enable` wires `pam-krb5` into the PAM stack ahead of the local password check, so a normal login does a real kinit and drops a TGT into the session ticket cache - keycloak's kerberos integration rides the existing ldap federation component (`serverPrincipal=HTTP/id.lament.gay`, its own service keytab); the browser flow's `auth-spnego` execution (disabled by default, not a component-config field) gets flipped to `ALTERNATIVE` over the admin REST API in the same realm oneshot - firefox + zen (ishtar's actual default) both get SPNEGO trust for `id.lament.gay` - zen needed the enterprise `Authentication.SPNEGO` policy instead of profile prefs, since its profile is deliberately left unmanaged for noctalia's theming - one negotiation now covers grafana/forgejo/wiki-js too, since they all delegate login to the same realm session mobile deliberately gets no kerberos equivalent - it already inherits the SSO payoff from the web-tier work (one password, one realm session, all three apps) and there's no sane device-side ticket cache to hook into on a phone. tower is functionally complete; the one open thread is verifying the SPNEGO negotiation live from ishtar.
realm-as-code via keycloak-config-cli: LDAP user federation to slapd
(password auth rides the existing {SASL} -> KDC passthrough), attribute
mappers, and a group-ldap-mapper lifting cn=admins -> /admins -> realm-admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rUDy1aEZoephQHL2FnQpz
the edge lands on the lament realm, not master
All checks were successful
CI / check (pull_request) Successful in 52s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m3s
CI / dry-activate (pull_request) Successful in 29s
f0e857a059
id.lament.gay + /admin redirect to the lament realm console; master
stays break-glass at its explicit /admin/master/ path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rUDy1aEZoephQHL2FnQpz
grafana graduates to oidc
All checks were successful
CI / check (pull_request) Successful in 56s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m2s
CI / dry-activate (pull_request) Successful in 27s
b265d40060
drops the direct svc-ldap search-bind for keycloak's `lament` realm:
a `grafana` oidc client (secret seeded onto the realm via config-cli's
$(env:...)), `generic_oauth` on grafana's side, /admins -> GrafanaAdmin
from the groups claim. oauth_allow_insecure_email_lookup links the login
onto the existing admin account (one trusted idp over our own directory).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rUDy1aEZoephQHL2FnQpz
forgejo graduates to oidc
All checks were successful
CI / check (pull_request) Successful in 51s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m48s
CI / dry-activate (pull_request) Successful in 27s
ea32249de5
drop the LDAP search-bind auth source for a keycloak OIDC one - the
forgejo-oidc oneshot registers the source (name keycloak, admin via the
/admins groups claim) and deactivates the old directory LDAP row. add
the forgejo realm client + FORGEJO_CLIENT_SECRET, and an oauth2_client
block that auto-links the existing lament admin onto oauth by email.
lament force-pushed oidc from ea32249de5
All checks were successful
CI / check (pull_request) Successful in 51s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m48s
CI / dry-activate (pull_request) Successful in 27s
to 4475692079
All checks were successful
CI / check (pull_request) Successful in 51s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m56s
CI / dry-activate (pull_request) Successful in 28s
2026-07-24 15:24:13 -05:00
Compare
lament force-pushed oidc from 4475692079
All checks were successful
CI / check (pull_request) Successful in 51s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m56s
CI / dry-activate (pull_request) Successful in 28s
to d63eb4d1ee
All checks were successful
CI / check (pull_request) Successful in 54s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m2s
CI / dry-activate (pull_request) Successful in 28s
2026-07-24 15:35:22 -05:00
Compare
lament force-pushed oidc from d63eb4d1ee
All checks were successful
CI / check (pull_request) Successful in 54s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m2s
CI / dry-activate (pull_request) Successful in 28s
to bb902ae558
Some checks failed
CI / check (pull_request) Successful in 56s
CI / deploy (pull_request) Has been skipped
CI / dry-activate (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
2026-07-24 16:15:36 -05:00
Compare
lament force-pushed oidc from bb902ae558
Some checks failed
CI / check (pull_request) Successful in 56s
CI / deploy (pull_request) Has been skipped
CI / dry-activate (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
to 313de360db
All checks were successful
CI / check (pull_request) Successful in 56s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m5s
CI / dry-activate (pull_request) Successful in 31s
2026-07-24 16:18:17 -05:00
Compare
lament changed title from WIP: an oidc tower on verdandi: keycloak + the lament realm to oidc tower on verdandi: keycloak, the lament realm, web-tier SSO + desktop SPNEGO 2026-07-24 16:20:14 -05:00
local passwd now differs
All checks were successful
CI / check (pull_request) Successful in 54s
CI / deploy (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m3s
CI / dry-activate (pull_request) Successful in 29s
CI / check (push) Has been skipped
CI / build (push) Has been skipped
CI / dry-activate (push) Has been skipped
CI / deploy (push) Successful in 2m35s
008c295f39
lament merged commit 008c295f39 into main 2026-07-24 16:51:00 -05:00
lament deleted branch oidc 2026-07-24 16:51:00 -05:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
lament/pantheon!109
No description provided.