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 noUser,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-servicefor 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 Organisationreturns nothing; the platform's architecture rules (inCLAUDE.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.
| Plane | Modules (in this repo) | Concern | Boundary |
|---|---|---|---|
| Broker | authorization-hub | The OAuth 2.0 / OIDC engine — authorize, PAR, token, revoke, introspect, userinfo, device grant, DCR, logout, federation | Its own public issuer hostname; no user data |
| Identity | federation-members/* | The identity sources — Thoryn's own Identity Service plus per-provider OIDC/SAML connectors | Each member owns its identity data; one concern each |
| Customer | product-api + api-gateway | The self-service management API and its public edge — tenant config, OAuth-client registration, federation, RBAC/FGA, audit | Public gateway ingress; tenant:* scopes; tnt-claim isolation |
| Operator | admin/** + internal/** surfaces | Thoryn-internal cross-tenant operations and server-to-server calls | admin:* 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:*andadmin:*scope namespaces are disjoint by construction. The customer-plane authorization-server config only ever mintstenant:*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
tntclaim; the trusted-issuer SSRF boundary; per-tenant JWKS; the composite(tenant_id, client_id)key. - Federation runtime — the
FederationProviderdispatch, per-tenant Vault-enveloped credentials, the discovery/JWKS caches, outbox sync, and the statelessFED_TOKENsign-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/*, andcore/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).