Skip to content

Third-Party Integration Guide (Auth Server)

Overview

This guide is for developers of an external application ("the client") that needs to authenticate users against, and read authorization data from, an authorization server built on Pramnos Framework. It covers the full lifecycle: discovery → registration → the OAuth2/OIDC login flow → reading permissions → declaring your capabilities → cache invalidation.

The design follows an Atlassian-style model:

  • Runtime is standard OAuth2 / OIDC. You get lightweight, audience-scoped tokens carrying identity only — never permissions (so tokens stay small and change instantly).
  • Authorization is fetched, then cached. Your app reads a user's effective permissions from a server-to-server endpoint and caches them, invalidating on a webhook.

Endpoints below use the paths the server advertises by default. Always read the discovery document first rather than hard-coding paths.


1. Discovery

Fetch the server's metadata:

GET /.well-known/openid-configuration
GET /.well-known/oauth-authorization-server
GET /.well-known/oauth-protected-resource

The two halves of discovery

The first two say where the authorization server is. The third — RFC 9728, Protected Resource Metadata — says where the resource is and which authorization servers it trusts. Each server is named by its issuer, character for character, trailing slash included: a client fetches that server's metadata and refuses it when the issuer there differs from the name it was given (RFC 8414 §3.3). Discovery::issuer() is the one place the string comes from, and the ID token's iss uses it too:

{
  "resource": "https://example.com",
  "authorization_servers": ["https://example.com/"],
  "scopes_supported": ["profile", "email", "phone", "address", "user", "openid", "offline_access"],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://example.com/docs"
}

Both halves exist so a client can start from either end and find the other. With only the first, a client has to be told the rest out of band — configuration somebody types into a file, gets wrong, and has no way to verify.

It is also where an MCP client starts. The Model Context Protocol's authorization flow is: call a protected endpoint, be refused with 401 and a WWW-Authenticate header naming this document, read it, find the authorization server, then run the ordinary authorization-code-with-PKCE exchange in §3. See the MCP guide.

One endpoint has a document of its own. RFC 9728 §3.1 appends a resource's path to the well-known one: /.well-known/oauth-protected-resource/api/1.0/mcp answers with "resource": "https://example.com/api/1.0/mcp". That is the form the MCP endpoint's 401 points at, because a client checks that resource is the URL it started from. A suffix that is not a plain path answers 404.

A resource lists the scopes it takes, because a client, and an MCP connector in particular, asks for everything scopes_supported lists, and gets everything the user's role allows:

Resource scopes_supported
The site (/.well-known/oauth-protected-resource) every scope the server has
The MCP endpoint (a path ending in mcp) mcp, the scopes its offered tools require, offline_access
A path named in 'oauth' => ['resource_scopes' => ['api/1.0/reports' => ['reports:read']]] those
Any other path every scope the server has

Without this, a Root user connecting an assistant to the MCP endpoint got a token carrying system:admin and the rest, to call tools that needed mcp.

Three things about that document are deliberate. scopes_supported is read from the server's own scope table rather than written out, so it cannot drift from what the server will actually grant. bearer_methods_supported is ["header"] and only that — RFC 6750 also permits a token in a form body and a query string, and the query form puts a credential in every access log and Referer between you and here. And the identifiers carry no trailing slash, because resource is compared as a string when a token's audience is checked, and https://example.com and https://example.com/ are the same address and different strings.

The document lists the real endpoints, e.g.:

Key Default
authorization_endpoint /oauth/authorize
token_endpoint /oauth/token
userinfo_endpoint /oauth/userinfo
device_authorization_endpoint /oauth/deviceauthorization
registration_endpoint /oauth/register — only when dynamic registration is on
jwks_uri /.well-known/jwks.json

code_challenge_methods_supported and token_endpoint_auth_methods_supported (which includes none, a public client) are in both documents; a public client reads them before it starts.

Validate ID tokens against the keys in jwks_uri.

If discovery answers 404

These paths are fixed by specification, so they cannot be reached through the framework's controller/action URL shape — the web server has to be told about them. init writes the rules when the authserver feature is enabled:

RewriteRule ^\.well-known/openid-configuration$ index.php?r=Discovery/configuration [L]
RewriteRule ^\.well-known/openid_configuration$ index.php?r=Discovery/configuration [L]
RewriteRule ^\.well-known/jwks\.json$          index.php?r=Discovery/jwks [L]
RewriteRule ^\.well-known/oauth-authorization-server$ index.php?r=Discovery/oauth2Metadata [L]
RewriteRule ^\.well-known/oauth-protected-resource$ index.php?r=Discovery/oauthProtectedResource [L]
RewriteRule ^\.well-known/oauth-protected-resource/(.+)$ index.php?r=Discovery/oauthProtectedResource&resource_path=$1 [L]
RewriteRule ^\.well-known/health$               index.php?r=Discovery/health [L]

Two things about that block are worth knowing before you edit it.

Order matters. The catch-all rule below them matches every path, and mod_rewrite runs rules in order — a discovery rule moved beneath the catch-all never fires. On a SPA project the failure is worse than a 404: the shell fallback answers with the application's HTML and a 200, so a client sees malformed JSON rather than a missing endpoint.

The underscore spelling is deliberate. openid_configuration appears in no specification and in a good number of clients. Answering it costs one line.

.htaccess is the project's own file and is not regenerated. If yours lacks the block, add it by hand, above the catch-all.

If a discovery response will not parse

Every discovery endpoint — openid-configuration, jwks.json, oauth-authorization-server, .well-known/health, /Discovery/serverConfig and the project-level /config — answers through the framework's raw document, so the body is the JSON and nothing follows it. A response that does not parse is almost always the .htaccess order above: the shell fallback answered with HTML. Look at the first bytes:

$ curl -s https://auth.example.com/.well-known/openid-configuration | head -c 200

A summary built for a person

The two documents above are built for a client library. When you want the one a developer reads while integrating — the URLs, the grants that work here, the scopes that exist, whether the device flow is on — ask for:

GET /Discovery/serverConfig

It is not a standards document and a client should not depend on it; read /.well-known/openid-configuration for that. This is the page to paste into a ticket. Every list in it is read from whatever actually decides it, so it cannot drift out of agreement with the server the way a hand-written integration note does.

Client credentials, and the account behind the token

The token's iss is the issuer the discovery document publishes (Discovery::issuer()), character for character, trailing slash included, so a client that compares the two finds them equal. The server resolves a token it receives by its usertokens row, not by iss, so a token issued before carrying the form without the slash is still accepted.

A client_credentials token has no end user — it represents your application. The server still needs an account to hang it on, because usertokens.userid is a foreign key, so each application gets one system account, created on first use and reused afterwards.

You never see it directly, but it explains two things you will see:

POST /oauth/introspect → { "active": true, "sub": "4", "username": "sys_3a5c9a25…" }

sub is that account, not a person, and username is a generated sys_* name. It is usertype 1 — below every administrative threshold — so a token issued to an application can never be mistaken for one issued to an operator.

And the account is never userid 0 or 1. Those are the framework's guest and system rows, so an application whose systemuser column holds either has a gap rather than an account — and a token stored under one of them would sit beneath an identity shared with every other application in the same state, which makes "what has this account been doing" unanswerable. The check is > 1, in both places that could produce such an id: the column when it is read, and the row creation when it answers.

Every refusal along the way returns no account rather than raising, and the token insert then fails on its foreign key. That is deliberate: a token for a client that cannot be resolved is not stored under a user invented for it. If a client_credentials grant answers with a server error rather than a token, this is one of the things to check — the log carries Could not create a system user for application <id> or Could not resolve a system user for client <id>.

A client with a registered public_key, or a jwks_uri publishing its keys, can authenticate its client_credentials request with a signed assertion instead of a secret (client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer, RFC 7523 §2.2). The assertion must carry:

Claim Value
iss, sub the client id
aud the token endpoint, https://<server>/oauth/token — or the issuer, https://<server>
iat, exp both; exp - iat at most 300 seconds
jti unique per assertion; a jti this client has presented before is refused until the first one's exp

It is signed RS256/384/512 or ES256/384/512. With a jwks_uri, the header's kid chooses the key; a JWKS with a single key needs none. A refused assertion answers 401 invalid_client with the reason in error_description — which claim is wrong, or that the jti is a replay.

