Green version of the Azure Logo

DotNetCore, Azure AD, and SAML

20 April 2023

DotNetCore, Azure AD, and SAML are an odd mix of old and new tech I found myself needing to combine. You’ve got the newer cloudiness of Azure, the fresh C# runtime DotNetCore, but a crusty old authentication protocol with SAML.

If you’re like me and have been working on a (mostly) greenfield C# project and find that you need to authenticate to Azure AD using SAML, this guide might come in useful for you. There is nothing on the internet about this specific mix of application runtime, identity provider and protocol. Although there is this guide from Okta on using DotNetCore with their own SAML authentication, which I am partly basing this article off.

First, a little explanation of these three technologies:

Scaffolding A DotNetCore Web App #

You can skip this part if you have an existing DotNetCore app you’re trying to get authenticated, although it’s probably good to take a look at the architecture I’ll be using for the example.

To keep things simple, we’re going to use a server-rendered razor pages app (although it is easily possible to use these technologies in a single page application with React or Angular). You’ll need to have DotNetCore installed, and the dotnet CLI added to your path. To scaffold a server-rendered app with DotNetCore, navigate to a new directory and use this command:

    dotnet new razor

Then you should be able to immediately start your app with:

    # Generates a developer https certificate
    dotnet dev-certs https
    # Starts app
    dotnet run

You should be able to hit the localhost URL it spits out (mine defaults to https://localhost:7269) and see a welcome page.

Setting Up Azure AD #

You can skip this part if your company has an infrastructure department who are going to do this for you.

In this part, we’re setting up the Microsoft Active Directory service which will know that we are going to be writing an application and we want to authenticate with SAML.

Now you need to click “Edit” on the first block of items - the “Basic SAML Configuration”. Here we need to enter three things:

Getting Your Certificate #

Now you can scroll down and download the “Certificate (Raw)”. This certificate is something our app needs to tell that the user has been authenticated by Microsoft. It’s important to keep this safe, as if somebody steals it, they can pretend that they have been logged in by Microsoft when they haven’t been.

Save the certificate in the root folder of your application as cert.cer and add it to .gitignore.

Getting Your Metadata URL #

There’ll be another item on the SAML toolkit page where the name is “App Federation Metadata Url”. Copy this URL and keep it somewhere. Basically what this is is a link to an XML description of the SAML API Microsoft is providing, with a whole bunch of data that our app needs to understand and verify what we get back from Microsoft.

Adding Yourself As A User #

Now you’ll need to add yourself to say you can get into the app. From the Azure AD SAML Toolkit enterprise application, click “Users and Groups” on the left. Then click “Add user/group”, then click “None selected” under users. Now you should be able to add yourself to the app.

ITfoxtec SAML #

We’re going to go ahead and use a nuget package from the folks over at ITfoxtec: ITfoxtec - ITfoxtec Identity SAML 2.0. This is because implementing your own SAML client would be a horrible, time consuming experience. You can install the package with these commands:

    dotnet add package ITfoxtec.Identity.Saml2
    dotnet add package ITfoxtec.Identity.Saml2.MvcCore

Now we can actually start writing some code. Edit your program.cs file. It should be on the root level of your application folder. First add the imports at the top:

    using ITfoxtec.Identity.Saml2;
    using ITfoxtec.Identity.Saml2.Schemas.Metadata;
    using ITfoxtec.Identity.Saml2.MvcCore.Configuration;

Then lower down, just before it says app.UseAuthorization(); add in:

    app.UseAuthentication();

Then scroll back up to builder.Services.AddRazorPages(); and add the following config to register that you want to use ITfoxtec for auth:

    builder.Services.AddRazorPages();
    ConfigurationManager configuration = builder.Configuration;
    builder.Services.Configure<Saml2Configuration>(configuration.GetSection("Saml2"));
    builder.Services.Configure<Saml2Configuration>(saml2Configuration =>
    {
        saml2Configuration.AllowedAudienceUris.Add(saml2Configuration.Issuer);
        string rootDirectory = configuration.GetValue<string>(WebHostDefaults.ContentRootKey);
        var cert = new System.Security.Cryptography.X509Certificates.X509Certificate2(rootDirectory + "\\cert.cer");
        saml2Configuration.SignatureValidationCertificates.Add(cert);
        var entityDescriptor = new EntityDescriptor();
        entityDescriptor.ReadIdPSsoDescriptorFromUrl(new Uri(configuration["Saml2:IdPMetadata"]));
        if (entityDescriptor.IdPSsoDescriptor != null)
        {
            saml2Configuration.SingleSignOnDestination = entityDescriptor.IdPSsoDescriptor.SingleSignOnServices.First().Location;
        }
        else
        {
            throw new Exception("IdPSsoDescriptor not loaded from metadata.");
        }
    });
    builder.Services.AddSaml2();
    var app = builder.Build();

Appsettings.json Configuration #

What the code above is doing is grabbing a bunch of stuff from appsettings.json and giving it to the ITFoxtec library. We need to fill appsettings.json with data from Azure AD so that ITFoxtec knows how to connect to Microsoft’s SAML service. Create a new key in the top level JSON called “saml2” pointing to a JSON object with 3 keys: “IdPMetadata”, "Issuer", and "CertificateValidationMode”. In “IdPMetadata”, add the metadata URL we grabbed earlier. In “Issuer”, add the Identifier from earlier (yes it bothers me these have different names). Finally in “CertificateValidationMode” add “None”.

This is what mine looks like (with the actual GUIDs replaced with “a-random-GUID”):

    {
      "Saml2": {
        "IdPMetadata": "https://login.microsoftonline.com/a-random-GUID/federationmetadata/2007-06/federationmetadata.xml?appid=a-random-GUID",
        "Issuer": "myidentifier",
        "CertificateValidationMode": "None"
      }
    }

Why are we putting CertificateValidationMode as none? That sounds a bit gung-ho with security? I can assure you that it’s not. Basically, what is going on here is that there’s a cartel of companies and organisations who create certificates for SSL and have programmed a large amount of software (including your browser) to get angry if certificates aren’t made by them. CertificateValidationMode is a way to tell ITFoxTec to get mad at you for using a non-supported certificate. The thing is, that the Azure AD certs aren’t part of the certificate club, so we need to set this to false to not throw an error.

Adding Controller Routing #

Now we’re going to need a controller. This means we need to change some of the routing from the default for server-rendered razor apps. Replace this part of the config:

    app.MapRazorPages();

With this:

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapRazorPages();
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=Home}/{action=Index}/{id?}");
    });

