DotNetCore, Azure AD, and SAML
20 April 2023DotNetCore, 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:
- DotNetCore (also spelled .NET Core). This is a runtime Microsoft provide, usually for C#. Like all things Microsoft provide, it’s confusingly named. This is a different product from .NET Framework. We’re specifically going to be looking at a web application built with DotNetCore. If you need advice on using SAML authentication with .NET Framework, you’re going to find a lot more articles on this (because they’re both old).
- Azure AD (Azure Active Directory). This is Microsoft’s way of giving you the ability to know who someone is when they hit your application. It gets used by schools and businesses to keep a list of people who should be able to log into internal apps. It means we don’t have to keep any passwords or usernames. Microsoft is going to do all that dirty work for us.
- SAML (Security Assertion Markup Language) is not a Microsoft product. This is an open standard for authentication. It was created in the early 2000s when XML was very cool. Most of us have fortunately moved on from XML and now use JSON because we want to enjoy our lives. Unfortunately, the enterprise ecosystem hasn’t. There is a lot of jargon that gets used to SAML protocol. I’m going to avoid using it as much as possible in this article because it makes my eyes glaze over. Basically it’s a way for you to be able to redirect the user to a 3rd party who holds their credentials, then allow that 3rd party to come back and tell us who the user is. In this case, the 3rd party is Microsoft.
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.
- Step one: get an Azure account if you don’t have one.
- Step two: head to https://portal.azure.com and searching “Azure AD” in the search bar and click into “Azure Active Directory”.
- Step three: click into “Enterprise Applications” on the left. Now search for “azure ad SAML toolkit” and click this type of app to create your new application registration.
- Step four: Click into your new application, then into “Single Sign On” on the left. Finally select “SAML”.
Now you need to click “Edit” on the first block of items - the “Basic SAML Configuration”. Here we need to enter three things:
- Identifier - This is an arbitrary string which identifies which of your applications you are trying to authenticate (in case you have more than 1 application). It can be literally anything, but it’s pretty normal to make it the URL of your site. I’m going to make it “myidentifier”
- Reply URL - this is where Microsoft is going to send the user back to after they’ve authenticated by using a POST redirect when the user signs in with Microsoft. More on this later. I’m going to use the URL: https://localhost:7269/Auth/SamlResponse. You would usually give it a few — one for dev and one for production
- Sign On URL - this is the URL for our application - so that Microsoft knows that the request is coming from the right place. I’m going to use the URL: https://localhost:7269.
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 AssertionConsumerServiceUrl
property 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:
- Creating some middleware that will redirect the user to the home page if they aren’t logged in
- Adding some custom claims to the user to give them different access rights to different parts of the application
- Make it configurable from
appsettings.json
which URL out from dev and prod you want Microsoft to loop back to using using theAssertionConsumerServiceUrl
property of theSaml2AuthRequest