OpenID Connect and OAuth

pykrete is an OpenID Connect provider. This page is for developers of relying parties (apps that sign users in) and of APIs that accept pykrete access tokens. Scopes, audiences, consent and the exact claim set are covered in Scopes and claims.

All examples use https://auth.infra.delta.rocks as the server. The issuer is that origin with a trailing slash: https://auth.infra.delta.rocks/. It is derived from the configured domain and the https setting, see Configuration.

Discovery and endpoints

Discovery document: https://auth.infra.delta.rocks/.well-known/openid-configuration.

curl -s https://auth.infra.delta.rocks/.well-known/openid-configuration
Purpose Path Method Notes

Discovery

/.well-known/openid-configuration

GET

CORS open to any origin

JWKS

/jwks (the advertised jwks_uri), also /.well-known/jwks.json

GET

Public signing keys

Authorization

/authorize

GET

Browser page, see Authorization request

Token

/oauth/token

POST

Form (application/x-www-form-urlencoded) or JSON body

Userinfo

/userinfo

GET

Authorization: Bearer <access token>

Device authorization

/device-auth

POST

RFC 8628, see Device authorization grant

Device verification page

/code

GET

Where users type the device code

Backchannel authentication

/oauth/bc-authorize

POST

CIBA, poll mode, see Backchannel authentication (CIBA)

Backchannel approval page

/approve/<id>

GET

Where users approve a backchannel request

End session

/oauth/logout (alias /v2/logout)

GET, POST

RP-initiated logout

Introspection

/oauth/introspect

POST

Confidential clients only, see Backend API

Revocation

/oauth/revoke

POST

Confidential clients only, see Backend API

Dynamic registration

/register, /register/<client_id>

POST; GET, PUT, DELETE

RFC 7591 / 7592

Supported response type: code only, with the response in the query string. Tokens are signed with RS256 or ES256, see Signing and keys.

Registering a client

A client (relying party) has a client id of the form C + 32 letters and digits, for example C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH. There are three ways to get one:

Self-service

Any signed-in user can create clients under Account → OIDC → Clients (/account/clients), owned by themselves or by an organization where they hold the org:clients permission. See Self-service.

Admin GraphQL

Administrators can create and edit every client through GraphQL. See Admin access.

Dynamic registration

POST /register (RFC 7591), see Dynamic client registration.

Clients created on the account pages start with: PKCE required, grant types authorization_code and refresh_token, public global subjects, RS256 ID tokens, no audience scopes. After creating one:

  1. Add the redirect URLs (Redirect URLs tab). Matching is exact. Mark one as Default URL if you want to be able to omit redirect_uri from requests.

  2. For a confidential client, Issue secret on the Client secret tab. The secret is shown once.

  3. Optionally add post-logout redirect URLs, grant types, scopes of your APIs (Scopes), and subject settings.

Client types

Public Confidential

Use for

SPAs, mobile and desktop apps, CLIs

Server-side web apps

Client secret

None. Sending any client_secret (even empty) is rejected

Required at the token, introspection and revocation endpoints

PKCE

Required (default)

Required by default; can be switched off

Introspection, revocation

Not available

Available

Two independent client flags decide the behavior: Require client secret (usesClientSecret) and Require PKCE (usesPkce). The type only sets their defaults.

Client secrets look like DELTA-CLIENT-SECRET- followed by 128 letters and digits. A client has one secret at a time: rotating it invalidates the old one immediately. The token endpoint accepts it either as HTTP Basic (client_secret_basic; id and secret form-urlencoded as in RFC 6749 section 2.3.1) or in the body (client_secret_post).

PKCE is all or nothing per client. If the client requires PKCE, code_challenge must be sent to /authorize and code_verifier to /oauth/token; if it does not, sending a code_verifier is an error. S256 and plain are accepted; use S256.

Note

Requests to /oauth/token that carry an Origin header (that is, made by JavaScript in a browser) are treated as coming from a public browser app:

  • authorization_code must include code_verifier;

  • refresh_token, device_code, urn:openid:params:grant-type:ciba and client_credentials are refused with invalid_request.

A browser-only app therefore cannot use refresh tokens. Refresh from a backend, or send the user through /authorize again (with prompt=none it completes without UI while the session and consent are valid).

Authorization code flow with PKCE

pykrete App Browser pykrete App Browser 302 to /authorize?...&code_challenge=... GET /authorize sign in, organization choice, consent GET /callback?code=DELTA-CODE-...&state=... POST /oauth/token (code, code_verifier) access_token, id_token, refresh_token