$assertion = \Pramnos\Auth\JWT::encode([
    'iss' => $clientId, 'sub' => $clientId, 'aud' => 'https://auth.example.com/oauth/token',
    'iat' => time(), 'exp' => time() + 60, 'jti' => bin2hex(random_bytes(16)),
], $privateKeyPem, 'RS256');

The token it receives is signed RS256 with the server's own key — the one the server's JWKS publishes — and introspects and revokes like any other. A server with no signing key answers server_error and issues nothing. Pramnos\Auth\OAuth2\JwtAssertion makes the check, for an application endpoint that accepts client assertions of its own.

If you need a token that acts as a particular person without that person signing in, that is the JWT bearer grant below rather than this one.

Grants beyond the authorization code

The token endpoint also answers three grants for situations the authorization code does not fit. Each authenticates the client as the others do — its secret, a client assertion, or for a public client its client_id alone — and each is subject to the application's grant policy.

Grant policy. Every grant at /oauth/token is checked against applications.oauth2_application_grants. A row decides its one grant; a grant without a row follows the defaults (authorization_code, client_credentials, password, refresh_token, device_code, exchange_token). jwt_bearer is never a default:

use Pramnos\Auth\OAuth2\GrantPolicy;

GrantPolicy::enable($appId, 'jwt_bearer');   // adds jwt_bearer; the defaults still apply
GrantPolicy::disable($appId, 'password');    // removes password only

A grant the application is not allowed answers 400 unauthorized_client.

The defaults are the installation's, in app.php (init writes them out):

'authserver' => [
    'default_grants'       => ['authorization_code', 'client_credentials', 'device_code', 'password', 'refresh_token', 'exchange_token'],
    'default_auth_methods' => ['client_secret_basic', 'client_secret_post', 'private_key_jwt'],
    'default_scopes'       => [],   // e.g. ['profile', 'email', 'user']
],

default_scopes are scopes every client may request beside its own Allowed Scopes (applications.scope); a system: scope listed there is dropped with a warning. Without a key, these built-in lists apply, and no default scopes. jwt_bearer and none are never defaults: listed there, they are dropped with a warning, since the first gives its holder a token for any user and the second removes client authentication. An application gets them only from its own policy.

Device authorization (RFC 8628) — for a TV, a CLI, anything without a browser of its own.

POST /oauth/deviceauthorization     client_id=…&scope=openid profile
→ { "device_code": "…", "user_code": "WDJB-MJHT", "verification_uri": "https://<server>/device",
    "verification_uri_complete": "…?user_code=WDJB-MJHT", "expires_in": 600, "interval": 5 }

The device shows the code, the user approves it at /device, and meanwhile the device polls:

POST /oauth/token   grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=…&client_id=…
Answer Meaning
400 authorization_pending The user has not answered; poll again after interval seconds.
400 slow_down Polled sooner than interval after the last poll; wait longer.
400 access_denied The user refused.
400 expired_token Ten minutes passed; start again.
200 The access and refresh tokens. A device_code is good for one set of tokens; after that it is invalid_grant.

grant_type=device_code is accepted as well.

JWT bearer (RFC 7523 §2.1) — a trusted service acting for one of its users without that user signing in. Its holder can obtain a token for any user, so it is never a default: the application needs GrantPolicy::enable($appId, 'jwt_bearer').

POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<JWT>&scope=profile

The assertion is signed with the client's own key and makes every check a client assertion makes (iss the client id, aud the token endpoint, at most five minutes, a fresh jti); its sub names the user by email or username. The answer is an access token for that user and no refresh token — the service asserts again when it needs another.

Token exchange (RFC 8693) — a short-lived access token for a sixty-day one.

POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<an access token issued to this client>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token

The answer is an access token for the same user and scopes, lasting sixty days, with no refresh token; the token presented is revoked. grant_type=exchange_token is accepted as the older name, and subject_token_type may be left out.

Signing out

Two endpoints, because there are two situations.

POST /oauth/logout      Authorization: Bearer <token>     → JSON
GET  /login/logout                                        → redirect

/oauth/logout is for your backend. It revokes the token family: the access token you present and the refresh token issued with it, linked through usertokens.parentToken. A token issued to another device belongs to another family and is untouched — that is what separates this from "sign out of everything".

POST /oauth/logout
Authorization: Bearer <access_token>

logoutwebsession=1        # optional — end the browser session as well

{ "success": true, "user_id": 42, "tokens_revoked": 2 }

Without logoutwebsession=1 the browser session is left alone. That is usually what a backend wants and rarely what a "sign out everywhere" button wants.

An unknown token still answers {"success": true}, in the spirit of RFC 7009: an endpoint that distinguished a real token from an invented one would tell an attacker which of their guesses exist.

/oauth/revoke (RFC 7009) is for a client that holds a token it no longer needs. The client authenticates as it does at the token endpoint — the secret in the Basic header or the body, a client assertion, or for a public client its client_id alone — and revokes only its own tokens:

POST /oauth/revoke
Authorization: Basic base64(client_id:client_secret)

token=<access or refresh token>&token_type_hint=refresh_token

{ "success": true }

An access token is revoked alone. A refresh token is revoked with the access token issued beside it, since both belong to one grant. token_type_hint is used to look in the likelier place first and is otherwise only a hint. An unknown or already revoked token answers 200 like a revoked one; a request without client authentication answers 401 invalid_client, and a token issued to another client 400 unauthorized_client. /oauth/introspect authenticates the client the same way — its application's authentication methods apply to both — finds refresh tokens the same way, and reports them active until they expire or are revoked.

/login/logout is for a browser. It reads the session cookie, needs no header, and redirects afterwards. It is also the OpenID Connect end-session endpoint (RP-Initiated Logout 1.0) — the discovery document's end_session_endpoint — so an application can sign its user out here and have the browser sent back:

GET /login/logout?id_token_hint=<the user's ID token>
                 &post_logout_redirect_uri=https://app.example/signed-out
                 &state=<opaque>
Parameter
id_token_hint An ID token this server issued to the user. It may have expired; it must be validly signed, about the user signed in here, and name the client.
client_id The client, when there is no hint. With a hint it must be among the hint's aud.
post_logout_redirect_uri Where to go afterwards. Matched exactly against the client's registered redirect URIs and its homepage (url).
state Appended to that redirect.
local=1 End this browser session and leave the client's tokens valid.

With a valid hint and without local=1, the user's access and refresh tokens for that client are revoked as well; other clients' tokens and the user's other browsers are left alone. A bare client_id can return the browser to a registered address but revokes nothing, since such a link can sit on any page. Anything that does not check out — a forged or foreign hint, an unregistered address — still signs the browser out, ends on the sign-in page, and is logged with the reason. A site-relative redirect_uri is carried to the next sign-in as its return address instead.

Is the server up?

GET /.well-known/health
{
  "status": "healthy",
  "timestamp": "2026-08-25T14:46:08+00:00"
}

503 when anything is wrong. That is what a monitor, a load balancer or a relying party needs, and it is public. Which component failed is a map of the installation for anybody who asks, so the components map is added only where app/app.php says so:

'health' => ['public_details' => true],
// → "components": { "database": "ok", "signing_keys": "ok", "session": "ok" }

It lists every check the server has registered — including signing_keys, which is the one that catches a server answering pages normally and refusing every token. An operator reads the same report, with the reasons, on the signed-in health screen or with health:check. See Health checks for what each check means and how to add one.

If a bearer token reads as no token

Apache does not hand the Authorization header to PHP-FPM or CGI unless it is copied into the environment first:

RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

init writes this for every project, not only authorization servers — any REST API authenticated with Authorization: Bearer … needs it. Without it the request arrives anonymous, which reads as a rejected credential; the time then goes into the token, and the token was never the problem.


Which scopes you may ask for

The list in scopes_supported is the list. Ask for one that is not in it and the token endpoint answers invalid_scope:

{
  "error": "invalid_scope",
  "error_description": "The requested scope is invalid, unknown, or malformed",
  "hint": "Check the `profile` scope"
}

The token endpoint and the discovery document read the same scope registry, and the four older identifiers — read, write, admin, user — are still accepted.

