08 August 2023
Hung Tran – Webscope.io
Tran Manh Hung

A Breezy Guide to Implementing SAML2 Authentication with JWT in .NET WebAPI using Sustainsys

Did you pull a new assignment to sprinkle SAML2 magic on your .NET WebAPI using JWT with a free library? Well, your search ends here, fellow coder! By the end of this guide, you'll be equipped with a production-ready solution and glean insights into how to extend it and construct a truly enterprise-grade authentication system.
My inspiration for this guide draws heavily from this stackoverflow thread that offers a great example.

Gearing Up:


Before we delve into the thick of things, it would be beneficial to have:
  • A basic understanding of .NET Core and C# programming. (This article by Jason Watmore is a gem!)
  • Some degree of familiarity with SAML2.
But fret not. Armed with the examples provided here, you'll manage to put together a working solution.

Visualizing the Flow

Before we embark, let's visualize how our app's authentication flow transforms with and without SAML.
Here is really simplified sequence diagram of how basic authentication looks like:
And now with SAML2:

Step 1: Loading Up the Necessary Packages

First off, we need to install the necessary NuGet packages for Sustainsys, JWT, and other supporting libraries. Head to the Package Manager Console and run these commands:
1Install-Package Sustainsys.Saml2.AspNetCore2
2Install-Package Microsoft.AspNetCore.Authentication.JwtBearer
3Install-Package System.IdentityModel.Tokens.Jwt

Step 1.1: Certificates 101

SAML authentication protocol mandates certificate management, primarily used for signing and encrypting SAML assertions. Here's a brief rundown on how to handle these certificates.
For our demo, we'll use the certificate from https://stubidp.sustainsys.com/Certificate
Here's how you'd use a .pfx file:
1public static X509Certificate2 GetCertificate()
3    X509Certificate2 certificate = new X509Certificate2(@"path/to/your/certificate.pfx", certificatePassword);

Do you need a reliable partner in tech for your next project?

Step 2: Configuring SAML2 & JWT Authentication

Fantastic! Now, we'll couple SAML2 authentication with Sustainsys and set up JWT authentication in our .NET WebAPI application. Open your Startup.cs file (or Program.cs, based on your configs) and add these configurations:
3    .AddAuthentication(options =>
4    {
5        options.DefaultScheme = "AppScheme";
6        options.DefaultSignInScheme = "AppExternalScheme"; // If you didn't have add default scheme
7        options.DefaultSignOutScheme = "AppExternalScheme"; // If you didn't have add default scheme
8    })
9    .AddCookie("AppExternalScheme") // If you didn't have add this
10    .AddJwtBearer("AppScheme", options => ...);
2    .AddAuthentication()
3    // AddSaml2 must always be after AddAuthentication
4    .AddSaml2(sharedOptions =>
5    {
6        string siteAddress = "https://your.application.example.com/" // take note!
7        sharedOptions.SPOptions.EntityId = new EntityId(siteAddress); 
8        sharedOptions.SPOptions.ServiceCertificates.Add(serviceCertificate);
9        sharedOptions.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Always;
10        sharedOptions.SPOptions.WantAssertionsSigned = true;
12        RegisterIdentityProvider(sharedOptions, siteAddress, selectedIdentityProvider);
13    });
15    // Register a single identity provider
16    void RegisterIdentityProvider(Saml2Options sharedOptions, string siteAddress, IdentityProviderCustomClass identityProvider)
17    {
18        var ip = new IdentityProvider(
19            new EntityId(identityProvider.MetadataId), sharedOptions.SPOptions)
20        {
21            MetadataLocation = identityProvider.MetadataLocation, // example: https://stubidp.sustainsys.com/metadata
22            LoadMetadata = true,
23            DisableOutboundLogoutRequests = false,
24            AllowUnsolicitedAuthnResponse = identityProvider.AllowUnsolicitedAuthResponse,
25        };
28        X509Certificate2 customerSsoCertificate = GetCertificate();
29        ip.SigningKeys.AddConfiguredKey(customerSsoCertificate);
31        //Finally, add identity provider to the app
32        sharedOptions.IdentityProviders.Add(ip);
33    }

Step 3: Crafting New Controller Endpoints