1. Prepare state, nonce and PKCE

state is mandatory. Generate a fresh state, nonce and PKCE verifier per login and keep them in the user's session (for example a short-lived HttpOnly cookie).

VERIFIER=$(openssl rand -base64 48 | tr -d '=+/\n')
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)

2. Send the user to /authorize

https://auth.infra.delta.rocks/authorize
  ?response_type=code
  &client_id=C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=openid%20profile%20email%20offline_access
  &state=6f1c0b6e2a7d4c39a0f5e8b1d2c3a4f5
  &nonce=0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

(Line breaks added for readability.) The user signs in, may be asked to choose an organization (see Organizations) and to approve the requested permissions (see Consent).

3. Handle the callback

On success the browser is redirected to:

https://app.example.com/callback?code=DELTA-CODE-q3T...&state=6f1c0b6e2a7d4c39a0f5e8b1d2c3a4f5

Check that state matches what you stored. The code is valid for 10 minutes and can be used once.

4. Exchange the code

Confidential client (HTTP Basic):

curl -s https://auth.infra.delta.rocks/oauth/token \
  -u "C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=https://app.example.com/callback \
  -d code_verifier="$VERIFIER"

Public client: no secret, client_id in the body:

curl -s https://auth.infra.delta.rocks/oauth/token \
  -d grant_type=authorization_code \
  -d client_id=C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH \
  -d code="$CODE" \
  -d redirect_uri=https://app.example.com/callback \
  -d code_verifier="$VERIFIER"

redirect_uri must be the same value as in the authorization request. If omitted, the client's Default URL is assumed. An optional scope parameter narrows the token to a subset of the granted scopes. grant_type defaults to authorization_code when missing.

Response (Cache-Control: no-store):

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Im9pZGMtdG9rZW4tc2lnbmluZy1yczI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Im9pZGMtdG9rZW4tc2lnbmluZy1yczI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 3600,
  "scope": "openid profile email offline_access",
  "refresh_token": "eyJ..."
}
  • id_token is present only when openid was granted.

  • refresh_token is present only when offline_access was granted (see Refresh tokens).

  • keys_jwe is added when app keys were requested, see App keys.

5. Validate the ID token

Verify the signature with the JWKS (pick the key by kid), then check iss is https://auth.infra.delta.rocks/ (trailing slash), aud is your client id, exp is in the future and nonce equals the value you sent. Decoded payload:

{
  "iss": "https://auth.infra.delta.rocks/",
  "sub": "U4Fh8kL2mN6pQ9rS3tV7wX1yZ5bC0dE2g",
  "aud": "C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH",
  "exp": 1793530800,
  "iat": 1793527200,
  "email": "ada@example.com",
  "email_verified": true,
  "name": "Ada Lovelace",
  "picture": "https://auth.infra.delta.rocks/images/3b0c...e1",
  "updated_at": 1790000000,
  "nonce": "0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a"
}

sub depends on the client's subject settings, see Subject identifiers.

Library settings

Any standard OIDC client library works with discovery. Typical settings:

issuer: https://auth.infra.delta.rocks/          # trailing slash, must equal "iss"
client_id: C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH
client_secret: DELTA-CLIENT-SECRET-...     # confidential clients only
token_endpoint_auth_method: client_secret_basic   # or client_secret_post; "none" for public clients
redirect_uri: https://app.example.com/callback
response_type: code
scope: openid profile email offline_access
pkce: S256                                 # required unless the client switched PKCE off
id_token_signed_response_alg: RS256        # or ES256, as set on the client
post_logout_redirect_uri: https://app.example.com/
Tip
Make sure the library sends state; some skip it when PKCE is on, and pykrete rejects the request.

Authorization request

Parameters

Parameter Status Notes

response_type

Required

Only code is supported.

client_id

Required

state

Required

Echoed back on success and on errors. A request without it fails.

scope

Required

Space-separated. Validation is strict, see Scope validation.

redirect_uri

Optional

