Skip to content

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.
  • init with the authserver feature routes PUT/POST /api/internal/clients/{clientId}/capabilities and GET /api/internal/permissions outside the version prefix, at the paths the integration guide publishes, with an InternalPermissions API wrapper. It no longer writes POST /api/<version>/capabilities/sync[/{clientId}] or puts it in the OpenAPI overrides. An existing project's routes.php is 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 answer 403 APIKeyMissing to 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 permissions and roles views.

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 as aud, carry iat and exp no more than 300 seconds apart, and a jti. Each jti is 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 introspect answered active: false for it and revoke did nothing.
  • A refused assertion says why, in error_description.

Added

  • ES256/384/512 assertions, and a client's jwks_uri as the key source when it has no public_key, the key chosen by kid.
  • 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_client without it, 400 unauthorized_client for 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_token looks there first.
  • /oauth/introspect reports a refresh token active; it answered false for 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/logout is the OpenID Connect end-session endpoint (RP-Initiated Logout 1.0): id_token_hint, client_id, post_logout_redirect_uri, state and local=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 unless local=1. Discovery points end_session_endpoint there.
  • Pramnos\Auth\OAuth2\EndSession makes 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-relative redirect_uri is 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_code is 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), and exchange_token: an active access token of the client for a sixty-day one without a refresh token, revoking the one presented.
  • GrantPolicy reads and writes applications.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_at on authserver.oauth2_device_codes; jwt_bearer in 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/token for every grant (the four League grants included) and at /oauth/revoke and /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 TooManyRequests with Retry-After; X-RateLimit-Limit and X-RateLimit-Remaining on 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 Origin is 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(), and ApplicationPolicyMiddleware in 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.
  • password is one of the default grant types, so an application signing users in with it keeps working now that every grant is checked.