Skip to content

Product documentation

Architecture guide

How the Thoryn identity platform is built — the identity-broker pattern, the four planes, and how they compose. The explanation annex to the product white paper.

Architecture guide

This is the explanation annex to the product white paper. The white paper's §4 sketches the architecture in a page; this guide goes down to the code — the enforced invariants, the class names, the request paths, and the boundaries a security reviewer or a self-managing operator needs to reason about the platform precisely.

It is written for architects evaluating Thoryn, security reviewers, and operators running the self-managed distribution. Every claim is grounded in the sources above and in the sibling pages' own source anchors. Where the white paper, the security dossier, or the API reference already own a subject, this guide links rather than restates.

Thoryn is a multi-tenant SaaS OAuth 2.0 / OpenID Connect identity broker, delivered both as an EU-operated managed service and as a self-managed distribution that runs the identical artefacts. It is a Hub identity product — it brokers and governs identity. It is not a verifiable-credentials or wallet platform; that surface was deliberately separated into a sibling repository, and the hub is now product-agnostic by design (see The product-agnostic hub below).

The identity-broker pattern — the hub owns no user data

A traditional identity product is a system of record for people: it stores your users, their credentials, and their organisations, and authenticates them directly. Thoryn is a broker. It speaks OAuth/OIDC to your application and federation protocols to whatever identity source actually owns the user, and in between it mints tokens, carries claims, and enforces policy — but it never becomes the system of record for a person.

Relying party (your app)
    │  OAuth 2.0 / OIDC
    ▼
Authorization Hub                  ← tokens, claims, protocol only — no user data
    │  PAR + OIDC (RFC 9126)
    ▼
Federation member                  ← owns the identity; one concern each
    ├── Thoryn Identity Service     ← Thoryn-managed users (passwords, passkeys, MFA…)
    └── Enterprise OIDC IdP          ← Entra ID / Okta / Google Workspace / generic OIDC

The invariant, and why it is enforced, not aspirational

The load-bearing rule of the whole platform is:

servers/authorization-hub/ contains no User, Organisation, or credential entities.

This is not a style preference — it is the property that keeps the trust surface small and the tenant boundary crisp. Three consequences follow directly from it:

  • Claim enrichment reads from the federation member's ID token, never from a user table. When the hub needs given_name, groups, or a role, it maps them out of the upstream ID token at token-mint time (see Token lifecycle). There is no user row to query because there is no user table.
  • If a feature seems to need user data inside the hub, the feature is in the wrong place. The documented answer is to move the logic to the appropriate federation member — identity-service for user CRUD and login, a dedicated member for organisation data — not to add an entity to the hub.
  • The invariant is verifiable. A source search of the hub for class User / class Organisation returns nothing; the platform's architecture rules (in CLAUDE.md) and code review keep it that way. There is no user-data schema to audit in the broker because there is no user data in the broker.

The payoff is exactly the two properties a reviewer cares about most: a small, auditable trust surface (the hub holds tokens, keys, and protocol state — not identities) and a clean multi-tenant boundary (there is no shared user table across tenants to leak through).

Sources: servers/authorization-hub/ (no User/Organisation entities), servers/federation-members/identity-service/, CLAUDE.md (identity-broker pattern enforcement).

The product-agnostic hub

The broker invariant has a second, newer half. Per the product-agnostic-hub ADR (adrs/2026-07-01-hub-product-agnostic-platform.md), neither the authorization-hub binary nor product-api knows the name or semantics of any downstream product family in code. The hub stores OAuth clients and their scope grants as opaque strings — it attaches no product meaning to tenant:users.read versus any other scope. What the platform may know is tenants, clients-as-opaque-registrations, federation, and a generic claim-enrichment webhook extension point; what it must not know is product-family names, product-specific state, or direct calls to product services.

This is why oathy is a credible standalone identity product: it is a pure OAuth/OIDC broker plus a customer-plane management API, with no coupling to any particular vertical.

Source: adrs/2026-07-01-hub-product-agnostic-platform.md.

The four planes

The platform is organised into four planes with structural boundaries — enforced by module separation, by disjoint OAuth scope namespaces, and by separate network ingresses, not by configuration flags that can drift.