Must exactly match a registered URL as stored (a bare origin is stored with a trailing slash: https://app.example.com/). URLs with a query string never match. When omitted, the client's single Default URL is used; without one the request fails.

nonce

Optional

Copied into the ID token.

code_challenge, code_challenge_method

Per client

Required when the client requires PKCE.

audience

Optional

The Audience ID (A...) of one API. See Audiences.

organization

Optional

Organization id or slug to sign in to. See Organizations.

prompt

Optional

See below.

response_mode

Ignored

The response always uses the query string. form_post is rejected.

keys_jwk

Optional

Ephemeral key for app keys, see App keys.

login_hint, max_age, acr_values, ui_locales, id_token_hint, display

Ignored

Use prompt=login to force re-authentication.

access_type

Ignored

Firefox Accounts compatibility. Request offline_access instead.

action, entrypoint, email, utm_*

Ignored

Firefox Accounts compatibility.

Unknown parameters (for example resource) are ignored.

prompt

Value Effect

none

No UI is shown. If any would be needed, the user is redirected back with login_required (no session), consent_required (new permissions or an organization decision needed), interaction_required (several organizations to choose from) or access_denied (the client requires an organization and none is available).

login

Ends the current session and asks the user to sign in again.

consent

Shows the consent screen and the organization access screen even if everything was granted before.

select_account

Forces the organization picker for clients that require an organization. Account switching is not supported.

Values can be combined (prompt=login consent), except none. Unknown values or none combined with others make the request fail.

Errors

Problems with client_id, redirect_uri or the request syntax (missing state, unknown prompt) are shown on an error page and never redirect. Everything else is returned to the redirect URI:

https://app.example.com/callback?error=invalid_scope&error_description=invalid+scope%3A+user+has+no+permission+to+use+scope+write+at+AudienceId%28%22A9xK3mP7qR2sT6vW0yZ4bC8dF1gH5jL3n%22%29&state=6f1c0b6e2a7d4c39a0f5e8b1d2c3a4f5
error Typical cause

invalid_scope

A scope is undefined, not allowed for the client, or not held by the user; also returned when the client is not allowed the authorization_code grant

invalid_client

The audience does not exist

access_denied

The user cancelled consent or the organization step

login_required, consent_required, interaction_required

prompt=none could not complete silently

invalid_request

organization sent to a client that does not use organizations

server_error

Internal error

Values a user could have typed are obfuscated in error_description as <E>...</E> (Crockford base32), so the description is safe to show but not always readable.

Token endpoint

POST /oauth/token, form or JSON body. Errors are JSON:

{"error": "invalid_grant", "error_description": "invalid authorization code"}

Status is 400, except invalid_client (401 with WWW-Authenticate: Basic). Common errors:

error Cause

invalid_request

Missing parameter or client_id; browser Origin rules above

invalid_client

Unknown client, missing or wrong secret, secret sent by a public client

invalid_grant

Code unknown, expired, already used, issued to another client or redirect_uri mismatch; PKCE failure; invalid refresh token; unknown or already redeemed device_code or auth_req_id; the active organization is no longer available

invalid_scope

Scope validation failed; grant type not allowed for the client

invalid_target

The token's audiences use different access token signature kinds

unsupported_grant_type

implicit

authorization_pending

Device or CIBA flow, the user has not decided yet

slow_down

Device or CIBA flow, polled again sooner than interval seconds

expired_token

Device or CIBA flow, the request expired before the user approved it

access_denied

Device or CIBA flow, the user denied the request

server_error

Internal error, for example a failing claims hook

Refresh tokens

A refresh token is returned by the authorization_code grant when the granted scope contains offline_access. To use it, the client also needs the refresh_token grant type (on by default for clients created on the account pages; add refresh_token to grant_types for dynamically registered ones).

curl -s https://auth.infra.delta.rocks/oauth/token \
  -u "C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH:$CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN"
  • The response has the same shape as above, including a new id_token when openid is in scope (without nonce).

  • Rotation is mandatory: every refresh returns a new refresh token and the presented one stops working. Store the new one.

  • Reuse detection: presenting an already used refresh token revokes the whole chain; the user has to sign in again. Avoid parallel refreshes with the same token.

  • Lifetime: 30 days, absolute, counted from the first issuance. Rotation does not extend it.

  • scope may narrow the new access token to a subset of the original scope.

  • Each refresh re-checks scopes and organization access, so a user who lost a role or removed the app's access to an organization gets invalid_scope / invalid_grant.

  • Refresh tokens are opaque encrypted strings; do not parse them.

  • Public clients refresh without a secret, but not from a browser (see the Origin note above).

Revoking a refresh token (POST /oauth/revoke) revokes its chain, see Backend API.

Device authorization grant

For CLIs and TVs (RFC 8628). The client needs the urn:ietf:params:oauth:grant-type:device_code grant type.

curl -s https://auth.infra.delta.rocks/device-auth \
  -d client_id=C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH \
  -d scope="openid profile offline_access"
{
  "device_code": "DELTA-DEVICE-Xk2...",
  "user_code": "HMQT-4WXA",
  "verification_uri": "https://auth.infra.delta.rocks/code",
  "verification_uri_complete": "https://auth.infra.delta.rocks/code?user_code=HMQT-4WXA",
  "expires_in": 1800,
  "interval": 5
}

Request parameters (form or JSON body):

  • client_id: required, in the body or as HTTP Basic user name.

  • client_secret: required for clients that use a client secret, in the body or as HTTP Basic password, the same way as at the token endpoint. Public clients must not send one.

  • scope: optional, space-separated. The user sees it on the consent screen; the issued token carries what they approved, plus the client's required scopes (see Client scopes). openid adds an ID token, offline_access a refresh token. app_key: scopes are refused with invalid_scope, because the device has no keys_jwk; use the authorization code flow or CIBA for those.

  • audience: optional audience id.

Show the user the verification_uri and user_code. verification_uri_complete opens the page with the code already filled in; the user still submits it. Then poll every interval seconds, authenticating the same way:

curl -s https://auth.infra.delta.rocks/oauth/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
  -d client_id=C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH \
  -d device_code="$DEVICE_CODE"

Until the user confirms, the answer is HTTP 400 with authorization_pending. Polling again sooner than interval seconds returns slow_down; wait longer before the next poll. When the user denies, the answer is access_denied; after expires_in seconds it is expired_token. Both end the flow: request a new code to try again. On approval the answer is a normal token response, once; the device_code is gone afterwards.

Backchannel authentication (CIBA)

For machines that have no browser and nobody in front of them, such as a server that needs a key at boot (OpenID CIBA Core 1.0, poll mode). The machine asks pykrete for a specific user's approval; the user approves on pykrete and the machine picks up the tokens. With an app_key: scope, the approval also delivers the user's end-to-end encryption key, see App encryption keys.

Requirements:

  • The client uses a client secret, and has the urn:openid:params:grant-type:ciba grant type (Backchannel (CIBA) on the Grant types tab). Dynamic registration can't enable it.

  • The client may only ask for users it belongs to: a personal client only for its owner, an organization's client only for members of that organization. A client without an owner (created by an administrator) may ask for any user. Any other user is answered with unknown_user_id, the same as a user that doesn't exist.

User (browser) pykrete Machine User (browser) pykrete Machine loop [every interval seconds] sign in, check message and address, Allow POST /oauth/bc-authorize (client secret, login_hint, scope) auth_req_id, expires_in, interval POST /oauth/token (auth_req_id) authorization_pending Account -> Pending sign-ins -> Review POST /oauth/token (auth_req_id) tokens (and keys_jwe)

Request

curl -s https://auth.infra.delta.rocks/oauth/bc-authorize \
  -u "C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH:$CLIENT_SECRET" \
  -d scope="openid app_key:luks" \
  -d login_hint=alice \
  -d binding_message="nas1: unlock 2 disks" \
  -d keys_jwk="$KEYS_JWK"
{"auth_req_id": "DELTA-BC-Xk2...", "expires_in": 600, "interval": 5}
Parameter Required Meaning

scope

yes

Space-separated; must contain openid.

login_hint

one of the two

User name (current or previous) or user id (U...).

id_token_hint

one of the two

An ID token pykrete issued to this client earlier; its sub selects the user. Expired ID tokens are accepted.

binding_message

no

Up to 128 characters, no control characters. Shown to the user on the approval screen; put the machine's name and why it is asking.

requested_expiry

no

Seconds the request stays valid, 60 to 3600 (other values are clamped). Default 600.

keys_jwk

with app_key: scopes

Base64url-encoded public EC P-256 JWK, as for /authorize. Must be a valid point on the curve without d.

audience

no

Audience id.

login_hint_token and user_code are not supported. Client authentication works as at the token endpoint (client_secret_basic or client_secret_post).

Errors:

error Cause

invalid_client (401)

Missing or wrong client credentials, or the client has no client secret

unauthorized_client

The client doesn't have the CIBA grant type

unknown_user_id

No such user, or the client may not ask for this user

invalid_binding_message

binding_message too long or contains control characters

invalid_request

No openid in scope, not exactly one of login_hint/id_token_hint, login_hint_token given, missing or invalid keys_jwk, or 5 requests for this user from this client are already pending

invalid_scope

Scope validation failed (unknown scope, scope not allowed for the client or the user)

pykrete records the address the request came from (the X-Real-IP header set by the reverse proxy, see Reverse proxy and TLS) and shows it to the user.

Approval

The request appears under Account → Pending sign-ins (/account/pending) of that user, with the client name, the binding message, the address and the requested scopes. Review opens /approve/<id>, where the user signs in (with a passkey or password when an app_key: scope is requested, even with an active session), sees the binding message, address and time next to the requested permissions, and chooses Allow or Deny. The confirmation is always shown, also for scopes the user consented to before. Deny on the list rejects the request without signing in.

pykrete doesn't notify the user by itself: the user has to open the page. Tell the user out of band if needed, for example from the machine's own monitoring.

Polling

curl -s https://auth.infra.delta.rocks/oauth/token \
  -u "C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH:$CLIENT_SECRET" \
  -d grant_type=urn:openid:params:grant-type:ciba \
  -d auth_req_id="$AUTH_REQ_ID"

The answers are the same as for the device flow: authorization_pending while waiting, slow_down when polling faster than interval, access_denied after Deny, expired_token after expires_in seconds, invalid_grant for an unknown or already redeemed auth_req_id. On approval the response is a normal token response, once, with keys_jwe when app keys were requested. offline_access adds a refresh token.

Client credentials

Not available yet. Use API keys for machine access.

Userinfo

curl -s https://auth.infra.delta.rocks/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"
{
  "sub": "U4Fh8kL2mN6pQ9rS3tV7wX1yZ5bC0dE2g",
  "email": "ada@example.com",
  "email_verified": true,
  "name": "Ada Lovelace",
  "picture": "https://auth.infra.delta.rocks/images/3b0c...e1",
  "updated_at": 1790000000
}
  • GET only, CORS open to any origin.

  • The token must include openid and must not be expired or revoked; otherwise 401 invalid_token (or 400 invalid_scope).

  • The response is sub plus the ID token claims recorded when the access token was issued (including organization and claims-hook claims); it does not reflect later profile changes. Refresh the token to see them.

Tokens

Signing and keys

All tokens issued to apps are JWS compact JWTs:

{"alg": "RS256", "kid": "oidc-token-signing-rs256", "typ": "JWT"}
kid Key

oidc-token-signing-rs256

RSA 2048, RS256

oidc-token-signing-es256

P-256, ES256

oidc-refresh-encrypt-es256

use: enc, internal (refresh token encryption); ignore it

  • ID tokens are signed with the client's ID token signature setting: RS256 (default) or ES256.

  • Access tokens are signed with the access token signature of their audience (API): RS256 (default) or ES256. Tokens without an audience use RS256. If one token would cover audiences with different settings, the request fails with invalid_target.

  • Lifetimes: access and ID tokens 1 hour (expires_in: 3600); the server accepts access tokens for 2 more minutes of clock skew. Authorization codes 10 minutes. Refresh token chains 30 days.

Access token

{
  "iss": "https://auth.infra.delta.rocks/",
  "sub": "U4Fh8kL2mN6pQ9rS3tV7wX1yZ5bC0dE2g",
  "aud": "A9xK3mP7qR2sT6vW0yZ4bC8dF1gH5jL3n",
  "exp": 1793530800,
  "iat": 1793527200,
  "azp": "C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH",
  "scope": "openid email read:projects"
}
  • aud holds Audience IDs: a string for one audience, an array for several, [] when the token has none.

  • azp is the client the token was issued to; scope lists every granted scope, builtin ones included.

  • Organization claims (org_id, org_slug, org_name, org_roles) and claims-hook claims are added when present, see Claims.

Access tokens are also recorded server-side. Revocation and expiry are enforced by /userinfo, /graphql and introspection, but an API that only checks the JWT signature cannot see a revocation until the token expires.

Validating access tokens in an API

Introspection only reports tokens issued to the calling client, so an API that serves several apps validates the JWT itself. Check signature (JWKS), iss, that aud contains your Audience ID, exp, and the scopes you need. TypeScript with jose:

import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL("https://auth.infra.delta.rocks/jwks"));

