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.
providerType | Provider | Provider-specific providerConfig |
|---|---|---|
entra-id | Microsoft Entra ID | { "tenantId": "<directory (tenant) GUID>" } |
okta | Okta | { "orgUrl": "https://<org>.okta.com" } |
google | Google Workspace | { "hd": "example.com" } — the hosted-domain claim, or "*" to allow any domain |
oidc | Generic 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:
- 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_secretthe tenant registered with that provider. - Optionally map claims — override the per-provider default mapping of upstream claims onto Thoryn's canonical claim set.
- 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 ofentra-id,okta,google,oidc.displayName— required; unique within your tenant (409 duplicate_display_nameon 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{}foroidc).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:
providerType | Default mapped claims |
|---|---|
entra-id | email, name, preferred_username, groups |
okta | email, email_verified, name, preferred_username, groups |
google | email, email_verified, name, picture, hd |
oidc | email, 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:
- Your
POST/PATCH/DELETEpersists toproduct-api'sfederation_membertable and enqueues a row inoutbox_federation_member. The API's success response reflects this persistence — it does not wait on the hub. - 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 (andDELETEfor removals). On a hub blip it retries with exponential backoff capped at 60 s. - 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.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/federation-members | tenant:federation.read | List the tenant's members (a bare JSON array, server-capped). |
POST | /api/v1/federation-members | tenant:federation.write | Add a member; returns 201 with the stored member (no secret). |
GET | /api/v1/federation-members/{memberId} | tenant:federation.read | Get one member (404 cross-tenant / missing). |
PATCH | /api/v1/federation-members/{memberId} | tenant:federation.write | Update displayName / discoveryUrl / clientId / clientSecret / providerConfig / claimMapping. |
DELETE | /api/v1/federation-members/{memberId} | tenant:federation.write | Remove a member (204). |
POST | /api/v1/federation-members/test | tenant:federation.write | Test an Entra ID connection before saving. |
POST | /api/v1/federation-members/test-okta | tenant:federation.write | Test an Okta connection before saving. |
POST | /api/v1/federation-members/google/test | tenant:federation.write | Test 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 failureThe 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
| Symptom | Cause / fix |
|---|---|
400 unsupported_provider | providerType is not one of entra-id, okta, google, oidc. |
400 missing_required_fields | One of displayName, discoveryUrl, clientId, clientSecret is absent or blank. |
400 invalid_provider_config | The 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_url | The 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_name | A member with that displayName already exists in this tenant — names are unique per tenant. |
409 version_conflict | Another PATCH updated the member first; refresh and retry. |
502 vault_unavailable | Vault/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 exists | It 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 seconds | The 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 fail | Update the member with a PATCH carrying the new clientSecret — the hub presents whatever is currently stored. |
Security notes
- The
client_secretis Vault/OpenBao Transit envelope-encrypted at rest (aclient_secret_envelopecolumn, with the per-tenant key version prefixed on the kid asv<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'sorgUrl) is tenant-supplied,product-apivalidates it throughOutboundUrlGuardon 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-apiowns 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
tntclaim on every request; cross-tenant reads, updates, and deletes return404, never403. - 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
FederationProviderdispatch, credential custody, discovery/JWKS caching, outbox sync, and the statelessFED_TOKENbridge. - 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.