1010from pydantic import AnyUrl
1111
1212from fastmcp .server .auth .providers .azure import AzureProvider
13- from fastmcp .server .auth .providers .jwt import JWTVerifier
1413
1514
1615class 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