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:
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:
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:
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:
| 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.
/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?¶
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:
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:
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:
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:
openidfor a sign-in andmcpto 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:
- 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'sclient_idalone, and check a secret only if one is sent. The authorization endpoint requires PKCE from it. - No secret is stored (
apisecretempty 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:
- Somebody builds an
/oauth/authorizelink with the client'sclient_id— a public identifier, visible in every copy of the app — and aredirect_uriof their own. - 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.
- 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/authorizerefuses anyredirect_urithat 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-actionpolicy 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.
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
applicationstable 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''(or0for a number). - Input has the administration form's fields and defaults.
trusted,access_token_ttlandrefresh_token_ttlchange 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 legacyvarchar(255)column is refused with the fix.create()andupdate()throwInvalidArgumentExceptioncarrying the sentencevalidate()returns. It is plain text, so escape it if you render it as HTML. - The client secret is stored hashed.
create()androtateSecret()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:
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] }] }
]
}
grantis the resolved allow/deny: the higher priority decides, and a deny wins a tie.conditionsis ABAC context your app evaluates against the current request — a list of predicates, any one of which is enough.nullmeans unconditional.organization_idasks 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 carriesorganization_idtoo.- A
client_idparameter, when sent, must be your own; asking for another client's is403.
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:
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:
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:
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:
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' => falseif your receiver speaks plainhttp; - the server running on the same machine, in Docker — register
http://host.docker.internal:<port>/…, which resolves to a private address, withrequire_httpsoff 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_revealedactivity-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:
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:
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.
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:
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',