Skip to content

Product documentation

Add a federation member (external IdP)

Let a tenant's users sign in through their own Entra ID, Okta, Google Workspace, or generic-OIDC identity provider — the product-api federation-member management API and console wizard.

Add a federation member

A federation member is an external identity provider (IdP) a tenant attaches so its users sign in through that provider — their corporate Microsoft Entra ID, Okta, or Google Workspace — instead of through Thoryn's own Identity Service. Attaching one is how a tenant turns on enterprise SSO for its workforce.

Where it sits in the broker flow:

Relying Party (your app)
    ↓  OAuth 2.0 / OIDC
Authorization Hub          ← tokens, claims, protocol only — never stores user data
    ↓  OIDC (authorize + token exchange)
Federation Member          ← the tenant's own IdP — owns the user identity

The hub is a pure identity broker: it brokers the sign-in and mints Thoryn tokens, but it never stores user profiles or credentials. Every claim it puts in a token is read from the federation member's ID token. Federation members are configured on the customer plane (product-api, behind the public api-gateway); the hub mirrors only the routing subset it needs (ADR 2026-05-20-multi-provider-federation-runtime.md). For the runtime internals — the FederationProvider dispatch, credential custody, and the stateless callback bridge — see Federation runtime.

Supported providers today

Each provider has a FederationProvider implementation in the hub and is selected by the providerType discriminator on the create request. Exactly these four are supported today; the create endpoint rejects any other value with 400 unsupported_provider.

providerTypeProviderProvider-specific providerConfig
entra-idMicrosoft Entra ID{ "tenantId": "<directory (tenant) GUID>" }
oktaOkta{ "orgUrl": "https://<org>.okta.com" }
googleGoogle Workspace{ "hd": "example.com" } — the hosted-domain claim, or "*" to allow any domain
oidcGeneric OIDC(none) — a standards-only RFC 6749 + OIDC-discovery provider, described fully by its discovery URL and client credentials

