{"id":1102,"date":"2025-12-24T23:41:35","date_gmt":"2025-12-24T23:41:35","guid":{"rendered":"https:\/\/www.piglets.org\/blog\/?p=1102"},"modified":"2026-01-15T15:26:34","modified_gmt":"2026-01-15T15:26:34","slug":"customising-django-entra-id-authentication","status":"publish","type":"post","link":"https:\/\/www.piglets.org\/blog\/2025\/12\/24\/customising-django-entra-id-authentication\/","title":{"rendered":"Customising Django Microsoft Entra ID Authentication"},"content":{"rendered":"\n<p>I recently had need to add Microsoft <a href=\"https:\/\/en.wikipedia.org\/wiki\/Microsoft_Entra_ID\" data-type=\"link\" data-id=\"https:\/\/en.wikipedia.org\/wiki\/Microsoft_Entra_ID\">Azure \/ Entra ID<\/a> authentication to my <a href=\"https:\/\/github.com\/profcturner\/WAM\">Workload and Assessment Modelling (WAM)<\/a> app, which is written in the rather excellent Django Python framework. After a little bit of desk research it seemed clear that <a href=\"https:\/\/django-auth-adfs.readthedocs.io\/en\/latest\/\">django-auth-adfs<\/a> looked like a suitable package to enable this, and it was pretty quick to add this to my app and get basic authentication working.<\/p>\n\n\n\n<p>Previously WAM was using a legacy CAS method to authenticate University users - which worked, but never provided much more than a <code>REMOTE_USER<\/code> variable containing a username and the fact that the user was authenticated. I had hoped that this transition would allow much richer user data to be brought into WAM - especially when auto creating new users. In a large organisation, having new users simply created in a system with no context or home is a bit problematic.<\/p>\n\n\n\n<p>In theory, django-auth-adfs supports this. It has a <a href=\"https:\/\/django-auth-adfs.readthedocs.io\/en\/latest\/azure_ad_config_guide.html#step-2-configuring-settings-py\">stanza<\/a> in its configuration that does this. It looks something like this, or the relevant bit does.<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">AUTH_ADFS = {\n    # There is some config above this\n    'CLAIM_MAPPING': {'first_name': 'given_name',\n                      'last_name': 'family_name',\n                      'email': 'upn',\n                      'staff': {'staff_number': 'onPremisesSamAccountName',\n                                'job_title': 'jobTitle'},\n                      },\n    # And some config below\n}    <\/pre>\n\n\n\n<p><\/p>\n\n\n\n<p>What this does, is read values from the Entra ID system on the right, from its \"claims\" and maps them into values in the Django User model. It even has support for secondary models that extend the User class (like the Staff example above and below). Hopefully that is all you will need.<\/p>\n\n\n\n<p>However, I had a few concerns when I looked at doing this with my own University claims:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>the claims were - a bit unintuitive in places - for instance requiring checking several obscure values to determine what type of member of staff was logging in;<\/li>\n\n\n\n<li>django-auth-adfs works by needing to mirror values in the Django Model layer for the claims you wanted to map, and I didn't want to hardcode the Database Layer around legacy claim design at one institution;<\/li>\n\n\n\n<li>even if you could neatly map things like School data, I didn't want long strings that are the names of Schools in many models when a primary key to a dedicated model would be more efficient.<\/li>\n<\/ul>\n\n\n\n<p>In short, I needed some way to pre-process the incoming claims before the mapping process into the database layer.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Extending django-auth-adfs<\/h2>\n\n\n\n<p>I decided the best way to approach this was to write a new class to extend the authentication backend in django-auth-adfs, but I had a lot of false starts in doing this, and couldn't find much help online, which is my main reason for writing this for anyone trying to do the same thing. Looking at the <a href=\"https:\/\/github.com\/snok\/django-auth-adfs\">source code<\/a>, you'll need to create a custom version of <code><a href=\"https:\/\/github.com\/snok\/django-auth-adfs\/blob\/main\/django_auth_adfs\/backend.py\">backend.py<\/a><\/code>, or just one class in it. There is a function in the class that handles the claim mapping and user manipulation. I thought I could override that as it takes place after user creation. In a perfect world, it would look a bit like this.<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\"># You need to extend not from AdfsBaseBackend (which I discovered painfully)\n# But from one of the backends that actually undertakes the authentication.\nclass CustomAdfsBackend(AdfsAuthCodeBackend):\n \n    def update_user_attributes(self, user, claims, claim_mapping=None):\n        \n        # My theory was simple, create a new_claims dict that was more\n        # simple or sane than the remote supplied ones\n\n        new_claims = dict()\n     \n        foo = claims.get(\"foo\")\n        if foo:\n            new_claims[\"bar\"] = do_some_transformation(foo)\n\n        # Then merge the dicts with the new one having preference\n        merged_claims = claims | new_claims\n\n        # And then, finally call the super() function to do the hard work.\n        # The claim mapping stanza in the configuration should be written against\n        # the merged claims.\n        return super().update_user_attributes(user, merged_claims)\n<\/pre>\n\n\n\n<p>It took quite a bit of trial and error to get to this point. I had used <code>AdfsBaseBackend<\/code> initiatially to derive from and that caused oauth errors (essentially because the base class doesn't provide authentication). So you need to use one of the two derived classes that django-auth-adfs provides in its <code>backends.py<\/code> file as your base. For me, <code>AdfsAuthCodeBackend<\/code> worked, or it seemed to, but still with some problems.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The subtle secondary problem<\/h2>\n\n\n\n<p>It took a little while to understand why this didn't quite work for me. It imported correctly into the <code>User<\/code> object, but (in my case) mangled doing so with the <code>Staff <\/code>object. Close examination of the original <code>update_user_attributes()<\/code> function in the base class reveals the culprit. The function is called recursively to handle secondary Model objects (configured by nested dictionaries). Unfortunately that calls our doctored version in the derived class a second time and calling the transformation logic twice just ends up breaking things. I suppose one could try and add a variable into the derived class to only call the transformation logic once, but in the end, I opted for a more direct solution. Not least because I wanted to do all sorts of house keeping such as creation new Faculty, School etc. entities on a new user login from unknown Schools.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The not so subtle solution<\/h2>\n\n\n\n<p>The most direct solution I can find is to deliberately <strong>not<\/strong> call the function in the base class with <code>super().update_user_attributes()<\/code>. You will need to do the hard work yourself. At least in my code, I left the merged claims logic intact should django-auth-adfs allow this in the future (for example, by including a trivial transformation function in the base class that can be derived, and which allows the transformation step to be separated from manipulating the <code>User<\/code> model).<\/p>\n\n\n\n<p>So here's some pseudo-code with a bit more detail about imports and the like that you may need. Call this local-adfs.py or something similar. Unfortunately this is a long snippet for a blog, but I hope this might help someone.<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">import logging\n\nfrom django_auth_adfs.config import *\nfrom django_auth_adfs.backend import AdfsBaseBackend, AdfsAccessTokenBackend, AdfsAuthCodeBackend\n\n# Import the models from your app you will need to manipulate\nfrom app.models import Staff, Campus, Faculty, School\n\n# Get an instance of a logger\nlogger = logging.getLogger(__name__)\n\nlogger.debug(\"loading custom adfs backend\")\n\nclass CustomAdfsBackend(AdfsAuthCodeBackend):\n\n    def update_user_attributes(self, user, claims, claim_mapping=None):\n        \"\"\"\n        For brevity, I've removed these comments, but you should comment your claims\n        and the mappings you need.\n        \"\"\"\n\n        logger.debug(\"fetch claims for mapping\")\n        logger.debug(\"Incoming claims are {claims}\".format(claims=claims))\n\n        local_username = claims.get(\"what_your_username_is_called\")\n        # Get the faculty string name (and all the other claims you need,\n        # this is one example)\n        faculty_string = claims.get(\"faculty\")\n\n        # Make a new dict for the transformed data\n        new_claims = dict()\n\n        logger.debug(\"remapping claims\")\n        new_claims['new_name'] = do_some_transformation(claims.get('old_name'])\n\n        # I used the incoming data to make new structures in the database, for instance\n        # I put a function in the Faculty model layer to create an object if it didn't\n        # exist, and return the object (or just fetch the existing one next time).\n\n        faculty_object = Faculty.get_or_create(faculty_string)\n\n        # Your new claim could, now, for instance, have a primary key and not a string\n        new_claims['faculty_pk'] = None if faculty_object is None else faculty_object.pk\n\n        logger.debug(\"outgoing claims are {new_claims}\".format(new_claims=new_claims))\n\n        # Merge the claims, with new versions taking priority\n        merged_claims = claims | new_claims\n        logger.debug(\"merged claims: {}\".format(merged_claims))\n\n        # Can't call super() because it recurses over sub-dicts\n        # which breaks our remapping &lt;sigh>\n        # return super().update_user_attributes(user, merged_claims)\n\n        # &lt;Thanos> Fine. I'll do it myself. &lt;\/Thanos>\n\n        user.first_name = new_claims.get('first_name')\n        logger.debug(\"updated %s first name to %s\" % (user, new_claims.get('first_name')))\n        user.last_name = new_claims.get('last_name')\n        logger.debug(\"updated %s last name to %s\" % (user, new_claims.get('last_name')))\n        user.email = new_claims.get('email')\n        logger.debug(\"updated %s email to %s\" % (user, new_claims.get('email')))\n        user.save()\n        logger.debug(\"saving data to User model %s\" % user)\n\n        try:\n            staff = Staff.objects.get(staff_number=new_claims.get('local_username'))\n        except Staff.DoesNotExist:\n            logger.debug(\"Staff model object for %s not found, could not update\" % user)\n            return\n\n        staff.faculty = faculty_object\n        logger.debug(\"updated %s faculty to %s\" % (user, faculty_object))\n        staff.save()\n        logger.debug(\"saving data to Staff model %s\" % staff)\n<\/pre>\n\n\n\n<p>If you want some more detail on how those <code>get_or_create()<\/code> functions should look, you can check out an example in the WAM <a href=\"https:\/\/github.com\/profcturner\/WAM\/blob\/master\/loads\/models.py\">source code<\/a>. Look at the Faculty model, for instance.<\/p>\n\n\n\n<p>You will now need to edit your<code>settings.py<\/code>to change your authentication backend to your new derived version, or it will still call the base class logic. If you put that in a local directory under your app, it should look something like this.<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\"># We need the ADFS authentication, but also the other backend for admins\nAUTHENTICATION_BACKENDS = (\n    'app.local.local_adfs.CustomAdfsBackend',\n    #'django_auth_adfs.backend.AdfsAuthCodeBackend',\n    'django.contrib.auth.backends.ModelBackend',\n)<\/pre>\n\n\n\n<p>Remember, this means the claim mapping in your <code>AUTH_ADFS<\/code> stanza is not used - you are doing it manually. I also added code to create groups for each department and add users into them automatically by type. The world is your oyster. If you had the same problem as me, I hope this helps and saved you some of the research hours I spent on it.<br><br><\/p>\n","protected":false},"excerpt":{"rendered":"<p>I recently had need to add Microsoft Azure \/ Entra ID authentication to my Workload and Assessment Modelling (WAM) app, which is written in the rather excellent Django Python framework. After a little bit of desk research it seemed clear that django-auth-adfs looked like a suitable package to enable this, and it was pretty quick [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":1103,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"jetpack_post_was_ever_published":false,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_memberships_contains_paid_content":false,"vkexunit_cta_each_option":"","footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"enabled":false},"version":2}},"categories":[7,6,14],"tags":[307,304,308,131,306,305,16,22,58],"class_list":["post-1102","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-11-free-software","category-7-programming","category-17-python","tag-authentication","tag-azure-ad","tag-customisation","tag-django","tag-django-auth-adfs","tag-entra-id","tag-free-software","tag-microsoft","tag-python"],"jetpack_publicize_connections":[],"jetpack_featured_media_url":"https:\/\/www.piglets.org\/blog\/wp-content\/uploads\/2025\/12\/image.png","jetpack_sharing_enabled":true,"jetpack-related-posts":[],"jetpack_shortlink":"https:\/\/wp.me\/p52I4w-hM","_links":{"self":[{"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/posts\/1102","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/comments?post=1102"}],"version-history":[{"count":8,"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/posts\/1102\/revisions"}],"predecessor-version":[{"id":1118,"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/posts\/1102\/revisions\/1118"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/media\/1103"}],"wp:attachment":[{"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/media?parent=1102"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/categories?post=1102"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.piglets.org\/blog\/wp-json\/wp\/v2\/tags?post=1102"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}