Skip to content

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:

providerTypeProvider
entra-idMicrosoft Entra ID
oktaOkta
googleGoogle Workspace
oidcGeneric 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-members rejects these provider names with 400 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: false in 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:

  • OutboundUrlGuard runs in exactly one place. Discovery URLs are tenant-supplied data. product-api validates them through com.devnow.core.common.security.OutboundUrlGuard at 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-api serves 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_TOKEN is opaque, single-use, and short-lived — a random session id, 120-second TTL, deleted immediately after DatabaseFederationAuthenticationFilter reads it and authenticates the principal. The backing federation_session row is deleted in the same step.
  • The cookie carries the hub's standard security attributesHttpOnly, Secure, SameSite=Lax, Path=/ — applied through the shared securedHubCookie(...) 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 as code_challenge out 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-api to 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