This is going to tell it to try and find a razor page to match the route, then if it can’t find it, it will look for a controller using the format controller/action/id where ID will be passed to the action method if it asks for it. This is a pretty standard way to do controller routing these days.

Now our app should still be work and won’t error out when we hit the home page.

Writing An Auth Controller #

Create a new file called AuthController.cs and fill it with this:

    using ITfoxtec.Identity.Saml2;
    using ITfoxtec.Identity.Saml2.Schemas;
    using ITfoxtec.Identity.Saml2.MvcCore;
    using System.Security.Authentication;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.Options;
    namespace AzureAd
    {
        [AllowAnonymous]
        [Route("Auth")]
        public class AuthController : Controller
        {
            const string relayStateReturnUrl = "ReturnUrl";
            private readonly Saml2Configuration config;
            public AuthController(IOptions<Saml2Configuration> configAccessor)
            {
                config = configAccessor.Value;
            }
            [Route("Login")]
            public IActionResult Login()
            {
                var binding = new Saml2RedirectBinding();
                return binding.Bind(new Saml2AuthnRequest(config)).ToActionResult();
            }
            [Route("SamlResponse")]
            public async Task<IActionResult> SamlResponse()
            {
                var binding = new Saml2PostBinding();
                var saml2AuthnResponse = new Saml2AuthnResponse(config);
                binding.ReadSamlResponse(Request.ToGenericHttpRequest(), saml2AuthnResponse);
                if (saml2AuthnResponse.Status != Saml2StatusCodes.Success)
                {
                    throw new AuthenticationException($"SAML Response status: {saml2AuthnResponse.Status}");
                }
                binding.Unbind(Request.ToGenericHttpRequest(), saml2AuthnResponse);
                await saml2AuthnResponse.CreateSession(HttpContext);
               return Redirect("https://localhost:7269");
            }
        }
    }