PlaneModules (in this repo)ConcernBoundary
Brokerauthorization-hubThe OAuth 2.0 / OIDC engine — authorize, PAR, token, revoke, introspect, userinfo, device grant, DCR, logout, federationIts own public issuer hostname; no user data
Identityfederation-members/*The identity sources — Thoryn's own Identity Service plus per-provider OIDC/SAML connectorsEach member owns its identity data; one concern each
Customerproduct-api + api-gatewayThe self-service management API and its public edge — tenant config, OAuth-client registration, federation, RBAC/FGA, auditPublic gateway ingress; tenant:* scopes; tnt-claim isolation
Operatoradmin/** + internal/** surfacesThoryn-internal cross-tenant operations and server-to-server callsadmin:* scopes; not routed through the public gateway; VPN-gated

Two boundary facts are worth stating explicitly because they are what make the separation real rather than nominal:

  • The tenant:* and admin:* scope namespaces are disjoint by construction. The customer-plane authorization-server config only ever mints tenant:* scopes, so an operator scope cannot accidentally be granted to a customer client — it is structurally impossible, not merely policy-prevented. See the customer-plane ADR (adrs/2026-04-25-customer-plane-product-api.md).
  • The broker keeps its own public ingress, separate from the customer gateway. The hub is the JWKS that the gateway validates tokens against, so routing the hub through the gateway would be circular. The hub gets a stable issuer hostname; the gateway gets a different public hostname; shared public-edge defence (TLS, WAF, DDoS, per-IP rate limits) sits on an edge/CDN layer above both.

The customer plane's consumers live in a sibling repository

The console (web UI), the self-service BFF (its server-side session backend), and the thoryn CLI are the customer plane's clients — but their code lives in the sibling thoryn-web repository, not in oathy. Within oathy, the customer plane is exactly two modules: product-api (the resource server) and api-gateway (its public edge). Both the console-BFF and the CLI are relying parties of the hub — a tenant admin authenticates through the hub itself before any customer-plane call. This matters when reading the code: you will not find a login form or a session store in oathy's customer plane, because browser-session concerns belong to the BFF in thoryn-web.

Sources: servers/product-api/, servers/api-gateway/, CLAUDE.md (customer plane; module map — BFF and CLI extracted to thoryn-web), adrs/2026-04-25-customer-plane-product-api.md.

How the planes compose

Two everyday operations show the planes working together.

A user signs in (broker + identity planes):

Relying party ──/oauth2/authorize (PAR)──▶ Hub ──redirect──▶ Federation member (IdP)
                                            │                        │
                                            │◀──── callback ─────────┘
                                            │  mint access + ID + refresh tokens
Relying party ◀──── code, then tokens ──────┘  (signed by the tenant's key)

The hub is stateless with respect to HTTP sessions — the federation round-trip is bridged by a short-lived, single-use FED_TOKEN cookie backed by a database row, not by a server session, which is what lets the hub scale horizontally without sticky sessions. The full sequence is in Token lifecycle and Federation runtime.

A tenant admin changes configuration (customer plane, with a broker mirror):

Console ─▶ self-service BFF ─▶ api-gateway ─▶ product-api ──(outbox)──▶ Hub mirror
 (thoryn-web)   (session)      (public edge)   (owns state)     (routing subset)

Configuration is written to product-api, which owns the customer-plane state; the subset the broker needs for routing (federation members, client registrations) is propagated to the hub through an outbox table drained by a poller, so a save succeeds even if the hub is momentarily unavailable. See Federation runtime for the sync model.

Reading map

This guide continues across five focused pages:

  • Multi-tenancy & the multi-issuer model — one deployment, thousands of tenants; each tenant its own OIDC issuer; the tnt claim; the trusted-issuer SSRF boundary; per-tenant JWKS; the composite (tenant_id, client_id) key.
  • Federation runtime — the FederationProvider dispatch, per-tenant Vault-enveloped credentials, the discovery/JWKS caches, outbox sync, and the stateless FED_TOKEN sign-in flow.
  • Token lifecycle — PAR → authorize → federation → token, the claims-enrichment customizer chain, and signing via Vault/OpenBao Transit (ES256).
  • Module map — every servers/*, core/lib/*, and core/starters/* module, and the contributor extension points the starters expose.
  • Delivery models — how the same artefacts run managed vs. self-managed, and the "rebuildable from git" discipline that underwrites both.

Related reading outside this annex: the product white paper (the narrative), the security & compliance dossier (the trust model and CI guards in depth), and the API reference (the generated customer-plane surface).