1/// <summary>
2/// Redirects the user to the SAML authentication provider (SSO)
3/// </summary>
4/// <param name="returnUrl">URL to which the user should return post successful authentication.</param>
6public async Task<ActionResult> SamlRedirectSaml(string returnUrl)
8    return new ChallengeResult(
9        Saml2Defaults.Scheme,
10        new AuthenticationProperties
11        {
12            RedirectUri = Url.Action(nameof(SamlLoginCallback), new { returnUrlOrError.Value.Url }),
13            IsPersistent = true
14        });
17/// <summary>
18/// Logs the user in (SSO).
19/// </summary>
20/// <param name="url">URL to which the user should return post successful authentication.</param>
22public async Task<ActionResult> SamlLoginCallback(string url)
24    // Use .AuthenticateAsync to verify if user authentication was successful
25    AuthenticateResult authenticateResult = await HttpContext.AuthenticateAsync("AppExternalScheme");
26    if (!authenticateResult.Succeeded)
27    {
28        return await Problem(statusCode: StatusCodes.Status401Unauthorized);
29    }
31    // Use claims to identify authenticated user 
32    Collection<Claim> claimCollection = authenticateResult?.Principal?.Claims;
33    if (claimCollection.Count == 0) // No claims specified
34    {
35        return await Problem(statusCode: StatusCodes.Status400BadRequest);
36    }
38    // You can use ClaimTypes instead of long string like 
39    // Always refer to the identity provider documentation of your choice to match the correct claims with your app
40    string? userName = claimCollection.FirstOrDefault(c => c.Name.Equals(ClaimTypes.NameIdentifier, StringComparison.OrdinalIgnoreCase)).Value;    
41    Maybe<UserSecurityData> existingUser = await _userService.GetUser(userName);
42    if (existingUser.HasNoValue) // User doesn't exist 
43    {
44        // Optional: Auto-create a user account if none exists
45        Result<User> autoCreatedUser = await _userService.AutoCreateAccount(userNameOrError.Value, claimCollection);
46        if (autoCreatedUser.IsFailure)
47        { 
48            return await Problem(statusCode: StatusCodes.Status401Unauthorized);
49        }
51        // Log in user as you normally would
52        await LogInSsoUser(autoCreatedUser.Value.Id);
53    }
54    else
55    {
56        // Log in user as you normally would
57        await LogInSsoUser(existingUser.Value.Id);
58    }
60    // Redirect user to the original address
61    return Redirect(url);

Unveiling the Hidden Treasures

This section'll explore a few more tips to enhance your solution.

Tap into Azure Key Vault

Did you know there's a savvy way to manage your certificates? Azure Key Vault is your secret weapon among Microsoft Azure's cloud-based services. Think of it as a secure digital chest where you can safely stash your cryptographic keys, certificates, secrets, and connection strings used by your cloud apps and services. Rather than spreading them throughout your application code or configuration files, Azure Key Vault offers centralized, safe, and scalable storage.
To give you an idea, here's a snapshot of how you can utilize Azure Key Vault:
1public static X509Certificate2 GetCertificate()
3    DefaultAzureCredential azureCredentials = new DefaultAzureCredential();
4    CertificateClient client = new CertificateClient(new Uri($"https://{VaultName}.vault.azure.net/"), azureCredentials);
6    Azure.Response<X509Certificate2> certificate = client.DownloadCertificate("CertificateName");
8    return certificate.Value;

Juggling Multiple Identity Providers

Imagine handling multiple identity providers, such as Azure AD and OKTA. No problem at all! A few modifications to our existing solution, and you're all set.
Firstly, let's revisit our existing code and integrate some additional lines. We'll begin with the registration:
2    .AddAuthentication()
3    // AddSaml2 must always follow AddAuthentication
4    .AddSaml2(sharedOptions =>
5    {
6        string siteAddress = "https://your.application.example.com/" // be careful here
7        sharedOptions.SPOptions.EntityId = new EntityId(siteAddress); 
8        sharedOptions.SPOptions.ServiceCertificates.Add(serviceCertificate);
9        sharedOptions.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Always;
10        sharedOptions.SPOptions.WantAssertionsSigned = true;
12        // NEW - Here's where we register multiple identity providers
13        foreach(IdentityProviderCustomClass selectedIdentityProvider in multipleIdentityProviders)
14        {
15            RegisterIdentityProvider(sharedOptions, siteAddress, selectedIdentityProvider);
16        }
18    });
Then, let's add some flavors to the saml/redirect endpoint by incorporating identity provider selection logic.
2public async Task<ActionResult> SamlRedirect(string returnUrl, string entity)
4    var properties = new AuthenticationProperties
5    {
6        RedirectUri = Url.Action(nameof(SamlLoginCallback), new { returnUrlOrError.Value.Url }),
7        IsPersistent = true
8    };
10    properties.Items.Add("idp", new EntityId(entity).Id) // NEW - This line is crucial. It explicitly defines the identity provider to use.
12    return new ChallengeResult(Saml2Defaults.Scheme, properties);

