Repository navigation
[Client] Fix step-up with setScopes() and tokens granted without offline_access - #629
nicolas-grekas wants to merge 4 commits into
Conversation
With setScopes(), an insufficient_scope challenge made the client refresh the token, which came back with the same scopes, until the attempts ran out.
Keycloak accepts offline_access at the authorization endpoint, and only refuses it when the code is exchanged, with not_allowed, for a user who may not have offline tokens. Once refused, the client also stops asking for it.
The client checked the stored token against the scopes it asked for, offline_access included. So when the authorization server did not grant offline_access, the token was never reused nor refreshed, and the user had to log in again.
There was a problem hiding this comment.
🟡 Changes recommended
The OAuth fallback fails to remove refused offline_access scopes and treats unrelated HTTP 400 errors as scope refusals, causing repeated logins and incorrect refusal state.
2 open issues
What changed in this PR
This PR improves the client's OAuth handling for scope step-up challenges and servers that refuse offline_access, including Keycloak.
Changes:
- Adds challenged scopes to those configured with
setScopes(). - Extends the offline-access fallback to token-endpoint HTTP 400 errors and records refusals.
- Allows stored-token reuse and refresh without
offline_access, with regression tests.
| File | Description |
|---|---|
tests/Unit/Client/Auth/TokenEndpointTest.php |
Verifies that exceptions preserve the HTTP status. |
tests/Unit/Client/Auth/OAuthAuthenticatorTest.php |
Tests step-up, offline-access fallback, token reuse, and refresh. |
src/Client/Auth/TokenEndpoint.php |
Includes HTTP status codes in authorization exceptions. |
src/Client/Auth/OAuthAuthenticator.php |
Updates scope selection, fallback, and stored-token handling. |
src/Client/Auth/OAuth.php |
Clarifies setScopes() documentation for step-up challenges. |
docs/client/authorization.md |
Documents how step-up extends configured scopes. |
Review effort: Balanced
| // A refresh token is only worth asking for where the authorization server says | ||
| // it understands the request; sending an unknown scope is an error, not a hint. | ||
| if ($this->configuration->offlineAccess | ||
| && !$this->offlineAccessRefused |
There was a problem hiding this comment.
Fixed in fe8d5e0: once refused, offline_access is removed from the merged set, also when setScopes() or scopes_supported lists it.
| $reduced = array_values(array_diff($scopes, ['offline_access'])); | ||
|
|
||
| if ($reduced === $scopes || !str_contains($e->getMessage(), 'invalid_scope')) { | ||
| if ($reduced === $scopes || (400 !== $e->getCode() && !str_contains($e->getMessage(), 'invalid_scope'))) { |
There was a problem hiding this comment.
Fixed in fe8d5e0: the fallback now only applies to invalid_scope and not_allowed, so an expired code or a bad secret fails the flow. Keycloak 26.8 answers 400 not_allowed for a user who may not have offline tokens, see TokenManager.java.
An expired code or a bad client secret is also a 400. The flow now fails on them instead of starting another login. Once refused, offline_access is left out even when setScopes() or the resource's scopes_supported lists it.

I ran
feat-client-authagainst the MCP server of the Symfony showcase app, behind Keycloak 26.8, and it needed three fixes, one commit each:setScopes(), a 403insufficient_scopemade the client refresh the token three times. The new token had the same scopes, so the call failed. The challenged scope is now added to the configured ones.offline_accessat the authorization endpoint. Then it refuses the code with400 not_allowedwhen the user may not have offline tokens. The fallback only looked forinvalid_scope, so the login failed with the default settings. It now also retries afternot_allowed, and the client stops asking foroffline_accessonce refused.offline_accessincluded. When the server didn't grant that scope, a valid token was never reused nor refreshed, and the user had to log in again.@chr-hertel the branch conflicts with
mainin 7 files now. Do you plan to open its PR soon? I can do the rebase if you want.