@@ -83,4 +83,96 @@ async def test_get_authorization_token_localhost():
8383 token_provider = AzureIdentityAccessTokenProvider (DummySyncAzureTokenCredential (), None )
8484 token = await token_provider .get_authorization_token ('HTTP://LOCALHOST:8080' )
8585 assert token
86-
86+
87+
88+ class RecordingSyncAzureTokenCredential (DummySyncAzureTokenCredential ):
89+ """Sync credential that records the scopes passed to get_token."""
90+
91+ def __init__ (self ):
92+ self .received_scopes : list [tuple [str , ...]] = []
93+
94+ def get_token (self , * scopes , ** kwargs ):
95+ self .received_scopes .append (scopes )
96+ return super ().get_token (* scopes , ** kwargs )
97+
98+
99+ @pytest .mark .asyncio
100+ async def test_derived_scope_strips_userinfo_and_port ():
101+ """The default `.default` scope passed to `get_token` must be derived
102+ from the hostname only — never include userinfo or
103+ a `:port` (which Entra ID rejects for `.default` scopes).
104+ """
105+ credential = RecordingSyncAzureTokenCredential ()
106+ token_provider = AzureIdentityAccessTokenProvider (credential , None )
107+
108+ await token_provider .get_authorization_token (
109+ 'https://alice:secret@graph.microsoft.com:8443/v1.0/me'
110+ )
111+
112+ assert credential .received_scopes == [('https://graph.microsoft.com/.default' ,)]
113+
114+
115+ @pytest .mark .asyncio
116+ async def test_derived_scope_is_not_cached_across_hosts ():
117+ """The first URL's derived scope must not be reused for later URLs.
118+
119+ Previously the scope was assigned to `self._scopes`, making it sticky for
120+ the lifetime of the provider instance and causing tokens to be requested
121+ for the wrong audience after the first call.
122+ """
123+ credential = RecordingSyncAzureTokenCredential ()
124+ token_provider = AzureIdentityAccessTokenProvider (credential , None )
125+
126+ await token_provider .get_authorization_token ('https://graph.microsoft.com/v1.0/me' )
127+ await token_provider .get_authorization_token ('https://graph.microsoft.us/v1.0/me' )
128+
129+ assert credential .received_scopes == [
130+ ('https://graph.microsoft.com/.default' ,),
131+ ('https://graph.microsoft.us/.default' ,),
132+ ]
133+ # Provider must not have cached derived scopes into `_scopes`.
134+ assert token_provider ._scopes == []
135+
136+
137+ @pytest .mark .asyncio
138+ async def test_explicit_scopes_are_respected ():
139+ credential = RecordingSyncAzureTokenCredential ()
140+ token_provider = AzureIdentityAccessTokenProvider (
141+ credential , None , scopes = ['https://graph.microsoft.com/.default' ]
142+ )
143+
144+ await token_provider .get_authorization_token ('https://graph.microsoft.com/v1.0/me' )
145+ await token_provider .get_authorization_token ('https://graph.microsoft.us/v1.0/me' )
146+
147+ assert credential .received_scopes == [
148+ ('https://graph.microsoft.com/.default' ,),
149+ ('https://graph.microsoft.com/.default' ,),
150+ ]
151+
152+
153+ @pytest .mark .asyncio
154+ async def test_derived_scope_rejects_url_without_hostname ():
155+ """A URI whose netloc has no hostname (e.g. `https://@/path`) must not
156+ silently derive a scope like `https://None/.default`; it must raise.
157+ """
158+ credential = RecordingSyncAzureTokenCredential ()
159+ token_provider = AzureIdentityAccessTokenProvider (credential , None )
160+
161+ with pytest .raises (Exception ):
162+ await token_provider .get_authorization_token ('https://@/path' )
163+ assert credential .received_scopes == []
164+
165+
166+ @pytest .mark .asyncio
167+ async def test_derived_scope_brackets_ipv6_hostname ():
168+ """`urlparse` strips brackets from IPv6 literals; the derived scope
169+ must re-add them so the resulting URL is syntactically valid.
170+ """
171+ credential = RecordingSyncAzureTokenCredential ()
172+ token_provider = AzureIdentityAccessTokenProvider (credential , None )
173+
174+ await token_provider .get_authorization_token ('https://[2001:db8::1]/v1.0/me' )
175+
176+ assert credential .received_scopes == [('https://[2001:db8::1]/.default' ,)]
177+
178+
0 commit comments