Bring your own IdP¶
Operator guide for plugging a customer's existing IdP (Keycloak, Okta, Entra ID, any OIDC issuer) into lumnik — enterprise onboarding without deploying a second Keycloak.
The model¶
Your IdP authenticates; lumnik provisions. The hub validates the Bearer token against
your issuer (%prod OIDC, discovery + JWKS), reads the mandatory tenant_id claim to
scope every query through PostgreSQL Row-Level Security, and — on a user's first
login — JIT-provisions a lumnik user keyed on (idp_issuer, idp_subject)
(JitUserProvisioner). No password ever lives in lumnik; no user list is synced up
front. Departures ride SCIM — JIT never reactivates a deactivated
identity.
The crucial rule: JIT creates users, never tenants. The tenant row must pre-exist — seeded by the operator at onboarding. A token claiming a
tenant_idwith no matchingidentity.tenantrow fails the first login (FK violation), by design: a tenant is an operator decision, not a side effect of someone signing in.
Prerequisites checklist¶
Before the first user logs in:
- [ ] Tenant row seeded. A fresh install seeds tenant
1(code='default', migrationV13__default_tenant.sql). Every other tenant is yours to create — the same gesture the k8s rehearsal script (deploy/helm/up.sh) uses, run as the owner role (BYPASSRLS):
INSERT INTO identity.tenant (id, code, name, created_at, updated_at)
VALUES (42, 'acme', 'ACME Corp', now(), now())
ON CONFLICT DO NOTHING;
(Or POST /api/tenants as lm_superadmin. Explicit ids below 1000 are safe —
the sequence starts at 1000.)
- [ ] A client registered in your IdP for each surface: one for lm (device
flow) and — if you use the PWA — the SPA client (public + PKCE). See the
SPA warning below.
- [ ] Claims mapped on the access token:
- aud: lumnik-backend — mandatory, the hub pins this audience
(quarkus.oidc.token.audience=lumnik-backend). No token without it gets past
the OIDC layer (401).
- tenant_id — mandatory, integer. Missing or non-numeric aborts with
401 {"error":"missing or invalid tenant_id claim"}. A fractional value
(e.g. 1.9) is rejected, never truncated — silent truncation could land a
user in the wrong tenant.
- groups — the realm-role array (lm_user, lm_integrator, lm_admin, plus
any scope:<tag> grants). The hub reads roles from this claim path
(roles.role-claim-path=groups). No hierarchy — grant every role a user
needs explicitly (Role management).
- user_id — optional. Emit it only if your IdP knows lumnik's DB PK (the
bundled realm does; a customer IdP normally cannot). When absent, the hub
takes the JIT path from iss + the subject claim — this is the normal
external-IdP posture.
- name, email — optional profile fields, used only at first-login creation
(name clamped to 60 chars, an invalid email is dropped to null — a profile
claim never turns a login into a 500).
Subject claim. The identity key's subject side defaults to
sub(lumnik.oidc.subject-claim, envOIDC_SUBJECT_CLAIM). Bothissand the subject must fit 255 chars — an oversized one is rejected (401 {"error":"subject/issuer claim too long"}), never truncated: truncating the key would change the identity.
What JIT creates (and what it refuses)¶
On first login with no user_id claim, the hub creates one identity.app_user row
in the token's tenant: login = subject, display name from name (fallback:
subject), optional email, and the (idp_issuer, idp_subject) key. Creation is
race-safe with replicas > 1 — a concurrent first login on two pods resolves to one
row via the unique constraint (ExternalIdentityCreator).
Every later login resolves the same row. A deactivated identity is refused with
403 {"error":"user is deactivated"} — JIT never reactivates or recreates; only an
admin (or a SCIM re-activation flow you control) brings a user back.
Keycloak walkthrough¶
The shipped realm (infra/keycloak/lumnik-realm.json) is the working reference —
copy its mapper shapes onto your client:
- Realm roles: create
lm_user,lm_integrator,lm_admin(andscope:<tag>roles if you bind métiers — Scopes). Assign them to users or groups; the hub matches names flat. tenant_idmapper: User Attribute mapper, user attributetenant_id, claimtenant_id, JSON type long, on the access token. Set the attribute per user (or per group) to the seeded tenant id.groupsmapper: User Realm Role mapper, claim namegroups, multivalued, on the access token.- Audience mapper: Audience mapper adding
lumnik-backendto the access token. Every client that calls the hub needs it — see the SPA scar below. lmclient: OIDC client with device flow enabled; point the CLI at your issuer (lm config set-context ... --oidc-issuer https://idp.example.com/realms/corp --oidc-client-id <your-client>).
Okta / Entra ID notes¶
The hub is generic OIDC — nothing in the code is Keycloak-specific; it needs only
discovery/JWKS at the issuer URL and the claims above. So Okta and Entra work on
paper; honestly: only Keycloak is exercised by the shipped rehearsal
(make k8s-up-oidc-external), Okta/Entra have not been run against a live hub.
- Okta: custom claims (
tenant_id,groups) and a custom audience require a custom authorization server; mapgroupsto the lumnik role names, not Okta group ids. - Entra ID:
subis pairwise per app registration — stable for one app, but re-registering the app mints new subjects and orphans every JIT identity. PreferOIDC_SUBJECT_CLAIM=oid(the tenant-stable object id). Entra's defaultgroupsclaim emits group GUIDs — you need claims-mapping policy (or app roles emitted into agroupsclaim) to carry the literallm_*names. - SAML-only IdPs (ADFS and friends): not supported by direct trust — front them with a broker (a Keycloak you or the customer operates) and point lumnik at the broker.
Kubernetes: auth.mode=oidc-external¶
The Helm chart carries a first-class mode for this: trust the customer's issuer, deploy no Keycloak.
# deploy/helm/lumnik/values.yaml
auth:
mode: oidc-external
oidc:
external:
issuerUrl: "https://idp.customer.com/realms/corp" # discovery/JWKS
tokenIssuer: "" # token `iss` to validate; defaults to issuerUrl.
# Set ONLY when it differs (a port-forward split on kind).
The chart fails fast (hub-configmap.yaml): a typo'd auth.mode aborts
helm install (instead of silently pointing the hub at a Keycloak that was never
deployed), and oidc-external with an empty issuerUrl refuses to render. Both
oidc and oidc-external run the hub on %prod (dev-bypass OFF); only where the
issuer points differs.
To rehearse the whole shape locally — a stand-in "customer IdP" in its own namespace, the hub trusting it, the release provably Keycloak-free:
make k8s-up-oidc-external
A real customer IdP is then an issuerUrl swap — zero chart change, zero hub code
change. Leave tokenIssuer empty in real deployments (the split exists only for
the kind port-forward).
The PWA client (lumnik-spa)¶
⚠️ The audience-mapper scar. The hub pins
aud: lumnik-backendon every access token — and a freshly-created SPA client emits no audience by default. The bundledlumnik-spaclient learned this live: without the audience mapper, every PWA call is a 401 whilelmworks fine. When you register the SPA client in the customer IdP: public client + PKCE (S256), redirect URIhttps://<hub-origin>/app/*, and the audience mapper on the access token. Full PWA auth lifecycle: the métier PWA.
The issuer is part of the identity¶
⚠️ Changing the issuer URL orphans JIT identities. Users are keyed
(idp_issuer, idp_subject). Move the origin — plain-http to TLS, hostname change, issuer path change — and the same human becomes a new identity, whose first login then collides with the old row's login (uq_user_login, surfaced honestly as an error). Pick the public origin once, early, before the first real login. If you must move: migrateidentity.app_user.idp_issuerdeliberately, or accept re-provisioning by clearing the old-issuer users. Details: upgrade and backup/restore.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
401 {"error":"missing or invalid tenant_id claim"} |
No tenant_id on the access token, or non-integer value |
Add/fix the tenant_id mapper (JSON type long, access token) |
| 401 with no lumnik error body | Token rejected by the OIDC layer: wrong/missing aud: lumnik-backend, or iss ≠ the configured token issuer |
Add the audience mapper; check issuerUrl/tokenIssuer match the token's iss |
401 {"error":"missing subject/issuer claim"} |
No user_id claim and the subject claim is absent |
Check OIDC_SUBJECT_CLAIM names a claim the IdP actually emits |
401 {"error":"subject/issuer claim too long"} |
iss or subject exceeds 255 chars |
Use a shorter stable claim (e.g. Entra oid) |
First login 500, logs show fk_user_tenant |
Tenant row not seeded — JIT creates users, never tenants | Seed the tenant (checklist above), retry |
403 {"error":"user is deactivated"} |
Identity exists but was deactivated (e.g. SCIM departure) | Reactivate via admin — JIT never resurrects |
| 403 on every endpoint despite valid login | groups claim missing or carrying IdP-native names instead of lm_* |
Map realm roles into groups as literal lumnik role names |
| Login error after moving to TLS / new hostname | Issuer change orphaned the JIT identity; new row collides on uq_user_login |
Migrate idp_issuer on existing rows (scar box above) |
See also¶
- Role management — the realm-role catalog and
roles.yaml - Scopes —
scope:<tag>métier grants - Deploy — self-host and k8s paths; pointing
lmat the hub - SCIM deprovisioning — the departure side of the identity lifecycle
- The filter that enforces all of this:
lumnik-hub/src/main/java/io/lumnik/hub/tenant/TenantContextFilter.java - The design background:
docs/superpowers/specs/2026-06-19-cloud-edition-external-idp-design.md