From 354cadfd93cd5fe50e989e71522bb1c0a0a3edc1 Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Tue, 4 Aug 2026 14:56:56 +0200 Subject: [PATCH 1/8] docs: define core SSO architecture for OIDC domains Add docs/sso.md describing the OIDC domain abstraction that mirrors the existing LDAP account domain model (internal/external, backed by a broker provider such as Keycloak), covering discovery, per-app client credentials, capability contracts, and its relation to existing LDAP domains. Cross-link it from docs/design.md. Assisted-by: Claude Code:claude-sonnet-5 --- docs/design.md | 2 + docs/sso.md | 256 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 docs/sso.md diff --git a/docs/design.md b/docs/design.md index efc2836c61..193fbd6e15 100644 --- a/docs/design.md +++ b/docs/design.md @@ -67,6 +67,8 @@ List of things considered almost stable, with or without an existing prototype i - Samba AD and OpenLDAP account providers (both are LDAP) - Remote LDAP account provider - No Unix accounts for domain users + - [OIDC domains](sso.md) for federated authentication (SSO), alongside + the LDAP-based domains above - Cluster agent, Node agent, Module agents - Events and Actions - Container Registry as software repository for everything diff --git a/docs/sso.md b/docs/sso.md new file mode 100644 index 0000000000..311b255b2b --- /dev/null +++ b/docs/sso.md @@ -0,0 +1,256 @@ +--- +layout: default +title: Federated identity (SSO) +nav_order: 5 +--- + +# Federated identity (SSO) + +* TOC +{:toc} + +## Goal + +Applications currently authenticate users only against [account provider domains](core/user_domains.md): +Samba AD, OpenLDAP, or a remote LDAP server. There is growing demand to let +applications authenticate against cloud identity providers (e.g. Microsoft +Entra ID, Google), enabling single sign-on (SSO) across the cluster. + +This document defines the core architecture for federated authentication +through OIDC domains, using OpenID Connect (OIDC) as the protocol. It +does not replace LDAP-based authentication, which remains fully +supported, and it does not implement SSO in any specific application: +each application integration is tracked by its own issue. + +## OIDC domains and brokers + +NS8 already models LDAP as an **account provider domain**: an +administrator-facing configuration entity, stored in Redis, backed by a +provider module (Samba AD, OpenLDAP, or a remote LDAP server). This +document introduces the equivalent entity for federated authentication: +the **OIDC domain**. + +An OIDC domain is, like an LDAP domain, either: + +- **Internal**: backed by a broker module installed in the cluster. +- **External**: backed by a broker instance the administrator runs and + manages elsewhere, supplying core with management API credentials for + it. + +Behind an OIDC domain sits a **broker**: a service such as Keycloak that +speaks standard OIDC to applications and exposes a management API core +can drive to provision per-application clients automatically. The broker +plays exactly the role Samba AD/OpenLDAP play for an LDAP domain: a core +module, the same category as Samba AD/OpenLDAP themselves, installed on +demand only when an OIDC domain is actually configured. + +A plain external identity provider with no broker in front of it (a raw +Entra ID tenant or Google Workspace account) does not fit this model at +all: there is no cluster-level abstraction to build around it, it is +closer to an application-specific manual configuration step (comparable +to configuring an external service via Nextcloud's `occ` tool) than to a +core-managed domain. It is out of scope for this document, see +[Non-goals](#non-goals). + +### Cardinality between domains and brokers + +Each OIDC domain is bound to exactly one broker. The reverse isn't true: +a single broker instance, and therefore a single FQDN, can serve +multiple OIDC domains at once (Keycloak, for example, supports many +realms/identity sources per instance), and nothing in this model +restricts how many broker instances a cluster, or even a single node, +may run. The `srv` key's [optional +qualifier](modules/service_providers.md#srv-keys) is what resolves this +one-FQDN-many-domains cardinality at the discovery level, see +[Discovery](#discovery). + +Sharing one broker across several domains is a resource-saving choice +available to the administrator, not a requirement. A cluster hosting +multiple companies may instead run one broker instance per tenant, each +with its own FQDN and certificate, keeping tenants' OIDC endpoints and +management APIs fully separate. Consolidation and isolation are both +valid deployment choices under the same model. + +A broker can, in turn, draw identities from more than one upstream +source: NS8's own LDAP domains via user federation, or external identity +providers (Entra ID, Google Workspace...) via identity-provider +brokering — see [Consuming brokered +identities](#consuming-brokered-identities-oidc-apps-vs-legacy-ldap-apps) +for how these two differ. This is what actually solves the +multi-application redirect URI problem: applications are provisioned +against the broker's management API, not against each upstream IdP's +console, one-by-one. + +Provisioning logic is necessarily broker-product-specific (Keycloak's +Admin REST API has nothing in common with Auth0's or Okta's Management +API), the same way Samba AD and OpenLDAP are separate implementations +behind the shared "LDAP domain" concept. This document only requires +that a broker, whatever the product, exposes a standard OIDC endpoint for +applications and a management API adapter core can drive, participating +in the discovery mechanism below. Which broker product(s) core ships an +adapter for first (Keycloak is the leading candidate), and any future +support for broker replicas of the same OIDC domain, are left to a +follow-up implementation issue. + +## Broker endpoint and TLS + +Unlike the [LDAP proxy](core/user_domains.md), which is an internal-only, +per-node local relay (`127.0.0.1`, no TLS required on the container +side), a broker is a public HTTPS endpoint: it is assigned one FQDN and +needs a real TLS certificate, issued the same way as any other +publicly-routed module through the [edge proxy / Let's Encrypt +mechanism](core/proxy_certificates.md). + +This has a direct consequence for connectivity: every application bound +to an OIDC domain must be able to reach, over the network, the FQDN of +the broker backing that domain. Unlike LDAP, where any node reaches its +bound domain through its own local proxy regardless of where the +account-provider module physically runs, an OIDC domain's reachability is +tied to wherever its broker's FQDN actually resolves and routes to. + +The same requirement extends to the end user: in the OIDC authorization +code flow, the user's own browser is redirected to the broker to +authenticate, so it must also resolve the broker's FQDN and successfully +validate its TLS certificate, not just the application backend. A broker +that is unreachable or presents an untrusted certificate to the client +breaks login just as effectively as one unreachable from the +application. + +## Discovery + +OIDC domains are published through the existing generic +[service provider discovery mechanism](modules/service_providers.md), +the same one used for other cluster services. No new discovery channel is +introduced. + +An **internal** broker module publishes one +`module/{id}/{qualifier}/srv/http/oidc` HASH key per OIDC domain +(identity source) it serves, discoverable via +`agent.list_service_providers()`. The `{qualifier}` disambiguates domains +served by the same broker instance, and therefore the same FQDN. Each key +describes: + +- the broker's FQDN, issuer URL and discovery document location +- which capabilities it supports (see below) +- the identity source it serves (an LDAP-federated domain, Entra ID, + Google Workspace...) + +An **external** broker is not a cluster module and cannot publish its own +`srv` keys. As with external LDAP domains today, the cluster itself would +publish an equivalent `srv/http/oidc` key on the administrator's behalf, +from the connection details entered when the external OIDC domain is +configured, so both cases look the same to `agent.list_service_providers()` +callers. Whether external brokers are supported at all, and how exactly +the cluster keeps that key in sync, is an implementation detail left to +the follow-up issue — it isn't certain a use case for external brokers +will actually materialize. + +Each OIDC domain configuration record (stored in Redis, mirroring the +shape of LDAP domain records) references the `srv/http/oidc` key that +backs it. Broker configuration changes fire a `service-oidc-changed` +event, the same pattern used for other service providers. + +## Client credentials + +Each application registers as its own OIDC client with the broker +backing the OIDC domain it uses, and gets back its own +`client_id`/`client_secret`, rather than sharing a single set of +credentials the way LDAP bind credentials are shared today. An +application obtains its client credentials by running an agent action +against the broker (e.g. `register-oidc-client`), which drives the +broker's management API adapter and returns the resulting credentials to +the caller. This is designed to work identically whether the domain is +internal or external, as long as both expose the same management API to +core — see the note on external brokers under +[Discovery](#discovery). + +This is already an improvement over the current LDAP model, where +bind credentials are stored in the domain configuration and handed out to +any authorized caller. It also leaves room for brokers that support +[Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591) +(RFC 7591) to automate registration end-to-end without a product-specific +adapter, without changing the action's interface from the application's +point of view. + +## Capability contract + +Application integrations vary in how deeply they can use an OIDC domain: +some only need authentication (the app keeps provisioning users from +LDAP as it does today), others can also consume group membership or role +claims from the broker and reduce their LDAP dependency. + +Rather than the application declaring an integration "tier", the broker +advertises, per domain, which capabilities that identity source supports +(e.g. group membership claims, a specific claims-mapping contract) as +part of the corresponding `srv/http/oidc` key. Each application's own +integration checks the advertised capabilities +against what it needs, and falls back or degrades gracefully when a +capability is not available. This keeps the compliance check local to +each application instead of requiring a central tier registry. + +[Nextcloud's `user_oidc` app](https://github.com/nextcloud/user_oidc) is +a mature, broadly deployed reference implementation of this pattern +(authentication and provisioning are cleanly separated) and is a useful +model to point other application teams (NethVoice, WebTop) toward. + +## Relation to the existing user domain model + +OIDC domains are a new, independent domain type alongside LDAP domains, +not a replacement — both share the same conceptual shape (a Redis-stored +domain record backed by a provider module) and both can be bound to the +same application at once. An application can keep using its bound LDAP +domain for user and group provisioning while delegating authentication +to an OIDC domain's broker. + +The two domain types aren't mutually exclusive at the identity-source +level either: a broker may itself use LDAP user federation against an +existing NS8 LDAP domain as one of its identity sources, in which case +authenticating through the OIDC domain and provisioning through the LDAP +domain ultimately point at the same underlying user base. Mapping an +authenticated OIDC identity back to an existing LDAP user (or +provisioning a new one) is left to each application's own integration, +guided by the capability contract above. + +## Consuming brokered identities: OIDC apps vs legacy LDAP apps + +The broker only changes things for applications that speak OIDC. It does +not, by itself, make an Entra ID/Google identity visible to an +LDAP-only application. + +- **OIDC-native applications** authenticate against the broker and read + identity/group claims straight from the ID token or userinfo endpoint, + regardless of which upstream identity source (LDAP-federated or an + external IdP) authenticated the user. Group membership from Entra ID or + Google, which is not usable as-is over plain OIDC (Entra ID's `groups` + claim holds opaque object IDs, Google requires the separate Admin + Directory API), is normalized by the broker's own per-upstream mapper + mechanism, so the application always sees one uniform claims contract. + This is the path [Nextcloud's `user_oidc`](https://github.com/nextcloud/user_oidc) + model follows, and it requires no LDAP involvement at all. +- **Legacy, LDAP-only applications** cannot consume a brokered identity at + all: the broker does not expose brokered users over the LDAP protocol, + only through its own OIDC endpoint and management API. A user who only + exists behind the broker (e.g. authenticated through Entra ID or + Google) is simply invisible to these applications. What such + applications *can* still do is bypass the broker entirely and connect + directly to a federated LDAP domain, exactly as they do today: if the + broker uses LDAP user federation against an NS8 LDAP domain, the users + in that domain remain fully accessible to LDAP-only applications + through the existing LDAP proxy, independent of the broker. + +## Non-goals + +- Implementing a broker module (e.g. Keycloak) and its management API + adapter, including support for running broker replicas of the same + OIDC domain: tracked by a follow-up issue. +- Supporting broker products beyond the first adapter core ships: + additional adapters (Authentik, Auth0, Okta...) are added on demand, as + follow-up issues. +- Modeling a plain external identity provider (a raw Entra ID tenant or + Google Workspace account with no broker in front of it) as a core + domain: each application configures it directly against its own admin + tooling (e.g. `occ` for Nextcloud), core is not involved. +- Implementing SSO support in individual applications: each integration + is tracked by its own issue. +- Replacing existing LDAP-based authentication, which remains fully + supported. From f96d3d5e5bc7fc5c7a4214b656320a32e7ddfe3b Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Tue, 4 Aug 2026 17:15:04 +0200 Subject: [PATCH 2/8] docs: note multi-FQDN brokers as a follow-up detail Some broker products (e.g. Keycloak's request-based hostname provider) can present multiple FQDNs from one instance without touching the OIDC domain model, since the FQDN already lives on the domain's own srv/http/oidc key. Clarify this stays out of scope for now. Assisted-by: Claude Code:claude-sonnet-5 --- docs/sso.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/sso.md b/docs/sso.md index 311b255b2b..f03b83c345 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -92,6 +92,17 @@ adapter for first (Keycloak is the leading candidate), and any future support for broker replicas of the same OIDC domain, are left to a follow-up implementation issue. +This document assumes one FQDN per broker instance, isolation between +tenants being achieved by running separate instances (see above). Some +broker products can present multiple FQDNs from a single instance at +the reverse-proxy level (e.g. Keycloak's request-based hostname +provider) without this affecting the OIDC domain model at all: the +FQDN already lives on the domain's own `srv/http/oidc` key, not on some +cluster-wide single-hostname assumption, so adding multi-FQDN support +for a broker instance later is a broker-module/proxy detail, not a +change to this architecture. Left to a follow-up implementation issue, +should the need arise. + ## Broker endpoint and TLS Unlike the [LDAP proxy](core/user_domains.md), which is an internal-only, From 116ba1c912b867697c14068bb5eac86503806d63 Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Tue, 4 Aug 2026 17:36:02 +0200 Subject: [PATCH 3/8] docs: defer AI agent impersonation as non-goal Agent-driven delegated access is a machine-to-machine flow (e.g. OAuth token exchange, RFC 8693), not the browser-based authorization code flow this document assumes. Note it as a future per-domain broker capability rather than reopening the domain/broker model now. Assisted-by: Claude Code:claude-sonnet-5 --- docs/sso.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/sso.md b/docs/sso.md index f03b83c345..ad07a88453 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -265,3 +265,11 @@ LDAP-only application. is tracked by its own issue. - Replacing existing LDAP-based authentication, which remains fully supported. +- Supporting delegated/impersonated access for AI agents acting on a + user's behalf: this is a machine-to-machine flow (e.g. OAuth token + exchange, RFC 8693) rather than the browser-based authorization code + flow this document assumes, and does not require reopening the + domain/broker model above — a broker can advertise support for it as + a future capability, the same way other per-domain capabilities are + advertised today. Left to a follow-up design issue, should the need + arise. From 46a109e3773fdcc78e9f3932450be4b31d0b6c47 Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Wed, 5 Aug 2026 12:05:33 +0200 Subject: [PATCH 4/8] docs: note oauth2-proxy as a legacy-app integration option An auth-proxy sidecar can front a non-OIDC-native application, running the auth code flow against the broker and forwarding identity via headers. This is an app-integration pattern, not a change to the broker architecture. Assisted-by: Claude Code:claude-sonnet-5 --- docs/sso.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/sso.md b/docs/sso.md index ad07a88453..e113dc59b4 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -249,6 +249,17 @@ LDAP-only application. in that domain remain fully accessible to LDAP-only applications through the existing LDAP proxy, independent of the broker. +A third option, between these two, is fronting a legacy application with +an auth-proxy sidecar such as +[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/): the sidecar +runs the OIDC authorization code flow against the broker on the +application's behalf and forwards identity/group claims as HTTP headers, +letting an application that cannot speak OIDC itself still consume a +brokered identity, without becoming OIDC-native. This is an +application-integration pattern, not a broker architecture change — it +still requires a broker behind it — and is left to each application's +own integration to adopt where it fits. + ## Non-goals - Implementing a broker module (e.g. Keycloak) and its management API From 52a500aea09fac1f2562ce9a0386ee6fb1ff696c Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Thu, 6 Aug 2026 19:09:59 +0200 Subject: [PATCH 5/8] docs: refine broker capabilities and DCR authorization model - Internal domains expose user/group management as NS8 actions (an illustrative, non-exhaustive set), which may drive the broker's admin API internally but never expose it directly. - External domains are read-only to NS8, matching external LDAP domains: core only registers applications against them. - User federation and identity brokering are scoped to the OIDC domain (a realm, in Keycloak's terms), not shared across other domains a broker instance hosts, and are optional and managed independently of the domain's own lifecycle. - Client registration follows RFC 7591 DCR; authorizing it (e.g. Keycloak's Initial Access Token) is broker-specific. External domains need a scoped-down admin credential able to mint IATs on demand, not a single pre-minted token, alongside the OIDC discovery manifest. - Note that combining identity sources in one domain requires an explicit, admin-surfaced choice for email-based auto-linking. Assisted-by: Claude Code:claude-sonnet-5 --- docs/sso.md | 82 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/docs/sso.md b/docs/sso.md index e113dc59b4..86b434f84f 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -32,10 +32,17 @@ the **OIDC domain**. An OIDC domain is, like an LDAP domain, either: -- **Internal**: backed by a broker module installed in the cluster. +- **Internal**: backed by a broker module installed in the cluster. The + module may expose user/group management actions equivalent to the Samba + AD/OpenLDAP providers (e.g. add/alter/remove user, add/alter/remove + group — the exact set is not fixed by this document); the actions' + own implementation is free to drive the broker's admin API directly, + but only those NS8 actions are exposed as the module's interface, not + the broker's API itself. - **External**: backed by a broker instance the administrator runs and - manages elsewhere, supplying core with management API credentials for - it. + manages elsewhere. Like an external LDAP domain, it is read-only to + NS8: core cannot manage its users or groups, only register + applications against it — see [Client credentials](#client-credentials). Behind an OIDC domain sits a **broker**: a service such as Keycloak that speaks standard OIDC to applications and exposes a management API core @@ -71,15 +78,20 @@ with its own FQDN and certificate, keeping tenants' OIDC endpoints and management APIs fully separate. Consolidation and isolation are both valid deployment choices under the same model. -A broker can, in turn, draw identities from more than one upstream -source: NS8's own LDAP domains via user federation, or external identity -providers (Entra ID, Google Workspace...) via identity-provider -brokering — see [Consuming brokered +Each OIDC domain can, in turn, draw identities from more than one +upstream source: NS8's own LDAP domains via user federation, or external +identity providers (Entra ID, Google Workspace...) via identity +brokering — both scoped to the domain itself (a realm, in Keycloak's +terms), not shared across the other domains a broker instance may also +be hosting — see [Consuming brokered identities](#consuming-brokered-identities-oidc-apps-vs-legacy-ldap-apps) -for how these two differ. This is what actually solves the -multi-application redirect URI problem: applications are provisioned -against the broker's management API, not against each upstream IdP's -console, one-by-one. +for how these two differ. Both are optional and managed independently of +the OIDC domain's own lifecycle: an administrator can add or remove an +identity provider (configured with its own `client_id`/`client_secret`) +or a user federation source at any time, without reconfiguring the +domain itself. This is what actually solves the multi-application +redirect URI problem: applications are provisioned against the broker's +management API, not against each upstream IdP's console, one-by-one. Provisioning logic is necessarily broker-product-specific (Keycloak's Admin REST API has nothing in common with Auth0's or Okta's Management @@ -166,22 +178,31 @@ event, the same pattern used for other service providers. Each application registers as its own OIDC client with the broker backing the OIDC domain it uses, and gets back its own `client_id`/`client_secret`, rather than sharing a single set of -credentials the way LDAP bind credentials are shared today. An -application obtains its client credentials by running an agent action -against the broker (e.g. `register-oidc-client`), which drives the -broker's management API adapter and returns the resulting credentials to -the caller. This is designed to work identically whether the domain is -internal or external, as long as both expose the same management API to -core — see the note on external brokers under -[Discovery](#discovery). - -This is already an improvement over the current LDAP model, where -bind credentials are stored in the domain configuration and handed out to -any authorized caller. It also leaves room for brokers that support -[Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591) -(RFC 7591) to automate registration end-to-end without a product-specific -adapter, without changing the action's interface from the application's -point of view. +credentials the way LDAP bind credentials are shared today. + +Registration follows [Dynamic Client +Registration](https://www.rfc-editor.org/rfc/rfc7591) (RFC 7591): the +application registers directly against the broker's +`registration_endpoint`, a standardized call needing no +product-specific adapter. RFC 7591 does not standardize how that call +gets authorized, though — Keycloak, for example, requires a short-lived +Initial Access Token (IAT), minted per registering application (a +single pre-minted IAT would only cover the first app). For an +**internal** domain, core mints that IAT itself, since the broker +module already holds full admin access. + +An **external** domain has no such standing admin access, so its +configuration record must carry two attributes instead of just +connection details: + +1. its OIDC discovery manifest, and +2. an admin credential scoped down to the single permission of minting + IATs (e.g. in Keycloak, a client-credentials grant limited to + creating registration tokens, not general realm-admin access). + +Together these are enough for core to keep minting IATs for new +application registrations over time, without ever holding full admin +rights on a broker it doesn't own. ## Capability contract @@ -222,6 +243,13 @@ authenticated OIDC identity back to an existing LDAP user (or provisioning a new one) is left to each application's own integration, guided by the capability contract above. +When a domain combines more than one identity source, the broker needs +a way to recognize the same person across sources — typically by +matching a claimed email address. This must be an explicit, surfaced +choice for the administrator to enable, not a silent default: trusting +an upstream-claimed email as a match key is also a potential +impersonation vector. + ## Consuming brokered identities: OIDC apps vs legacy LDAP apps The broker only changes things for applications that speak OIDC. It does From 5f5f2bd9cabe4d0bc61a0304f46f10114f8c66a7 Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Fri, 7 Aug 2026 12:19:03 +0200 Subject: [PATCH 6/8] docs: split out upstream identity source model The paragraph covering user federation and identity brokering was packed into the broker cardinality section, mixing two different relations: domain-to-broker and domain-to-upstream-sources. Give the upstream sources their own subsection and expand it: name the two mechanisms separately, state why the federation/brokering distinction matters for LDAP-only applications, and spell out what survives adding or removing an upstream. Also state the problem the broker layer avoids before the solution. Assisted-by: Claude Code:claude-opus-5[1m] --- docs/sso.md | 58 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/docs/sso.md b/docs/sso.md index 86b434f84f..31f22af4f6 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -78,21 +78,6 @@ with its own FQDN and certificate, keeping tenants' OIDC endpoints and management APIs fully separate. Consolidation and isolation are both valid deployment choices under the same model. -Each OIDC domain can, in turn, draw identities from more than one -upstream source: NS8's own LDAP domains via user federation, or external -identity providers (Entra ID, Google Workspace...) via identity -brokering — both scoped to the domain itself (a realm, in Keycloak's -terms), not shared across the other domains a broker instance may also -be hosting — see [Consuming brokered -identities](#consuming-brokered-identities-oidc-apps-vs-legacy-ldap-apps) -for how these two differ. Both are optional and managed independently of -the OIDC domain's own lifecycle: an administrator can add or remove an -identity provider (configured with its own `client_id`/`client_secret`) -or a user federation source at any time, without reconfiguring the -domain itself. This is what actually solves the multi-application -redirect URI problem: applications are provisioned against the broker's -management API, not against each upstream IdP's console, one-by-one. - Provisioning logic is necessarily broker-product-specific (Keycloak's Admin REST API has nothing in common with Auth0's or Okta's Management API), the same way Samba AD and OpenLDAP are separate implementations @@ -115,6 +100,49 @@ for a broker instance later is a broker-module/proxy detail, not a change to this architecture. Left to a follow-up implementation issue, should the need arise. +### Cardinality between a domain and its upstream identity sources + +Cardinality does not stop at the broker. Each OIDC domain can, in turn, +draw identities from more than one **upstream identity source**, through +two distinct mechanisms: + +- **User federation**: the broker reads users directly from an existing + NS8 LDAP domain, so authenticating through the OIDC domain and + provisioning from the LDAP domain ultimately hit the same user base. +- **Identity brokering**: the broker delegates authentication to an + external identity provider (Entra ID, Google Workspace...), acting as + that provider's own OIDC or SAML client. + +The distinction goes well beyond configuration: users reached by +federation remain visible to LDAP-only applications, while users reached +by brokering do not exist in LDAP at all — see [Consuming brokered +identities](#consuming-brokered-identities-oidc-apps-vs-legacy-ldap-apps). + +Either way, upstream sources are scoped to the domain itself — a realm, +in Keycloak's terms — and are not shared with the other domains the same +broker instance may be hosting. This is what keeps the +one-broker-many-domains cardinality of the previous section safe: +consolidating several domains on a single instance never leaks one +domain's upstream identities into another's. + +Upstream sources are also optional, and managed independently of the +OIDC domain's own lifecycle. A domain with no upstream at all is still a +valid domain, backed by the broker's local user store; an administrator +can add an identity provider (configured with the +`client_id`/`client_secret` issued by that provider's own console) or a +user federation source at any time, and remove it later, without +reconfiguring the domain, its `srv/http/oidc` key, or the application +clients already registered against it. + +This layering is what actually solves the multi-application redirect URI +problem. Registering applications directly with an external IdP means +one manual console registration per application, repeated for every IdP +and redone whenever an application's FQDN changes. With a broker in +front, the broker is the only client registered upstream, once, and +applications are provisioned against the broker's management API +instead — a step core can automate, see [Client +credentials](#client-credentials). + ## Broker endpoint and TLS Unlike the [LDAP proxy](core/user_domains.md), which is an internal-only, From eec6331ccf82bf197e995377c2a4d12afdaa717e Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Fri, 7 Aug 2026 12:40:45 +0200 Subject: [PATCH 7/8] docs: stop equating OIDC domains with sources A domain draws identities from many upstream sources, so wording that treated the two as synonyms no longer holds: drop it from the Keycloak realm aside and from the capability contract. The srv key now advertises the sources a domain serves, which contradicted the claim that adding or removing a source leaves the key untouched. State instead what stays stable across the change, and note that capabilities follow the upstream sources. Assisted-by: Claude Code:claude-opus-5[1m] --- docs/sso.md | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/sso.md b/docs/sso.md index 31f22af4f6..ffd93980be 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -64,7 +64,7 @@ core-managed domain. It is out of scope for this document, see Each OIDC domain is bound to exactly one broker. The reverse isn't true: a single broker instance, and therefore a single FQDN, can serve multiple OIDC domains at once (Keycloak, for example, supports many -realms/identity sources per instance), and nothing in this model +realms per instance), and nothing in this model restricts how many broker instances a cluster, or even a single node, may run. The `srv` key's [optional qualifier](modules/service_providers.md#srv-keys) is what resolves this @@ -131,8 +131,11 @@ valid domain, backed by the broker's local user store; an administrator can add an identity provider (configured with the `client_id`/`client_secret` issued by that provider's own console) or a user federation source at any time, and remove it later, without -reconfiguring the domain, its `srv/http/oidc` key, or the application -clients already registered against it. +reconfiguring the domain itself or the application clients already +registered against it. The domain keeps its identity, its issuer URL and +its FQDN across the change; what the broker module refreshes is the +sources and capabilities advertised on the domain's `srv/http/oidc` key, +see [Discovery](#discovery). This layering is what actually solves the multi-application redirect URI problem. Registering applications directly with an external IdP means @@ -175,15 +178,14 @@ the same one used for other cluster services. No new discovery channel is introduced. An **internal** broker module publishes one -`module/{id}/{qualifier}/srv/http/oidc` HASH key per OIDC domain -(identity source) it serves, discoverable via -`agent.list_service_providers()`. The `{qualifier}` disambiguates domains -served by the same broker instance, and therefore the same FQDN. Each key -describes: +`module/{id}/{qualifier}/srv/http/oidc` HASH key per OIDC domain it +serves, discoverable via `agent.list_service_providers()`. The +`{qualifier}` disambiguates domains served by the same broker instance. +Each key describes: - the broker's FQDN, issuer URL and discovery document location - which capabilities it supports (see below) -- the identity source it serves (an LDAP-federated domain, Entra ID, +- the identity sources it serves (an LDAP-federated domain, Entra ID, Google Workspace...) An **external** broker is not a cluster module and cannot publish its own @@ -240,13 +242,15 @@ LDAP as it does today), others can also consume group membership or role claims from the broker and reduce their LDAP dependency. Rather than the application declaring an integration "tier", the broker -advertises, per domain, which capabilities that identity source supports -(e.g. group membership claims, a specific claims-mapping contract) as -part of the corresponding `srv/http/oidc` key. Each application's own -integration checks the advertised capabilities -against what it needs, and falls back or degrades gracefully when a -capability is not available. This keeps the compliance check local to -each application instead of requiring a central tier registry. +advertises, per domain, which capabilities that domain supports (e.g. +group membership claims, a specific claims-mapping contract) as part of +the corresponding `srv/http/oidc` key. A domain's capabilities depend on +what its upstream sources can provide, so they can change as sources are +added or removed. Each application's own integration checks the +advertised capabilities against what it needs, and falls back or degrades +gracefully when a capability is not available. This keeps the compliance +check local to each application instead of requiring a central tier +registry. [Nextcloud's `user_oidc` app](https://github.com/nextcloud/user_oidc) is a mature, broadly deployed reference implementation of this pattern From f96d40e43509e056e39cd9eba6e60b1f45b04151 Mon Sep 17 00:00:00 2001 From: Davide Principi Date: Fri, 7 Aug 2026 17:40:14 +0200 Subject: [PATCH 8/8] docs: narrow OIDC domain provisioning scope Adopt Keycloak as the broker product instead of leaving the choice open, and reduce what core owns to a one-shot provisioning procedure: create the realm, optionally federate one existing LDAP domain, grant realm administration, then step back. Everything else is configured by the administrator in the Keycloak console. This keeps the implementation cost of an MVP bounded. Mirroring Keycloak's configuration surface in core would mean tracking its schema forever, and a core view of the realm would need a reconciliation loop to stay accurate. Two consequences follow. The srv/http/oidc key becomes write-once, so capabilities cannot live on it: applications discover domains through the existing agent.list_service_providers() and then ask the broker itself what it provides, filtering out domains that fall short. And since an OIDC domain is a user domain, its creation and removal fire the existing user-domain-changed event rather than a new one. Assisted-by: Claude Code:claude-opus-5 --- docs/sso.md | 306 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 215 insertions(+), 91 deletions(-) diff --git a/docs/sso.md b/docs/sso.md index ffd93980be..e6b9433ccf 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -32,24 +32,30 @@ the **OIDC domain**. An OIDC domain is, like an LDAP domain, either: -- **Internal**: backed by a broker module installed in the cluster. The - module may expose user/group management actions equivalent to the Samba - AD/OpenLDAP providers (e.g. add/alter/remove user, add/alter/remove - group — the exact set is not fixed by this document); the actions' - own implementation is free to drive the broker's admin API directly, - but only those NS8 actions are exposed as the module's interface, not - the broker's API itself. +- **Internal**: backed by a broker module installed in the cluster. Core + provisions the domain once, when it is created, and exposes no actions + to manage its users, groups or configuration afterwards — see [One-time + provisioning](#one-time-provisioning). - **External**: backed by a broker instance the administrator runs and - manages elsewhere. Like an external LDAP domain, it is read-only to - NS8: core cannot manage its users or groups, only register - applications against it — see [Client credentials](#client-credentials). - -Behind an OIDC domain sits a **broker**: a service such as Keycloak that -speaks standard OIDC to applications and exposes a management API core -can drive to provision per-application clients automatically. The broker -plays exactly the role Samba AD/OpenLDAP play for an LDAP domain: a core -module, the same category as Samba AD/OpenLDAP themselves, installed on -demand only when an OIDC domain is actually configured. + manages elsewhere. Because core steps back from an internal domain + right after creating it, the two cases end up looking much alike — in + both, the broker's configuration lives in the broker's own admin + console, not in NS8. Only two things really differ: an external domain + gets no automatically provisioned LDAP user federation, since core did + not create the realm and does not configure it (the administrator sets + one up themselves if they want it), and it must be registered with a + credential core can use to mint registration tokens, which an internal + domain provides on its own — see [Client + credentials](#client-credentials). + +Behind an OIDC domain sits a **broker**: a service that speaks standard +OIDC to applications and exposes a management API core can drive to +provision per-application clients automatically. **Keycloak is the broker +product this architecture adopts**, and the only one core supports. The +broker plays exactly the role Samba AD/OpenLDAP play for an LDAP domain: +a core module, the same category as Samba AD/OpenLDAP themselves, +installed on demand only when an OIDC domain is actually configured. An +OIDC domain maps to one Keycloak **realm**. A plain external identity provider with no broker in front of it (a raw Entra ID tenant or Google Workspace account) does not fit this model at @@ -81,13 +87,14 @@ valid deployment choices under the same model. Provisioning logic is necessarily broker-product-specific (Keycloak's Admin REST API has nothing in common with Auth0's or Okta's Management API), the same way Samba AD and OpenLDAP are separate implementations -behind the shared "LDAP domain" concept. This document only requires -that a broker, whatever the product, exposes a standard OIDC endpoint for -applications and a management API adapter core can drive, participating -in the discovery mechanism below. Which broker product(s) core ships an -adapter for first (Keycloak is the leading candidate), and any future -support for broker replicas of the same OIDC domain, are left to a -follow-up implementation issue. +behind the shared "LDAP domain" concept. The "OIDC domain" concept is +therefore kept product-neutral — it requires only that a broker exposes a +standard OIDC endpoint for applications and a management API adapter core +can drive, participating in the discovery mechanism below — but Keycloak +is the single implementation in scope, and additional adapters are a +non-goal until a concrete need appears. Implementing the Keycloak module +itself, and any future support for broker replicas of the same OIDC +domain, are left to a follow-up implementation issue. This document assumes one FQDN per broker instance, isolation between tenants being achieved by running separate instances (see above). Some @@ -103,7 +110,7 @@ should the need arise. ### Cardinality between a domain and its upstream identity sources Cardinality does not stop at the broker. Each OIDC domain can, in turn, -draw identities from more than one **upstream identity source**, through +draw identities from any number of **upstream identity sources**, through two distinct mechanisms: - **User federation**: the broker reads users directly from an existing @@ -118,24 +125,24 @@ federation remain visible to LDAP-only applications, while users reached by brokering do not exist in LDAP at all — see [Consuming brokered identities](#consuming-brokered-identities-oidc-apps-vs-legacy-ldap-apps). -Either way, upstream sources are scoped to the domain itself — a realm, -in Keycloak's terms — and are not shared with the other domains the same -broker instance may be hosting. This is what keeps the -one-broker-many-domains cardinality of the previous section safe: -consolidating several domains on a single instance never leaks one -domain's upstream identities into another's. - -Upstream sources are also optional, and managed independently of the -OIDC domain's own lifecycle. A domain with no upstream at all is still a -valid domain, backed by the broker's local user store; an administrator -can add an identity provider (configured with the -`client_id`/`client_secret` issued by that provider's own console) or a -user federation source at any time, and remove it later, without -reconfiguring the domain itself or the application clients already -registered against it. The domain keeps its identity, its issuer URL and -its FQDN across the change; what the broker module refreshes is the -sources and capabilities advertised on the domain's `srv/http/oidc` key, -see [Discovery](#discovery). +The two mechanisms differ in another respect too, and this is what +[One-time provisioning](#one-time-provisioning) narrows: core provisions +**at most one** upstream source, a single LDAP user federation, and only +at domain creation. Identity brokering is never set up by core. Both are +optional: a domain with no upstream at all is a valid domain, backed by +the broker's local user store. + +Either way, upstream sources are scoped to the domain itself — the realm +— and are not shared with the other domains the same broker instance may +be hosting. This is what keeps the one-broker-many-domains cardinality of +the previous section safe: consolidating several domains on a single +instance never leaks one domain's upstream identities into another's. + +Sources added later, in the Keycloak admin console, do not disturb what +core provisioned: the domain keeps its identity, its issuer URL, its FQDN +and the application clients already registered against it. Core simply +does not track them, and does not need to — see [Capability +discovery](#capability-discovery). This layering is what actually solves the multi-application redirect URI problem. Registering applications directly with an external IdP means @@ -146,6 +153,76 @@ applications are provisioned against the broker's management API instead — a step core can automate, see [Client credentials](#client-credentials). +## One-time provisioning + +Creating an OIDC domain is a **one-shot provisioning procedure**. Core +creates the realm, applies a fixed configuration profile, and steps back: +it exposes no actions to refine that configuration afterwards, and no +actions to manage the realm's users and groups. Every subsequent change — +adding an Entra ID or Google identity provider, adding a second +federation source, editing authentication flows, creating local users — is +made by the administrator, logged into the Keycloak admin console as +realm administrator. + +This is a deliberate cost boundary, not a limitation to be lifted later +by default. Keycloak's configuration surface is far larger than anything +core could usefully mirror in its own UI, and mirroring it would mean +tracking upstream schema changes forever. Core owns exactly two moments in +a domain's life — creation and destruction — plus the ongoing ability to +register application clients, which is the one operation applications +cannot perform for the administrator. + +At creation the administrator chooses one of two branches. + +### Branch 1: federate an existing LDAP domain + +The new OIDC domain is provisioned with a single LDAP user federation +source pointing at one existing [account provider +domain](core/user_domains.md) of the cluster. That LDAP domain remains +authoritative: it keeps serving LDAP-only applications directly, exactly +as it does today, and the realm is a second, OIDC-speaking front end onto +the same user base. + +Provisioning implies a few concrete constraints: + +- **Reachability.** The broker reaches the LDAP domain through the + per-node [LDAP proxy](core/user_domains.md) (`127.0.0.1`), so the node + running the broker module must be bound to that LDAP domain, and the + connection URL core writes into the realm is the local proxy's. This + also means an OIDC domain's federation source is fixed to a domain the + broker's own node can see. +- **Bind credentials.** Core supplies the bind DN and password the same + way it does when binding any other module to an LDAP domain. +- **Read-only.** The realm is provisioned with Keycloak's LDAP edit mode + set to `READ_ONLY`, so the broker never writes back to the directory and + never stores a local password for a federated user: every login + delegates the credential check to LDAP, keeping a single source of + truth for passwords. +- **Lifecycle coupling.** The realm breaks if its federated LDAP domain + is removed, or if the broker's node is unbound from it. Core must + surface this dependency rather than let the domain fail silently; how + it does so is an implementation detail for the follow-up issue. + +Realm administration is granted through the LDAP domain itself: the +domain's existing administrators group is imported by a group mapper and +mapped to Keycloak's `realm-management` administrative role, so current +LDAP admins can log into the console with the credentials they already +have. No separate account is created. + +### Branch 2: no federation, local user store + +The realm is provisioned empty, backed by Keycloak's own user store, and +core creates an **initial administrator account** — the same pattern the +Samba AD and OpenLDAP providers already follow when a new LDAP domain is +created. The account is a member of a provisioned administrators group +that carries the `realm-management` administrative role mapping, so +administration is granted through group membership and can be extended to +further accounts from the console. + +The Samba AD/OpenLDAP analogy stops there: it covers the initial admin +account, not ongoing parity. Core does not manage this realm's users and +groups the way it manages an LDAP domain's. + ## Broker endpoint and TLS Unlike the [LDAP proxy](core/user_domains.md), which is an internal-only, @@ -184,24 +261,36 @@ serves, discoverable via `agent.list_service_providers()`. The Each key describes: - the broker's FQDN, issuer URL and discovery document location -- which capabilities it supports (see below) -- the identity sources it serves (an LDAP-federated domain, Entra ID, - Google Workspace...) +- the LDAP domain federated at creation, if any + +The key is written when the domain is created and deleted when the domain +is removed; it is never updated in between. Nothing on it needs to track +what the administrator later configures in the Keycloak console — which is +what makes [one-time provisioning](#one-time-provisioning) affordable: +there is no reconciliation loop between core's view of a domain and the +broker's actual state. What an application needs beyond these stable +facts, it asks the broker for directly, see [Capability +discovery](#capability-discovery). An **external** broker is not a cluster module and cannot publish its own `srv` keys. As with external LDAP domains today, the cluster itself would publish an equivalent `srv/http/oidc` key on the administrator's behalf, from the connection details entered when the external OIDC domain is configured, so both cases look the same to `agent.list_service_providers()` -callers. Whether external brokers are supported at all, and how exactly -the cluster keeps that key in sync, is an implementation detail left to -the follow-up issue — it isn't certain a use case for external brokers -will actually materialize. +callers. The write-once rule holds here too: the key is created when the +administrator registers the external domain and dropped when they remove +it. Whether external brokers are supported at all is left to the follow-up +issue — it isn't certain a use case for external brokers will actually +materialize. Each OIDC domain configuration record (stored in Redis, mirroring the shape of LDAP domain records) references the `srv/http/oidc` key that -backs it. Broker configuration changes fire a `service-oidc-changed` -event, the same pattern used for other service providers. +backs it. Creating or destroying a domain fires the existing +[`user-domain-changed`](core/events.md) event, the same one LDAP domains +already use — an OIDC domain is a user domain, so applications watch a +single event for both types. Consistently with the above, no event follows +ordinary configuration changes made in the Keycloak console, because core +never sees them. ## Client credentials @@ -218,8 +307,15 @@ product-specific adapter. RFC 7591 does not standardize how that call gets authorized, though — Keycloak, for example, requires a short-lived Initial Access Token (IAT), minted per registering application (a single pre-minted IAT would only cover the first app). For an -**internal** domain, core mints that IAT itself, since the broker -module already holds full admin access. +**internal** domain, core mints that IAT itself, through a **service +account** the broker module provisions in the realm for its own use. + +That service account is distinct from the human realm administrator of +[one-time provisioning](#one-time-provisioning), and is the one piece of +realm configuration core keeps depending on after creation: an +administrator who deletes it, or strips its permissions, silently breaks +registration of any further application. Handing the console to the +administrator means this account has to be recognizable as core-owned. An **external** domain has no such standing admin access, so its configuration record must carry two attributes instead of just @@ -234,23 +330,36 @@ Together these are enough for core to keep minting IATs for new application registrations over time, without ever holding full admin rights on a broker it doesn't own. -## Capability contract +## Capability discovery Application integrations vary in how deeply they can use an OIDC domain: -some only need authentication (the app keeps provisioning users from -LDAP as it does today), others can also consume group membership or role -claims from the broker and reduce their LDAP dependency. - -Rather than the application declaring an integration "tier", the broker -advertises, per domain, which capabilities that domain supports (e.g. -group membership claims, a specific claims-mapping contract) as part of -the corresponding `srv/http/oidc` key. A domain's capabilities depend on -what its upstream sources can provide, so they can change as sources are -added or removed. Each application's own integration checks the -advertised capabilities against what it needs, and falls back or degrades -gracefully when a capability is not available. This keeps the compliance -check local to each application instead of requiring a central tier -registry. +some only need authentication (the app keeps provisioning users from LDAP +as it does today), others can also consume group membership or role claims +from the broker and reduce their LDAP dependency. An application therefore +needs to know what a domain offers before it can offer that domain to the +administrator. No NS8-specific capability registry is needed for this: +the broker itself is the authority, and answering the question is a +two-step process. + +**First**, the application calls the existing +`agent.list_service_providers()` and gets the user domains configured in +the cluster, LDAP and OIDC alike. This is the same discovery function +applications already use, and for an OIDC domain it returns the stable +facts of the [`srv/http/oidc` key](#discovery) — enough to reach the +broker, not enough to know what it will hand over. + +**Second**, for each OIDC domain, the application queries the broker's own +endpoints to refine the picture, and filters out the domains that cannot +satisfy its requirements. An application that needs group membership, for +example, keeps only the domains that actually provide a group claim; the +claim to look for is a configurable name, defaulting to `groups`. The +domains left after filtering are the ones the application presents to the +administrator as usable. + +Putting the second step on the broker rather than on the `srv` key is what +lets the key stay write-once. The broker's answer reflects the realm as it +is now, including whatever the administrator changed in the console after +provisioning, and it costs core nothing to keep current. [Nextcloud's `user_oidc` app](https://github.com/nextcloud/user_oidc) is a mature, broadly deployed reference implementation of this pattern @@ -267,20 +376,23 @@ domain for user and group provisioning while delegating authentication to an OIDC domain's broker. The two domain types aren't mutually exclusive at the identity-source -level either: a broker may itself use LDAP user federation against an -existing NS8 LDAP domain as one of its identity sources, in which case -authenticating through the OIDC domain and provisioning through the LDAP -domain ultimately point at the same underlying user base. Mapping an +level either: this is exactly [branch 1](#branch-1-federate-an-existing-ldap-domain) +of provisioning, where the broker federates an existing NS8 LDAP domain, +so authenticating through the OIDC domain and provisioning through the +LDAP domain point at the same underlying user base. Mapping an authenticated OIDC identity back to an existing LDAP user (or provisioning a new one) is left to each application's own integration, -guided by the capability contract above. - -When a domain combines more than one identity source, the broker needs -a way to recognize the same person across sources — typically by -matching a claimed email address. This must be an explicit, surfaced -choice for the administrator to enable, not a silent default: trusting -an upstream-claimed email as a match key is also a potential -impersonation vector. +guided by the claims the domain turns out to provide. + +A core-provisioned domain has at most one upstream source, so it never +has to recognize the same person across sources. That question appears +only once the administrator adds a second source in the Keycloak console, +and it is answered there: Keycloak's first-broker-login flow prompts for +account linking rather than silently trusting a claimed email address. +Administrators should keep it that way — configuring automatic linking on +an upstream-claimed email turns that claim into an impersonation vector — +but this is console guidance for the administrator, not a behaviour core +enforces. ## Consuming brokered identities: OIDC apps vs legacy LDAP apps @@ -322,12 +434,24 @@ own integration to adopt where it fits. ## Non-goals -- Implementing a broker module (e.g. Keycloak) and its management API - adapter, including support for running broker replicas of the same - OIDC domain: tracked by a follow-up issue. -- Supporting broker products beyond the first adapter core ships: - additional adapters (Authentik, Auth0, Okta...) are added on demand, as - follow-up issues. +- Implementing the Keycloak broker module and its management API adapter, + including support for running broker replicas of the same OIDC domain: + tracked by a follow-up issue. +- Supporting broker products other than Keycloak: additional adapters + (Authentik, Auth0, Okta...) are added on demand, as follow-up issues. +- Refining an OIDC domain's configuration from core after creation: + provisioning is one-shot and post-creation changes are made in the + Keycloak admin console, see [One-time + provisioning](#one-time-provisioning). Reopening this — a core-side UI + for identity providers, federation sources or authentication flows — is + a follow-up design issue, should the need arise. +- Managing the users and groups of a broker's local user store from core, + the way core manages an LDAP domain's: the Keycloak console is the + management surface, and core only creates the initial administrator + account at provisioning time. +- Provisioning more than one upstream identity source per domain, or any + identity brokering at all: core federates at most one existing LDAP + domain, at creation time. - Modeling a plain external identity provider (a raw Entra ID tenant or Google Workspace account with no broker in front of it) as a core domain: each application configures it directly against its own admin @@ -340,7 +464,7 @@ own integration to adopt where it fits. user's behalf: this is a machine-to-machine flow (e.g. OAuth token exchange, RFC 8693) rather than the browser-based authorization code flow this document assumes, and does not require reopening the - domain/broker model above — a broker can advertise support for it as - a future capability, the same way other per-domain capabilities are - advertised today. Left to a follow-up design issue, should the need - arise. + domain/broker model above — token exchange is a standard grant type, so + a broker supporting it advertises it the same way it advertises any + other, see [Capability discovery](#capability-discovery). Left to a + follow-up design issue, should the need arise.