Skip to content

Commit 9c21754

Browse files
authored
Fix Azure provider OIDC scope handling (#2506)
* Fix Azure provider to handle OIDC scopes correctly OIDC scopes (openid, profile, email, offline_access) were being incorrectly prefixed with identifier_uri, causing Azure to reject authorization requests. This fix: - Detects OIDC scopes and sends them unprefixed to Azure - Filters OIDC scopes from token validation (Azure doesn't include them in access token scp claims) - Still advertises OIDC scopes to clients via valid_scopes - Also handles dot-notation scopes (e.g., User.Read) correctly Fixes #2451, #2420 * Fix dot-notation scopes to be prefixed (custom scopes can have dots) * Improve Azure scope handling docs with clear examples
1 parent 83085c3 commit 9c21754

File tree

3 files changed

+243
-11
lines changed

3 files changed

+243
-11
lines changed

docs/integrations/azure.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,34 @@ Using your specific tenant ID is recommended for better security and control.
164164
**Important**: The `required_scopes` parameter is **REQUIRED** and must include at least one scope. Azure's OAuth API requires the `scope` parameter in all authorization requests - you cannot authenticate without specifying at least one scope. Use the unprefixed scope names from your Azure App registration (e.g., `["read", "write"]`). These scopes must be created under **Expose an API** in your App registration.
165165
</Note>
166166

167+
### Scope Handling
168+
169+
FastMCP automatically prefixes `required_scopes` with your `identifier_uri` (e.g., `api://your-client-id`) since these are your custom API scopes. Scopes in `additional_authorize_scopes` are sent as-is since they target external resources like Microsoft Graph.
170+
171+
**`required_scopes`** — Your custom API scopes, defined in Azure "Expose an API":
172+
173+
| You write | Sent to Azure | Validated on tokens |
174+
|-----------|---------------|---------------------|
175+
| `mcp-read` | `api://xxx/mcp-read` ||
176+
| `my.scope` | `api://xxx/my.scope` ||
177+
| `openid` | `openid` | ✗ (OIDC scope) |
178+
| `api://xxx/read` | `api://xxx/read` ||
179+
180+
**`additional_authorize_scopes`** — External scopes (e.g., Microsoft Graph) for server-side use:
181+
182+
| You write | Sent to Azure | Validated on tokens |
183+
|-----------|---------------|---------------------|
184+
| `User.Read` | `User.Read` ||
185+
| `Mail.Send` | `Mail.Send` ||
186+
187+
<Info>
188+
**Why aren't `additional_authorize_scopes` validated?** Azure issues separate tokens per resource. The access token FastMCP receives is for *your API*—Graph scopes aren't in its `scp` claim. To call Graph APIs, your server uses the upstream Azure token in an on-behalf-of (OBO) flow.
189+
</Info>
190+
191+
<Note>
192+
OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are never prefixed and excluded from validation because Azure doesn't include them in access token `scp` claims.
193+
</Note>
194+
167195
## Testing
168196

169197
### Running the Server
@@ -304,6 +332,8 @@ Redirect path configured in your Azure App registration
304332
<ParamField path="FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES" required>
305333
Comma-, space-, or JSON-separated list of required scopes for your API (at least one scope required). These are validated on tokens and used as defaults if the client does not request specific scopes. Use unprefixed scope names from your Azure App registration (e.g., `read,write`).
306334

335+
You can include standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`) in `required_scopes`. FastMCP automatically handles them correctly: they're sent to Azure unprefixed and excluded from token validation (since Azure doesn't include OIDC scopes in access token `scp` claims).
336+
307337
<Note>
308338
Azure's OAuth API requires the `scope` parameter - you must provide at least one scope.
309339
</Note>

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

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525

2626
logger = get_logger(__name__)
2727

28+
# Standard OIDC scopes that should never be prefixed with identifier_uri.
29+
# Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc
30+
# "OIDC scopes are requested as simple string identifiers without resource prefixes"
31+
OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"})
32+
2833

2934
class AzureProviderSettings(BaseSettings):
3035
"""Settings for Azure OAuth provider."""
@@ -240,13 +245,25 @@ def __init__(
240245
f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys"
241246
)
242247

243-
# Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes
248+
# Azure access tokens only include custom API scopes in the `scp` claim,
249+
# NOT standard OIDC scopes (openid, profile, email, offline_access).
250+
# Filter out OIDC scopes from validation - they'll still be sent to Azure
251+
# during authorization (handled by _prefix_scopes_for_azure).
252+
validation_scopes = None
253+
if settings.required_scopes:
254+
validation_scopes = [
255+
s for s in settings.required_scopes if s not in OIDC_SCOPES
256+
]
257+
# If all scopes were OIDC scopes, use None (no scope validation)
258+
if not validation_scopes:
259+
validation_scopes = None
260+
244261
token_verifier = JWTVerifier(
245262
jwks_uri=jwks_uri,
246263
issuer=issuer,
247264
audience=settings.client_id,
248265
algorithm="RS256",
249-
required_scopes=settings.required_scopes, # Unprefixed scopes for validation
266+
required_scopes=validation_scopes, # Only validate non-OIDC scopes
250267
)
251268

252269
# Extract secret string from SecretStr
@@ -277,6 +294,8 @@ def __init__(
277294
client_storage=client_storage,
278295
jwt_signing_key=settings.jwt_signing_key,
279296
require_authorization_consent=require_authorization_consent,
297+
# Advertise full scopes including OIDC (even though we only validate non-OIDC)
298+
valid_scopes=settings.required_scopes,
280299
)
281300

282301
authority_info = ""
@@ -328,11 +347,20 @@ async def authorize(
328347
return f"{auth_url}{separator}prompt=select_account"
329348

330349
def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
331-
"""Prefix unprefixed scopes with identifier_uri for Azure.
350+
"""Prefix unprefixed custom API scopes with identifier_uri for Azure.
332351
333352
This helper centralizes the scope prefixing logic used in both
334353
authorization and token refresh flows.
335354
355+
Scopes that are NOT prefixed:
356+
- Standard OIDC scopes (openid, profile, email, offline_access)
357+
- Fully-qualified URIs (contain "://")
358+
- Scopes with path component (contain "/")
359+
360+
Note: Microsoft Graph scopes (e.g., User.Read) should be passed via
361+
`additional_authorize_scopes` or use fully-qualified format
362+
(e.g., https://graph.microsoft.com/User.Read).
363+
336364
Args:
337365
scopes: List of scopes, may be prefixed or unprefixed
338366
@@ -341,11 +369,15 @@ def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
341369
"""
342370
prefixed = []
343371
for scope in scopes:
344-
if "://" in scope or "/" in scope:
345-
# Already fully-qualified (e.g., "api://xxx/read" or "User.Read")
372+
if scope in OIDC_SCOPES:
373+
# Standard OIDC scopes - never prefix
374+
prefixed.append(scope)
375+
elif "://" in scope or "/" in scope:
376+
# Already fully-qualified (e.g., "api://xxx/read" or
377+
# "https://graph.microsoft.com/User.Read")
346378
prefixed.append(scope)
347379
else:
348-
# Unprefixed client scope - prefix with identifier_uri
380+
# Unprefixed custom API scope - prefix with identifier_uri
349381
prefixed.append(f"{self.identifier_uri}/{scope}")
350382
return prefixed
351383

tests/server/auth/providers/test_azure.py

Lines changed: 175 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from mcp.shared.auth import OAuthClientInformationFull
1010
from pydantic import AnyUrl
1111

12-
from fastmcp.server.auth.providers.azure import AzureProvider
12+
from fastmcp.server.auth.providers.azure import OIDC_SCOPES, AzureProvider
1313

1414

1515
class TestAzureProvider:
@@ -288,7 +288,7 @@ async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
288288
redirect_uri_provided_explicitly=True,
289289
scopes=[
290290
"read",
291-
"profile",
291+
"write",
292292
], # Client sends unprefixed scopes (from PRM which advertises unprefixed)
293293
state="abc",
294294
code_challenge="xyz",
@@ -307,7 +307,7 @@ async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
307307
transaction = await provider._transaction_store.get(key=txn_id)
308308
assert transaction is not None
309309
assert "read" in transaction.scopes
310-
assert "profile" in transaction.scopes
310+
assert "write" in transaction.scopes
311311
# Azure provider filters resource parameter (not stored in transaction)
312312
assert transaction.resource is None
313313

@@ -320,8 +320,8 @@ async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
320320
or "api://my-api/read" in upstream_url
321321
)
322322
assert (
323-
"api%3A%2F%2Fmy-api%2Fprofile" in upstream_url
324-
or "api://my-api/profile" in upstream_url
323+
"api%3A%2F%2Fmy-api%2Fwrite" in upstream_url
324+
or "api://my-api/write" in upstream_url
325325
)
326326

327327
async def test_authorize_appends_additional_scopes(self):
@@ -709,3 +709,173 @@ def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants(self
709709
# Should only have 2 items (read processed twice, but deduplicated)
710710
assert len(result) == 2
711711
assert result.count("api://my-api/read") == 1
712+
713+
714+
class TestOIDCScopeHandling:
715+
"""Tests for OIDC scope handling in Azure provider.
716+
717+
Azure access tokens do NOT include OIDC scopes (openid, profile, email,
718+
offline_access) in the `scp` claim - they're only used during authorization.
719+
These tests verify that:
720+
1. OIDC scopes are never prefixed with identifier_uri
721+
2. OIDC scopes are filtered from token validation
722+
3. OIDC scopes are still advertised to clients via valid_scopes
723+
"""
724+
725+
def test_oidc_scopes_constant(self):
726+
"""Verify OIDC_SCOPES contains the standard OIDC scopes."""
727+
assert OIDC_SCOPES == {"openid", "profile", "email", "offline_access"}
728+
729+
def test_prefix_scopes_does_not_prefix_oidc_scopes(self):
730+
"""Test that _prefix_scopes_for_azure never prefixes OIDC scopes."""
731+
provider = AzureProvider(
732+
client_id="test_client",
733+
client_secret="test_secret",
734+
tenant_id="test-tenant",
735+
identifier_uri="api://my-api",
736+
required_scopes=["read"],
737+
jwt_signing_key="test-secret",
738+
)
739+
740+
# All OIDC scopes should pass through unchanged
741+
result = provider._prefix_scopes_for_azure(
742+
["openid", "profile", "email", "offline_access"]
743+
)
744+
745+
assert result == ["openid", "profile", "email", "offline_access"]
746+
747+
def test_prefix_scopes_mixed_oidc_and_custom(self):
748+
"""Test prefixing with a mix of OIDC and custom scopes."""
749+
provider = AzureProvider(
750+
client_id="test_client",
751+
client_secret="test_secret",
752+
tenant_id="test-tenant",
753+
identifier_uri="api://my-api",
754+
required_scopes=["read"],
755+
jwt_signing_key="test-secret",
756+
)
757+
758+
result = provider._prefix_scopes_for_azure(
759+
["read", "openid", "write", "profile"]
760+
)
761+
762+
# Custom scopes should be prefixed, OIDC scopes should not
763+
assert "api://my-api/read" in result
764+
assert "api://my-api/write" in result
765+
assert "openid" in result
766+
assert "profile" in result
767+
# Verify OIDC scopes are NOT prefixed
768+
assert "api://my-api/openid" not in result
769+
assert "api://my-api/profile" not in result
770+
771+
def test_prefix_scopes_dot_notation_gets_prefixed(self):
772+
"""Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph)."""
773+
provider = AzureProvider(
774+
client_id="test_client",
775+
client_secret="test_secret",
776+
tenant_id="test-tenant",
777+
identifier_uri="api://my-api",
778+
required_scopes=["read"],
779+
jwt_signing_key="test-secret",
780+
)
781+
782+
# Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph
783+
# or fully-qualified format like https://graph.microsoft.com/User.Read
784+
result = provider._prefix_scopes_for_azure(["my.scope", "admin.read"])
785+
786+
assert result == ["api://my-api/my.scope", "api://my-api/admin.read"]
787+
788+
def test_prefix_scopes_fully_qualified_graph_not_prefixed(self):
789+
"""Test that fully-qualified Graph scopes are not prefixed."""
790+
provider = AzureProvider(
791+
client_id="test_client",
792+
client_secret="test_secret",
793+
tenant_id="test-tenant",
794+
identifier_uri="api://my-api",
795+
required_scopes=["read"],
796+
jwt_signing_key="test-secret",
797+
)
798+
799+
result = provider._prefix_scopes_for_azure(
800+
[
801+
"https://graph.microsoft.com/User.Read",
802+
"https://graph.microsoft.com/Mail.Send",
803+
]
804+
)
805+
806+
# Fully-qualified URIs pass through unchanged
807+
assert result == [
808+
"https://graph.microsoft.com/User.Read",
809+
"https://graph.microsoft.com/Mail.Send",
810+
]
811+
812+
def test_required_scopes_with_oidc_filters_validation(self):
813+
"""Test that OIDC scopes in required_scopes are filtered from token validation."""
814+
provider = AzureProvider(
815+
client_id="test_client",
816+
client_secret="test_secret",
817+
tenant_id="test-tenant",
818+
identifier_uri="api://my-api",
819+
required_scopes=["read", "openid", "profile"],
820+
jwt_signing_key="test-secret",
821+
)
822+
823+
# Token validator should only require non-OIDC scopes
824+
assert provider._token_validator.required_scopes == ["read"]
825+
826+
def test_required_scopes_all_oidc_results_in_no_validation(self):
827+
"""Test that if all required_scopes are OIDC, no scope validation occurs."""
828+
provider = AzureProvider(
829+
client_id="test_client",
830+
client_secret="test_secret",
831+
tenant_id="test-tenant",
832+
identifier_uri="api://my-api",
833+
required_scopes=["openid", "profile"],
834+
jwt_signing_key="test-secret",
835+
)
836+
837+
# Token validator should have empty required scopes (all were OIDC)
838+
assert provider._token_validator.required_scopes == []
839+
840+
def test_valid_scopes_includes_oidc_scopes(self):
841+
"""Test that valid_scopes advertises OIDC scopes to clients."""
842+
provider = AzureProvider(
843+
client_id="test_client",
844+
client_secret="test_secret",
845+
tenant_id="test-tenant",
846+
identifier_uri="api://my-api",
847+
required_scopes=["read", "openid", "profile"],
848+
jwt_signing_key="test-secret",
849+
)
850+
851+
# required_scopes (used for validation) excludes OIDC scopes
852+
assert provider.required_scopes == ["read"]
853+
# But valid_scopes (advertised to clients) includes all scopes
854+
assert provider.client_registration_options.valid_scopes == [
855+
"read",
856+
"openid",
857+
"profile",
858+
]
859+
860+
def test_prepare_scopes_for_refresh_handles_oidc_scopes(self):
861+
"""Test that token refresh correctly handles OIDC scopes."""
862+
provider = AzureProvider(
863+
client_id="test_client",
864+
client_secret="test_secret",
865+
tenant_id="test-tenant",
866+
identifier_uri="api://my-api",
867+
required_scopes=["read"],
868+
jwt_signing_key="test-secret",
869+
)
870+
871+
# Simulate stored scopes that include OIDC scopes
872+
result = provider._prepare_scopes_for_upstream_refresh(
873+
["read", "openid", "profile"]
874+
)
875+
876+
# Custom scope should be prefixed, OIDC scopes should not
877+
assert "api://my-api/read" in result
878+
assert "openid" in result
879+
assert "profile" in result
880+
assert "api://my-api/openid" not in result
881+
assert "api://my-api/profile" not in result

0 commit comments

Comments
 (0)