08 August 2023
by
Tran Manh Hung
A Breezy Guide to Implementing SAML2 Authentication with JWT in .NET WebAPI using Sustainsys
.NET
SAML2
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:
Prerequisites
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.
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()
2{
3 X509Certificate2 certificate = new X509Certificate2(@"path/to/your/certificate.pfx", certificatePassword);
4}
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:
1JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
2services
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 => ...);
1services
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;
11
12 RegisterIdentityProvider(sharedOptions, siteAddress, selectedIdentityProvider);
13 });
14
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 };
26
27
28 X509Certificate2 customerSsoCertificate = GetCertificate();
29 ip.SigningKeys.AddConfiguredKey(customerSsoCertificate);
30
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>
5[HttpGet("saml/redirect")]
6public async Task<ActionResult> SamlRedirectSaml(string returnUrl)
7{
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 });
15}
16
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>
21[HttpGet("saml/login")]
22public async Task<ActionResult> SamlLoginCallback(string url)
23{
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 }
30
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 }
37
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 }
50
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 }
59
60 // Redirect user to the original address
61 return Redirect(url);
62}
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()
2{
3 DefaultAzureCredential azureCredentials = new DefaultAzureCredential();
4 CertificateClient client = new CertificateClient(new Uri($"https://{VaultName}.vault.azure.net/"), azureCredentials);
5
6 Azure.Response<X509Certificate2> certificate = client.DownloadCertificate("CertificateName");
7
8 return certificate.Value;
9}
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:
1services
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;
11
12 // NEW - Here's where we register multiple identity providers
13 foreach(IdentityProviderCustomClass selectedIdentityProvider in multipleIdentityProviders)
14 {
15 RegisterIdentityProvider(sharedOptions, siteAddress, selectedIdentityProvider);
16 }
17
18 });
Then, let's add some flavors to the
saml/redirect
endpoint by incorporating identity provider selection logic.1[HttpGet("saml/redirect")]
2public async Task<ActionResult> SamlRedirect(string returnUrl, string entity)
3{
4 var properties = new AuthenticationProperties
5 {
6 RedirectUri = Url.Action(nameof(SamlLoginCallback), new { returnUrlOrError.Value.Url }),
7 IsPersistent = true
8 };
9
10 properties.Items.Add("idp", new EntityId(entity).Id) // NEW - This line is crucial. It explicitly defines the identity provider to use.
11
12 return new ChallengeResult(Saml2Defaults.Scheme, properties);
13}
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:1@page
2@model ErrorModel
3@{
4 Layout = null;
5}
6
7<!DOCTYPE html>
8<html>
9<head>
10 <title>Oops, Something Went Wrong!</title>
11</head>
12<body>
13 <h1>Error</h1>
14 <p>@Model.ErrorMessage</p>
15</body>
16</html>
1using Microsoft.AspNetCore.Mvc.RazorPages;
2
3public class ErrorModel : PageModel
4{
5 public string ErrorMessage { get; set; }
6
7 public void OnGet(string message)
8 {
9 ErrorMessage = message;
10 }
11}
Next, integrate this into your controller. Here's how it would look with some pre-existing code:
1
2[HttpGet("saml/login")]
3public async Task<ActionResult> SamlLoginCallback(string url)
4{
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;
10
11 return view;
12 }
13...
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>
2{
3 // Constructors...
4 // ...
5
6 public Result<UserName> GetUserName() => this.FirstOrDefault(c => c.Type.Equals(ClaimTypes.NameIdentifier, StringComparison.OrdinalIgnoreCase)));
7
8 public Result<Email> GetEmail() => this.FirstOrDefault(c => c.Type.Equals(ClaimTypes.Email, StringComparison.OrdinalIgnoreCase));
9
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.1services
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
11
12...
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!
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