Skip to content

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_id with no matching identity.tenant row 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', migration V13__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-backendmandatory, the hub pins this audience (quarkus.oidc.token.audience=lumnik-backend). No token without it gets past the OIDC layer (401). - tenant_idmandatory, 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_idoptional. 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, env OIDC_SUBJECT_CLAIM). Both iss and 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:

  1. Realm roles: create lm_user, lm_integrator, lm_admin (and scope:<tag> roles if you bind métiers — Scopes). Assign them to users or groups; the hub matches names flat.
  2. tenant_id mapper: User Attribute mapper, user attribute tenant_id, claim tenant_id, JSON type long, on the access token. Set the attribute per user (or per group) to the seeded tenant id.
  3. groups mapper: User Realm Role mapper, claim name groups, multivalued, on the access token.
  4. Audience mapper: Audience mapper adding lumnik-backend to the access token. Every client that calls the hub needs it — see the SPA scar below.
  5. lm client: 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; map groups to the lumnik role names, not Okta group ids.
  • Entra ID: sub is pairwise per app registration — stable for one app, but re-registering the app mints new subjects and orphans every JIT identity. Prefer OIDC_SUBJECT_CLAIM=oid (the tenant-stable object id). Entra's default groups claim emits group GUIDs — you need claims-mapping policy (or app roles emitted into a groups claim) to carry the literal lm_* 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-backend on every access token — and a freshly-created SPA client emits no audience by default. The bundled lumnik-spa client learned this live: without the audience mapper, every PWA call is a 401 while lm works fine. When you register the SPA client in the customer IdP: public client + PKCE (S256), redirect URI https://<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: migrate identity.app_user.idp_issuer deliberately, 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
  • Scopesscope:<tag> métier grants
  • Deploy — self-host and k8s paths; pointing lm at 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