Skip to content

Fix Azure OAuth token refresh with unprefixed scopes#2462

Merged
jlowin merged 5 commits intoPrefectHQ:mainfrom
Neet-Nestor:main
Nov 22, 2025
Merged

Fix Azure OAuth token refresh with unprefixed scopes#2462
jlowin merged 5 commits intoPrefectHQ:mainfrom
Neet-Nestor:main

Conversation

@Neet-Nestor
Copy link
Copy Markdown
Contributor

@Neet-Nestor Neet-Nestor commented Nov 20, 2025

Azure OAuth token refresh was failing because the proxy was sending unprefixed scopes (e.g., read) during refresh, which is inconsistent with how we process initial authentication token exchange. Azure AD requires fully-qualified scopes (e.g., api://xxx/read) for both authorization and token refresh.

The Problem

After successful initial authorization, the first token refresh would fail with:

DEBUG    Refreshing upstream token (jti=nL_kxmjt)
DEBUG    _upstream_token_endpoint: https://login.microsoftonline.com/.../oauth2/v2.0/token
DEBUG    scopes: ['read']
ERROR    Upstream token refresh failed: invalid_grant: AADSTS65001: The user or administrator
       has not consented to use the application with ID 'xxx'

The issue: during initial authorization, scopes were correctly prefixed (api://xxx/read User.Read openid profile offline_access), but during refresh, only the unprefixed base scope (read) was sent.

The Solution

Introduced a hook method _prepare_scopes_for_upstream_refresh() in OAuthProxy that allows provider-specific scope transformation before sending to upstream token endpoints, while keeping base scopes unprefixed in storage.

The Azure provider now overrides this to:

  1. Prefix base scopes with identifier_uri (e.g., read → api://xxx/read)
  2. Add prefixed Microsoft Graph scopes from additional_authorize_scopes (e.g., User.Read, openid, profile, offline_access)
  3. Filter out any already-present additional scopes to prevent accumulation (otherwise additional_authorize_scopes will show in stored token's base scopes next time and the scope string will grow longer and longer).

This approach maintains backward compatibility—unprefixed scopes remain stored in tokens, and prefixing happens dynamically only when sending to Azure.

# Before: scopes sent to Azure: ['read']
# After: scopes sent to Azure: ['api://xxx/read', 'User.Read', 'openid', 'profile', 'offline_access']

Contributors Checklist

Review Checklist

  • I have self-reviewed my changes
  • My Pull Request is ready for review

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. labels Nov 20, 2025
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 20, 2025

Walkthrough

A new hook method \_prepare_scopes_for_upstream_refresh was added to the OAuth proxy and is invoked by exchange_refresh_token to transform scopes before calling the upstream token endpoint. The Azure provider adds \_prefix_scopes_for_azure and its own \_prepare_scopes_for_upstream_refresh, uses the prefix helper when building upstream authorize URLs, and implements refresh-time scope preparation by filtering configured extra scopes, prefixing unqualified scopes with the provider identifier URI, appending additional authorize scopes, deduplicating while preserving order, and logging input and output scope lists.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing Azure OAuth token refresh by addressing the issue of unprefixed scopes being sent to the upstream token endpoint.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed The PR description is comprehensive and provides clear context, problem explanation, solution details, and examples of the fix, though the first Contributors Checklist item is unchecked.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/fastmcp/server/auth/providers/azure.py (1)

360-400: Azure-specific scope transformation for refresh is correct; consider minor hardening

The logic here matches the design goal:

  • Treats scopes as unprefixed client/base scopes from storage.
  • Strips out any additional_authorize_scopes that may have leaked into storage.
  • Prefixes remaining client scopes with identifier_uri unless they already look fully qualified.
  • Re-appends additional_authorize_scopes so Azure still receives Graph/OIDC scopes on refresh.
  • Leaves stored scopes unchanged, so client-facing behavior remains backward compatible.

This should fix the invalid_grant on Azure refresh while preventing unbounded scope growth.

Two optional refinements you might consider (non-blocking):

  • Deduplicate prefixed_scopes before returning, in case older tokens already contain prefixed variants (avoids redundant entries in the scope string).
  • Centralize the prefixing heuristic in a small helper shared with _build_upstream_authorize_url so future Azure scope rules stay in sync across authorize and refresh paths.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b3bce0c and 9e28da1.

📒 Files selected for processing (2)
  • src/fastmcp/server/auth/oauth_proxy.py (2 hunks)
  • src/fastmcp/server/auth/providers/azure.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/fastmcp/server/auth/oauth_proxy.py (1)
src/fastmcp/server/auth/providers/azure.py (1)
  • _prepare_scopes_for_upstream_refresh (360-400)
src/fastmcp/server/auth/providers/azure.py (1)
src/fastmcp/server/auth/oauth_proxy.py (1)
  • _prepare_scopes_for_upstream_refresh (1276-1291)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run tests: Python 3.10 on windows-latest
  • GitHub Check: label-issue-or-pr
🔇 Additional comments (3)
src/fastmcp/server/auth/providers/azure.py (1)

9-13: Imports for type annotations are consistent with usage

The added imports (Any, AuthorizationParams, OAuthClientInformationFull) match how authorize() is typed later in the file and improve clarity without changing behavior.

src/fastmcp/server/auth/oauth_proxy.py (2)

1276-1292: Hook for provider-specific refresh scopes is well-scoped and backward compatible

The new _prepare_scopes_for_upstream_refresh hook is clearly documented, and returning scopes unchanged in the base implementation preserves existing behavior for all current providers. It gives derived providers (like Azure) a precise interception point without altering the contract of exchange_refresh_token.


1353-1357: Using transformed scopes only for the upstream call cleanly isolates provider behavior

Deriving upstream_scopes via _prepare_scopes_for_upstream_refresh(scopes) and using only those for the upstream scope parameter while keeping the original scopes for FastMCP-issued tokens achieves the intended separation:

  • Providers can adjust what Azure/others see during refresh (e.g., prefix + add Graph/OIDC).
  • Stored/advertised scopes for MCP clients remain the base, untransformed set.

This wires the hook into the refresh flow without regressing other providers.

Also applies to: 1363-1363

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/fastmcp/server/auth/providers/azure.py (1)

394-394: Use lazy logging for better performance.

The f-string formatting in debug log statements evaluates even when debug logging is disabled, creating unnecessary overhead.

Apply this diff to use lazy evaluation:

-        logger.debug(f"Base scopes from storage: {scopes}")
+        logger.debug("Base scopes from storage: %s", scopes)

And:

-        logger.debug(f"Scopes for Azure token endpoint: {deduplicated_scopes}")
+        logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)

Also applies to: 413-413

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 463652a and 119bf44.

⛔ Files ignored due to path filters (1)
  • tests/server/auth/providers/test_azure.py is excluded by none and included by none
📒 Files selected for processing (1)
  • src/fastmcp/server/auth/providers/azure.py (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/fastmcp/server/auth/providers/azure.py (1)
src/fastmcp/server/auth/oauth_proxy.py (1)
  • _prepare_scopes_for_upstream_refresh (1276-1291)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run tests: Python 3.10 on windows-latest
🔇 Additional comments (3)
src/fastmcp/server/auth/providers/azure.py (3)

330-350: LGTM! Clean scope prefixing logic.

The helper method correctly centralizes scope prefixing for both authorization and refresh flows. The detection logic using "://" in scope or "/" in scope appropriately handles Azure's scope formats:

  • Fully-qualified scopes like api://xxx/read are preserved
  • Unprefixed scopes like read get prefixed with identifier_uri
  • Already-prefixed scopes from migrations are handled correctly

The approach relies on Azure's well-defined scope format and the provider's documented requirement for unprefixed scope names in required_scopes.


364-364: Good refactoring to use centralized helper.

Using _prefix_scopes_for_azure improves maintainability by ensuring consistent scope prefixing logic across authorization and refresh flows.


377-414: Excellent fix for Azure token refresh scope handling.

The implementation correctly addresses the AADSTS65001 error by:

  1. Defensive filtering (lines 398-399): Cleans up any accidentally stored additional_authorize_scopes
  2. Dynamic prefixing (line 402): Transforms base scopes to fully-qualified format for Azure
  3. Scope augmentation (lines 406-407): Includes Graph/OIDC scopes in the refresh request
  4. Deduplication (line 411): Handles legacy tokens with duplicate scopes efficiently

The approach maintains backward compatibility by keeping stored scopes unprefixed while transforming them only when sending to Azure. This prevents scope accumulation and aligns with Azure AD v2.0 requirements.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The Windows test job failed due to a pytest-xdist worker crash during the test_multi_client_with_transforms test. This appears to be a Windows-specific race condition or process cleanup issue, not directly related to the Azure OAuth changes in this PR.

Root Cause: The worker crash is triggered by a ConnectionResetError (WinError 10054) in the pytest-retry plugin's server thread. The error logs show:

[gw1] node down: Not properly terminated
worker 'gw1' crashed while running 'tests/test_mcp_config.py::test_multi_client_with_transforms'

PytestUnhandledThreadExceptionWarning: Exception in thread Thread-1 (run_server)
ConnectionResetError: [WinError 10054] An existing connection was forcibly closed by the remote host

This occurs at ~35% test completion when the test spawns multiple Python subprocess-based MCP servers using StdioTransport with the MCPConfigTransport.

Why This Is Not PR-Related:

  • All other test jobs passed (Ubuntu, lowest-direct dependencies, integration tests)
  • The PR changes are specific to Azure OAuth token refresh logic in src/fastmcp/server/auth/azure.py
  • The failing test (test_multi_client_with_transforms) tests MCP client configuration with tool transformations, which has no dependency on OAuth authentication code
  • The test creates subprocess-based stdio servers - a pattern known to have Windows-specific process lifecycle issues (see line 290-293 of tests/test_mcp_config.py which skips test_multi_client_lifespan on Windows for exactly this reason)

Known Windows Issues: The codebase already acknowledges Windows process lifecycle issues:

Suggested Solution:

Option 1 (Recommended): Mark test_multi_client_with_transforms as flaky on Windows:

@pytest.mark.skipif(
    sys.platform.startswith("win32"),
    reason="Windows has process lifecycle issues with pytest-xdist workers"
)
async def test_multi_client_with_transforms(tmp_path: Path):
    ...

Option 2: Mark the test as flaky to allow retries:

@pytest.mark.flaky(retries=3)
async def test_multi_client_with_transforms(tmp_path: Path):
    ...

(Note: test_multi_client_transform_with_filtering already uses this pattern on line 639)

Option 3: Investigate the underlying Windows subprocess cleanup issue, but this is a larger effort that should be tracked separately from this PR.


Detailed Analysis

Relevant Log Excerpts

The failure shows the worker crashed during test execution:

[gw1] node down: Not properly terminated
[31mF[0m
replacing crashed worker gw1

The underlying cause is a connection reset in pytest-retry:

PytestUnhandledThreadExceptionWarning: Exception in thread Thread-1 (run_server)

Traceback (most recent call last):
  File "D:\\a\\fastmcp\\fastmcp\\.venv\\lib\\site-packages\\pytest_retry\\server.py", line 45, in run_server
    chunk = conn.recv(4096)
ConnectionResetError: [WinError 10054] An existing connection was forcibly closed by the remote host

Test Structure

The failing test:

  1. Creates a simple FastMCP server script with an add tool
  2. Spawns two separate Python subprocesses running the server via StdioTransport
  3. Applies tool transformations (renaming tools and arguments)
  4. Exercises both transformed and non-transformed tool calls

This multi-process subprocess pattern with StdioTransport is known to be problematic on Windows.

Related Files
  • tests/test_mcp_config.py:534-588 - The failing test
  • tests/test_mcp_config.py:290-293 - Existing Windows skip for similar subprocess test
  • tests/test_mcp_config.py:356-359 - Another Windows skip for subprocess tests
  • src/fastmcp/client/transports.py - StdioTransport implementation
  • .github/workflows/run-tests.yml:50-52 - Windows test configuration with pytest-xdist

Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

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

Thanks!

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

Labels

auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AzureProvider: AADSTS65001 on refresh_token for Microsoft Graph despite offline_access + admin consent

2 participants