Skip to content

Commit 46e0e01

Browse files
authored
fix: correctly send resource when exchanging code for the upstream to… (#3013)
1 parent 17d3f13 commit 46e0e01

3 files changed

Lines changed: 158 additions & 221 deletions

File tree

src/fastmcp/server/auth/oauth_proxy/proxy.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,20 @@ async def exchange_authorization_code(
978978
# Refresh Token Flow
979979
# -------------------------------------------------------------------------
980980

981+
def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]:
982+
"""Prepare scopes for initial token exchange (auth code -> tokens).
983+
984+
Override this method to provide scopes during the authorization
985+
code exchange. Some providers (like Azure) require scopes to be sent.
986+
987+
Args:
988+
scopes: Scopes from the authorization request
989+
990+
Returns:
991+
List of scopes to send, or empty list to omit scope parameter
992+
"""
993+
return scopes
994+
981995
def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
982996
"""Prepare scopes for upstream token refresh request.
983997
@@ -1532,6 +1546,13 @@ async def _handle_idp_callback(
15321546
txn_id,
15331547
)
15341548

1549+
# Allow providers to specify scope for token exchange
1550+
exchange_scopes = self._prepare_scopes_for_token_exchange(
1551+
transaction.get("scopes") or []
1552+
)
1553+
if exchange_scopes:
1554+
token_params["scope"] = " ".join(exchange_scopes)
1555+
15351556
# Add any extra token parameters configured for this proxy
15361557
if self._extra_token_params:
15371558
token_params.update(self._extra_token_params)

src/fastmcp/server/auth/providers/azure.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ def __init__(
193193
token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token"
194194

195195
# Initialize OAuth proxy with Azure endpoints
196+
# Remember there's hooks called, such as _prepare_scopes_for_token_exchange
197+
# and _prepare_scopes_for_upstream_refresh
196198
super().__init__(
197199
upstream_authorization_endpoint=authorization_endpoint,
198200
upstream_token_endpoint=token_endpoint,
@@ -206,7 +208,6 @@ def __init__(
206208
client_storage=client_storage,
207209
jwt_signing_key=jwt_signing_key,
208210
require_authorization_consent=require_authorization_consent,
209-
# Advertise full scopes including OIDC (even though we only validate non-OIDC)
210211
valid_scopes=parsed_required_scopes,
211212
)
212213

@@ -318,16 +319,37 @@ def _build_upstream_authorize_url(
318319
# Let parent build the URL with prefixed scopes
319320
return super()._build_upstream_authorize_url(txn_id, modified_transaction)
320321

322+
def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]:
323+
"""Prepare scopes for Azure authorization code exchange.
324+
325+
Azure requires scopes during token exchange (AADSTS28003 error if missing).
326+
Azure only allows ONE resource per token request (AADSTS28000), so we only
327+
include scopes for this API plus OIDC scopes.
328+
329+
Args:
330+
scopes: Scopes from the authorization request (unprefixed)
331+
332+
Returns:
333+
List of scopes for Azure token endpoint
334+
"""
335+
# Prefix scopes for this API
336+
prefixed_scopes = self._prefix_scopes_for_azure(scopes or [])
337+
338+
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
339+
if self.additional_authorize_scopes:
340+
prefixed_scopes.extend(
341+
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
342+
)
343+
344+
deduplicated = list(dict.fromkeys(prefixed_scopes))
345+
logger.debug("Token exchange scopes: %s", deduplicated)
346+
return deduplicated
347+
321348
def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
322349
"""Prepare scopes for Azure token refresh.
323350
324-
Azure requires:
325-
1. Fully-qualified custom scopes (e.g., "api://xxx/read" not "read")
326-
2. Microsoft Graph scopes (e.g., "User.Read", "openid") sent as-is
327-
3. Additional scopes from provider config (additional_authorize_scopes)
328-
329-
This method transforms base client scopes for Azure while keeping them
330-
unprefixed in storage to prevent accumulation.
351+
Azure requires fully-qualified scopes and only allows ONE resource per
352+
token request (AADSTS28000). We include scopes for this API plus OIDC scopes.
331353
332354
Args:
333355
scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"])
@@ -338,22 +360,19 @@ def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
338360
logger.debug("Base scopes from storage: %s", scopes)
339361

340362
# Filter out any additional_authorize_scopes that may have been stored
341-
# (they shouldn't be in storage, but clean them up if they are)
342363
additional_scopes_set = set(self.additional_authorize_scopes or [])
343364
base_scopes = [s for s in scopes if s not in additional_scopes_set]
344365

345-
# Prefix base scopes with identifier_uri for Azure using shared helper
366+
# Prefix base scopes with identifier_uri for Azure
346367
prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
347368

348-
# Add additional scopes (Graph + OIDC) for the Azure request
349-
# These are NOT stored in RefreshToken, only sent to Azure
369+
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
350370
if self.additional_authorize_scopes:
351-
prefixed_scopes.extend(self.additional_authorize_scopes)
371+
prefixed_scopes.extend(
372+
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
373+
)
352374

353-
# Deduplicate while preserving order (in case older tokens have duplicates)
354-
# Use dict.fromkeys() for O(n) deduplication with order preservation
355375
deduplicated_scopes = list(dict.fromkeys(prefixed_scopes))
356-
357376
logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)
358377
return deduplicated_scopes
359378

0 commit comments

Comments
 (0)