A User-Friendly Approach to Error Handling

When directing users to multiple websites, the traditional error messaging approach between the Front-End (FE) and the API can fall short. However, you can handle this user-friendly by designing a cshtml view on the API end to display error messages.
Let's look at an example. Start by creating Error.cshtml and ErrorModel.cs files:
2@model ErrorModel
4    Layout = null;
7<!DOCTYPE html>
10    <title>Oops, Something Went Wrong!</title>
13    <h1>Error</h1>
14    <p>@Model.ErrorMessage</p>
1using Microsoft.AspNetCore.Mvc.RazorPages;
3public class ErrorModel : PageModel
5    public string ErrorMessage { get; set; }
7    public void OnGet(string message)
8    {
9        ErrorMessage = message;
10    }
Next, integrate this into your controller. Here's how it would look with some pre-existing code:
3public async Task<ActionResult> SamlLoginCallback(string url)
5    AuthenticateResult authenticateResult = await HttpContext.AuthenticateAsync("AppExternalScheme");
6    if (!authenticateResult.Succeeded)
7    {
8        ViewResult view = View("Error", new ErrorModel{ ErrorMessage = "Authentication failure!"});
9        view.StatusCode = StatusCodes.Status401Unauthorized;
11        return view;
12    }

Simplifying Claims Processing With a Helper Class

Claims are generally used to identify an authenticated user. You might want to consider creating a helper class to make your code easier to understand and maintain.
1 public class ClaimCollection : Collection<Claim>
3    // Constructors...
4    // ...
6    public Result<UserName> GetUserName() => this.FirstOrDefault(c => c.Type.Equals(ClaimTypes.NameIdentifier, StringComparison.OrdinalIgnoreCase)));
8    public Result<Email> GetEmail() => this.FirstOrDefault(c => c.Type.Equals(ClaimTypes.Email, StringComparison.OrdinalIgnoreCase));
10// Add other claims as needed... 

The Importance of Logging

If you've ever been caught up in a SAML2 flow issue, you'd know how challenging it can be to identify the problem. Was it an error in the Identity Provider (IdP) configuration? Or was there a bug within your code? For this reason, it's crucial to incorporate a logging system into your application's communication with the identity provider.
The sustainsys library handles this aspect and allows for the injection of a logger. In the following example, we've used the pre-defined AspNetCoreLoggerAdapter, which employs the standard Microsoft.Extensions.Logging class.
2    .AddAuthentication()
3    .AddSaml2(sharedOptions =>
4    {
5        string siteAddress = "https://your.application.example.com/" // remember this!
6        sharedOptions.SPOptions.EntityId = new EntityId(siteAddress); 
7        sharedOptions.SPOptions.ServiceCertificates.Add(serviceCertificate);
8        sharedOptions.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Always;
9        sharedOptions.SPOptions.WantAssertionsSigned = true;
10        sharedOptions.SPOptions.Logger = new AspNetCoreLoggerAdapter(logger)  // Here's where you add the logger

The Charm of User-Password Authentication with SAML2

What's so special about SAML2 in .NET? With SAML2, you no longer need to implement user-password authentication separately. SAML2 seamlessly integrates into your authentication processes, allowing you to maintain your existing authentication endpoints. It handles the Single Sign-On (SSO) flow, so users can log in once and access multiple applications without having to repeatedly enter their credentials.

Final Thoughts

Voila! You've now become part of the elite club capable of integrating SAML2 into their .NET applications. Recognizing the potential for research headaches, I decided to put together this comprehensive guide. But don't rest on your laurels. This is just the starting point, and there's a whole universe to explore beyond. However, with this demo, you've already hit the ground running.
If you've discovered an even better solution, please share! We're all in this learning journey together. Also, a hearty shoutout to the talented team behind the Sustainsys library. Keep the coding spirit alive. Happy coding!
Share post

Let’s stay connected

Do you want the latest and greatest from our blog straight to your inbox? Chuck us your email address and get informed.
You can unsubscribe any time. For more details, review our privacy policy