export async function verify(token: string, needed: string) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: "https://auth.infra.delta.rocks/",
    audience: "A9xK3mP7qR2sT6vW0yZ4bC8dF1gH5jL3n",
  });
  if (!String(payload.scope ?? "").split(" ").includes(needed)) {
    throw new Error("insufficient_scope");
  }
  return payload;
}

Logout

RP-initiated logout: send the browser to the end session endpoint (GET, or POST as a form).

https://auth.infra.delta.rocks/oauth/logout
  ?id_token_hint=eyJhbGciOiJSUzI1NiIs...
  &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2F
  &state=4c1d...
Parameter Notes

id_token_hint

Recommended. Must be a valid ID token from this server (signature and issuer are checked; expired is fine). Its aud identifies the client.

client_id

Needed with post_logout_redirect_uri when there is no id_token_hint. If both are sent, client_id must be in the hint's aud.

post_logout_redirect_uri

Must exactly match one of the client's post-logout redirect URLs. Auth0's returnTo is accepted as an alias.

state

Appended to the post-logout redirect.

logout_hint, ui_locales

Ignored.

Clear your app's own session before sending the user here. If the user has a pykrete session, they are asked whether to sign out of pykrete too (and warned if the hint belongs to another account). Then they are redirected to post_logout_redirect_uri (with state), or shown a message if none was given.

