Product documentation
Federation runtime
How the hub brokers sign-in to external identity providers — the FederationProvider dispatch, Vault-enveloped per-tenant credentials, product-api-owned discovery caches, outbox sync, and the stateless FED_TOKEN flow.
Federation runtime
A federation member is an identity source a tenant attaches so its users can sign in via that source rather than through Thoryn's own Identity Service. Four provider types are attachable through the self-service federation-member API today:
providerType | Provider |
|---|---|
entra-id | Microsoft Entra ID |
okta | Okta |
google | Google Workspace |
oidc | Generic OIDC — any conformant OpenID Connect provider |
This page explains the runtime that makes adding and driving those members a matter of
configuration, not code changes — and the three architectural decisions (the
multi-provider-federation-runtime ADR, adrs/2026-05-20-multi-provider-federation-runtime.md)
that pin it. For the API and console wizard that attach one, see
Add a federation member.
Dispatch: FederationProvider, never a providerType switch
Provider-specific behaviour is dispatched through a Kotlin interface, not a config-driven flag or a branch in shared code:
interface FederationProvider {
val providerType: String // "entra-id", "okta", "google", "oidc"
suspend fun startAuthorize(member: FederationMemberView, request: AuthorizeRequest): AuthorizeRedirect
suspend fun exchangeCode(member: FederationMemberView, code: String): IdTokenResult
suspend fun mapClaims(member: FederationMemberView, idToken: Jwt): Map<String, Any>
}FederationProviderRegistry indexes the implementations by providerType via Spring
auto-wiring and throws at startup if two beans report the same type. Dispatch in the authorize
/ token-exchange / claim-mapping paths goes through the registry.
Hard rule: no
if (providerType == "X")switches in hub code. A new provider is a new implementation, never a new branch.
The reason it is an interface and not a JSON config blob is that per-provider behaviour only
reduces to "make an OIDC request" at the protocol layer. Above that, the differences are real
and do not reduce to flags: Google's hd= domain enforcement needs a post-token-exchange
ID-token claim check; Okta's groups claim is an array of strings while Entra's is an array
of GUIDs needing a separate Graph lookup; PAR support varies by provider. Encoding those as
supports_par: bool, hd_required: bool, and so on drifts into building a parser for a
config language. The interface keeps each provider's specifics in one file you can read
top to bottom. Adding a fifth provider is a new FederationProvider implementation plus a
console wizard — no change to dispatch, persistence, sync, or cache.
The four types are the same set in three independent places, which is what keeps the API and
the runtime from drifting apart: SUPPORTED_PROVIDERS in product-api's
FederationMemberService (rejecting anything else with 400 unsupported_provider), the
provider_type CHECK constraint on both federation_member and the hub's
federation_member_mirror, and the registry's own implementations.
Sources: servers/authorization-hub/.../federation/FederationProvider.kt,
…/FederationProviderRegistry.kt, servers/product-api/.../federation/FederationMemberService.kt,
adrs/2026-05-20-multi-provider-federation-runtime.md.
Connector modules are not self-service providers
servers/federation-members/ also holds a set of standalone connector applications —
JumpCloud, OneLogin, Apple, GitHub, LinkedIn, ADFS, PingFederate, a SAML 2.0 member, and
LDAP / ERP connectors. These are a different shape from the four provider types above, and
they are not attachable through the federation-member API.
Each is its own Spring Boot application that presents itself to the hub as a generic OIDC
provider while handling the upstream IdP or directory on its own side. Because the hub only
ever sees generic OIDC, no FederationProvider implementation is involved. They attach
through the operator-managed federation_member_client table instead, which
FederationAuthorizationFilter distinguishes by id shape: a UUID-shaped
/oauth2/authorization/{id} goes to the mirror + registry path above, and anything else falls
through to Spring's standard OIDC client filter, which resolves the registration by id and
drives it from its issuer_uri discovery document.
That path is operator-wired, not product surface:
- There is no console wizard, and
POST /api/v1/federation-membersrejects these provider names with400 unsupported_provider. - The seed migrations that wire the LDAP, ERP, and SAML members are demo-gated behind the
${seedDemo}Flyway placeholder — production deploys do not seed them (SSO-787). - Every connector defaults to
enabled: falsein the Helm chart, and several have no published image.
So the accurate summary is four self-service provider types; the connector modules are
source-level and operator-wired. Promoting one to a self-service provider is the extension
path the dispatch section describes: a FederationProvider implementation, an entry in the
supported-provider set and the CHECK constraints, and a console wizard.
Sources: servers/federation-members/, servers/authorization-hub/.../federation/FederationAuthorizationFilter.kt,
…/clients/FederationMemberClientRegistrationRepository.kt,
servers/authorization-hub/src/main/resources/db/migration/V14__seed_connector_clients.sql.
Credential custody: Vault-enveloped, plaintext only in-flight
The credential the hub presents to an upstream IdP — the federation member's client_secret —
is Vault/OpenBao Transit envelope-encrypted at rest in product-api (a
client_secret_envelope column on federation_member), with the per-tenant key version
prefixed on the kid (v<n>:<hex>). The plaintext exists in JVM memory only for the
duration of a single token-exchange call: at exchange time the hub asks product-api to
decrypt, product-api proxies to Vault/OpenBao Transit, and the plaintext is returned to the
hub for that one call. It is never persisted in Postgres in the clear, never cached decrypted
across requests, and never written to logs or audit rows.
Two custody rules follow:
- A DB dump must not leak upstream credentials, and neither must a JVM heap dump — Vault custody is part of the threat model, which is exactly why the plaintext is never held.
- Per-secret revocation is preserved — the secret is genuinely encrypted (not derived from a master HMAC), so a compromised tenant's Okta/Entra credential can be rotated per member.
The hub's own projection of a member — FederationMemberView — deliberately does not
carry the encrypted envelope; it holds only what routing and claim-mapping need. This also
keeps the hub and product-api free of a cross-module compile-time dependency.
Caches and outbound safety live in product-api
The hub validates upstream ID tokens at sign-in, which needs the IdP's discovery document and
JWKS. Those caches live in product-api, not the hub. The hub reads through the
in-cluster server-to-server endpoints
GET /internal/federation-members/{tenantId}/{memberId}/discovery and .../jwks;
product-api owns a Redis cache (TTL 300 s, stale-while-revalidate 600 s, keyed per
(tenantId, memberId)), with a background refresh before expiry to avoid thundering-herd on
cache-miss.
These endpoints are internal, not part of the public /api/v1 customer-plane surface —
the gateway does not route /internal/, so they are unreachable from the public internet. The
tenant rides in the path rather than in a tnt claim because the caller is the hub's own
service account, whose token is tenant-agnostic and carries no tnt; the path tenantId is
therefore the authoritative tenant, and a mismatched (tenant, memberId) pair returns 404.
This placement is a security decision as much as a performance one:
OutboundUrlGuardruns in exactly one place. Discovery URLs are tenant-supplied data.product-apivalidates them throughcom.devnow.core.common.security.OutboundUrlGuardat persistence-write and re-validates at every cache-miss fetch. The guard rejects URLs that resolve to private, loopback, link-local, broadcast/multicast, IPv6 ULA/link-local, or IPv4-mapped forms of any of those — covering the cloud metadata IPs and the RFC 1918 ranges. Centralising the guard prevents the drift that would otherwise open SSRF gaps if the hub duplicated the check.- The hub's hot path stays free of outbound HTTP under normal load —
product-apiserves the cached discovery/JWKS, and cache-misses happen on a background poller, not on the hub's authorize path.
Sync: an outbox, not a synchronous call
product-api owns the federation_member state; the hub mirrors the subset it needs
to route /oauth2/authorization/{memberId}. The two are reconciled through an outbox:
product-api CRUD ──▶ write outbox_federation_member row ──▶ scheduled poller
│
▼ (idempotent upsert, S2S auth)
Hub POST /admin/federation-members
Every successful member CRUD enqueues an outbox_federation_member row; a scheduled poller
drains it to the hub's idempotent upsert endpoint. This decouples the wizard's "saved" success
(which reflects product-api persistence) from hub availability — a hub restart becomes a
brief sign-in-availability concern, not a save failure. Under normal load the new member is
usable for sign-in within ≤ 2 s. An outbox table plus a poller is the right shape at this
scale; a message queue would add an operational dependency for no throughput benefit.
The direction of ownership is the mirror image of the OAuth-client boundary (where the hub is
authoritative and product-api proxies): here product-api owns, the hub mirrors. The
platform rule in both directions is the same — do not keep a local copy of the other plane's
authoritative state "to make the UI faster", because that is the road to cache-staleness bugs
and audit-chain forks.
Sources: servers/product-api/.../federation/, core/lib/common/.../security/OutboundUrlGuard.kt,
adrs/2026-05-20-multi-provider-federation-runtime.md.
The sign-in flow is stateless — the FED_TOKEN bridge
The hub carries no HTTP session state. When a user is redirected out to an IdP and back, the federation callback is bridged to the OAuth2 authorize flow by a short-lived, single-use cookie backed by a database row — not by a server session. This is what lets the hub scale horizontally without sticky sessions or session replication.
Client Hub Federation member (IdP)
│ POST /oauth2/authorize/par │ │
│────────────────────────────▶│ store params, return request_uri│
│ GET /oauth2/authorize │ │
│ ?request_uri=…&client_id=… │ resolve client + members │
│────────────────────────────▶│ 302 /oauth2/authorization/{id} │
│ (follow redirect) ───────────────────────────────────────────▶ │ user logs in
│ GET /login/oauth2/code/{id}?code=… ◀───────────────────────────┘
│────────────────────────────▶│ exchange code → ID token
│ │ write federation_session row
│ │ set FED_TOKEN cookie (120 s)
│ 302 /oauth2/authorize ◀─────│
│ + FED_TOKEN cookie ─────────▶│ DatabaseFederationAuthenticationFilter:
│ │ read cookie → look up session
│ │ set SecurityContext
│ │ delete cookie + row (one-time)
│ 302 redirect_uri?code= ◀────│ issue authorization code
│ POST /oauth2/token ─────────▶│ access + ID + refresh tokens
Key properties:
FED_TOKENis opaque, single-use, and short-lived — a random session id, 120-second TTL, deleted immediately afterDatabaseFederationAuthenticationFilterreads it and authenticates the principal. The backingfederation_sessionrow is deleted in the same step.- The cookie carries the hub's standard security attributes —
HttpOnly,Secure,SameSite=Lax,Path=/— applied through the sharedsecuredHubCookie(...)helper so no cookie site can regress into an insecure attribute set. - PAR comes first. Pushing authorization parameters to
POST /oauth2/authorize/par(RFC 9126) keeps sensitive parameters such ascode_challengeout of the browser URL bar. - An org-scoped connection provisions org membership here. When the connection belongs to a
B2B organization, the callback asks
product-apito just-in-time-provision the member (trigger=CONNECTION) once the session row is written — best-effort, so a provisioning failure never fails the login, gated on a verified email, and off unless the org opted in. See Organization SSO and the login gate.
The token minting at the final step — the claims-enrichment chain and the per-tenant signing —
is covered in Token lifecycle. The member's mapped claims (the per-member
claimMapping a tenant admin configures in the console wizard, applied by mapClaims) are
carried to that step through the federation-claims cache and emitted as the standard OIDC set by
FederationClaimsCustomizer under the shared FederationStandardClaims contract — so the wizard
is observable in issued tokens and at /userinfo.
Sources: servers/authorization-hub/.../federation/FederationCallbackFilter.kt,
…/authorization/DatabaseFederationAuthenticationFilter.kt,
docs/modules/ROOT/pages/authorization-hub/federation-flow.adoc,
core/lib/common/.../web (securedHubCookie).
See also
- Token lifecycle — what happens after
DatabaseFederationAuthenticationFilterauthenticates the principal. - Multi-tenancy & the multi-issuer model — per-tenant member resolution and the composite client key.
- Module map — the full federation-member module inventory.