@@ -162,7 +162,43 @@ def _validate_audience(claims: Dict[str, Any], oauth_config: Dict[str, Any], gat
162162 return
163163
164164 aud_list = token_aud if isinstance (token_aud , list ) else [token_aud ]
165- if expected_audience in aud_list :
165+
166+ # Build set of acceptable audiences:
167+ # 1. The configured resource or gateway URL
168+ # 2. The OAuth client_id (Azure AD v1 tokens use client_id as audience
169+ # when scopes are requested with {client_id}/.default)
170+ # 3. api://{client_id} (Azure AD app ID URI convention)
171+ # 4. Resource IDs derived from {resource_id}/.default scope patterns
172+ # (Azure AD issues tokens with aud={resource_id} for these scopes)
173+ acceptable = {expected_audience }
174+ client_id = oauth_config .get ("client_id" )
175+ if client_id :
176+ acceptable .add (client_id )
177+ acceptable .add (f"api://{ client_id } " )
178+
179+ # Extract resource IDs from Azure AD scope patterns.
180+ # Pattern 1: "{resource_id}/.default" — token aud = resource_id
181+ # Pattern 2: "api://{resource_id}/{permission}" — token aud = resource_id
182+ # In both cases Entra ID issues the token with aud set to the
183+ # resource application's client_id (a GUID), not the URI.
184+ for scope in oauth_config .get ("scopes" , []):
185+ if not isinstance (scope , str ):
186+ continue
187+ if scope .endswith ("/.default" ):
188+ resource_id = scope .removesuffix ("/.default" )
189+ if resource_id :
190+ acceptable .add (resource_id )
191+ acceptable .add (f"api://{ resource_id } " )
192+ elif scope .startswith ("api://" ):
193+ # api://{resource_id}/{permission} — extract the resource_id
194+ without_scheme = scope [len ("api://" ):]
195+ slash_pos = without_scheme .find ("/" )
196+ if slash_pos > 0 :
197+ resource_id = without_scheme [:slash_pos ]
198+ acceptable .add (resource_id )
199+ acceptable .add (f"api://{ resource_id } " )
200+
201+ if any (a in acceptable for a in aud_list ):
166202 result .audience_match = True
167203 else :
168204 result .audience_match = False
@@ -184,6 +220,11 @@ def _validate_scopes(claims: Dict[str, Any], oauth_config: Dict[str, Any], gatew
184220 if not configured_scopes :
185221 return
186222
223+ # OIDC meta-scopes control the authorization request behavior but never
224+ # appear in the access token's scope/scp claim. Exclude them from the
225+ # validation check to avoid false-positive "missing scope" errors.
226+ _OIDC_META_SCOPES = {"openid" , "offline_access" , "profile" , "email" }
227+
187228 # Entra ID uses 'scp' claim; standard OAuth uses 'scope'
188229 token_scope_str = claims .get ("scope" ) or claims .get ("scp" ) or ""
189230 if not token_scope_str :
@@ -193,6 +234,14 @@ def _validate_scopes(claims: Dict[str, Any], oauth_config: Dict[str, Any], gatew
193234 granted_scopes = _normalize_scope (token_scope_str )
194235 missing = []
195236 for cfg_scope in configured_scopes :
237+ if cfg_scope .lower () in _OIDC_META_SCOPES :
238+ continue
239+ # Azure AD /.default is a request-time directive meaning "all
240+ # configured permissions for this app". The actual granted
241+ # permissions appear individually in the scp claim, never as
242+ # "{client_id}/.default". Skip it.
243+ if cfg_scope .endswith ("/.default" ):
244+ continue
196245 short = cfg_scope .rsplit ("/" , 1 )[- 1 ] if "/" in cfg_scope else cfg_scope
197246 if cfg_scope not in granted_scopes and short not in granted_scopes :
198247 missing .append (cfg_scope )
@@ -223,13 +272,31 @@ def _validate_issuer(claims: Dict[str, Any], oauth_config: Dict[str, Any], gatew
223272 logger .debug ("OAuth token for gateway %s has no 'iss' claim" , gateway_name )
224273 return
225274
226- if token_iss .rstrip ("/" ) == expected_issuer .rstrip ("/" ):
275+ # Normalize both to compare without trailing slashes
276+ norm_iss = token_iss .rstrip ("/" )
277+ norm_expected = expected_issuer .rstrip ("/" )
278+
279+ if norm_iss == norm_expected :
227280 result .issuer_match = True
228- else :
229- result .issuer_match = False
230- safe_iss = str (token_iss )[:80 ]
231- safe_expected = str (expected_issuer )[:80 ]
232- result .warnings .append (f"Token issuer mismatch: token iss='{ safe_iss } ', expected '{ safe_expected } '" )
281+ return
282+
283+ # Azure AD v1/v2 duality: v1 tokens use iss=https://sts.windows.net/{tenant}
284+ # while v2 tokens use iss=https://login.microsoftonline.com/{tenant}/v2.0.
285+ # Both are valid for the same tenant — accept either when the tenant matches.
286+ entra_v1_prefix = "https://sts.windows.net/"
287+ entra_v2_prefix = "https://login.microsoftonline.com/"
288+ v1_tenant = norm_iss .removeprefix (entra_v1_prefix ) if norm_iss .startswith (entra_v1_prefix ) else None
289+ v2_tenant = norm_expected .removeprefix (entra_v2_prefix ).removesuffix ("/v2.0" ) if norm_expected .startswith (entra_v2_prefix ) else None
290+
291+ if v1_tenant and v2_tenant and v1_tenant == v2_tenant :
292+ result .issuer_match = True
293+ logger .debug ("Azure AD v1/v2 issuer duality accepted for gateway %s (tenant %s)" , gateway_name , v1_tenant )
294+ return
295+
296+ result .issuer_match = False
297+ safe_iss = str (token_iss )[:80 ]
298+ safe_expected = str (expected_issuer )[:80 ]
299+ result .warnings .append (f"Token issuer mismatch: token iss='{ safe_iss } ', expected '{ safe_expected } '" )
233300
234301
235302def validate_oauth_token_claims (
0 commit comments