2. Registering your application

An administrator registers your application on the server and gives you a client_id and client_secret, plus your registered redirect URI(s). A public client can instead register itself, where the server allows it — see Dynamic client registration.

Token lifetimes

How long a token lasts is the server's default unless the application sets its own:

// app/app.php — the server's defaults, in seconds
'oauth' => [
    'access_token_ttl'  => 3600,      // one hour unless set
    'refresh_token_ttl' => 2592000,   // thirty days
    'auth_code_ttl'     => 600,       // ten minutes
],

An application's Access token lifetime and Refresh token lifetime (OAuth2 tab of its edit screen, seconds) replace the first two for that client; empty is the default. Values are clamped: an access token lasts a minute to a day, a refresh token a minute to a year, a code at most ten minutes. The lifetime is the same in the JWT's exp, the response's expires_in and the stored token — Pramnos\Auth\OAuth2\TokenLifetimes is the rule, applied as each token is persisted.

Access and limits

The Access & limits tab of an application's edit screen sets what it may do and how much. Every setting is enforced; none is only for show.

Setting Default Where it is applied Refusal
Grant types authorization_code, client_credentials, device_code, password, refresh_token, exchange_token /oauth/token, every grant 400 unauthorized_client
Client authentication client_secret_basic, client_secret_post, private_key_jwt; and none for a client with no secret /oauth/token, /oauth/revoke, /oauth/introspect 400 unauthorized_client
Rate limit 1000 requests per 3600 s, burst 100 the API, per application 429 TooManyRequests, Retry-After
Pagination not enforced until saved; then default 20, maximum 100 every list endpoint of the API the page is resized, not refused
Require HTTPS on the API and the OAuth endpoints 403 HTTPSRequired / 400 invalid_request
IP lock: allowed / blocked addresses off / none the API and the OAuth endpoints 403 AddressNotAllowed / 400 unauthorized_client
Browser origins (CORS) not restricted the API 403 OriginNotAllowed

An application whose settings were never saved has these defaults. Its grant types and authentication methods are the defaults until a different selection is saved: a selection equal to the defaults stores nothing, so it follows them when they change, and an empty one means none.

The rate limit is a token bucket. A full bucket holds burst requests and refills at requests per window: a client that has been quiet may send the burst at once, and one that keeps sending is held to the rate. A burst of 0 is a bucket of one — the rate, and nothing at once. Every API answer to an application carries X-RateLimit-Limit (requests per window) and X-RateLimit-Remaining; a refusal carries Retry-After in seconds. On Redis the bucket is one atomic script; on any other cache two requests racing for the last token can both get through. A clock that steps back (NTP correcting it, a virtual machine resumed) counts as no time passed, so it never takes tokens away.

HTTPS is HTTPS on the server, or a proxy's X-Forwarded-Proto: https. A request from the server's own loopback address is exempt — a developer's machine has no certificate.

The IP lock. A blocked address or range (198.51.100.0/24, IPv6 too) is always refused. With the lock on and allowed addresses listed, only those are accepted. The address is the one Request::clientIp() resolves, which honours the trusted proxies.

