Product documentation
Integrate an application (OAuth 2.0 / OIDC client)
Register an OAuth client for your app and wire it up with the standard Authorization Code + PKCE flow against the tenant issuer — the primary Thoryn SSO integration workflow.
Integrate an application
An application is an OAuth 2.0 / OIDC client — a relying party (an external SaaS app, a native app, a machine service) that lets its users sign in through Thoryn SSO, or calls Thoryn-protected APIs. Registering one is the primary integration workflow: without a registered client, an app has no way to start an OAuth flow against the hub.
Applications are managed from the customer plane (product-api, behind the public
api-gateway), but the canonical client data lives in the authorization hub's
RegisteredClient store — the hub is authoritative, and product-api is a tnt-enforcing
read-through proxy over it (ADR 2026-05-20-console-oauth-client-registration-boundary.md).
You never store client data twice, and cross-tenant access returns 404, never 403
(no existence leak) — the same isolation contract every tnt-scoped endpoint honours.
For end users
A tenant administrator (or a machine client with the right scopes) can:
- Register an application with a display name, redirect URIs, grant types, a client type (confidential / public / keyed), and a set of scopes.
- Retrieve credentials — the
clientId, and for a confidential client theclientSecret, which is shown exactly once. - Run the OIDC flow — the app follows the tenant's discovery document and drives the standard Authorization Code + PKCE flow against the tenant issuer.
- Rotate the secret with a graceful 24-hour overlap, and watch token usage over a 24h / 7d window.
The primary surface is the console → Applications page (the console BFF calls
product-api, which proxies to the hub). The same operations are available directly on the
management API below for automation and the thoryn CLI.
Register an application
POST /api/v1/applications with tenant:applications.write:
POST /api/v1/applications
Authorization: Bearer <tenant-admin token>
Content-Type: application/json
{
"displayName": "Acme Portal",
"redirectUris": ["https://app.acme.example/login/oauth2/code/thoryn"],
"scopes": ["openid", "profile", "email", "offline_access"],
"grantTypes": ["authorization_code", "refresh_token"],
"clientType": "confidential"
}Field notes (all grounded in the request DTO):
clientId— optional. When omitted the hub generates one (app-<12 hex>); uniqueness is enforced by the hub'sRegisteredClientstore. You cannot supply aclientSecret— the hub mints it so the plaintext is never browser-supplied.redirectUris— required for a redirect-based grant (authorization_code/implicit); a pure machine client (client_credentialsonly) registers with an empty list. Each URI is validated server-side (see Redirect URI rules).grantTypes— defaults to["authorization_code", "refresh_token"]. Mutable viaPATCH(validated against the allowed set); changing it affects which flows the client may use going forward.clientType—confidential(default) orpublic. Immutable after creation.scopes— a subset of your own active scopes (see Scopes you can grant).- Advanced OAuth settings —
clientAuthenticationMethods,requireProofKey,requireAuthorizationConsent,reuseRefreshTokens,postLogoutRedirectUris,accessTokenTtlSeconds,refreshTokenTtlSeconds,backchannelLogoutUri. All optional — see Advanced OAuth settings.
The response returns the clientSecret once (null for a public or keyed client). Store
it now — product-api never persists the plaintext, and later GETs omit it:
{
"clientId": "app-3f9c1a20b7e4",
"clientSecret": "s3cr3t-shown-once…",
"createdAt": "2026-07-16T09:30:00Z",
"displayName": "Acme Portal",
"redirectUris": ["https://app.acme.example/login/oauth2/code/thoryn"],
"scopes": ["openid", "profile", "email", "offline_access"],
"grantTypes": ["authorization_code", "refresh_token"],
"status": "active"
}Client types
The client type fixes how the app authenticates to the token endpoint:
| Type | How to register | Token-endpoint auth | Secret minted? |
|---|---|---|---|
| Confidential | clientType: "confidential" (default) | client_secret_basic / client_secret_post | Yes (once) |
| Public | clientType: "public" | none — PKCE required | No |
Keyed (private_key_jwt) | supply jwks (inline JWK Set) and/or jwksUri (hosted HTTPS) | private_key_jwt (signed client assertion) | No |
- A confidential client keeps a secret on a server it controls — the default for a server-side SaaS integration.
- A public client (SPA, native / mobile app) holds no secret; PKCE (RFC 7636) is
mandatory and is the substitute for the secret (
requireProofKeyis on). - A keyed client authenticates with a private key instead of a shared secret. Register
its public key as an inline
jwksJWK Set and/or a hostedjwksUri(HTTPS only); when either is present the client usesprivate_key_jwtand no secret is minted. The hub fetches ajwksUribehind its SSRF guard.
Advanced OAuth settings
Beyond the basics, the create (POST) and update (PATCH) bodies accept a set of advanced
OAuth settings. Every one is optional — omit them all and the client behaves exactly as
before (the hub keeps its platform defaults). Each is forwarded to the hub, which is
authoritative and re-validates; product-api pre-validates the bounds below so you get a fast
RFC 9457 error before the round-trip. All are readable back on the response so the console's
Advanced view can render and round-trip the current configuration.
| Field | Type | Default | Notes |
|---|---|---|---|
clientAuthenticationMethods | string[] | derived from clientType | Explicit token-endpoint auth methods. Allowed: client_secret_basic, client_secret_post, private_key_jwt, none. none (public) must not be combined with a secret-bearing method. Takes precedence over clientType. |
requireProofKey | boolean | true | PKCE requirement (RFC 7636). Cannot be false for a public (none) client. |
requireAuthorizationConsent | boolean | false | Require the OAuth consent screen. The inverse view of firstParty; when both are supplied requireAuthorizationConsent wins. |
reuseRefreshTokens | boolean | false | Whether a refresh token is reused (vs. rotated) on refresh. |
postLogoutRedirectUris | string[] | [] | OIDC RP-Initiated-Logout post-logout redirect URIs. |
accessTokenTtlSeconds | integer | 900 (15 min) | Access-token lifetime in seconds. Bounded 300 – 86 400 (5 min – 24 h). |
refreshTokenTtlSeconds | integer | 3600 (1 h) | Refresh-token lifetime in seconds. Bounded 3 600 – 7 776 000 (1 h – 90 d). |
backchannelLogoutUri | string | (none) | OIDC Back-Channel Logout URI — the endpoint the hub POSTs a signed logout token to when a subject's session ends. Must be an https URL. On PATCH, a blank string clears it. |
grantTypes — previously create-only — is now also mutable via PATCH; it is validated
against the allowed set (authorization_code, refresh_token, client_credentials,
urn:ietf:params:oauth:grant-type:device_code, …:token-exchange, …:jwt-bearer).
Token-TTL caps are a platform boundary, not a per-tenant knob. Long-lived tokens weaken
the platform's revocation posture, so the customer plane cannot exceed the ceilings above; an
out-of-range value is rejected with 400 token_ttl_out_of_range (the field, min, and
max ride in the problem extensions).
{
"displayName": "Acme Portal",
"redirectUris": ["https://app.acme.example/login/oauth2/code/thoryn"],
"grantTypes": ["authorization_code", "refresh_token"],
"clientType": "confidential",
"clientAuthenticationMethods": ["client_secret_basic"],
"requireProofKey": true,
"requireAuthorizationConsent": false,
"reuseRefreshTokens": false,
"postLogoutRedirectUris": ["https://app.acme.example/logout"],
"accessTokenTtlSeconds": 1800,
"refreshTokenTtlSeconds": 86400,
"backchannelLogoutUri": "https://app.acme.example/backchannel-logout"
}Run the OIDC Authorization Code + PKCE flow
Each tenant is its own OIDC issuer: https://{slug}.hub.<platformDomain> for a named
tenant, and https://hub.<platformDomain> for the default tenant (staging concrete:
https://hub.stg.thoryn.org). Always start from discovery — the discovery document
rewrites authorization_endpoint, token_endpoint, userinfo_endpoint, and jwks_uri to
the tenant's own host, so a client that follows it is pointed at the right endpoints
automatically:
GET https://{slug}.hub.<platformDomain>/.well-known/openid-configuration
The hub exposes the standard Spring Authorization Server endpoints under the issuer:
| Purpose | Path |
|---|---|
| Discovery | /.well-known/openid-configuration |
| Authorization | /oauth2/authorize |
| Token | /oauth2/token |
| UserInfo | /userinfo |
| JWKS | /oauth2/jwks |
1. Authorization request — redirect the browser to the authorization endpoint with a PKCE challenge (mandatory for public clients, supported for all):
GET /oauth2/authorize
?response_type=code
&client_id=app-3f9c1a20b7e4
&redirect_uri=https://app.acme.example/login/oauth2/code/thoryn
&scope=openid%20profile%20email%20offline_access
&state=<opaque>
&code_challenge=<base64url(SHA256(verifier))>
&code_challenge_method=S256
The user authenticates at their federation member, and the hub redirects back to your
registered redirect_uri with code and state.
2. Token exchange — exchange the code at the token endpoint. Authenticate per the
client type (confidential shown; a public client sends no secret and relies on the
code_verifier; a keyed client sends a client_assertion):
POST /oauth2/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<code>
&redirect_uri=https://app.acme.example/login/oauth2/code/thoryn
&code_verifier=<verifier>You receive an access_token, an id_token (when openid was requested), and a
refresh_token (when offline_access was granted). Validate the JWTs against the tenant's
jwks_uri; refresh later with grant_type=refresh_token.
3. UserInfo — call GET /userinfo with the access token as a Bearer to read the
authenticated subject's claims.
For machine-to-machine apps, register the client with grantTypes: ["client_credentials"]
and no redirect URI, then obtain tokens directly from /oauth2/token with
grant_type=client_credentials.
Rotate the client secret
Confidential clients rotate with a graceful 24-hour overlap so no request is dropped during a deploy:
POST /api/v1/applications/{clientId}/secret/rotate
Authorization: Bearer <token with tenant:applications.write>- The new secret is active immediately and returned once as
newSecret— surface it and never re-fetch it. - The previous secret keeps validating for 24 hours (
oldSecretExpiresAt); a scheduled task prunes it afterward. Both secrets authenticateclient_secret_basic/client_secret_postduring the window. - One rotation is allowed in flight at a time. Starting a second before the window closes
returns
409 rotation_in_flight(a three-secret state is rejected).
Poll GET /api/v1/applications/{clientId}/secrets/state to render the overlap countdown and
gate the rotate button; rotationAvailable is true when a new rotation may start.
Scopes you can grant
A client may be granted any scope you yourself currently hold — the AWS-IAM PassRole
transitive model. The requested scopes[] are validated against the caller's own scope
claim; any scope the caller does not hold is rejected with 403 scope_not_grantable (the
whole request fails; it is not silently filtered). This is enforced server-side regardless
of what the console UI shows.
admin:*is structurally ungrantable by a tenant admin — those scopes are never present in a tenant-admin token, so the intersection can never include them.- The OIDC building-block scopes
openid,profile,email,address,phone, andoffline_accessare always grantable — every legitimate client needs at leastopenidto receive an ID token. - The grantable
tenant:*catalog is the API reference's scope list. To enrich the ID token withpermissions/org/org_roles, see Claims in the token.
Endpoint reference
All paths are under /api/v1; the tenant is taken from the tnt claim, never the path.
Responses are camelCase; errors are RFC 9457 problem-details (application/problem+json)
with a stable errorCode.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/applications | tenant:applications.read | List the tenant's applications. |
POST | /api/v1/applications | tenant:applications.write | Register an application; returns clientId (+ clientSecret once). |
GET | /api/v1/applications/{clientId} | tenant:applications.read | Get one application (404 cross-tenant / missing). |
PATCH | /api/v1/applications/{clientId} | tenant:applications.write | Update displayName / redirectUris / scopes / grantTypes + the advanced OAuth settings. |
DELETE | /api/v1/applications/{clientId} | tenant:applications.write | Delete an application. |
POST | /api/v1/applications/{clientId}/secret/rotate | tenant:applications.write | Rotate the secret (24h overlap); returns the new secret once. |
GET | /api/v1/applications/{clientId}/secrets/state | tenant:applications.read | Current rotation-overlap state (drives the countdown). |
GET | /api/v1/applications/{clientId}/usage?window=24h | tenant:applications.read | Token-issuance usage buckets (window is 24h or 7d). |
PATCH touches displayName, redirectUris, scopes, grantTypes, and the
advanced OAuth settings — clientId and clientType are
immutable after creation. The generated, machine-readable contract is the
Applications API reference.
Redirect URI rules
Registered redirect URIs are validated on create / update, and matched exactly at
/oauth2/authorize:
https://is always allowed;http://only forlocalhost/127.0.0.1/[::1](local dev, per RFC 8252 §7.3). Every other scheme (javascript:,data:,file:, …) is rejected.- No embedded userinfo (
https://user:pass@host/…) and no fragment (RFC 6749 §3.1.2). - A host that resolves to a private / loopback / link-local / multicast address or the cloud
metadata IP (
169.254.169.254) is rejected — a residual-SSRF and open-redirect defence.
Configuration
product-api reaches the hub's internal client surface over an in-cluster address; this is
trusted operator config, not a tenant-supplied URL:
oauthy:
applications:
hub:
# In-cluster hub Service. Staging/prod set OAUTHY_APPLICATIONS_HUB_BASE_URL.
base-url: http://thoryn-hub:8080The hub's public issuer is set per environment via
spring.security.oauth2.authorizationserver.issuer (so discovery advertises the correct
https:// issuer behind TLS termination); the tenant-subdomain suffix comes from
oauthy.hub.platform-domain. The two scopes tenant:applications.read /
tenant:applications.write are granted on the customer-plane clients by a hub migration.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
400 invalid_redirect_uris on create | A redirect URI is required for an authorization_code client; supply at least one (or register a client_credentials-only machine client). |
400 invalid_redirect_uri | A specific URI failed a rule above (scheme, fragment, embedded userinfo, or a private/metadata host). The detail names the offending URI. |
invalid_redirect_uri / redirect_uri_mismatch at /oauth2/authorize | The redirect_uri sent to authorize is not exactly one of the registered URIs (exact string match — trailing slash and case matter). |
403 scope_not_grantable | You requested a scope you do not hold — you can grant only scopes present in your own token. The detail lists the offending scopes. |
400 token_ttl_out_of_range | An accessTokenTtlSeconds / refreshTokenTtlSeconds is outside the platform cap (access 5 min – 24 h, refresh 1 h – 90 d). The field, min, and max ride in the problem extensions. |
400 invalid_grant_type | A requested grant type is not in the customer-plane allowed set. |
400 invalid_client_authentication_method / 400 incoherent_client_authentication_methods | An auth method is outside the allowed set, or none (public) was combined with a secret-bearing method. |
400 invalid_backchannel_logout_uri | The backchannelLogoutUri is not a valid https:// URL. |
404 not_found reading/patching an app that exists | The application belongs to another tenant — cross-tenant access returns 404 by design. |
409 rotation_in_flight | A secret rotation is already within its 24h overlap; wait for it to close (the detail carries the next-available time). |
invalid_client at /oauth2/token | Wrong / expired secret, or the client type does not match how you authenticated (e.g. a public client sending a secret). During a rotation, the previous secret is valid only until oldSecretExpiresAt. |
400 invalid_grant on token exchange | The authorization code was reused, expired, or the code_verifier does not match the code_challenge (PKCE). |
502 hub_unavailable on a management call | product-api could not reach the hub; the client data is unchanged — retry. |
Security notes
- The hub is authoritative;
product-apiis a proxy. Client data is never duplicated into aproduct-apitable (only atnt → clientIdmapping), so there is one source of truth and one audit trail. - The secret is shown once and stored only as a bcrypt hash in the hub.
product-apinever persists the plaintext; a lost secret is rotated, not recovered. - Cross-tenant access is
404, never403— a privacy invariant, not a politeness convention. - Scope grants are bounded by your own scopes and checked server-side — never rely on the UI hiding a scope.
- Every create / update / delete / rotate writes a tamper-evident audit row in the tenant audit trail, cross-referenced to the hub's own record.