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

10👍

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 datetime
from django.utils.timezone import make_aware

def 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.

👤J.B

3👍

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 logging
import uuid
from os import getenv

import msal
import requests
from django.http import JsonResponse
from django.shortcuts import redirect, render
from rest_framework.generics import ListAPIView

logging.getLogger("msal").setLevel(logging.WARN)

# Application (client) ID of app registration
CLIENT_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 consent
GRAPH_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 AD
REDIRECT_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 resp


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


def load_cache(session):
    '''loads from msal cache
    '''
    cache = msal.SerializableTokenCache()
    if session.get("token_cache"):
        cache.deserialize(session["token_cache"])
    return cache

def 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 result

def 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}

Leave a comment