The generic oidc provider is the escape hatch for any conformant OpenID Connect provider that is not one of the three named integrations (it also backs attaching Thoryn's own Identity Service as a member). It applies no provider-specific transform above the protocol layer.

OIDC federation. These four are OIDC providers. Enterprise SAML 2.0 federation is a different runtime (a separate federation-member shape) and is not configured through this API.

For end users

A tenant administrator (or a machine client with the right scopes) can:

  1. Add a federation member — pick a provider, give it a display name, and supply the provider's OIDC discovery URL plus the OAuth client_id / client_secret the tenant registered with that provider.
  2. Optionally map claims — override the per-provider default mapping of upstream claims onto Thoryn's canonical claim set.
  3. List, update, and remove members for their tenant.

The primary surface is the console federation wizard (Settings → Federation), which walks through provider selection, credential entry, and — for Entra, Okta, and Google — a "test connection" step that fetches the provider's discovery document before you save. The console BFF mediates every call through the session. The same operations are available directly on the management API below for automation and the thoryn CLI.

Add a federation member

POST /api/v1/federation-members with tenant:federation.write:

POST /api/v1/federation-members
Authorization: Bearer <tenant-admin token>
Content-Type: application/json
 
{
  "providerType": "okta",
  "displayName": "Acme Okta",
  "discoveryUrl": "https://acme.okta.com/.well-known/openid-configuration",
  "clientId": "0oa1a2b3c4d5e6f7g8h9",
  "clientSecret": "<the secret you created in Okta>",
  "providerConfig": { "orgUrl": "https://acme.okta.com" }
}

Field notes (grounded in the request DTO and the service's validation):

  • providerType — required; one of entra-id, okta, google, oidc.
  • displayName — required; unique within your tenant (409 duplicate_display_name on a repeat). Different tenants may reuse the same name.
  • discoveryUrl — required; the provider's OpenID Connect discovery document (.../.well-known/openid-configuration). It is validated by the SSRF guard on write and on every cache refresh (see Security notes).
  • clientId / clientSecret — required; the OAuth application credentials the tenant registered with the upstream provider. The secret is Vault/OpenBao Transit envelope-encrypted at rest and is never returned by any endpoint.
  • providerConfig — the per-provider map from the table above (omit or send {} for oidc).
  • claimMapping — optional. When omitted, the per-provider default is applied (see Claim mapping); when present, it replaces the default.

On success the endpoint returns 201 Created with the stored member. The response carries the structural fields the wizard renders — and nothing a holder could use to forge an upstream token exchange (no secret, no key id):

{
  "id": "6f1e2d3c-4b5a-6789-0abc-def012345678",
  "providerType": "okta",
  "displayName": "Acme Okta",
  "discoveryUrl": "https://acme.okta.com/.well-known/openid-configuration",
  "clientId": "0oa1a2b3c4d5e6f7g8h9",
  "providerConfig": { "orgUrl": "https://acme.okta.com" },
  "claimMapping": {
    "email": "email",
    "email_verified": "email_verified",
    "name": "name",
    "preferred_username": "preferred_username",
    "groups": "groups"
  },
  "createdAt": "2026-07-16T09:30:00Z",
  "updatedAt": "2026-07-16T09:30:00Z",
  "version": 0
}

Provider examples

The discoveryUrl and providerConfig are the two provider-specific inputs. Representative values:

// Microsoft Entra ID — the directory (tenant) GUID also appears in the discovery URL
{
  "providerType": "entra-id",
  "displayName": "Acme Entra",
  "discoveryUrl": "https://login.microsoftonline.com/<tenantId>/v2.0/.well-known/openid-configuration",
  "clientId": "<app registration client id>",
  "clientSecret": "<client secret>",
  "providerConfig": { "tenantId": "<directory (tenant) GUID>" }
}
 
// Google Workspace — hd pins the workspace domain ("*" admits any hosted domain)
{
  "providerType": "google",
  "displayName": "Acme Google",
  "discoveryUrl": "https://accounts.google.com/.well-known/openid-configuration",
  "clientId": "<oauth client id>.apps.googleusercontent.com",
  "clientSecret": "<client secret>",
  "providerConfig": { "hd": "acme.com" }
}
 
// Generic OIDC — no provider-specific config
{
  "providerType": "oidc",
  "displayName": "Acme OP",
  "discoveryUrl": "https://op.acme.example/.well-known/openid-configuration",
  "clientId": "<client id>",
  "clientSecret": "<client secret>",
  "providerConfig": {}
}

Claim mapping

claimMapping maps each upstream ID-token claim name to the Thoryn canonical claim name. Omit it and the member gets the per-provider default:

providerTypeDefault mapped claims
entra-idemail, name, preferred_username, groups
oktaemail, email_verified, name, preferred_username, groups
googleemail, email_verified, name, picture, hd
oidcemail, email_verified, name, preferred_username

Send an explicit claimMapping to override — for example, an Okta org that emits a custom department claim you want carried through. Changing the mapping later is a PATCH, and it affects only sign-ins after the change (existing tokens are unaffected).

What happens after you save

The management API and the hub's sign-in runtime are decoupled by an outbox:

  1. Your POST / PATCH / DELETE persists to product-api's federation_member table and enqueues a row in outbox_federation_member. The API's success response reflects this persistence — it does not wait on the hub.
  2. A scheduled poller (every 5 s by default, Redis-leader-locked so it runs once across replicas) drains the outbox to the hub's idempotent POST /internal/federation-members/{tenantId} upsert (and DELETE for removals). On a hub blip it retries with exponential backoff capped at 60 s.
  3. Once the hub has mirrored the member, /oauth2/authorization/{memberId} can dispatch to it, and users can choose that IdP at sign-in.

Under normal load the new member is usable for sign-in within ≤ 2 s of a successful save (worst case ≤ 30 s if the hub is mid-restart). The wizard's "saved" is therefore honest about persistence while hub-side availability follows a moment later.

Endpoint reference

All paths are public at /api/v1/federation-members through the gateway. The thoryn CLI still reaches the unversioned /federation-members, which the gateway rewrites onto the canonical /api/v1/federation-members and stamps with RFC 8594 deprecation headers (Deprecation: true plus a successor-version Link). The tenant is taken from the tnt claim, never the path. Errors are RFC 9457 problem-details (application/problem+json) with a stable errorCode; providerType rides along as an extension member where relevant. Cross-tenant access returns 404, never 403.

MethodPathScopeDescription
GET/api/v1/federation-memberstenant:federation.readList the tenant's members (a bare JSON array, server-capped).
POST/api/v1/federation-memberstenant:federation.writeAdd a member; returns 201 with the stored member (no secret).
GET/api/v1/federation-members/{memberId}tenant:federation.readGet one member (404 cross-tenant / missing).
PATCH/api/v1/federation-members/{memberId}tenant:federation.writeUpdate displayName / discoveryUrl / clientId / clientSecret / providerConfig / claimMapping.
DELETE/api/v1/federation-members/{memberId}tenant:federation.writeRemove a member (204).
POST/api/v1/federation-members/testtenant:federation.writeTest an Entra ID connection before saving.
POST/api/v1/federation-members/test-oktatenant:federation.writeTest an Okta connection before saving.
POST/api/v1/federation-members/google/testtenant:federation.writeTest a Google Workspace connection before saving.

providerType is fixed at creation — a PATCH never changes it (switching providers means deleting and re-adding). On PATCH, clientSecret is optional: omit it to keep the stored secret, or send a new one to re-encrypt under the current key version. Concurrent PATCHes to the same member use optimistic locking and the stale writer gets 409 version_conflict.

Configuration

The two scopes tenant:federation.read / tenant:federation.write are already granted on the standard customer-plane clients (the console BFF and the thoryn CLI), so a tenant admin's token carries them out of the box.

Operator-tunable outbox behaviour (product-api), with defaults:

oauthy:
  federation:
    outbox:
      enabled: true              # drain the outbox to the hub (default on)
      poll-interval-ms: 5000     # poller cadence
      batch-size: 32             # rows drained per tick
      max-backoff-seconds: 60    # cap on per-row retry backoff after a hub failure

The hub reads each member's discovery document and JWKS through product-api (which owns the Redis cache — TTL 300 s, stale-while-revalidate), over the in-cluster server-to-server endpoint GET /internal/federation-members/{tenantId}/{memberId}/discovery (and .../jwks). Centralising the fetch in product-api keeps the SSRF guard in one place and the hub's sign-in hot path free of outbound HTTP.

Troubleshooting

SymptomCause / fix
400 unsupported_providerproviderType is not one of entra-id, okta, google, oidc.
400 missing_required_fieldsOne of displayName, discoveryUrl, clientId, clientSecret is absent or blank.
400 invalid_provider_configThe providerConfig is wrong for the provider — e.g. entra-id without tenantId, okta without a valid https:// orgUrl, or google without hd (use "*" for any domain). The detail names the exact rule.
400 invalid_discovery_urlThe discovery URL is malformed, or the SSRF guard rejected it (a private / loopback / link-local / cloud-metadata host). Use the provider's public HTTPS discovery URL.
409 duplicate_display_nameA member with that displayName already exists in this tenant — names are unique per tenant.
409 version_conflictAnother PATCH updated the member first; refresh and retry.
502 vault_unavailableVault/OpenBao Transit could not encrypt the client_secret; the member was not created. Retry once the secrets backend is healthy.
404 not_found on a member that existsIt belongs to another tenant — cross-tenant access returns 404 by design (no existence leak).
Member saved, but sign-in via it 404s for a few secondsThe outbox → hub-mirror sync has not landed yet; it completes within ≤ 2 s under normal load (≤ 30 s if the hub is restarting).
Rotated the upstream secret and exchanges now failUpdate the member with a PATCH carrying the new clientSecret — the hub presents whatever is currently stored.

Security notes

  • The client_secret is Vault/OpenBao Transit envelope-encrypted at rest (a client_secret_envelope column, with the per-tenant key version prefixed on the kid as v<n>:<hex>). Plaintext exists in memory only for the duration of a single upstream token-exchange call; it is never persisted in the clear, never cached decrypted across requests, never logged, and never returned by any endpoint.
  • Discovery URLs are SSRF-guarded. Because discoveryUrl (and Okta's orgUrl) is tenant-supplied, product-api validates it through OutboundUrlGuard on write and on every cache-miss fetch. The guard rejects hosts that resolve to private, loopback, link-local, multicast, or cloud-metadata addresses — the residual-SSRF defence.
  • product-api owns the state; the hub mirrors a routing subset. The hub's projection of a member deliberately excludes the encrypted secret envelope — it holds only what routing and claim-mapping need. Don't add a local copy of the other plane's authoritative state.
  • Tenant isolation is enforced by the tnt claim on every request; cross-tenant reads, updates, and deletes return 404, never 403.
  • The hub never stores user data. Identity comes from the federation member's ID token at sign-in; the hub brokers it into a Thoryn token and keeps nothing.

See also

  • Federation runtime — the FederationProvider dispatch, credential custody, discovery/JWKS caching, outbox sync, and the stateless FED_TOKEN bridge.
  • Integrate an application — register the OAuth client your users sign in to (the relying-party side of the same flow).
  • Claims in the token — enrich the ID token beyond the mapped federation claims.