There’s a lot going on here. There are two endpoints here; the login endpoint, and the SamlResponse endpoint.

The Login Endpoint #

The login endpoint is where the unauthenticated user sends a request to when they want to log in:

            [Route("Login")]
            public IActionResult Login()
            {
                var binding = new Saml2RedirectBinding();
                return binding.Bind(new Saml2AuthnRequest(config)).ToActionResult();
            }

What will happen here is that the SAML library will redirect the user away from our app and over to Microsoft. As it does this, it adds a large amount of data in URL parameter “saml2” which will contain a bunch of information Microsoft asks for in its metadata (including the identifier). It deflates it (applying a kind of compression), base64 encodes it, then URL encodes, then sends it. You can see what the contents contains using this handy tool.

Thankfully this is all abstracted away from us with the Saml2RedirectBinding class, and we just give it the config it needs to go ahead. Here we can also tell Microsoft to loop back to a different domain (eg. between development and production) by setting the AssertionConsumerServiceUrlproperty on the Saml2AuthnRequest.

The SamlResponse Endpoint #

The SamlResponse endpoint is where the user is going to get redirected back to after Microsoft has said they’ve entered in the correct username and password and they’re logged in:\

           [Route("SamlResponse")]
            public async Task<IActionResult> SamlResponse()
            {
                var binding = new Saml2PostBinding();
                var saml2AuthnResponse = new Saml2AuthnResponse(config);
                binding.ReadSamlResponse(Request.ToGenericHttpRequest(), saml2AuthnResponse);
                if (saml2AuthnResponse.Status != Saml2StatusCodes.Success)
                {
                    throw new AuthenticationException($"SAML Response status: {saml2AuthnResponse.Status}");
                }
                binding.Unbind(Request.ToGenericHttpRequest(), saml2AuthnResponse);
                await saml2AuthnResponse.CreateSession(HttpContext);
                return Redirect("https://localhost:7269");
            }

binding.ReadSamlResponse will get all the information from the SAML data, and if there’s no error here we should fully have all the data we need about the user. If you printed the raw POST body as a string then check it with the SAML defalting tool you can see what Microsoft is sending back.

However, even with all this data it’s important to run the unbind method. This checks whether hashing the response with our certificate will get the same result as what Microsoft adds to the “certificate” field in the SAML response. It’s a way to make sure that the information in the response is actually coming from Azure AD. Then if all has gone well we can create the user session, and from here we’ll know who is logged into the app.

User Claims #

The way information is stored about the logged in user is in the User class, which is made accessible to all pages and controllers in DotNetCore apps. What we get is a list of “claims” which are basically key-value pairs of information about the user, which might be their name, email address, or whether they’re a super user. Azure AD will send a bunch of default ones which we can use. You can grab the user claims like this:

    var nameClaim = User.Claims.FirstOrDefault(c=>c.Type=="http://schemas.microsoft.com/identity/claims/displayname");

Let's use this claim to display the user’s name in our app:

Updating The Homepage #

Edit the Pages/Index.cshtml page so the contents of it looks like this:

    @page
    @model IndexModel
    @{
        ViewData["Title"] = "Home page";
    }
    @if (User.Identity.IsAuthenticated)
    {
        var nameClaim = User.Claims.FirstOrDefault(c=>c.Type=="http://schemas.microsoft.com/identity/claims/displayname");
        var name = nameClaim == null ? null : nameClaim.Value;
        <p>Hello, @name</p>
    }
    else
    {
        <a asp-controller="Auth" asp-action="Login">Login</a>
     }

Now we have a functional page which will only display a login link for an unauthenticated user, and will display the users name after they’ve logged in.

Now you should be able to start your application with dotnet run then head to the home page, then click the “login” link.

Moving on from here #

There’s a bunch of stuff that I haven’t done which would be pretty normal in an app:

Back to blog