Front-channel and back-channel logout are not supported: other apps are not notified.

Introspection, revocation, API keys

POST /oauth/introspect (RFC 7662), POST /oauth/revoke (RFC 7009), first-party API keys and the Clerk-compatible REST API are described in Backend API. Both endpoints need a confidential client and only see that client's own tokens.

Dynamic client registration

Open registration (RFC 7591) with management (RFC 7592). An Authorization: Bearer access token carrying the account scope is optional; with it, the new client is owned by that user and appears under their /account/clients. Without it, the client has no owner and the consent screen shows "Unknown publisher".

curl -s https://auth.infra.delta.rocks/register \
  -H 'content-type: application/json' \
  -d '{
    "client_name": "Example App",
    "redirect_uris": ["https://app.example.com/callback"],
    "post_logout_redirect_uris": ["https://app.example.com/"],
    "grant_types": ["authorization_code", "refresh_token"],
    "token_endpoint_auth_method": "client_secret_basic",
    "subject_type": "public"
  }'
{
  "client_id": "C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH",
  "client_secret": "DELTA-CLIENT-SECRET-...",
  "client_id_issued_at": 1793527200,
  "client_secret_expires_at": 0,
  "registration_access_token": "DELTA-REGISTRATION-...",
  "registration_client_uri": "https://auth.infra.delta.rocks/register/C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH",
  "client_name": "Example App",
  "redirect_uris": ["https://app.example.com/callback"],
  "post_logout_redirect_uris": ["https://app.example.com/"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "client_secret_basic",
  "application_type": "web",
  "subject_type": "public",
  "id_token_signed_response_alg": "RS256"
}
Field Default Accepted

client_name

Unnamed app

Up to 100 characters

redirect_uris

Required for authorization_code. No fragment, query or credentials. web: https, or http on a loopback host. native: http loopback only, custom schemes are not supported

post_logout_redirect_uris

http or https, no fragment

grant_types

["authorization_code"]

authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code

response_types

["code"] with authorization_code

Only code, exactly when authorization_code is registered

token_endpoint_auth_method

client_secret_basic

none (public client), client_secret_basic, client_secret_post; the token endpoint accepts either secret method

application_type

web

web, native

subject_type

pairwise

pairwise, public

sector_identifier_uri

https URL returning a JSON array that lists every redirect URI

id_token_signed_response_alg

RS256

RS256, ES256

Other fields are ignored. Errors are invalid_redirect_uri or invalid_client_metadata (400); a bad bearer token gives 401 invalid_token, a token without account 403 insufficient_scope.

Registered clients always require PKCE, and their redirect URIs are never Default URLs, so always send redirect_uri. With pairwise subjects the sector is the redirect URIs' host (all non-loopback redirect URIs must share one host) or the host of sector_identifier_uri. A new client can only use builtin scopes until its owner or an admin allows it scopes of an API.

Manage the registration with the registration_access_token:

curl -s https://auth.infra.delta.rocks/register/C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH \
  -H "Authorization: Bearer $REGISTRATION_ACCESS_TOKEN"
  • GET returns the current metadata (including the secret).

  • PUT replaces the metadata; the body must contain the same client_id and, if sent, the current client_secret. Omitted fields fall back to the defaults. Changing subject_type or the sector after users have signed in is refused.

  • DELETE deletes the client (204).