Skip to content

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:

  1. Register an application with a display name, redirect URIs, grant types, a client type (confidential / public / keyed), and a set of scopes.
  2. Retrieve credentials — the clientId, and for a confidential client the clientSecret, which is shown exactly once.
  3. Run the OIDC flow — the app follows the tenant's discovery document and drives the standard Authorization Code + PKCE flow against the tenant issuer.
  4. 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's RegisteredClient store. You cannot supply a clientSecret — 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_credentials only) registers with an empty list. Each URI is validated server-side (see Redirect URI rules).
  • grantTypes — defaults to ["authorization_code", "refresh_token"]. Mutable via PATCH (validated against the allowed set); changing it affects which flows the client may use going forward.
  • clientTypeconfidential (default) or public. Immutable after creation.
  • scopes — a subset of your own active scopes (see Scopes you can grant).
  • Advanced OAuth settingsclientAuthenticationMethods, 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:

TypeHow to registerToken-endpoint authSecret minted?
ConfidentialclientType: "confidential" (default)client_secret_basic / client_secret_postYes (once)
PublicclientType: "public"nonePKCE requiredNo
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 (requireProofKey is on).
  • A keyed client authenticates with a private key instead of a shared secret. Register its public key as an inline jwks JWK Set and/or a hosted jwksUri (HTTPS only); when either is present the client uses private_key_jwt and no secret is minted. The hub fetches a jwksUri behind 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.

FieldTypeDefaultNotes
clientAuthenticationMethodsstring[]derived from clientTypeExplicit 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.
requireProofKeybooleantruePKCE requirement (RFC 7636). Cannot be false for a public (none) client.
requireAuthorizationConsentbooleanfalseRequire the OAuth consent screen. The inverse view of firstParty; when both are supplied requireAuthorizationConsent wins.
reuseRefreshTokensbooleanfalseWhether a refresh token is reused (vs. rotated) on refresh.
postLogoutRedirectUrisstring[][]OIDC RP-Initiated-Logout post-logout redirect URIs.
accessTokenTtlSecondsinteger900 (15 min)Access-token lifetime in seconds. Bounded 300 – 86 400 (5 min – 24 h).
refreshTokenTtlSecondsinteger3600 (1 h)Refresh-token lifetime in seconds. Bounded 3 600 – 7 776 000 (1 h – 90 d).
backchannelLogoutUristring(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:

PurposePath
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 authenticate client_secret_basic / client_secret_post during 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, and offline_access are always grantable — every legitimate client needs at least openid to receive an ID token.
  • The grantable tenant:* catalog is the API reference's scope list. To enrich the ID token with permissions / 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.

MethodPathScopeDescription
GET/api/v1/applicationstenant:applications.readList the tenant's applications.
POST/api/v1/applicationstenant:applications.writeRegister an application; returns clientId (+ clientSecret once).
GET/api/v1/applications/{clientId}tenant:applications.readGet one application (404 cross-tenant / missing).
PATCH/api/v1/applications/{clientId}tenant:applications.writeUpdate displayName / redirectUris / scopes / grantTypes + the advanced OAuth settings.
DELETE/api/v1/applications/{clientId}tenant:applications.writeDelete an application.
POST/api/v1/applications/{clientId}/secret/rotatetenant:applications.writeRotate the secret (24h overlap); returns the new secret once.
GET/api/v1/applications/{clientId}/secrets/statetenant:applications.readCurrent rotation-overlap state (drives the countdown).
GET/api/v1/applications/{clientId}/usage?window=24htenant:applications.readToken-issuance usage buckets (window is 24h or 7d).

PATCH touches displayName, redirectUris, scopes, grantTypes, and the advanced OAuth settingsclientId 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 for localhost / 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:8080

The 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

SymptomCause / fix
400 invalid_redirect_uris on createA redirect URI is required for an authorization_code client; supply at least one (or register a client_credentials-only machine client).
400 invalid_redirect_uriA 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/authorizeThe redirect_uri sent to authorize is not exactly one of the registered URIs (exact string match — trailing slash and case matter).
403 scope_not_grantableYou 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_rangeAn 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_typeA requested grant type is not in the customer-plane allowed set.
400 invalid_client_authentication_method / 400 incoherent_client_authentication_methodsAn auth method is outside the allowed set, or none (public) was combined with a secret-bearing method.
400 invalid_backchannel_logout_uriThe backchannelLogoutUri is not a valid https:// URL.
404 not_found reading/patching an app that existsThe application belongs to another tenant — cross-tenant access returns 404 by design.
409 rotation_in_flightA secret rotation is already within its 24h overlap; wait for it to close (the detail carries the next-available time).
invalid_client at /oauth2/tokenWrong / 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 exchangeThe authorization code was reused, expired, or the code_verifier does not match the code_challenge (PKCE).
502 hub_unavailable on a management callproduct-api could not reach the hub; the client data is unchanged — retry.

Security notes

  • The hub is authoritative; product-api is a proxy. Client data is never duplicated into a product-api table (only a tnt → clientId mapping), 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-api never persists the plaintext; a lost secret is rotated, not recovered.
  • Cross-tenant access is 404, never 403 — 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.