Skip to content

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

  • GrantPolicy reads each row of applications.oauth2_application_grants as an override of its one grant; a grant without a row follows OAuthPolicyHelper::getDefaultAllowedGrantTypes(). Before, one row replaced the defaults outright, so GrantPolicy::enable($appId, 'jwt_bearer') on an application with no rows silently took away authorization_code and refresh_token. Now it adds jwt_bearer and nothing else, and GrantPolicy::disable($appId, 'password') removes only password. 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_grants and authserver.default_auth_methods in app.php replace the built-in lists OAuthPolicyHelper::getDefaultAllowedGrantTypes() and getDefaultAllowedAuthMethods() return. Without them nothing changes. jwt_bearer and none listed there are dropped with an E_USER_WARNING: neither is ever a default.
  • init with the authserver feature 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/token with grant_type=password neither counted failures toward Loginlockout nor refused a locked account, so it allowed unlimited password guessing beside a login form that locked after three. UserRepository now uses the form's 'identifier' counter: a failure counts, a success clears it, and a locked account gets 400 invalid_grant with Retry-After before 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 //host and 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 answered 302 to it. It now uses the check the DevPanel already had, DevPanelController::isReturnable(): a URL under sURL with a boundary after it, or a path with one leading slash and no scheme. A look-alike host such as https://<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 under sURL, 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_code in 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 the device_code out.

Added

  • device_authorized is a built-in webhook type (WebhookEvents::builtIn(), WebhookService::EVENT_TYPES), so an application can subscribe to it; /device queued it already, and nobody could register for it.

Documentation

  • AuthServer Integration Guide, webhooks: device_authorized and 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/revoke and /oauth/introspect with its client_id alone, 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_credentials stays refused to it.
  • /oauth/authorize requires PKCE (code_challenge) from a public client.
  • /oauth/introspect authenticates the client as /oauth/revoke does — a client assertion (private_key_jwt) is accepted, the application's authentication methods apply, and a bare client_id only 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 bare client_id is 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_scopes in app.php: scopes every client may request beside its own Allowed Scopes, at /oauth/authorize, /oauth/token and in every grant (Application::scopesBeyondClient(), OAuthPolicyHelper::getDefaultAllowedScopes()). Without the key nothing changes. A system: scope listed there is dropped with an E_USER_WARNING. init writes 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 /login or /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's action now 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 $authLink without 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 $resendIn before defining it (a warning, and always «1 seconds»), and the Bootstrap and plain-CSS views had no message for it and showed the key email_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.