Skip to content

fix(auth): omit scope on refresh unless caller sets one - #1433

Open
no-hup wants to merge 2 commits into
modelcontextprotocol:mainfrom
no-hup:fix/oauth-refresh-scope
Open

no-hup wants to merge 2 commits into
modelcontextprotocol:mainfrom
no-hup:fix/oauth-refresh-scope

Conversation

@no-hup

@no-hup no-hup commented Nov 5, 2026

Copy link
Copy Markdown

fixes #1408

refresh was always sending scope from the stored grant, which some servers (salesforce) reject. rfc 6749 §6 says scope is optional on refresh and leaving it out keeps the original grant.

now refresh omits scope unless the caller sets it via AuthorizationManager::set_refresh_scopes(). when set, only scopes from the original grant get sent (keeps the #1331 behaviour). if the token response has no scope, granted scopes stay as the fallback.

added refresh_token_omits_scope_unless_caller_sets_one. existing refresh tests now set scopes explicitly. fmt + clippy clean, rmcp lib tests 792 passed.

I'm @no-hup. I spotted this from the issue. Claude Opus wrote the fix with Grok's help, I reviewed the diff and ran the tests.

🤖 Generated with Claude Code

@no-hup
no-hup requested a review from a team as a code owner November 5, 2026 13:41
@github-actions github-actions Bot added T-core Core library changes T-transport Transport layer changes labels Nov 5, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T12:50:23.829665Z f2df667 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions
github-actions Bot requested a review from jamadeo November 5, 2026 13:41
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

@jamadeo is on triage rotation this week and is the reviewer for this one.

Labels come from the PR labeler. Please review the change.

Automated triage. Labels are a first pass and the assigned maintainer has the final say.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e982f62091

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +2604 to +2606
let granted_scopes = stored_credentials.granted_scopes.clone();
let scopes_to_send =
Self::refresh_request_scopes(self.refresh_scopes.as_deref(), &granted_scopes);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the original grant when filtering refresh scopes

When the original grant is read write, one configured refresh can narrow it to read, and refresh_token then saves that latest access-token scope as stored_credentials.granted_scopes. If the caller later sets refresh scopes to write, these lines pass only read to refresh_request_scopes, which filters out the valid write; the empty result makes the next request omit scope, so a compliant server restores the full read write grant instead of issuing the requested write-only token. Preserve the refresh token's original grant separately (or reject rather than turning an empty filtered set into omission).

Useful? React with 👍 / 👎.

Comment on lines +1596 to +1597
/// RFC 6749 §6 makes `scope` optional. Refresh omits it unless the caller
/// sets one here. A scope that was not part of the original grant is not

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Document the refresh-scope opt-in in the OAuth guide

When using a provider that requires scope on refresh, the new default now omits it, but docs/OAUTH_SUPPORT.md:455 still promises that previously granted scopes are forwarded automatically and names Azure AD v2 as requiring this. The recommended OAuthState walkthrough never calls this setter, so users following the guide can start failing refreshes after upgrading; update the guide/example to describe the opt-in.

Useful? React with 👍 / 👎.

@github-actions github-actions Bot added the T-documentation Documentation improvements label Nov 6, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f2df667513

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +2155 to +2159
Self::dedup_scopes(
explicit
.iter()
.filter(|scope| granted_set.contains(scope.as_str()))
.cloned()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject refresh scope sets that filter to empty

When the original grant is read write and the caller sets set_refresh_scopes(["admin"]) (or simply has a typo), this filter returns an empty vector. The downstream empty-vector path omits scope, which this implementation treats as a request for the full original read write grant, so the invalid narrowing silently yields a more privileged token instead of failing. Distinguish a nonempty explicit request that filtered to empty from an intentionally empty setting; the new stored_grant branch only fixes the earlier valid read-to-write sequence and does not distinguish this all-ungranted case.

Useful? React with 👍 / 👎.

Comment on lines +2665 to +2668
let stored_grant = if original_grant.is_empty() || self.refresh_scopes.is_none() {
granted_scopes
} else {
original_grant

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the grant across unscoped narrowed responses

When an unscoped refresh for an original read write grant returns an explicit narrower scope: read and no new refresh token, the same refresh token still carries the original grant, but this self.refresh_scopes.is_none() arm replaces the stored grant with read. A later set_refresh_scopes(["write"]) is then filtered against the wrong set and cannot send the still-valid scope (and a provider that requires scope will fail the request). The new Some arm handles caller-requested narrowing, but the None arm still needs the refresh-token grant stored separately from the current access-token scopes.

Useful? React with 👍 / 👎.

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

T-core Core library changes T-documentation Documentation improvements T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth: refresh_token() always sends scope, so refresh fails against Salesforce ("scope parameter not supported")

1 participant