2626
2727logger = logging .getLogger (__name__ )
2828
29+ class AuthCodeExchangeException (Exception ):
30+ pass
31+
32+ class AuthMSGraphException (Exception ):
33+ pass
2934
3035class DummyAuthenticator (InternalAuthenticator ):
3136 """
@@ -303,7 +308,19 @@ def __init__(
303308 scopes_map : Optional [Dict [str , list [str ]]] = None ,
304309 client_secret : str = "" ,
305310 redirect_on_success : Optional [str ] = None ,
311+ graph_username_attribute : Optional [str ] = None ,
306312 ):
313+ """A MS Entra specific version of the OIDC authenticator
314+
315+ It attempts to extract a username from the standard list of claims returned
316+ from the token Entra provides. Alternatively if a graph_username_attribute
317+ is used then a call is made to MSGraphAPI to get the provided user attribute
318+ and use it as the username instead.
319+
320+ The graph API call is the recommended way to authenticate with MS products, as all
321+ claims in the token are inconsistent and not guaranteed.
322+
323+ """
307324 self .scopes_map = scopes_map if scopes_map is not None else {}
308325 self .extra_scopes = extra_scopes or []
309326 super ().__init__ (
@@ -318,6 +335,7 @@ def __init__(
318335 if client_secret :
319336 self ._client_secret = Secret (client_secret )
320337 self .redirect_on_success = redirect_on_success
338+ self .graph_username_attribute = graph_username_attribute
321339
322340 @property
323341 def scopes (self ):
@@ -402,6 +420,100 @@ def decode_token(self, id_token: str, access_token: Optional[str] = None) -> dic
402420
403421 return claims
404422
423+ async def graph_lookup (self , access_token , user_param ):
424+ """Uses the access token provided in the auth flow to lookup a user parameter"""
425+ headers = {
426+ "Authorization" : f"Bearer { access_token } "
427+ }
428+
429+ async with httpx .AsyncClient () as client :
430+ response = await client .get (
431+ "https://graph.microsoft.com/v1.0/me" ,
432+ params = {
433+ "$select" : user_param },
434+ headers = headers ,
435+ )
436+
437+ response .raise_for_status ()
438+
439+ return response .json ()
440+
441+ def log_token_claims (self , verified_body ):
442+ """ log token claims
443+ Includes logging of the token claims so misconfigurations are easier
444+ to diagnose. Keep at debug level to avoid leaking PII in production logs
445+ by default
446+ """
447+ logger .debug (
448+ "EntraAuthenticator.authenticate: id_token claims present: %s" ,
449+ sorted (verified_body .keys ()),
450+ )
451+ logger .debug (
452+ "EntraAuthenticator.authenticate: entra_username=%r user=%r entra_sub=%r preferred_username=%r" ,
453+ verified_body .get ("entra_username" ),
454+ verified_body .get ("user" ),
455+ verified_body .get ("entra_sub" ),
456+ verified_body .get ("preferred_username" ),
457+ )
458+
459+ async def get_username_from_graph (self , access_token ):
460+ """Attempts to get the username from either claims or MSGraphAPI call
461+
462+ If no username is found, there are errors in looking up the graphAPI
463+ username, or whatever it returns None
464+ """
465+ try :
466+ profile = await self .graph_lookup (access_token , self .graph_username_attribute )
467+ logger .debug ("Graph Profile: %r" , profile )
468+ except (httpx .HTTPStatusError , httpx .RequestError , ValueError ):
469+ logger .warning ("Graph lookup failed" )
470+ username = None
471+ if profile :
472+ username = profile .get (self .graph_username_attribute )
473+ if not username :
474+ logger .warning (
475+ "Graph lookup succeeded but %s was empty" ,
476+ self .graph_username_attribute ,
477+ )
478+ return username
479+
480+ def create_usersession (self , access_token , refresh_token , username ):
481+ """ Create usersession from tokens and final username
482+
483+ Store the Entra access and refresh tokens so that downstream
484+ services that rely on Tiled authentication can perform an OBO exchange
485+ to obtain per-user tokens for other services. The refresh token
486+ allows silent renewal without requiring the user to re-authenticate.
487+ """
488+ state : dict = {}
489+ if access_token :
490+ state ["entra_access_token" ] = access_token
491+ if refresh_token :
492+ state ["entra_refresh_token" ] = refresh_token
493+ return UserSessionState (username , state )
494+
495+ async def auth_code_exchange (self , request : Request ):
496+ """Perform the authorization code exchange"""
497+ code = request .query_params .get ("code" )
498+ if not code :
499+ logger .warning ("Authentication failed: No authorization code parameter provided." )
500+ raise AuthCodeExchangeException
501+ redirect_uri = f"{ get_root_url (request )} { request .url .path } "
502+ response = await exchange_code (
503+ self .token_endpoint ,
504+ code ,
505+ self ._client_id ,
506+ self ._client_secret .get_secret_value (),
507+ redirect_uri ,
508+ extra_scopes = self .extra_scopes ,
509+ )
510+ response_body = response .json ()
511+ if response .is_error :
512+ logger .error ("Authentication error: %r" , response_body )
513+ raise AuthCodeExchangeException
514+ logger .debug ("Response: %s" , response_body )
515+ return response_body
516+
405517 async def authenticate (self , request : Request ) -> Optional [UserSessionState ]:
406518 """Complete the Entra OIDC authorization-code flow and return a session.
407519
@@ -422,28 +534,18 @@ async def authenticate(self, request: Request) -> Optional[UserSessionState]:
422534 endpoint with ``grant_type=refresh_token`` to obtain a fresh pair and
423535 write it back to the session DB so subsequent Tiled ``slide_session``
424536 calls propagate the update automatically.
537+
538+ When an error occurs, the authenticate function will return None
539+ instead of a UserSessionState
425540 """
426- code = request .query_params .get ("code" )
427- if not code :
428- logger .warning ("Authentication failed: No authorization code parameter provided." )
429- return None
430- redirect_uri = f"{ get_root_url (request )} { request .url .path } "
431- response = await exchange_code (
432- self .token_endpoint ,
433- code ,
434- self ._client_id ,
435- self ._client_secret .get_secret_value (),
436- redirect_uri ,
437- extra_scopes = self .extra_scopes ,
438- )
439- response_body = response .json ()
440- if response .is_error :
441- logger .error ("Authentication error: %r" , response_body )
541+ try :
542+ response_body = await self .auth_code_exchange (request )
543+ except AuthCodeExchangeException :
442544 return None
443- logger .debug ("Response: %s" , response_body )
444545 id_token = response_body ["id_token" ]
445546 access_token = response_body .get ("access_token" )
446547 refresh_token = response_body .get ("refresh_token" )
548+
447549 try :
448550 verified_body = self .decode_token (id_token , access_token )
449551 except JWTError :
@@ -452,35 +554,17 @@ async def authenticate(self, request: Request) -> Optional[UserSessionState]:
452554 jwt .get_unverified_claims (id_token ),
453555 )
454556 return None
455- # Log the id_token claims available for username resolution so
456- # misconfigurations (missing optional claims) are easy to diagnose.
457- # Logged at DEBUG to avoid leaking PII in production logs by default.
458- logger .debug (
459- "EntraAuthenticator.authenticate: id_token claims present: %s" ,
460- sorted (verified_body .keys ()),
461- )
462- user_claims_list = [f"{ key } :{ value } " for key , value in verified_body .items ()]
463- logger .debug ("Claims:\n %s" , "\n " .join (user_claims_list ))
464- logger .debug ("Token claims: %s" , verified_body )
465- logger .debug (
466- "EntraAuthenticator.authenticate: entra_username=%r user=%r entra_sub=%r" ,
467- verified_body .get ("entra_username" ),
468- verified_body .get ("user" ),
469- verified_body .get ("entra_sub" ),
470- )
471- # Use the human-readable username (e.g. "dallan") instead of the
472- # opaque UUID-based "sub" that OIDCAuthenticator would use.
473- username = verified_body .get ("user" ) or verified_body ["sub" ]
474- # Store the Entra access and refresh tokens so that downstream
475- # services that rely on Tiled authentication can perform an OBO exchange
476- # to obtain per-user tokens for other services. The refresh token
477- # allows silent renewal without requiring the user to re-authenticate.
478- state : dict = {}
479- if access_token :
480- state ["entra_access_token" ] = access_token
481- if refresh_token :
482- state ["entra_refresh_token" ] = refresh_token
483- return UserSessionState (username , state )
557+ self .log_token_claims (verified_body )
558+
559+ if self .graph_username_attribute is not None :
560+ username = await self .get_username_from_graph (access_token )
561+ else :
562+ username = verified_body .get ("user" ) or verified_body ["sub" ]
563+
564+ if username is not None :
565+ return self .create_usersession (access_token , refresh_token , username )
566+ else :
567+ return None
484568
485569
486570async def exchange_code (
0 commit comments