Skip to content

[Client] Fix step-up with setScopes() and tokens granted without offline_access - #629

Open
nicolas-grekas wants to merge 4 commits into
modelcontextprotocol:feat-client-authfrom
nicolas-grekas:client-auth-keycloak
Open

nicolas-grekas wants to merge 4 commits into
modelcontextprotocol:feat-client-authfrom
nicolas-grekas:client-auth-keycloak

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

I ran feat-client-auth against the MCP server of the Symfony showcase app, behind Keycloak 26.8, and it needed three fixes, one commit each:

  1. With setScopes(), a 403 insufficient_scope made 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.
  2. Keycloak accepts offline_access at the authorization endpoint. Then it refuses the code with 400 not_allowed when the user may not have offline tokens. The fallback only looked for invalid_scope, so the login failed with the default settings. It now also retries after not_allowed, and the client stops asking for offline_access once refused.
  3. A stored token was checked against the requested scopes, offline_access included. 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 main in 7 files now. Do you plan to open its PR soon? I can do the rebase if you want.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread src/Client/Auth/OAuthAuthenticator.php Outdated
// 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fe8d5e0: once refused, offline_access is removed from the merged set, also when setScopes() or scopes_supported lists it.

Comment thread src/Client/Auth/OAuthAuthenticator.php Outdated
$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'))) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Client Issues & PRs related to the Client component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants