Skip to content

Commit ba47db9

Browse files
authored
Fix Azure scope validation (#2269)
* Update docs for required scopes * add scopes * Fix Azure scope validation Azure returns unprefixed scopes in JWT tokens but requires prefixed scopes in authorization requests. The previous implementation incorrectly validated tokens against prefixed scopes, causing "invalid_token" errors. Simplified AzureProvider to use standard JWTVerifier with unprefixed scopes for validation. Scopes are only prefixed when building the Azure authorization URL via _build_upstream_authorize_url() override. Closes #2263
1 parent 9d4c378 commit ba47db9

File tree

4 files changed

+167
-66
lines changed

4 files changed

+167
-66
lines changed

docs/integrations/azure.mdx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ auth_provider = AzureProvider(
122122
client_secret="your-client-secret", # Your Azure App Client Secret
123123
tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED)
124124
base_url="http://localhost:8000", # Must match your App registration
125-
required_scopes=["your-scope"], # Name of scope created when configuring your App
125+
required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App
126126
# identifier_uri defaults to api://{client_id}
127127
# identifier_uri="api://your-api-id",
128128
# Optional: request additional upstream scopes in the authorize request
@@ -159,6 +159,10 @@ async def get_user_info() -> dict:
159159
Using your specific tenant ID is recommended for better security and control.
160160
</Note>
161161

162+
<Note>
163+
**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.
164+
</Note>
165+
162166
## Testing
163167

164168
### Running the Server
@@ -296,8 +300,12 @@ Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL wh
296300
Redirect path configured in your Azure App registration
297301
</ParamField>
298302

299-
<ParamField path="FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES" default="">
300-
Comma-, space-, or JSON-separated list of required scopes for your API. These are validated on tokens and used as defaults if the client does not request specific scopes.
303+
<ParamField path="FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES" required>
304+
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`).
305+
306+
<Note>
307+
Azure's OAuth API requires the `scope` parameter - you must provide at least one scope.
308+
</Note>
301309
</ParamField>
302310

303311
<ParamField path="FASTMCP_SERVER_AUTH_AZURE_ADDITIONAL_AUTHORIZE_SCOPES" default="">

examples/auth/azure_oauth/server.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
- AZURE_CLIENT_SECRET: Your Azure client secret
88
- AZURE_TENANT_ID: Tenant ID
99
Options: "organizations" (work/school), "consumers" (personal), or specific tenant ID
10+
- AZURE_REQUIRED_SCOPES: At least one scope required (e.g., "read" or "read,write")
11+
These must match scope names created under "Expose an API" in your Azure App registration
1012
1113
To run:
1214
python server.py
@@ -18,11 +20,14 @@
1820
from fastmcp.server.auth.providers.azure import AzureProvider
1921

2022
auth = AzureProvider(
21-
client_id=os.getenv("AZURE_CLIENT_ID") or "",
22-
client_secret=os.getenv("AZURE_CLIENT_SECRET") or "",
23-
tenant_id=os.getenv("AZURE_TENANT_ID")
23+
client_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID") or "",
24+
client_secret=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET") or "",
25+
tenant_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_TENANT_ID")
2426
or "", # Required for single-tenant apps - get from Azure Portal
2527
base_url="http://localhost:8000",
28+
required_scopes=["read"],
29+
# required_scopes is automatically loaded from FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES
30+
# At least one scope is required - use unprefixed scope names from your Azure App (e.g., ["read", "write"])
2631
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
2732
)
2833

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

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from __future__ import annotations
88

9-
from typing import TYPE_CHECKING
9+
from typing import TYPE_CHECKING, Any
1010

1111
from key_value.aio.protocols import AsyncKeyValue
1212
from pydantic import SecretStr, field_validator
@@ -202,32 +202,34 @@ def __init__(
202202
)
203203
raise ValueError(msg)
204204

205+
# Validate required_scopes has at least one scope
205206
if not settings.required_scopes:
206-
raise ValueError("required_scopes is required")
207+
msg = (
208+
"required_scopes must include at least one scope - set via parameter or "
209+
"FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES. Azure's OAuth API requires "
210+
"the 'scope' parameter in authorization requests. Use the unprefixed scope "
211+
"names from your Azure App registration (e.g., ['read', 'write'])"
212+
)
213+
raise ValueError(msg)
207214

208215
# Apply defaults
209216
self.identifier_uri = settings.identifier_uri or f"api://{settings.client_id}"
210217
self.additional_authorize_scopes = settings.additional_authorize_scopes or []
211218
tenant_id_final = settings.tenant_id
212219

213-
# Prefix required scopes with identifier_uri for Azure
214-
# Azure returns scopes as full URIs (e.g., "api://xxx/read") in tokens
215-
prefixed_required_scopes = [
216-
f"{self.identifier_uri}/{scope}" for scope in settings.required_scopes
217-
]
218-
219220
# Always validate tokens against the app's API client ID using JWT
220221
issuer = f"https://login.microsoftonline.com/{tenant_id_final}/v2.0"
221222
jwks_uri = (
222223
f"https://login.microsoftonline.com/{tenant_id_final}/discovery/v2.0/keys"
223224
)
224225

226+
# Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes
225227
token_verifier = JWTVerifier(
226228
jwks_uri=jwks_uri,
227229
issuer=issuer,
228230
audience=settings.client_id,
229231
algorithm="RS256",
230-
required_scopes=prefixed_required_scopes,
232+
required_scopes=settings.required_scopes, # Unprefixed scopes for validation
231233
)
232234

233235
# Extract secret string from SecretStr
@@ -298,19 +300,40 @@ async def authorize(
298300
"Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
299301
original_resource,
300302
)
301-
# Scopes are already prefixed:
302-
# - self.required_scopes was prefixed during __init__
303-
# - Client scopes come from PRM which advertises prefixed scopes
304-
scopes = params_to_use.scopes or self.required_scopes
305-
306-
final_scopes = list(scopes)
307-
# Add Microsoft Graph scopes separately - these use shorthand format (e.g., "User.Read")
308-
# and should not be prefixed with identifier_uri. Azure returns them as-is in tokens.
303+
# Don't modify the scopes in params - they stay unprefixed for MCP clients
304+
# We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url)
305+
auth_url = await super().authorize(client, params_to_use)
306+
separator = "&" if "?" in auth_url else "?"
307+
return f"{auth_url}{separator}prompt=select_account"
308+
309+
def _build_upstream_authorize_url(
310+
self, txn_id: str, transaction: dict[str, Any]
311+
) -> str:
312+
"""Build Azure authorization URL with prefixed scopes.
313+
314+
Overrides parent to prefix scopes with identifier_uri before sending to Azure,
315+
while keeping unprefixed scopes in the transaction for MCP clients.
316+
"""
317+
# Get unprefixed scopes from transaction
318+
unprefixed_scopes = transaction.get("scopes") or self.required_scopes or []
319+
320+
# Prefix scopes for Azure authorization request
321+
prefixed_scopes = []
322+
for scope in unprefixed_scopes:
323+
if "://" in scope or "/" in scope:
324+
# Already a full URI or path (e.g., "api://xxx/read" or "User.Read")
325+
prefixed_scopes.append(scope)
326+
else:
327+
# Unprefixed scope name - prefix it with identifier_uri
328+
prefixed_scopes.append(f"{self.identifier_uri}/{scope}")
329+
330+
# Add Microsoft Graph scopes (not validated, not prefixed)
309331
if self.additional_authorize_scopes:
310-
final_scopes.extend(self.additional_authorize_scopes)
332+
prefixed_scopes.extend(self.additional_authorize_scopes)
311333

312-
modified_params = params_to_use.model_copy(update={"scopes": final_scopes})
334+
# Temporarily modify transaction dict for parent's URL building
335+
modified_transaction = transaction.copy()
336+
modified_transaction["scopes"] = prefixed_scopes
313337

314-
auth_url = await super().authorize(client, modified_params)
315-
separator = "&" if "?" in auth_url else "?"
316-
return f"{auth_url}{separator}prompt=select_account"
338+
# Let parent build the URL with prefixed scopes
339+
return super()._build_upstream_authorize_url(txn_id, modified_transaction)

tests/server/auth/providers/test_azure.py

Lines changed: 103 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from pydantic import AnyUrl
1111

1212
from fastmcp.server.auth.providers.azure import AzureProvider
13-
from fastmcp.server.auth.providers.jwt import JWTVerifier
1413

1514

1615
class TestAzureProvider:
@@ -61,10 +60,11 @@ def test_init_with_env_vars(self, scopes_env):
6160
assert provider._upstream_client_id == "env-client-id"
6261
assert provider._upstream_client_secret.get_secret_value() == "env-secret"
6362
assert str(provider.base_url) == "https://envserver.com/"
64-
# Scopes should be prefixed with identifier_uri in token validator
63+
# Scopes are stored unprefixed for token validation
64+
# (Azure returns unprefixed scopes in JWT tokens)
6565
assert provider._token_validator.required_scopes == [
66-
"api://env-client-id/read",
67-
"api://env-client-id/write",
66+
"read",
67+
"write",
6868
]
6969
# Check tenant is in the endpoints
7070
parsed_auth = urlparse(provider._upstream_authorization_endpoint)
@@ -74,27 +74,63 @@ def test_init_with_env_vars(self, scopes_env):
7474

7575
def test_init_missing_client_id_raises_error(self):
7676
"""Test that missing client_id raises ValueError."""
77-
with pytest.raises(ValueError, match="client_id is required"):
78-
AzureProvider(
79-
client_secret="test_secret",
80-
tenant_id="test-tenant",
81-
)
77+
# Clear environment variables to ensure we're testing the parameter validation
78+
with patch.dict(os.environ, {}, clear=True):
79+
with pytest.raises(ValueError, match="client_id is required"):
80+
AzureProvider(
81+
client_secret="test_secret",
82+
tenant_id="test-tenant",
83+
required_scopes=["read"],
84+
)
8285

8386
def test_init_missing_client_secret_raises_error(self):
8487
"""Test that missing client_secret raises ValueError."""
85-
with pytest.raises(ValueError, match="client_secret is required"):
86-
AzureProvider(
87-
client_id="test_client",
88-
tenant_id="test-tenant",
89-
)
88+
# Clear environment variables to ensure we're testing the parameter validation
89+
with patch.dict(os.environ, {}, clear=True):
90+
with pytest.raises(ValueError, match="client_secret is required"):
91+
AzureProvider(
92+
client_id="test_client",
93+
tenant_id="test-tenant",
94+
required_scopes=["read"],
95+
)
9096

9197
def test_init_missing_tenant_id_raises_error(self):
9298
"""Test that missing tenant_id raises ValueError."""
93-
with pytest.raises(ValueError, match="tenant_id is required"):
94-
AzureProvider(
95-
client_id="test_client",
96-
client_secret="test_secret",
97-
)
99+
# Clear environment variables to ensure we're testing the parameter validation
100+
with patch.dict(os.environ, {}, clear=True):
101+
with pytest.raises(ValueError, match="tenant_id is required"):
102+
AzureProvider(
103+
client_id="test_client",
104+
client_secret="test_secret",
105+
required_scopes=["read"],
106+
)
107+
108+
def test_init_missing_required_scopes_raises_error(self):
109+
"""Test that missing required_scopes raises ValueError."""
110+
# Clear environment variables to ensure we're testing the parameter validation
111+
with patch.dict(os.environ, {}, clear=True):
112+
with pytest.raises(
113+
ValueError, match="required_scopes must include at least one scope"
114+
):
115+
AzureProvider(
116+
client_id="test_client",
117+
client_secret="test_secret",
118+
tenant_id="test-tenant",
119+
)
120+
121+
def test_init_empty_required_scopes_raises_error(self):
122+
"""Test that empty required_scopes raises ValueError."""
123+
# Clear environment variables to ensure we're testing the parameter validation
124+
with patch.dict(os.environ, {}, clear=True):
125+
with pytest.raises(
126+
ValueError, match="required_scopes must include at least one scope"
127+
):
128+
AzureProvider(
129+
client_id="test_client",
130+
client_secret="test_secret",
131+
tenant_id="test-tenant",
132+
required_scopes=[],
133+
)
98134

99135
def test_init_defaults(self):
100136
"""Test that default values are applied correctly."""
@@ -176,11 +212,12 @@ def test_azure_specific_scopes(self):
176212

177213
# Provider should initialize successfully with these scopes
178214
assert provider is not None
179-
# Scopes should be prefixed in token validator
215+
# Scopes are stored unprefixed for token validation
216+
# (Azure returns unprefixed scopes in JWT tokens)
180217
assert provider._token_validator.required_scopes == [
181-
"api://test_client/read",
182-
"api://test_client/write",
183-
"api://test_client/admin",
218+
"read",
219+
"write",
220+
"admin",
184221
]
185222

186223
def test_init_does_not_require_api_client_id_anymore(self):
@@ -196,6 +233,8 @@ def test_init_does_not_require_api_client_id_anymore(self):
196233

197234
def test_init_with_custom_audience_uses_jwt_verifier(self):
198235
"""When audience is provided, JWTVerifier is configured with JWKS and issuer."""
236+
from fastmcp.server.auth.providers.jwt import JWTVerifier
237+
199238
provider = AzureProvider(
200239
client_id="test_client",
201240
client_secret="test_secret",
@@ -214,11 +253,12 @@ def test_init_with_custom_audience_uses_jwt_verifier(self):
214253
)
215254
assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0"
216255
assert verifier.audience == "test_client"
217-
# Scopes should be prefixed with identifier_uri
218-
assert verifier.required_scopes == ["api://my-api/.default"]
256+
# Scopes are stored unprefixed for token validation
257+
# (Azure returns unprefixed scopes like ".default" in JWT tokens)
258+
assert verifier.required_scopes == [".default"]
219259

220-
async def test_authorize_filters_resource_and_accepts_prefixed_scopes(self):
221-
"""authorize() should drop resource parameter and accept prefixed scopes from clients."""
260+
async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
261+
"""authorize() should drop resource parameter and store unprefixed scopes for MCP clients."""
222262
provider = AzureProvider(
223263
client_id="test_client",
224264
client_secret="test_secret",
@@ -247,9 +287,9 @@ async def test_authorize_filters_resource_and_accepts_prefixed_scopes(self):
247287
redirect_uri=AnyUrl("http://localhost:12345/callback"),
248288
redirect_uri_provided_explicitly=True,
249289
scopes=[
250-
"api://my-api/read",
251-
"api://my-api/profile",
252-
], # Client sends prefixed scopes from PRM
290+
"read",
291+
"profile",
292+
], # Client sends unprefixed scopes (from PRM which advertises unprefixed)
253293
state="abc",
254294
code_challenge="xyz",
255295
resource="https://should.be.ignored",
@@ -263,14 +303,27 @@ async def test_authorize_filters_resource_and_accepts_prefixed_scopes(self):
263303
assert "txn_id" in qs, "Should redirect to consent page with transaction ID"
264304
txn_id = qs["txn_id"][0]
265305

266-
# Verify transaction contains correct parameters (resource filtered, scopes prefixed)
306+
# Verify transaction stores UNPREFIXED scopes for MCP clients
267307
transaction = await provider._transaction_store.get(key=txn_id)
268308
assert transaction is not None
269-
assert "api://my-api/read" in transaction.scopes
270-
assert "api://my-api/profile" in transaction.scopes
309+
assert "read" in transaction.scopes
310+
assert "profile" in transaction.scopes
271311
# Azure provider filters resource parameter (not stored in transaction)
272312
assert transaction.resource is None
273313

314+
# Verify the upstream Azure URL will have PREFIXED scopes
315+
upstream_url = provider._build_upstream_authorize_url(
316+
txn_id, transaction.model_dump()
317+
)
318+
assert (
319+
"api%3A%2F%2Fmy-api%2Fread" in upstream_url
320+
or "api://my-api/read" in upstream_url
321+
)
322+
assert (
323+
"api%3A%2F%2Fmy-api%2Fprofile" in upstream_url
324+
or "api://my-api/profile" in upstream_url
325+
)
326+
274327
async def test_authorize_appends_additional_scopes(self):
275328
"""authorize() should append additional_authorize_scopes to the authorization request."""
276329
provider = AzureProvider(
@@ -301,7 +354,7 @@ async def test_authorize_appends_additional_scopes(self):
301354
params = AuthorizationParams(
302355
redirect_uri=AnyUrl("http://localhost:12345/callback"),
303356
redirect_uri_provided_explicitly=True,
304-
scopes=["api://my-api/read"], # Client sends prefixed scopes from PRM
357+
scopes=["read"], # Client sends unprefixed scopes
305358
state="abc",
306359
code_challenge="xyz",
307360
)
@@ -314,9 +367,21 @@ async def test_authorize_appends_additional_scopes(self):
314367
assert "txn_id" in qs, "Should redirect to consent page with transaction ID"
315368
txn_id = qs["txn_id"][0]
316369

317-
# Verify transaction contains correct scopes (prefixed + unprefixed additional)
370+
# Verify transaction stores ONLY MCP scopes (unprefixed)
371+
# additional_authorize_scopes are NOT stored in transaction
318372
transaction = await provider._transaction_store.get(key=txn_id)
319373
assert transaction is not None
320-
assert "api://my-api/read" in transaction.scopes
321-
assert "Mail.Read" in transaction.scopes
322-
assert "User.Read" in transaction.scopes
374+
assert "read" in transaction.scopes
375+
assert "Mail.Read" not in transaction.scopes # Not in transaction
376+
assert "User.Read" not in transaction.scopes # Not in transaction
377+
378+
# Verify upstream URL includes both MCP scopes (prefixed) AND additional Graph scopes
379+
upstream_url = provider._build_upstream_authorize_url(
380+
txn_id, transaction.model_dump()
381+
)
382+
assert (
383+
"api%3A%2F%2Fmy-api%2Fread" in upstream_url
384+
or "api://my-api/read" in upstream_url
385+
)
386+
assert "Mail.Read" in upstream_url
387+
assert "User.Read" in upstream_url

0 commit comments

Comments
 (0)