How should I be implementing user SSO with AAD in a Django application (using the Django Microsoft Authentication Backend module)? How should I be implementing user SSO with AAD in a Django application (using the Django Microsoft Authentication Backend module)? django django

How should I be implementing user SSO with AAD in a Django application (using the Django Microsoft Authentication Backend module)?


A couple more days at this and I eventually worked out the issues myself, and learned a little more about how Django works too.

The link I was missing was how/where context processors from (third party) Django modules pass their context's through to the page that's eventually rendered. I didn't realise that variables from the microsoft_auth package (such as the authorisation_url used in its template) were accessible to me in any of my templates by default as well. Knowing this, I was able to implement a slightly simpler version of the same JS based login process that the admin panel uses.

Assuming that anyone reading this in the future is going through the same (learning) process I have (with this package in particular), I might be able to guess at the next couple of questions you'll have...

The first one was "I've logged in successfully...how do I do anything on behalf of the user?!". One would assume you'd be given the user's access token to use for future requests, but at the time of writing this package didn't seem to do it in any obvious way by default. The docs for the package only get you as far as logging into the admin panel.

The (in my opinion, not so obvious) answer is that you have to set MICROSOFT_AUTH_AUTHENTICATE_HOOK to a function that can be called on a successful authentication. It will be passed the logged in user (model) and their token JSON object for you to do with as you wish. After some deliberation, I opted to extend my user model using AbstractUser and just keep each user's token with their other data.

models.py

class User(AbstractUser):    access_token = models.CharField(max_length=2048, blank=True, null=True)    id_token = models.CharField(max_length=2048, blank=True, null=True)    token_expires = models.DateTimeField(blank=True, null=True)

aad.py

from datetime import datetimefrom django.utils.timezone import make_awaredef store_token(user, token):    user.access_token = token["access_token"]    user.id_token = token["id_token"]    user.token_expires = make_aware(datetime.fromtimestamp(token["expires_at"]))    user.save()

settings.py

MICROSOFT_AUTH_EXTRA_SCOPES = "User.Read"MICROSOFT_AUTH_AUTHENTICATE_HOOK = "django_app.aad.store_token"

Note the MICROSOFT_AUTH_EXTRA_SCOPES setting, which might be your second/side question - The default scopes set in the package as SCOPE_MICROSOFT = ["openid", "email", "profile"], and how to add more isn't made obvious. I needed to add User.Read at the very least. Keep in mind that the setting expects a string of space separated scopes, not a list.

Once you have the access token, you're free to make requests to the Microsoft Graph API. Their Graph Explorer is extremely useful in helping out with this.


So I made this custom view in Django based on https://github.com/Azure-Samples/ms-identity-python-webapp.Hopefully, this will help someone.

import loggingimport uuidfrom os import getenvimport msalimport requestsfrom django.http import JsonResponsefrom django.shortcuts import redirect, renderfrom rest_framework.generics import ListAPIViewlogging.getLogger("msal").setLevel(logging.WARN)# Application (client) ID of app registrationCLIENT_ID = "<appid of client registered in AD>"TENANT_ID = "<tenantid of AD>"CLIENT_SECRET = getenv("CLIENT_SECRET")AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID# This resource requires no admin consentGRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0/me'SCOPE = ["User.Read"]LOGIN_URI = "https://<your_domain>/login"# This is registered as a redirect URI in app registrations in ADREDIRECT_URI = "https://<your_domain>/authorize"class Login(ListAPIView):    '''initial login    '''    def get(self, request):        session = request.session        id_token_claims = get_token_from_cache(session, SCOPE)        if id_token_claims:            access_token = id_token_claims.get("access_token")            if access_token:                graph_response = microsoft_graph_call(access_token)                if graph_response.get("error"):                    resp = JsonResponse(graph_response, status=401)                else:                    resp = render(request, 'API_AUTH.html', graph_response)            else:                session["state"] = str(uuid.uuid4())                auth_url = build_auth_url(scopes=SCOPE, state=session["state"])                resp = redirect(auth_url)        else:            session["state"] = str(uuid.uuid4())            auth_url = build_auth_url(scopes=SCOPE, state=session["state"])            resp  = redirect(auth_url)        return respclass Authorize(ListAPIView):    '''authorize after login    '''    def get(self, request):        session = request.session        # If states don't match login again        if request.GET.get('state') != session.get("state"):            return redirect(LOGIN_URI)        # Authentication/Authorization failure        if "error" in request.GET:            return JsonResponse({"error":request.GET.get("error")})        if request.GET.get('code'):            cache = load_cache(session)            result = build_msal_app(cache=cache).acquire_token_by_authorization_code(                request.GET['code'],                # Misspelled scope would cause an HTTP 400 error here                scopes=SCOPE,                redirect_uri=REDIRECT_URI            )            if "error" in result:                resp = JsonResponse({"error":request.GET.get("error")})            else:                access_token = result["access_token"]                session["user"] = result.get("id_token_claims")                save_cache(session, cache)                # Get user details using microsoft graph api call                graph_response = microsoft_graph_call(access_token)                resp = render(request, 'API_AUTH.html', graph_response)        else:            resp = JsonResponse({"login":"failed"}, status=401)        return respdef load_cache(session):    '''loads from msal cache    '''    cache = msal.SerializableTokenCache()    if session.get("token_cache"):        cache.deserialize(session["token_cache"])    return cachedef save_cache(session,cache):    '''saves to msal cache    '''    if cache.has_state_changed:        session["token_cache"] = cache.serialize()def build_msal_app(cache=None, authority=None):    '''builds msal cache    '''    return msal.ConfidentialClientApplication(        CLIENT_ID, authority=authority or AUTHORITY,        client_credential=CLIENT_SECRET, token_cache=cache)def build_auth_url(authority=None, scopes=None, state=None):    '''builds auth url per tenantid    '''    return build_msal_app(authority=authority).get_authorization_request_url(        scopes or [],        state=state or str(uuid.uuid4()),        redirect_uri=REDIRECT_URI)def get_token_from_cache(session, scope):    '''get accesstoken from cache    '''    # This web app maintains one cache per session    cache = load_cache(session)    cca = build_msal_app(cache=cache)    accounts = cca.get_accounts()    # So all account(s) belong to the current signed-in user    if accounts:        result = cca.acquire_token_silent(scope, account=accounts[0])        save_cache(session, cache)        return resultdef microsoft_graph_call(access_token):    '''graph api to microsoft    '''    # Use token to call downstream service    graph_data = requests.get(        url=GRAPH_ENDPOINT,        headers={'Authorization': 'Bearer ' + access_token},        ).json()    if "error" not in graph_data:        return {            "Login" : "success",            "UserId" : graph_data.get("id"),            "UserName" : graph_data.get("displayName"),            "AccessToken" : access_token            }    else:        return {"error" : graph_data}