9 October 2026¶
7 changes:
- A grant for an application speaks that application's vocabulary
- A client assertion is checked in full, and accepted once
- Revocation authenticates the client, and reaches refresh tokens
- An application can sign its user out and have them sent back
- The device, jwt-bearer and token-exchange grants
- One controller's legacy-ACL answer is no longer every controller's
- What each application may do, and how much
A grant for an application speaks that application's vocabulary¶
An application declares its resources, scopes and condition keys with
PUT /api/internal/clients/{client_id}/capabilities, and the only reader of that declaration
was the application's own page. The permission form took Object type and Action as free
text, so invoice for invoices, or View invoices for read, was stored and matched nothing,
with no message.
Added¶
CapabilitiesSyncService::catalog()— every application's declared resources, scopes and conditions, active and withdrawn;vocabulary()— the active part, for a form;problemWith()/problemsIn()— why a grant cannot match its application's declaration.- The permission form: with an Application ID that declared capabilities, Object type
offers its active resources, Action the chosen resource's scopes plus
*, and Conditions lists the condition keys with their value types. - The permissions list and a role's page mark a grant whose resource or scope its application no longer declares, or never did, with the reason.
Changed¶
- Saving a grant for such an application with an undeclared object type or action is refused, with the reason. No application, or one that declared nothing: free text, as before.
initwith theauthserverfeature routesPUT/POST /api/internal/clients/{clientId}/capabilitiesandGET /api/internal/permissionsoutside the version prefix, at the paths the integration guide publishes, with anInternalPermissionsAPI wrapper. It no longer writesPOST /api/<version>/capabilities/sync[/{clientId}]or puts it in the OpenAPI overrides. An existing project'sroutes.phpis its own; the guide shows the group to add.- It also declares both paths in
public_api_paths, so the API key check in front of them does not answer403 APIKeyMissingto a client that authenticates with its credentials, as the guide publishes.
Documentation¶
- AuthServer Integration Guide §5: the example authenticates with HTTP Basic
(
client_id:client_secret), as the text and the controller do — it showed a Bearer token. The section's history notes are replaced by what holds now. - Authorization Guide, The Permissions screen: the vocabulary, the refusal and the marks.
- Upgrade Guide: the refusal, and republishing the
permissionsandrolesviews.
A client assertion is checked in full, and accepted once¶
A client that authenticates to /oauth/token with a signed assertion (private_key_jwt,
RFC 7523) had the signature, sub and exp checked, and nothing else. An assertion caught in
transit could be presented again until it expired, however far away that was; and one made for
another server that trusts the same key was accepted here.
Fixed¶
- The assertion must name the client as
iss, this server's token endpoint or issuer asaud, carryiatandexpno more than 300 seconds apart, and ajti. Eachjtiis remembered per client until the assertion expires, and presenting it again is refused. Remembering is the insert into a unique key, so two racing requests cannot both pass. - The token issued this way was stored without its lookup, so
introspectansweredactive: falsefor it andrevokedid nothing. - A refused assertion says why, in
error_description.
Added¶
- ES256/384/512 assertions, and a client's
jwks_urias the key source when it has nopublic_key, the key chosen bykid. Pramnos\Auth\OAuth2\JwtAssertion, the check, for any endpoint that accepts client assertions.
Revocation authenticates the client, and reaches refresh tokens¶
/oauth/revoke asked nothing of its caller: anybody holding a token could revoke it, and
RFC 7009 has the server authenticate the client and check the token was issued to it. And a
refresh token could not be revoked at all — it is League's encrypted payload, stored by the id
inside it, and the lookup used the value as presented — while the endpoint answered
{"success": true} regardless.
Fixed¶
- The client authenticates as at the token endpoint (a secret, a client assertion, or a public
client's
client_id) and revokes only its own tokens:401 invalid_clientwithout it,400 unauthorized_clientfor another client's token. - A refresh token is found by the id it carries, and revoking it revokes the access token
issued with it.
token_type_hint=refresh_tokenlooks there first. /oauth/introspectreports a refresh tokenactive; it answeredfalsefor every one.
Added¶
- The authorization server metadata lists
private_key_jwt, the assertion algorithms, and the revocation endpoint's authentication methods.
An application can sign its user out and have them sent back¶
The discovery document named end_session_endpoint as /logout, which answered 404, and the
browser sign-out always ended on the sign-in page — so an application had no way to sign its
user out of the server and get them back.
Added¶
/login/logoutis the OpenID Connect end-session endpoint (RP-Initiated Logout 1.0):id_token_hint,client_id,post_logout_redirect_uri,stateandlocal=1. The return address must be one of the client's registered redirect URIs or its homepage, matched exactly. With a valid hint, the user's tokens for that client are revoked unlesslocal=1. Discovery pointsend_session_endpointthere.Pramnos\Auth\OAuth2\EndSessionmakes the decision.
Fixed¶
- The consent screen's "use a different account" link sent its own address as
redirect_uri, which the sign-out ignored, so the authorization request was lost. A site-relativeredirect_uriis now the next sign-in's return address.
The device, jwt-bearer and token-exchange grants¶
The token endpoint enabled four grants. The discovery document advertised the device flow, and
/oauth/deviceauthorization and the /device page wrote their rows — but no grant read them,
so a device could start the flow and never finish it. The jwt-bearer grant and token exchange
were grant types an application could be given in policy, with nothing behind them.
Added¶
urn:ietf:params:oauth:grant-type:device_code(RFC 8628):authorization_pending,slow_down,access_denied,expired_token, and the tokens once per device code — claimed by one request even when two polls race.device_codeis accepted as its name too.urn:ietf:params:oauth:grant-type:jwt-bearer(RFC 7523 §2.1): a token for the user an assertion signed with the client's key names. Opt-in per application.urn:ietf:params:oauth:grant-type:token-exchange(RFC 8693), andexchange_token: an active access token of the client for a sixty-day one without a refresh token, revoking the one presented.GrantPolicyreads and writesapplications.oauth2_application_grants; the three grants check it. Each also accepts a client assertion as client authentication.OAuth2ServerFactory::GRANT_TYPES, which the discovery documents now list.- Migrations:
last_polled_atonauthserver.oauth2_device_codes;jwt_bearerin the grant policy table's CHECK.
One controller's legacy-ACL answer is no longer every controller's¶
ApiCrudController::legacyAclExists() asks once per request whether the legacy ACL table
exists, and kept the answer in a static inside the method. PHP shares such a static with every
subclass, so the first controller to ask answered for all of them, whichever table
(legacyAclTable()) or connection (db()) they named.
Fixed¶
- The answer is kept per connection and table. A failed check is not kept at all.
What each application may do, and how much¶
The tables for an application's grant types, client authentication methods and limits existed, and no screen edited them; at runtime only three grants read the first, and nothing read the others. The application's edit screen now has an Access & limits tab, and every setting on it is enforced.
Added¶
- Grant types and client authentication, checked at
/oauth/tokenfor every grant (the four League grants included) and at/oauth/revokeand/oauth/introspect. A selection equal to the defaults stores nothing; an empty one means none. - Rate limit: a token bucket per application — the burst at once, then requests per window.
429 TooManyRequestswithRetry-After;X-RateLimit-LimitandX-RateLimit-Remainingon every answer. Atomic on Redis (one Lua script).Pramnos\Http\TokenBucket. - Require HTTPS and the IP lock (allowed and blocked addresses and ranges, IPv4 and
IPv6), on the API and the OAuth endpoints.
Helpers::ipInRange(). - Browser origins: with the restriction on, a request whose
Originis not listed is refused. - Pagination for every list built on
ApiListQuery: a request for every row becomes the first page at the default size where it is enforced, and no page exceeds the maximum. ApplicationSettings,ClientPolicy,GrantPolicy::setGrants()/effective()/useDefaults(),ApplicationService::policy()/updatePolicy(), andApplicationPolicyMiddlewarein the API pipeline.
Changed¶
- The defaults apply to every application that has none of its own: 1000 requests an hour, bursts of 100, HTTPS required. Pagination stays unenforced until an application's settings are saved. See the Upgrade Guide before upgrading.
passwordis one of the default grant types, so an application signing users in with it keeps working now that every grant is checked.