Categories
Multi-tenant Azure AD Application

Write an Issuer Validator

SERIES:
End-to-end walkthrough: How to Multi-tenant Application, register it with Azure AD, onboard tenants, go through the admin consent workflow, work with incremental [just-in-time] consent. All the code we write will be in C# and the application is an ASP.NET MVC application.

This is Step 5 of the series. To navigate to other articles in this series, look at the end of any article in the series for the list.


In the last chapter, when we configured the Azure AD UI services, we set ValidateIssuer to “false”. And I promised that we would soon write our own. So let’s do that now.


A little theory first.

The way this works is, when we wire up our custom validator, we pass in the concrete method that implements the validation itself. There is no interface to implement here — it all happens using magic signature voodoo. To be able to do this,


In your Solution Explorer, create a new directory at the project level, named “AzureAd“. We will place all AzureAd facing classes in this folder. Right-click on this folder and add a new Class named “MultiTenantIssuerValidator“.

Ensure you have the following references at the start:

using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Tokens;

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security;

When we wire up our validator later in Startup.cs, we will need to have a method with this signature in it:

public string Validate(string actualIssuer, SecurityToken securityToken, TokenValidationParameters validationParameters)

So add the method. Leave the body blank for now, we will add the body shortly.

The Open Id specification defines a configuration document. This is in Json format. For the Azure AD endpoint, this is at a well-known and public address. You can see the document here. We need to programmatically download and parse this Json document to configure our implementation. Thankfully, Microsoft has already done the job or writing the code for this. The only thing we do need to write is an implementation of the IConfigurationRetriever that will complete the circuit in the workflow.

There is quite a lot of code required here and this article will become unnecessarily long. Instead have a look at the code within the following files:

  • /AzureAd/Metadata.cs: Corresponds to the “metadata” element in the Json document.
  • /AzureAd/IssuerMetadata.cs: Is the overall Json document.
  • /AzureAd/AzureAdConfigurationRetriever.cs: Implementation of IConfigurationRetriever. Downloads the Json config document and deserializes it into our IssuerMetadata class instance.

The way we have written our Issuer Validator class, we can use it to retrieve configuration for multiple authorities [an authority is an OAuth 2.0 endpoint that authenticates the user and returns a set of claims / token]. This is implemented in the static GetIssuerValidator() method.

Now we can implement the actual Validate() method. Add the body to it. First thing we need to do is of course parameter validation. Ensure that none of them are NULL and the value of actualIssuer [first string parameter] is non-empty and non-whitespace.

if (string.IsNullOrWhiteSpace(actualIssuer)) { throw new ArgumentNullException(nameof(actualIssuer)); }

if (securityToken == null) { throw new ArgumentNullException(nameof(securityToken)); }

if (validationParameters == null) { throw new ArgumentNullException(nameof(validationParameters)); }

Next we validate the securityToken [2nd param] value. This needs to be a JwtSecurityToken or at least a JsonWebToken in our case. Also, the token needs to contain the “tid” [Tenant Id] claim.

string tenantId = string.Empty;
if (securityToken is JwtSecurityToken jwt)
{
  if (jwt.Payload.TryGetValue("tid", out object? tid))
  {
    tenantId = (string)tid;
  }
  else if (securityToken is JsonWebToken jwebToken)
  {
    tenantId = jwebToken.GetPayloadValue<string>("tid");
  }
}

if (string.IsNullOrWhiteSpace(tenantId))
{
  throw new SecurityTokenInvalidIssuerException("Neither 'tid' not 'tenantid' are present in the claim token.");
}

We ensure that the value of actualIssuer is actually an absolute URL:

if (!Uri.TryCreate(actualIssuer, UriKind.Absolute, out Uri? actualIssuerUri))
{
  throw new ArgumentException(nameof(actualIssuer));
}

Now we need to check if the issuer is valid. This is a repetitive task and involves multiple tests. Let’s go really nuts writing nested private functions!

bool IsIssuerValid(string template, string tenantId, Uri actualIssuer)
{
  if (string.IsNullOrWhiteSpace(template) || (issuerAliases == null))
  {
    return false;
  }

  if (Uri.TryCreate(template.Replace("{tenantid}", tenantId), UriKind.Absolute, out Uri? replacedIssuer))
  {
    return issuerAliases.Contains(replacedIssuer.Authority) && 
      issuerAliases.Contains(actualIssuer.Authority) && 
      HasValidTenantIdInLocalPath(tenantId, replacedIssuer) &&
      HasValidTenantIdInLocalPath(tenantId, actualIssuer);
  }

  return false;


  static bool HasValidTenantIdInLocalPath(string tenantId, Uri uri)
  {
    string trimmedPath = uri.LocalPath.Trim('/');
    return ((trimmedPath == tenantId) || (trimmedPath == $"{tenantId}/v2.0"));
  }
}

Let’s go from the innermost outward:

The HasValidTenantIdInLocalPath() function checks to see if the issuer URL contains the provided Tenant Id [tenant Id is passed in from the TID claim on the securityToken parameter value we are validating]. We check if the path is directly the Tenant Id, or is Tenant Id + “/v2.0”.

The outer IsIssuerValid() function takes the provided template URL [this is retrieved in the outer Validate() function] and performs token-replacement of the provided tenant in that template URL. Now, it checks:

  1. The issuerAliases set [populated in GetIssuerValidator() static method] contains the Authority value from either the original issuer URL or the token-replaced issuer URL.
  2. The actual or token-replaced issuer URL contains the TenantID [using our HasValidTenantIdInLocalPath() nested-nested private function].

If both conditions are true, we declare the issuer to be valid.

Now, we check the issuer in two places:

  1. Each valid issuer listed in validationParameters [3rd input parameter to the Validate() function] ValidIssuers property.
  2. The value of the validationParameters ValidIssuer property. This property is a single string.

The order of preference is: If ValidIssuers collection is non-empty, then we check there first. Otherwise, we check ValidIssuer [single string]:

if (validationParameters.ValidIssuers != null)
{
  foreach (string issuerTemplate in validationParameters.ValidIssuers)
  {
    if (IsIssuerValid(issuerTemplate, tenantId, actualIssuerUri))
    {
      return actualIssuer;
    }
  }
}

if (IsIssuerValid(validationParameters.ValidIssuer, tenantId, actualIssuerUri))
{
  return actualIssuer;
}

Yes, the function has to return the value of the issuer that was found valid. That is why our return statements return the value of ‘actualIssuer’ [variable] and not a true/false value. If we are unable to validate the issuer, we throw a SecurityTokenInvalidIssuerException and let the ASP.NET Core system handle it — results in an app fault.

Okay, now let’s go back into /Startup.cs and plug in our shiny new validator. Go to line 110 where we have turned off the issuer validation. Replace that line with these TWO statements:

options.TokenValidationParameters.IssuerValidator = MultiTenantIssuerValidator.GetIssuerValidator(options.Authority).Validate;
                        options.TokenValidationParameters.ValidateIssuer = true;

Hit a build (SHIFT+F6) and ensure that you don’t have any build errors. There is no point in trying to run the app at this time… you will not see any difference. That will come a little later.

The code added / modified in this chapter is this changeset.

See you in the next chapter!


Leave a Reply

Your email address will not be published. Required fields are marked *