Skip to content

Product documentation

Organization SSO and the login gate

Attach an IdP connection to a B2B organization, verify its email domains over DNS-TXT, route matching users to that connection, just-in-time-provision members, and the tenant-membership login gate that lets invited members sign in.

Organization SSO and the login gate

This guide covers the B2B-Organizations slice O3: giving an organization its own IdP connection, verifying its email domains, routing matching users to that connection at login, just-in-time-provisioning members, and — most importantly — the login gate that lets a tenant's invited members (not just the founder) sign in.

The organization model itself (create orgs, add members, invite by email) is in the B2B Organizations guide.

Org SSO connections

An organization can attach its own IdP (Okta, Entra ID, Google Workspace, …) so its users sign in through it rather than through Thoryn's shared identity-service. A connection is a federation member scoped to the org — the same federation runtime as tenant-wide members, with per-tenant Vault-Transit secret encryption unchanged; the only difference is that the member carries the org's id.

MethodPathScope
PUT/api/v1/organizations/{id}/connectionstenant:orgs.write
GET/api/v1/organizations/{id}/connectionstenant:orgs.read
DELETE/api/v1/organizations/{id}/connectionstenant:orgs.write

PUT is create-or-replace — the first call creates the org's connection, a later call updates it in place (one connection per org). The body is the standard federation-member shape:

{
  "providerType": "okta",
  "displayName": "Globex Okta",
  "discoveryUrl": "https://globex.okta.com/.well-known/openid-configuration",
  "clientId": "0oa...",
  "clientSecret": "…",
  "providerConfig": { "orgUrl": "https://globex.okta.com" }
}

Domain verification (DNS-TXT)

Before an org's domains can route users to its connection, they must be verified. Only verified domains route or auto-provision.

  1. Add the domainPOST /api/v1/organizations/{id}/domains with { "domain": "globex.example" }. The response carries a verificationRecord, e.g. thoryn-domain-verification=abc123….
  2. Publish it — add a DNS TXT record on the domain whose value contains that record.
  3. VerifyPOST /api/v1/organizations/{id}/domains/{domId}/verify. Thoryn resolves the domain's TXT records (a DNS query — never an HTTP fetch) and marks the domain verified when a record matches.

A domain verifies to at most one organization per tenant — a second org trying to add the same domain gets 409 domain_exists.

Remove a domainDELETE /api/v1/organizations/{id}/domains/{domId} returns 204 No Content. Removing a verified domain stops it routing logins to the org's connection immediately (the hub's routing cache is evicted for that domain). An unknown, cross-tenant, or cross-mode domain returns 404 (never 403 — no existence leak).

MethodPathScope
POST/api/v1/organizations/{id}/domainstenant:orgs.write
GET/api/v1/organizations/{id}/domainstenant:orgs.read
POST/api/v1/organizations/{id}/domains/{domId}/verifytenant:orgs.write
DELETE/api/v1/organizations/{id}/domains/{domId}tenant:orgs.write

Email-domain → org routing

At login, when a user's email domain matches a verified org domain, the hub routes them to that org's connection. The hub reads the routing decision from product-api (it owns no org table) and dispatches the user to the org's IdP. If no verified domain matches, login falls back to the tenant's default federation selection — routing is a convenience, never a gate.

JIT provisioning (opt-in, OFF by default)

When a user signs in through an org's connection, Thoryn can auto-create an active org membership. This is opt-in — two per-org toggles, both false by default:

  • jitProvisioningEnabled — a login through the org's connection creates a membership.
  • domainAutoMembership — a verified-email-domain match creates a membership.

Set them on PATCH /api/v1/organizations/{id} ({ "jitProvisioningEnabled": true }). JIT is idempotent — an already-active member is a no-op.

Each toggle is read only for its own trigger: a connection login consults jitProvisioningEnabled, a domain match consults domainAutoMembership, and neither opens the other. Two properties hold for both triggers:

  • A verified email is required. Membership is granted only when the login asserts email_verified: true. An absent or false claim withholds membership and the sign-in itself is unaffected. For a connection login the claim is read from the connection's mapped claims, and otherwise from the IdP's ID token, so a claim mapping that omits it changes nothing.
  • Provisioning never blocks sign-in. It runs after the session is established and any failure is logged and swallowed — the user is already authenticated.

The login gate (invited members can sign in)

Historically a self-service tenant was effectively single-founder: only the founding user could sign in through the shared identity-service IdP. O3 reconciles this. The hub's PlatformDefaultFederationGate now consults tenant membership (tenant_member) instead of the single founder:

  • A tenant's own corporate IdP is never gated (that IdP vouches for its users).
  • Through the shared identity-service IdP, any active tenant member may sign in. The founder is seeded as the first tenant member at workspace registration, so existing behaviour is preserved and invited members now work (closes SSO-1496).
  • The membership read is a version-evicted Redis cache; a membership change takes effect at once (product-api publishes an eviction on every write).

Fail-closed and oracle-proof. The gate admits a login only on a clean, confirmed membership hit — any error, timeout, or "not a member" denies. On the headless surface (POST /login/complete), a "valid credentials but not a member" rejection is wire-indistinguishable from a bad-password rejection (identical 401 invalid_credentials), so the endpoint cannot be used to probe who belongs to a tenant. See ADR 2026-07-16-b2b-organizations-org-sso-and-login-gate.md (amending 2026-06-08).

Configuration

oauthy:
  # Hub — the membership cache TTL backstop (eviction is the primary freshness mechanism).
  tenant-membership:
    cache:
      ttl-seconds: 60

The hub's Redis ACL grants the tenant-member:* keyspace and the hub reads the cross-service eviction on the tenant-member:evict channel (both wired in the chart's redis-acl-configmap).

Troubleshooting

SymptomCause / fix
409 domain_exists on addThe domain is already claimed by an org in this tenant (at most one org per domain).
409 verification_failed on verifyNo DNS TXT record on the domain matched the challenge — publish the verificationRecord and retry (DNS propagation can lag).
400 invalid_domainThe domain is not a valid bare DNS name (e.g. globex.example).
An invited member is denied at loginThey are not yet an active tenant_member; add them, or confirm the founder seed / V52 backfill ran.
Every platform-default login deniesThe membership read is failing (product-api unreachable, or the tenant-member:* Redis keyspace grant is missing) — the gate fails closed. Check the hub's tenant-membership WARN logs.

Security notes

  • The login gate is fail-closed. It admits only on a confirmed active membership; every indeterminate read denies. There is no path where an unverified subject signs in.
  • No membership oracle. The headless deny is byte-identical to a bad-password deny.
  • Domain verification is DNS-only — no HTTP well-known-file method (that would be an SSRF surface). A domain that cannot publish DNS TXT is a documented follow-up, not a workaround.
  • The hub owns no org, membership, or domain state — it reads product-api's internal decisions and caches the membership read; org state lives entirely in the customer plane.