10 October 2026¶
Eight entries today:
- A grant row overrides its own grant, not the defaults
- Default grants and client authentication methods in
app.php - The password grant honours the login lockout
- No redirect off the site after sign-in
- Device sign-in events reach only the device's application
- Public clients need not send the secret they ship with
- Default scopes in
app.php - A refresh after a failed sign-in keeps the destination
A grant row overrides its own grant, not the defaults¶
Fixed¶
GrantPolicyreads each row ofapplications.oauth2_application_grantsas an override of its one grant; a grant without a row followsOAuthPolicyHelper::getDefaultAllowedGrantTypes(). Before, one row replaced the defaults outright, soGrantPolicy::enable($appId, 'jwt_bearer')on an application with no rows silently took awayauthorization_codeandrefresh_token. Now it addsjwt_bearerand nothing else, andGrantPolicy::disable($appId, 'password')removes onlypassword. Applications saved from the Access & limits tab are unaffected: that form writes a row for every grant.
Documentation¶
- AuthServer Integration Guide, Grant policy: per-grant overrides, every grant checked.
Default grants and client authentication methods in app.php¶
Added¶
authserver.default_grantsandauthserver.default_auth_methodsinapp.phpreplace the built-in listsOAuthPolicyHelper::getDefaultAllowedGrantTypes()andgetDefaultAllowedAuthMethods()return. Without them nothing changes.jwt_bearerandnonelisted there are dropped with anE_USER_WARNING: neither is ever a default.initwith theauthserverfeature writes both keys, equal to the built-in lists.
Documentation¶
- AuthServer Integration Guide, Grant policy: the two keys.
The password grant honours the login lockout¶
Fixed¶
- Security.
/oauth/tokenwithgrant_type=passwordneither counted failures towardLoginlockoutnor refused a locked account, so it allowed unlimited password guessing beside a login form that locked after three.UserRepositorynow uses the form's'identifier'counter: a failure counts, a success clears it, and a locked account gets400 invalid_grantwithRetry-Afterbefore its password is checked.
Documentation¶
- Authentication Guide, Login Lockout: the password grant.
No redirect off the site after sign-in¶
Fixed¶
- Security.
Account::sanitizeReturnUrl()refused//hostand other sites'https://, but let through/\evil.example/— which browsers read as//evil.example/— and anything with a scheme and no slashes,javascript:alert(1)among them. After a correct password the sign-in screen answered302to it. It now uses the check the DevPanel already had,DevPanelController::isReturnable(): a URL undersURLwith a boundary after it, or a path with one leading slash and no scheme. A look-alike host such ashttps://<yours>.evil.example/is refused as well. Every post-login redirect reads?return=through it. - The first version of that fix also refused a relative return —
account/security, which is what a protected action sends a guest to sign in with — so every protected page lost its destination and signing in landed on the site root. A relative path without a scheme is now made absolute undersURL, which is where the browser resolved it before.
Documentation¶
- Security Guide, A URL is on your site only if the next character is a boundary: return paths.
Device sign-in events reach only the device's application¶
Fixed¶
- Security. Approving or refusing a device sign-in queued its webhook to every endpoint
subscribed to the type, whichever application owned it, with the
device_codein the payload — enough for another application to collect the tokens the user had just approved. Both events now go to the device's own application (queueEventForApplications()) and leave thedevice_codeout.
Added¶
device_authorizedis a built-in webhook type (WebhookEvents::builtIn(),WebhookService::EVENT_TYPES), so an application can subscribe to it;/devicequeued it already, and nobody could register for it.
Documentation¶
- AuthServer Integration Guide, webhooks:
device_authorizedand who receives device events.
Public clients need not send the secret they ship with¶
Changed¶
- A public client (
is_confidential = 0) may authenticate at/oauth/token,/oauth/revokeand/oauth/introspectwith itsclient_idalone, even with a secret on file: every copy of the app has that secret, so it proves nothing. A secret it does send is still checked.client_credentialsstays refused to it. /oauth/authorizerequires PKCE (code_challenge) from a public client./oauth/introspectauthenticates the client as/oauth/revokedoes — a client assertion (private_key_jwt) is accepted, the application's authentication methods apply, and a bareclient_idonly from a client that cannot keep a secret. It accepted only a secret before.- The internal endpoints (
ClientCredentialsAuthTrait) require a non-empty secret: they act for an application, and a public client's bareclient_idis no proof of one.
Documentation¶
- AuthServer Integration Guide: Confidential and public, in practice, Public clients, and revocation/introspection.
Default scopes in app.php¶
Added¶
authserver.default_scopesinapp.php: scopes every client may request beside its own Allowed Scopes, at/oauth/authorize,/oauth/tokenand in every grant (Application::scopesBeyondClient(),OAuthPolicyHelper::getDefaultAllowedScopes()). Without the key nothing changes. Asystem:scope listed there is dropped with anE_USER_WARNING.initwrites the key, empty. An API key's scopes are still compared without it (scopesBeyond()).
Documentation¶
- AuthServer Integration Guide, Grant policy:
default_scopes.
A refresh after a failed sign-in keeps the destination¶
Fixed¶
- The scaffolded sign-in and two-step forms (all three themes) posted to a bare
/loginor/verify, carrying the return address only in a hidden field. After a wrong password the address bar showed that bare URL, so a refresh, a restored tab or a bookmark loaded the form without it and the next sign-in landed on the dashboard. Each form'sactionnow carries?return=too;Account::returnUrl()already read it there. Copy the views into an application scaffolded earlier to get the fix. - The Tailwind two-step view read
$authLinkwithout defining it: a warning on every render, and the emailed sign-in link never offered. - Asking for another emailed code too soon: the Tailwind two-step view formatted the wait with
$resendInbefore defining it (a warning, and always «1 seconds»), and the Bootstrap and plain-CSS views had no message for it and showed the keyemail_code_wait. All three now say how many seconds are left.
Documentation¶
- Security Guide: the sign-in forms carry the return address in their URL.