Browser origins apply to a request that carries an Origin header: with the restriction on, an origin not listed (https://app.example, no path) is refused. A server calling the API sends no Origin and is not affected.

Pagination takes effect in ApiListQuery, so every list endpoint built on it follows the calling application: enforced, a request for every row (page=0) gets the first page at the default size, and no page exceeds the maximum, enforced or not.

For an application's own screens, ApplicationService::policy($appId) reads all of it and updatePolicy($appId, $form) saves it; underneath are GrantPolicy, ClientPolicy and ApplicationSettings.

Allowed Scopes

An application's Allowed Scopes (applications.scope, space-separated, on the OAuth2 tab of its edit screen) is the most it may ask for. A scope outside it is refused with invalid_scope:

  • at /oauth/authorize, before the user is asked to consent to anything;
  • at /oauth/token, for the authorization-code, client-credentials and JWT-assertion grants — and for a refresh, so narrowing the list takes effect at the client's next token rather than when its refresh token runs out.

An empty list is no restriction: the server's own scope registry alone decides, which is what a client registered through RFC 7591 gets. The rule is Application::scopesBeyond($allowed, $requested); League reaches it through ScopeRepository::finalizeScopes() and AccessTokenRepository::getNewToken().

Which scopes a person may grant

Allowed Scopes say what a client may ask for. What a person may hand it is a role per scope, in app/app.php:

'roles' => ['staff' => 50],
'oauth' => ['scopes' => [
    'mcp:*'        => ['min_role' => 'staff'],   // a glob
    'mcp:db_write' => ['min_role' => 'admin'],   // an exact name wins over a glob
    'reports:read' => ['min_usertype' => 20],
]],

Without it, any signed-in account could approve any scope a client asked for, so a site's customer could hand an assistant mcp:db_read. A scope with no rule is open to anybody signed in, as before. The rule is checked:

  • at /oauth/authorize, as soon as the person is signed in and before any consent, new or remembered. The answer is the error page Your account cannot grant mcp:db_read to this application;
  • at /oauth/token, for every token issued on a person's behalf, a refresh included. A role taken away is felt at the next token: invalid_scope, with the scopes named in the hint. A client acting for itself (client_credentials) has no person to judge.

Refusing, or dropping. By default one scope the account may not grant refuses the whole request. An assistant asks for every scope a resource lists, so an administrator below the role of a single scope could not connect at all. Set:

'oauth' => ['scope_refusal' => 'drop', 'scopes' => [...]],

With drop, those scopes are left out and the request goes on with the rest. RFC 6749 §3.3 lets a server issue fewer scopes than were asked for.

  • The consent page asks for the remaining scopes, and the code carries only those.
  • At /oauth/token, a refresh after a role was taken away narrows the token instead of ending the connection.
  • A request of nothing but such scopes is still refused.

Keep the default where a client that cannot work without a scope is better told so than handed less.

A rule that cannot be read refuses: a role nobody defined, or an account that cannot be loaded. A typo must never open a scope. For a rule a role cannot express (one client only, an organisation, a flag on the account), extend Pramnos\Auth\OAuth2\ScopePolicy, override allows(object $user, string $scope, ?string $clientId, ?array $rule): bool (call the parent for the configured rule), and name the class:

'oauth' => ['scope_policy' => \App\Auth\ScopePolicy::class, 'scopes' => [...]],

Applications marked trusted (internal/first-party) skip the user consent screen; untrusted (third-party) applications always show consent and receive only the scopes the user approves.

The consent page has a box per scope, ticked, and the person can clear the ones they would rather not grant. The code, the token and the stored consent then carry only what stayed ticked. Two kinds of scope have no box and are always granted with the rest:

  • the required ones, without which the client cannot work at all: openid for a sign-in and mcp to reach the MCP endpoint;
  • the ones named in 'oauth' => ['consent_required' => ['openid', 'mcp', 'reports:read']], which replaces the default list ([] gives every scope a box).

Granting nothing, with nothing required, is a refusal. A consent view an application published before the boxes existed sends no choice, and grants everything asked for, as it did. To offer the choice there, copy the checkbox markup and the hidden scope_choice field from the bundled OAuth2/authorize.html.php. A client should read the scopes it was given from the token, not assume the ones it asked for. Trusted is a switch on the application's edit screen (beside Client Type); it is saved only when the form sends it, so an application's own older copy of the form leaves it as it was. Mark only your own applications trusted — a trusted client is never asked about, whatever it requests.

When a redirect URI is required

Short version: a client that runs on a server you control and keeps its secret there may go without a registered redirect URI. Every other client — a browser app, a mobile app, a desktop app, or any client with no secret stored — must have one, and until it does the authorization endpoint refuses every sign-in for it. The admin screens warn about such a client on its page and on its edit form.

Confidential and public, in practice

The Client Type switch on the application's edit form (applications.is_confidential) records one fact: can this client keep a secret?

Confidential Public
Where the client's code runs A server you control The user's device: a browser (SPA), a phone, a desktop
Who can read its client secret Only you Every user of the app — it is inside what you shipped
What the secret proves at /oauth/token That the caller is the real client Nothing: anybody who unpacked the app has it
What ties an authorization code to the client The secret The registered redirect URI, and PKCE (required)
Client secret at /oauth/token, /oauth/revoke, /oauth/introspect Required Optional; checked when sent
client_credentials grant Allowed Refused by the token endpoint
Registered redirect URI Optional, recommended Required

When in doubt, ask where the code that calls /oauth/token runs. If it is on a machine the user holds, the client is public, whatever else is true of it.

Which clients the server treats as unable to keep a secret

Two conditions, and either is enough:

  1. Client Type is public (is_confidential = 0). The admin screen still issues such a client a secret when it is created, but a secret shipped inside an app is known to everybody who has the app, so it protects nothing: the token, revocation and introspection endpoints accept the client's client_id alone, and check a secret only if one is sent. The authorization endpoint requires PKCE from it.
  2. No secret is stored (apisecret empty or NULL). The token endpoint then accepts a request that presents no secret at all, whatever Client Type says. Clients created through dynamic client registration are like this, and so are rows written before secrets existed.

Pramnos\Auth\Application::cannotKeepASecret($row) answers this question, and needsARegisteredCallback($row) adds "…and has no usable registered redirect URI" — the exact condition the authorization endpoint refuses and the admin screens warn about. A callback holding only separators, or only a refused scheme such as javascript:, counts as none.

Why the redirect URI is what matters

An authorization code is delivered to the redirect_uri named in the request, and whoever holds the code can exchange it for a token. For a confidential client the exchange needs the secret, so a code that reaches the wrong address is useless. For a client that cannot keep a secret, nothing at the token endpoint tells the real client from anybody else:

  1. Somebody builds an /oauth/authorize link with the client's client_id — a public identifier, visible in every copy of the app — and a redirect_uri of their own.
  2. A user who is signed in, and who has approved this app before, opens it. The consent screen is skipped, because the app is already approved.
  3. The code goes to the address in the link, and its holder exchanges it with no secret, or with the one every user of the app has. They now hold a token for that user.

PKCE does not prevent this: whoever builds the link chooses the challenge, and so holds the verifier. Exact matching against a registered redirect URI is the only thing that stops the code from leaving for an address the client does not own. That is why RFC 6749 §3.1.2.2 makes registration a MUST for public clients, and why this server enforces it.

What the refusal looks like

The person signing in sees an authorization error page:

This application cannot sign anyone in yet: it has no client secret it can keep, so it needs a registered redirect URI. Ask the administrator to add one.

oauth.log records it with the client id and the requested address:

refused: client cannot keep a secret and has no registered redirect URI | endpoint=authorize client_id=… redirect_uri=… ip=…

Nothing is issued and the form-action policy is not widened toward the requested address.

Fixing it

Open the application in the admin area and, on the OAuth2 tab, enter every address the application really receives its code at under OAuth2 Redirect URI(s) — one per line, each exactly as the application sends it (see the matching rules below). The warning disappears once there is one.

If the client is in fact a server-side application that keeps its secret, tick Client Type instead. That is only correct if the secret never leaves your servers.

Register your redirect URI — optional for a confidential client, and matched exactly

For a client that cannot keep a secret, registration is required — see When a redirect URI is required. For a confidential client with a secret, registering its callback (applications.callback) is optional and strongly recommended, the way it is on every large authorization server. It is what buys you two things:

  • /oauth/authorize refuses any redirect_uri that is not on the list — RFC 6749 §3.1.2, and the reason the RFC asks for it is that the destination is a URL an authorization code is delivered to.
  • The server widens its form-action policy for that origin, so a login that goes through the sign-in form completes (see the note below).

A confidential client with a secret and nothing registered keeps working, and still gets the form-action widening: the endpoint accepted its redirect_uri, so the policy says so. The condition is recorded in oauth.log with the recommendation. For this client, whether to register is the operator's call.

A CSP cannot enforce what the layer below it does not

The widening is not gated on the registration. form-action can only answer may the form post toward the place this request names, not should this request be allowed. Gating it would refuse destinations the endpoint had just accepted, add no security, and fail in the browser, with nothing server-side to see.

Nor would it be the protection it looks like. form-action governs form submissions, so a user who already holds a session takes GET /oauth/authorize → 302 → client with no form in it, and the code is delivered regardless. A mismatch is worth a log line, never a refusal, while the layer that could refuse does not.

One registration, several environments

The same client is developed on http://localhost:3000, tested on a staging host and run in production, and all three belong to one registration. A field read as a single URI names one environment and refuses the others.

So callback holds a list, and the separator is permissive because a human types this field. All of these are the same three registrations:

http://localhost:3000/callback https://staging.app.example/cb https://app.example/cb
http://localhost:3000/callback,https://staging.app.example/cb,https://app.example/cb
http://localhost:3000/callback
https://staging.app.example/cb
https://app.example/cb

Commas, spaces, tabs and newlines all separate; the admin screen's field is a textarea and what it stores is normalised to one space-separated list. A single value is a list of one.

A comma inside a URI must be percent-encoded as %2C, since a comma separates.

A scheme that is only ever script is refused — javascript:, data:, vbscript:, file:, blob:, about:. The scheme is otherwise unrestricted, because myapp://oauth/callback is a real registration for a native client; what is refused is the set that has no other use. javascript://x/%0aalert(1) satisfies every structural rule a URL parser applies, and this value reaches a Location header and a CSP form-action source. The admin screen says so when you paste one; the endpoint refuses it on the request as well, because a client with nothing registered has no registration to check.

An older installation may still have a 255-character ceiling

create_applications_table declares text, but on a database whose applications table predates the migration system that migration is Skipped (cutoff) and never applied it — the column is varchar(255), and three long callbacks plus separators is about 190 characters.

2026_09_08_000001_widen_applications_callback fixes it, and it has to be a framework migration: PostgreSQL refuses to alter a column two framework views select from («cannot alter type of a column used by a view or rule»), so widening means dropping and rebuilding those views. The migration drops them with CASCADE and re-runs the views migration, so their definitions stay in one place. A usage_statistics that is still the installation's own materialized view is dropped for the ALTER and recreated from its own definition and indexes, not replaced with the live view: its refresh function and job keep working, and adopting the live view stays your decision (drop the relation with its refresh function and job, then re-run create_applications_views). Until you run it, the admin screen refuses an overflow with a message naming the ceiling rather than letting the driver truncate.

Active tokens: one view per token, one summary per application

applications.oauth2_active_tokens has one row per live token (access, refresh or auth code), with its client (client_name, client_id) and owner (username, email). Use it for audits and revocation lookups. applications.oauth2_active_tokens_summary has one row per application: token_count, expired_count, revoked_count and avg_expiry_days. On MySQL the prefix applies as usual: {prefix}applications_oauth2_active_tokens and {prefix}applications_oauth2_active_tokens_summary.

Two ways to stop form-action cancelling an SSO login

Both work; they differ in who names the origin and how narrowly.

1. Register the callback 2. List it in app.php
Where applications.callback on the client csp: form-action in app/app.php
Scope That client, that origin, on the pages of the OAuth flow Every page of the application
Effect The server widens the policy itself, per request The origin is in the header everywhere
Also gives you Exact-match refusal of an unregistered redirect_uri Nothing beyond the policy
Needs a deploy No — it is a database row Yes — it is configuration
Recommended Yes, for an authorization server When registration is not an option

1 — register the callback. Nothing else to do; this is the route the log line recommends.

UPDATE applications SET callback = 'https://client.example/login-sso' WHERE apikey = '…';

2 — name the origins in the application's own policy, which is what an application with no access to its clients' registrations does:

// app/app.php
'csp' => [
    'form-action' => [
        'https://client-one.example',
        'https://client-two.example',
    ],
],

Route 2 is deliberately available and is not a workaround: it is the same extension point img-src and connect-src have, and an application is entitled to widen its own policy. What it does not do is check anything — the origin is trusted because you wrote it down, not because a client registration vouches for it. That is the whole difference, and it is why route 1 is the recommendation for a server whose job is issuing authorization codes.

Doing both is harmless. The server appends to the list app.php provides, and an entry identical to one already there is not added twice.

The comparison is an exact string match, so register the URI your application will actually send, character for character. All of these are different registrations:

https://app.example/callback
https://app.example/callback/          ← trailing slash
https://app.example/callback?x=1       ← query string
http://app.example/callback            ← scheme
https://app.example:443/callback       ← explicit default port

Exact is the point rather than an inconvenience: a prefix comparison accepts https://app.example.attacker.test/callback, and a host comparison accepts any path on your domain — including one that reflects the query somewhere else. Each near miss is a published attack, and the destination is a URL an authorization code is delivered to.

Register several by storing a comma-separated list or a JSON array; any one of them is accepted, exactly. A native application registers its custom scheme URI in full (myapp://oauth). An empty callback, a lone comma and [] all mean not registered.

One exception: the port of a loopback callback (RFC 8252 §7.3). A native client, such as a CLI, an IDE or an MCP inspector, listens on whatever port the system gives it that run, so no port can be registered for it. Register the callback on a loopback host, with or without a port: http://127.0.0.1/callback, http://[::1]/callback, or http://localhost/callback. A request with the same scheme, host, path and query then matches on any port. Nothing else is relaxed: another path, another loopback host, https, an added query, or a public host on another port is still a different registration. The code still goes only to this machine.


Deleting a client revokes its tokens

applications.appid cascades to usertokens.applicationid. Delete an OAuth client and every token it issued goes with it.

Deleting a client is the one action an operator takes precisely to stop it having access. Leaving its tokens behind with a null applicationid would make them indistinguishable from tokens never issued through OAuth, and they would carry on authenticating.

Tokens whose applicationid is already null are left alone. Nothing separates "issued by a client that is gone" from "never an OAuth token", so a sweep would have to guess — which here means revoking working credentials.

Managing applications from your own screen: ApplicationService

The administration area's applications screens are HTML. An application with its own panel — an SPA, a developer portal — writes its own JSON endpoints and calls the service those screens call, so the screen is yours and the rules stay the framework's:

$apps = new \Pramnos\Auth\ApplicationService($db);

$check = $apps->validate($input);               // ['fields' => …, 'error' => ?string]
$new   = $apps->create($input);                 // ['appid', 'apikey', 'secret'] — secret shown once
$apps->update($appId, $input);                  // false when there is no such application
$secret = $apps->rotateSecret($appId);          // the new secret once, or null
$apps->deactivate($appId);                      // revokes its active tokens, then switches it off
$apps->tokens($appId);                          // active tokens, never the token values
$apps->find($appId);                            // the public description, no secrets
  • An older applications table is written as it is: a column it lacks (broadcast_secret, the lifetimes) is left out, and an empty value for a column that refuses NULL with no default is written as '' (or 0 for a number).
  • Input has the administration form's fields and defaults. trusted, access_token_ttl and refresh_token_ttl change only when present, and only where the column exists. Callbacks may be typed one per line, comma-separated or spaced; they are stored as one space-separated list. A script-only scheme is refused by name. A list longer than a legacy varchar(255) column is refused with the fix. create() and update() throw InvalidArgumentException carrying the sentence validate() returns. It is plain text, so escape it if you render it as HTML.
  • The client secret is stored hashed. create() and rotateSecret() return it, and nothing can read it back. Existing tokens survive a rotation, because they do not depend on the secret.
  • Retiring keeps the rows. Tokens become status 3 (revoked) and the application status 0, for the audit trail.
  • Who may call your endpoints is yours to decide. The service checks the input, not the caller. Webhook endpoints are managed through WebhookService (saveEndpoint(), rotateEndpointSecret(), deleteEndpoint()).

3. Logging a user in — Authorization Code + PKCE

Use the Authorization Code flow with PKCE (recommended for all clients).

Step 1 — redirect the user to the authorization endpoint:

GET /oauth/authorize
    ?response_type=code
    &client_id=YOUR_CLIENT_ID
    &redirect_uri=https://yourapp.example.com/callback
    &scope=openid profile email
    &state=RANDOM_STATE
    &code_challenge=BASE64URL(SHA256(verifier))
    &code_challenge_method=S256

The user authenticates (password + optional 2FA/passkey) and, for untrusted clients, approves the requested scopes. The server redirects back to your redirect_uri with ?code=…&state=….

The server widens its own form-action for you

A browser applies the form-action directive to every redirect a form submission passes through, and the last hop here is your origin. The authorization server adds your registered callback's origin to the policy of the pages whose forms start the chain — the login screen, the second-factor screen and the consent screen — after it has checked the redirect_uri against your registration. Nothing is required of you beyond having registered the callback, and there is nothing to configure on the server.

This is the practical reason to register. With no callback on file the server has nothing to vouch for the destination, so it leaves form-action 'self' in place and a sign-in that goes through the login form is cancelled at the last hop.

Worth knowing because of how it fails if it is ever missing: the code is issued and the Location header is sent, and the browser then cancels the navigation. The server logs a completed authorization, the user sees a page that did not move, and the console reports a violation against a same-origin URL.

Step 2 — exchange the code for tokens:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=THE_CODE
&redirect_uri=https://yourapp.example.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=THE_ORIGINAL_VERIFIER

You receive an access_token, and an id_token when openid was granted. Fetch the same profile claims later from GET /oauth/userinfo with the access token.

The ID token

A JWT signed RS256 with the server's key, its header naming the kid that jwks_uri publishes, so it verifies the way the access token does. Check the signature, then:

Claim Is
iss exactly the discovery document's issuer
aud your client id
sub the user's id — the same sub as /oauth/userinfo
exp, iat its expiry (the access token's) and when it was issued
nonce the nonce you sent to /oauth/authorize, unchanged — compare it with yours
auth_time when the user signed in; on a code, not on a refresh
scope claims email → email, email_verified; profile → name, given_name, family_name, preferred_username, updated_at, picture, website; phone → phone_number (the mobile column, else phone, else null)

Send a nonce (up to 255 characters) on the authorization request: it is carried with the code and returned in the ID token, which is how you know the answer belongs to the request you made. A refresh with openid returns a new ID token without one. Only the authorization-code flow issues ID tokens — response_types_supported is code, answered in the query.

The same token authenticates calls to this installation's API — send it as Authorization: Bearer <access_token> — and GET /oauth/userinfo. The server stores an access token from /oauth/token by its identifier (the jti claim) rather than its full text, so both look it up by the text first and then by the jti, which they read only after the token has been verified: the API middlewares from the claims JWT::decode() checked, /oauth/userinfo from League's resource server, which also checks expiry and revocation. A token whose signature does not verify is never looked up by its jti.

Binding the token to one resource

Add resource to the token request (RFC 8707) when the token is for one endpoint of this server — an MCP client always does:

POST /oauth/token
grant_type=authorization_code&code=…&code_verifier=…&redirect_uri=…&client_id=…
&resource=https://example.com/api/1.0/mcp

The access token's aud becomes ["<client_id>", "https://example.com/api/1.0/mcp"], and an endpoint that checks its audience — the MCP endpoint does — refuses a token bound to anything else. The resource must be an absolute URI on this server's own origin with no fragment; anything else is 400 invalid_target, not silently ignored. Without resource the token is bound to no resource and aud is the client id alone.

A confidential client's secret is required, on every grant

If your client is confidential and a secret is registered for it, the token endpoint will not authenticate you without it. This holds for every grant — authorization_code, refresh_token, password and client_credentials — and for both ways of presenting it, HTTP Basic or form field. An empty client_secret= counts as absent. A public client may leave it out; a secret it does send is checked.

For a confidential client the client_id alone is never enough. It is a public identifier: it travels in redirect URLs and ships inside every SPA bundle and mobile binary, so anything it could unlock on its own would be unlocked for everybody who has ever seen a login link.

Copy it when you are given it

The secret is shown once — on the screen that creates the registration, and again if you rotate it. It is stored hashed, the same way a password is, so nobody can read it back to you afterwards: not the operator, not a support request, not a database query. Lose it and the only route forward is rotating to a new one.

Public clients

A public client is one that cannot keep a secret: a single-page app or a mobile binary, where whatever you ship inside it every user of it has. Register it with Client Type unticked, and register its redirect URI — without one it cannot sign anyone in; When a redirect URI is required explains why.

The admin screen issues every new client a secret, public or not; a public client need not send it, and a secret it does send is checked. A client whose apisecret is empty — which is what dynamic registration creates — has none to send.

A public client authenticates its authorization code with PKCE instead: /oauth/authorize refuses a public client's request without a code_challenge. It cannot use client_credentials, which authenticates the application itself and has nothing but a secret to do it with — the token endpoint refuses that combination.

If your client runs on a server you control, leave it confidential. The secret is worth having.

The consent screen is translated. Its words, the scope categories and each scope's description go through t(), so a catalogue with Allow, Deny, Authorize %s and the descriptions (Scopes::getScopes(), the MCP scopes among them) shows the page in the site's language.

Dynamic client registration (RFC 7591)

A client can register itself, with nobody on the server's side involved. It is how Claude.ai, ChatGPT and other remote MCP clients connect: they register, then run §3 with PKCE, and the person approves the consent screen. It is off unless the operator turns it on in app/app.php:

'oauth_dynamic_registration' => true,

init writes that line for an authserver project. With it off, /oauth/register answers 404 and the discovery documents publish no registration_endpoint.

POST /oauth/register
Content-Type: application/json

{
  "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

The answer is 201 with the client_id, the metadata as registered, and no secret. What the endpoint admits, and why:

Field Accepted Why the limit
token_endpoint_auth_method none only — the RFC's default, client_secret_basic, is refused A confidential client could use client_credentials, which needs no person. Open registration would be open issuance of machine tokens
grant_types authorization_code, refresh_token The same reason; a public client cannot hold what the others need
response_types code The implicit flow puts the token in a URL
redirect_uris One to five; https, or http on localhost / 127.0.0.1 / [::1]; no fragment, space or comma A custom scheme proves nothing — any app on a phone can claim it. An operator can still register one by hand
client_name Up to 100 characters; Unnamed client if absent It is what the consent screen shows

Refusals follow RFC 7591 §3.2.2: 400 with invalid_client_metadata or invalid_redirect_uri. Each address may register twenty clients an hour; past that the answer is 429 with Retry-After. Every registration is a line in oauth.log.

A registered client is an ordinary row in applications — untrusted, so consent is always shown, public, and with no secret (apisecret is empty, which an older schema's NOT NULL column accepts). The redirect URIs are kept space-separated in callback. The row is read back after the insert: where the column is too narrow for the list (an older schema that has not run widen_applications_callback) and the database cut it quietly, the row is removed and the answer is 400 invalid_redirect_uri. Where the database refuses the insert, the answer is 500 server_error, with the reason in oauth.log. Either way no client is left with half its callbacks. An administrator sees it in the applications screen and deletes it the same way as any other, which revokes its tokens.

Lightweight tokens

Access/ID tokens carry identity claims only — user id, basic attributes, audience, optionally roles. They do not carry permissions. Do not try to derive what a user may do from the token; fetch it (next section) and cache it.


When a sign-in fails: the decision log

/oauth/authorize and /oauth/token write what they decided to oauth.log, under LOG_PATH/logs. Refusals are always written; the successful half is behind a setting.

token endpoint refused | endpoint=token status=400 grant_type=authorization_code
  client_id=3ad1d008… error=invalid_client error_description=Missing client_secret ip=…
authorize refused | endpoint=authorize error_description=The redirect_uri is not registered
  for this application. client_id=… redirect_uri=… ip=…
client has no registered redirect URI — recommended: set `applications.callback` to the exact
  URL this client sends… | endpoint=authorize client_id=… redirect_uri=… ip=…
consent form shown (no redirect) | endpoint=authorize client_id=… userid=… scope=…
denied by user | endpoint=authorize client_id=… userid=… scope=…

No credential is ever written. Not an authorization code, an access or refresh token, a client_secret or a code_verifier: the file is readable by anyone with the server, and a code in it is a code somebody can replay inside its lifetime. The line is built from a fixed list of keys, so a call site cannot add one by accident. client_id and redirect_uri are public by construction and already in the access log — they are what makes a line worth reading.

Turning the successful half on

An issued code or token is only logged when the oauth_decision_log setting is truthy (1, true, yes, on):

setting what is written
absent / off (default) refusals, consent shown, denials
on the above, plus code issued and token issued

The setting is read forced on every request, so it takes effect on the next request rather than whenever a cache expires — a switch nobody can see take effect is a switch nobody trusts. A scaffolded application already has a settings screen that edits the table generically, so no interface work is needed to flip it.

Leave it off by default on purpose. usertokens already holds every issued row with its timestamp, user, application and scope, and user_activity_log already holds application_authorized; a user id and a destination per sign-in kept for ever is a retention decision rather than a diagnostic. Turn it on for the afternoon a complaint is open, and off again afterwards.

oauth.log is rotated automatically like every other log — see Logging.

4. Reading a user's permissions

Your application server (never the browser) calls the internal permissions endpoint with its own client credentials, as HTTP Basic (RFC 6749 §2.3.1):

GET /api/internal/permissions?user_id={id}[&organization_id={id}]
Authorization: Basic base64({client_id}:{client_secret})

Response — the user's effective grants for your application (rows with app_id = your application, or none):

{
  "user_id": 42,
  "app_id": 7,
  "permissions": [
    { "object_type": "invoice", "object_id": "*", "action": "read",  "grant": "allow", "conditions": null },
    { "object_type": "invoice", "object_id": "*", "action": "write", "grant": "allow", "conditions": [{ "location_id": [1, 2] }] }
  ]
}
  • grant is the resolved allow/deny: the higher priority decides, and a deny wins a tie.
  • conditions is ABAC context your app evaluates against the current request — a list of predicates, any one of which is enough. null means unconditional.
  • organization_id asks the tenant's question: a role that belongs to an organisation counts only within it, and only while the user is an active member. Without it every role the user holds counts. The response then carries organization_id too.
  • A client_id parameter, when sent, must be your own; asking for another client's is 403.

Cache this response per user. Do not call the endpoint on every request.


5. Declaring your capabilities (manifest)

So the server knows which resources/scopes and ABAC condition keys your app understands, push a capabilities manifest (typically from CI/CD):

PUT /api/internal/clients/{client_id}/capabilities
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json

{
  "resources": {
    "invoices": {
      "description": "Customer invoices",
      "scopes": { "read": "View invoices", "write": "Edit invoices" }
    }
  },
  "conditions": {
    "location_id": { "value_type": "int[]", "description": "Restrict to locations" }
  }
}

The server computes an MD5 of the manifest and short-circuits if it is unchanged. On change it upserts resources/scopes/conditions; anything absent from the new manifest is soft-deleted (is_active = false), never hard deleted. An unchanged manifest answers 200 with {"status":"unchanged","resources":0,"scopes":0,"conditions":0,"deactivated":0} — the server compares its own hash of the body, so there is nothing extra to send.

Authenticate with HTTP Basic (client_id:client_secret) or with client_id and client_secret as form fields. Either is fine — RFC 6749 §2.3.1 allows both.

POST is accepted as well as PUT, for a CI runner that has no PUT. The operation is idempotent either way.

The response reports what it did, and the counts are worth checking in CI:

{"status":"synced","resources":1,"scopes":2,"conditions":1,"deactivated":0}

Both scope shapes are accepted: the keyed map above, and a list whose entries carry their own name (and conditions their own key). A map's value is the description, never the name, so {"read": "View invoices"} declares a scope called read. If CI reports "resources":0 for a manifest that has resources, the server did not read them — check the shape.

Seeing what a client declared

An administrator opens the client's own page — /admin/Applications/view/{appid} — and reads its declared resources, the scopes on each, and the condition keys, with the manifest's hash and when it last arrived.

That page is the answer to the question a grant raises: a permission names a resource, so "which names does this client actually publish" has to be visible before anybody writes one.

Anything the client has stopped declaring is listed struck through rather than removed. A grant may still refer to it, and that is exactly what somebody is looking for when a permission has quietly stopped working.

The declaration also shapes the permission form: with the client's id as Application ID, a grant can only name a resource and an action the client declares, and grants that point at a withdrawn one are marked — see The Permissions screen.

Routing the internal endpoints

A project scaffolded with the authserver feature routes both internal endpoints in src/Api/routes.php, outside the version prefix — they are server-to-server plumbing, not the public API, and are left out of the OpenAPI document:

$router->group(['prefix' => '/internal'], function (\Pramnos\Routing\Router $r): void {
    $r->put('/clients/{clientId}/capabilities', fn ($clientId) => (new Api\Controllers\Capabilities($this))->sync($clientId));
    $r->post('/clients/{clientId}/capabilities', fn ($clientId) => (new Api\Controllers\Capabilities($this))->sync($clientId));
    $r->get('/permissions', fn () => (new Api\Controllers\InternalPermissions($this))->index());
});

The API layer asks every request for an apikey header unless its path is declared open, and these two authenticate the calling application by its own client credentials instead — so the scaffold declares them in app/app.php:

'public_api_paths' => ['/internal/clients/*/capabilities', '/internal/permissions'],

Without that line, a client that sends only its credentials gets 403 APIKeyMissing, and has to send its client id as apikey: <client_id> beside them.

A project without that group — routes.php is the project's own file and is not regenerated — adds it by hand, with an InternalPermissions wrapper beside Capabilities in src/Api/Controllers/. A route at /api/<version>/capabilities/sync is not one this guide publishes; once the clients use the internal path it can go.


6. Instant invalidation — webhooks

When a user's permissions change — a grant, a role given or taken, a role edited or deactivated, an organisation membership — the server queues a permissions_changed webhook to your registered webhook URL:

{ "subject_type": "user", "subject_id": 123, "operation": "role_assigned", "roleid": 7 }

On receipt, drop that user's cached permissions so the next request re-fetches from /api/internal/permissions. When subject_type is role, drop the cache of every user holding that role — or all of it, if you do not track who holds what.

It is sent to your application only when the change concerns it: a permission with your app_id, or a user — or a holder of the role — who uses your application, meaning an unexpired token or a recorded consent. A change about somebody who never signed in to your application does not reach you: you hold no cache for them. Webhook deliveries are HMAC-SHA256 signed and retried — verify the signature before acting.

This is what makes lightweight tokens safe: permissions change instantly without re-issuing or bloating tokens.

Registering an endpoint

Authenticate with your client credentials — the same pair you use at the token endpoint, as a Basic header or in the body:

POST /Webhook/register
Authorization: Basic base64(client_id:client_secret)

endpoint_url=https://your-app.example.com/hooks/auth
&webhook_type=token_revoked

{
  "webhook_type": "token_revoked",
  "endpoint_url": "https://your-app.example.com/hooks/auth",
  "secret": "whsec_…64 hex characters…",
  "signature": "X-Webhook-Signature: sha256=HMAC-SHA256(secret, body)"
}

Store the secret. It is returned once, by this call, and never shown again — an endpoint that hands out its own signing secret to anyone who can reach it is not signing anything. Lost it? Register the same type again; that replaces the URL and issues a new secret.

appid comes from your credentials and is never read from the request, so an application can only ever see and change its own endpoints.

Route Does
POST /Webhook/register Register or replace an endpoint for one event type
GET /Webhook/list Your endpoints — without the secrets
GET /Webhook/stats Delivery counts by status
POST /Webhook/test Queue a test event through the real pipeline
POST /Webhook/delete Remove one endpoint

The endpoint URL must be https:// unless the server says otherwise (require_https, below). The event describes a person and is signed with a shared secret; over plaintext on the internet both are readable by anything on the path, which makes the signature decorative.

Event types: user_deauthorized, token_revoked, gdpr_request, user_profile_changed, device_authorized, device_deauthorized, account_deleted, scope_changed, permissions_changed — plus any the server application registered as its own (below).

What fires each one, who receives it, and the body it carries. The body is JSON, signed as it is sent:

Type Fired by Sent to Body
user_deauthorized the user removing the application from their account (AuthorizedApplications::revoke()) that application event_type, user_id, application_id, tokens_revoked, revoked_by
token_revoked revoking one live token (User::deleteToken(), used by DELETE /me/tokens/{id}) the token's application event_type, user_id, token_id, token_type, application_id
an administrator signing a user out of everything (POST /gdpr/deauthorizeAll) the user's applications event_type, user_id, reason, revoked_count, revoked_by
gdpr_request a data export from the account page the user's applications event_type, user_id, request_type: export, requested_by
a request through POST /gdpr/request the user's applications event_type, user_id, request_id, request_type, requested_by
user_profile_changed the user saving their profile with a changed field the user's applications event_type, user_id, changed (field names, no values), changed_by
POST /gdpr/notifyChange the user's applications event_type, user_id, changes, changed_by
account_deleted the account being erased (AccountErasure::erase()), after the erase the applications the user had event_type, user_id, email, deleted_at
scope_changed the user granting an application scopes beyond its earlier consent that application event_type, user_id, application_id, scope, previous_scope, scopes_added
device_authorized the user approving a device sign-in the application that started the device flow user_code, client_id, scope
device_deauthorized the user refusing a device sign-in the application that started the device flow user_code, client_id, reason: user_denied
permissions_changed any write of a grant or a role assignment the applications of the people affected subject_type, subject_id, plus operation and context

"The user's applications" means the ones the user holds an unexpired token for or has consented to (WebhookService::userEvent()); an application the user never used is told nothing about them. account_deleted names the user in the body only, because the event row's user_id column points at the account that was just removed. A consent never shrinks by itself: it ends by being revoked, which is user_deauthorized. The device events never carry the device_code: with it, anybody could collect the tokens the user just approved. GET /Webhook/list returns the full set as supported_types. One endpoint per type per application.

POST /Webhook/test queues an event for the endpoint you named and no other. That matters because a real event does the opposite: token_revoked from an administrator's sign-out concerns every application holding a token for that user, so it goes to each of their endpoints subscribed to the type. A test ping is not a real event — it is traffic you asked to have sent — so it stops at your own URL. If you need the fan-out behaviour from your own code, call queueEvent() without the last argument:

$service = new \Pramnos\Auth\WebhookService($db);

$service->queueEvent('token_revoked', $userid, ['token_id' => 42]);       // every subscriber
$service->queueEvent('token_revoked', $userid, [...], null, null, $id);   // one endpoint

The endpoint id is still matched against the event type and is_active, so naming one that does not subscribe queues nothing rather than queueing the wrong event.

Sending events of your own

The delivery machinery — signing, retries, auth:webhook-deliver, the private-network refusal — does not care what an event means. An application running the server delivers its own events through it by registering their types, from a service provider's boot() or its Application.php:

use Pramnos\Auth\WebhookEvents;

WebhookEvents::register([
    'station.live'  => ['title' => 'A station went on the air', 'payload' => ['station_id', 'slug']],
    'track.changed' => ['title' => 'The track changed'],
]);

A registered type is offered on the subscription screen and in supported_types, and accepted by /Webhook/register. A type nobody registered is refused, with the list of accepted ones, by /Webhook/register, the administration form and WebhookService::saveEndpoint() — so a typo cannot become an endpoint that never fires. The framework's own types win a collision: their payloads are the framework's to write. A name is 1–50 letters, digits, ., _ or -; a dotted namespace keeps yours apart from the framework's.

Queue an event with queueEvent(). An event about no particular person passes null as the user:

$webhooks = new \Pramnos\Auth\WebhookService($db);
$webhooks->queueEvent('station.live', null, ['station_id' => 42, 'slug' => 'radio-one']);

That fans out to every application subscribed to the type. When an event concerns only some of them — the applications authorised for this station — name them, and nobody else hears it; an empty list queues nothing:

$webhooks->queueEventForApplications('station.live', null, $payload, $appIdsForThisStation);

Who is in scope is your decision; the framework only narrows the fan-out to the list.

Where a delivery may go

The relying party chooses the address and this server makes the request, so the address is checked. What is allowed depends on who set it.

An endpoint the application registered through /Webhook/register is judged at registration and again, pinned, at every delivery (Http\Client::forUserSuppliedUrl()): the host must resolve to a public address or to a range this installation allows. By default that includes the organisation's private network — RFC 1918, carrier-grade NAT (which is what Tailscale hands out) and IPv6 unique-local — because the usual deployment is an authorisation server and its relying parties on one VPN or LAN. It never includes loopback or link-local, where this server's own ports and the cloud metadata address (169.254.169.254) answer, unless a range naming them is listed.

allow_private => false really does exclude carrier-grade NAT. It is worth saying because PHP's own address filters do not: FILTER_FLAG_NO_PRIV_RANGE|NO_RES_RANGE calls 100.64.0.0/10 public, and the check is "public or in an allowed range" — so without help the Tailscale block would pass as public whatever the allow-list said. OutboundUrl::NEVER_PUBLIC_RANGES is the rest of what those flags miss: CGNAT, the IETF protocol block, the benchmarking block and multicast.

// app/app.php
'authserver' => [
    'webhooks' => [
        'allow_private'        => true,              // the default
        'allow_private_ranges' => [],                // CIDR ranges allowed in addition
        'require_https'        => true,              // the default; false allows http://
    ],
],
Configuration A client may register
nothing set public addresses and the private network
'allow_private' => false public addresses only
'allow_private' => false, 'allow_private_ranges' => ['10.8.0.0/24'] public addresses and that VPN, nothing else private
'allow_private_ranges' => ['127.0.0.1/32'] the above, plus this host
'require_https' => false any of the above over http:// as well

A refused registration answers endpoint_url resolves to an address inside this network. A name that does not resolve yet is accepted, because every delivery resolves it again.

require_https applies to both kinds of endpoint. Turn it off where the network is encrypted underneath — a VPN — or while receivers under development have no certificate. It is a setting of its own rather than a consequence of this server's development mode on purpose: the receivers are other people's applications, and one of them may be under development on somebody's own machine while this server is the live one.

Testing webhooks from an application on your own machine. The server has to be able to reach you, so the address is one of:

  • a tunnel (ngrok, Cloudflare Tunnel) — a public https:// URL, accepted as it is;
  • the VPN or LAN you share with the server — a private address, accepted by default; add 'require_https' => false if your receiver speaks plain http;
  • the server running on the same machine, in Docker — register http://host.docker.internal:<port>/…, which resolves to a private address, with require_https off in that server's configuration.

An endpoint an administrator entered on the application's page is delivered to as written — the operator's own statement about their network, needing no setting.

Either way, redirects are not followed: a receiver answering 30x has failed the delivery, and re-registers if it moved. A refused or failed delivery is recorded with Delivery refused or failed: … as its reason.

Managing endpoints from the administration area

The application's page — /admin/Applications/view/{appid} — lists its endpoints: the event type, the URL, who set it (Application or Administrator), and how its deliveries have gone (sent, pending, failed). From the same card an administrator can:

  • add an endpoint for an event type, which replaces one already there. It is approved as entered and recorded as the administrator's. A signing secret typed into the form is kept as typed (for a receiver that already has one configured). An empty field gets a generated one. The confirmation shows the secret, which must be handed to the receiving application;
  • show the secret of one endpoint, when the receiver has lost it. This is a POST with the session's token, so a link cannot reveal it, and it writes a webhook_secret_revealed activity-log entry for the administrator. The secret is stored encrypted and decrypted for display; a row stored in plain text before encryption shows as stored;
  • regenerate one endpoint's secret: the new one is shown, and the old one stops verifying at once;
  • remove one.

A generated secret is whsec_ followed by 64 hex characters (WebhookService::generateSecret()), whether it comes from this screen, from regenerating, or from /Webhook/register. An endpoint never has an empty secret: WebhookService::saveEndpoint() generates one when it is given '', because a delivery signed with an empty HMAC key proves nothing. An existing endpoint stored with an empty secret gets one the next time it is saved. WebhookService::endpointSecret($appId, $webhookId) reads a secret back.

An application that later registers the same event type through the API replaces the administrator's entry, and the address becomes the application's again — checked like any other it registers.

Verifying a delivery

$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $yourSecret);

// hash_equals, not ===: this compares against attacker-supplied input.
if (!hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

Pramnos\Auth\WebhookService::verifySignature() does the same thing if you are receiving on this framework.

If nothing arrives

Deliveries are made by the auth:webhook-deliver schedule, every five minutes — not at the moment the event happens. Check that the scheduler is running on the server:

php pramnos schedule:list | grep webhook
php pramnos auth:webhook-deliver -v      # run it by hand

GET /Webhook/stats is the other half of that answer: a pending count that only grows means events are being queued and nothing is sending them.


What a 401 from the webhook endpoints tells you

Both failures are invalid_client with status 401, and the difference between them is the whole diagnostic:

{"error": "invalid_client", "error_description": "Client credentials required"}

You sent nothing the endpoint could read — no Authorization: Basic, no client_id/client_secret in the body. Check how your client is attaching them.

{"error": "invalid_client"}

You sent credentials and they did not authenticate. There is no description on purpose: saying whether the id or the secret was wrong would let a client id be confirmed by trying it, the same reason a sign-in form does not say which half failed. Check the id and the secret together, and that the application is still registered and enabled.

So: a description means "nothing arrived", and no description means "what arrived was wrong".

7. Putting it together

Discovery → Register → Authorize (PKCE) → Token (identity only)
   → Fetch /api/internal/permissions → cache
   → on permissions_changed webhook → invalidate cache

The effective access your app enforces is:

Licensing/entitlement (your app)  ∩  RBAC + ABAC (from the server)

The server returns the RBAC∩ABAC set; any licensing/entitlement gate is applied by your application on top.


What the site tells a machine that arrives uninvited

Two generated files, alongside the .well-known documents above.

/robots.txt

Generated rather than shipped as a file, because the line that matters is derived from the installation's own URL — a static file in a scaffold is a static file with somebody else's domain in it.

It names twelve AI crawlers one by one: GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, Claude-User, Claude-SearchBot, PerplexityBot, Perplexity-User, Google-Extended, Applebot-Extended, CCBot, meta-externalagent. Every one reads robots.txt and honours it.

Absence is not neutrality. With nothing said each crawler decides for itself, and they decide differently — Google-Extended opts a site out of model training while leaving Search untouched, which is a distinction a site cannot express by staying silent.

// app/settings.php — one setting flips all twelve
'ai_crawler_policy' => 'disallow',   // default: allow

The default is allow, because a framework must not choose a site's licensing posture. What it must do is make the choice visible and settable.

The side-effect paths — /admin/, /oauth/, /account/, /devpanel/, /adminer — are Disallowed rather than left to noindex. noindex keeps a page out of an index after it has been fetched; on an authentication server every one of those either costs a session, sends mail, or answers differently per visitor.

/llms.txt

The GEO counterpart of a sitemap: a short markdown document saying what this site is and where the things worth reading are. A crawler follows links; a model arriving cold guesses, and guessing is how a site gets described wrongly and confidently.

It is deliberately short — the format's premise is that it fits in a context window beside the question somebody actually asked.

And it is where the MCP endpoint is announced. A model reading it learns the site has tools it can call and where to authenticate for them. Without it the endpoint is a service nobody discovers. It appears only once something has actually been offered through PublicRegistry, because an endpoint serving an empty list is not worth pointing anybody at.

Every project gets both, and the addresses in them are real

The rewrite rules and the controller behind them are scaffolded into every project, not only an authorization server: what a crawler may read is not a question about issuing tokens, and a site answering 404 at /robots.txt has not declined to have a policy — it has left the policy to whichever crawler asked.

The three addresses these files advertise are derived rather than written down, because a machine-readable file exists to be read by something that will not look around for the right URL — a wrong address in one is worse than an absent one:

Line Where it points How it is derived
Sitemap: in robots.txt the site's sitemap sitemap_url if set; otherwise the line is omitted unless a file is actually served at /sitemap.xml. The framework ships no sitemap generator, and a Sitemap: pointing at a 404 teaches a crawler that the site is broken
[Documentation] in llms.txt the generated API docs Api::baseUrl() + docs/ — <site>/api/docs/, which is where init writes them
MCP endpoint: in llms.txt the JSON-RPC endpoint the API's own prefix + /mcp — <site>/api/1.0/mcp, which is where the scaffolded route puts it
// app/settings.php — when this site does generate a sitemap, or generates it elsewhere
'sitemap_url' => 'https://cdn.example.com/sitemap-index.xml',