Skip to content

Commit b4fc44f

Browse files
committed
Working version with MS graph API
1 parent fede804 commit b4fc44f

1 file changed

Lines changed: 130 additions & 46 deletions

File tree

bluesky_httpserver/authenticators.py

Lines changed: 130 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626

2727
logger = logging.getLogger(__name__)
2828

29+
class AuthCodeExchangeException(Exception):
30+
pass
31+
32+
class AuthMSGraphException(Exception):
33+
pass
2934

3035
class 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

486570
async def exchange_code(

0 commit comments

Comments
 (0)