We also need dynamic JWT audience handler, specifically for Azure B2C tenants. The tenant information is stored in a database which was used to configure individual OAuthBearerAuthenticationProvider()
entries per tenant and B2C policy (an additional parameter required for using B2C tenants).
We found that trying to add additional entries by trying to use IAppBuilder
UseOAuthBearerAuthentication()
after startup simply did not work - the providers where not correctly managed and therefore the signing tokens were not retrieved, resulting in a HTTP 401 challenge. (We kept the IAppBuiler
object around so it could be used later.)
Looking at the JwtFormat.cs
code which validates the token provided the clue (we are on version 3.1.0 - YMMV) on how to implement a solution:
https://github.com/aspnet/AspNetKatana/blob/v3.1.0/src/Microsoft.Owin.Security.Jwt/JwtFormat.cs#L193
This is where it pulls the issuers and signing keys from the supplied OAuthBearerAuthenticationProvider()
. Note that it is a bit inefficient for our purposes - it pulls ALL the issuers and signing keys, even though only one audience will match the JWT issued by the Azure B2C tenant.
Instead what we did was:
- Use only one
UseOAuthBearerAuthentication()
call with a no OAuthBearerAuthenticationProvider()
- just passing the TokenValidationParameters;
- Use a subclassed
JwtSecurityTokenHandler
class and override the ValidateToken
to dynamically manage the audiences;
- Create an instance of the subclassed
JwtSecurityTokenHandler
and poke it into JwtFormat.TokenHandler
.
How you manage and initiate adding new audiences is up to you. We use a database and Redis to deliver the reload command.
Here is the the Startup.Auth.cs snippet:
/// <summary>
/// The B2C token handler for handling dynamically loaded B2C tenants.
/// </summary>
protected B2CTokenHandler TokenHandler = new B2CTokenHandler();
/// <summary>
/// Setup the OAuth authentication. We use the database to retrieve the available B2C tenants.
/// </summary>
/// <param name="app">The application builder object</param>
public AuthOAuth2(IAppBuilder app) {
// get Active Directory endpoint
AadInstance = ConfigurationManager.AppSettings["b2c:AadInstance"];
// get the B2C policy list used by API1
PolicyIdList = ConfigurationManager.AppSettings["b2c:PolicyIdList"].Split(',').Select(p => p.Trim()).ToList();
TokenValidationParameters tvps = new TokenValidationParameters {
NameClaimType = "http://schemas.microsoft.com/identity/claims/objectidentifier"
};
// create a access token format
JwtFormat jwtFormat = new JwtFormat(tvps);
// add our custom token handler which will provide token validation parameters per tenant
jwtFormat.TokenHandler = TokenHandler;
// wire OAuth authentication for tenants
app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions {
// the security token provider handles Azure AD B2C metadata & signing keys from the OpenIDConnect metadata endpoint
AccessTokenFormat = jwtFormat,
Provider = new OAuthBearerAuthenticationProvider() {
OnValidateIdentity = async (context) => await OAuthValidateIdentity(context)
}
});
// load initial OAuth authentication tenants
LoadAuthentication();
}
/// <summary>
/// Load the OAuth authentication tenants. We maintain a local hash map of those tenants during
/// processing so we can track those tenants no longer in use.
/// </summary>
protected override void LoadAuthentication() {
AuthProcessing authProcessing = new AuthProcessing();
List<B2CAuthTenant> authTenantList = new List<B2CAuthTenant>();
// add all tenants for authentication
foreach (AuthTenantApp authTenantApp in authProcessing.GetAuthTenantsByAppId("API1")) {
// create a B2C authentication tenant per policy. Note that the policy may not exist, and
// this will be handled by the B2C token handler at configuration load time below
foreach (string policyId in PolicyIdList) {
authTenantList.Add(new B2CAuthTenant {
Audience = authTenantApp.ClientId,
PolicyId = policyId,
TenantName = authTenantApp.Tenant
});
}
}
// and load the token handler with the B2C authentication tenants
TokenHandler.LoadConfiguration(AadInstance, authTenantList);
// we must update the CORS origins
string origins = string.Join(",", authProcessing.GetAuthTenantAuthoritiesByAppId("API1").Select(a => a.AuthorityUri));
// note some browsers do not support wildcard for exposed headers - there specific needed. See
//
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
EnableCorsAttribute enableCors = new EnableCorsAttribute(origins, "*", "*", "Content-Disposition");
enableCors.SupportsCredentials = true;
enableCors.PreflightMaxAge = 30 * 60;
GlobalConfiguration.Configuration.EnableCors(enableCors);
}
Here is the snippet for the overridden JwtSecurityTokenHandler
class:
/// <summary>
/// Dictionary of currently configured OAuth audience+policy to the B2C endpoint signing key cache.
/// </summary>
protected ConcurrentDictionary<string, OpenIdConnectCachingSecurityTokenProvider> AudiencePolicyMap = new ConcurrentDictionary<string, OpenIdConnectCachingSecurityTokenProvider>();
/// <summary>
/// Load the B2C authentication tenant list, creating a B2C endpoint security token provider
/// which will bethe source of the token signing keys.
/// </summary>
/// <param name="aadInstance">The Active Directory instance endpoint URI</param>
/// <param name="b2cAuthTenantList">The B2C authentication tenant list</param>
public void LoadConfiguration(string aadInstance, List<B2CAuthTenant> b2cAuthTenantList) {
// maintain a list of keys that are loaded
HashSet<string> b2cAuthTenantSet = new HashSet<string>();
// attempt to create a security token provider for each authentication tenant
foreach(B2CAuthTenant b2cAuthTenant in b2cAuthTenantList) {
// form the dictionary key
string tenantKey = $"{b2cAuthTenant.Audience}:{b2cAuthTenant.PolicyId}";
if (!AudiencePolicyMap.ContainsKey(tenantKey)) {
try {
// attempt to create a B2C endpoint security token provider. We may fail if there is no policy
// defined for that tenant
OpenIdConnectCachingSecurityTokenProvider tokenProvider = new OpenIdConnectCachingSecurityTokenProvider(String.Format(aadInstance, b2cAuthTenant.TenantName, b2cAuthTenant.PolicyId));
// add to audience:policy map
AudiencePolicyMap[tenantKey] = tokenProvider;
// this guy is new
b2cAuthTenantSet.Add(tenantKey);
} catch (Exception ex) {
// exception has already been reported appropriately
}
} else {
// this guys is already present
b2cAuthTenantSet.Add(tenantKey);
}
}
// at this point we have a set of B2C authentication tenants that still exist. Remove any that are not
foreach (KeyValuePair<string, OpenIdConnectCachingSecurityTokenProvider> kvpAudiencePolicy in AudiencePolicyMap.Where(t => !b2cAuthTenantSet.Contains(t.Key))) {
AudiencePolicyMap.TryRemove(kvpAudiencePolicy.Key, out _);
}
}
/// <summary>
/// Validate a security token. We are responsible for priming the token validation parameters
/// with the specific parameters for the audience:policy, if found.
/// </summary>
/// <param name="securityToken">A 'JSON Web Token' (JWT) that has been encoded as a JSON object. May be signed using 'JSON Web Signature' (JWS)</param>
/// <param name="tvps">Contains validation parameters for the security token</param>
/// <param name="validatedToken">The security token that was validated</param>
/// <returns>A claims principal from the jwt. Does not include the header claims</returns>
public override ClaimsPrincipal ValidateToken(string securityToken, TokenValidationParameters tvps, out SecurityToken validatedToken) {
if (string.IsNullOrWhiteSpace(securityToken)) {
throw new ArgumentNullException("Security token is null");
}
// decode the token as we need the 'aud' and 'tfp' claims
JwtSecurityToken token = ReadToken(securityToken) as JwtSecurityToken;
if (token == null) {
throw new ArgumentOutOfRangeException("Security token is invalid");
}
// get the audience and policy
Claim audience = token.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Aud);
Claim policy = token.Claims.FirstOrDefault(c => c.Type == ClaimTypesB2C.Tfp);
if ((audience == null) || (policy == null)) {
throw new SecurityTokenInvalidAudienceException("Security token has no audience/policy id");
}
// generate the key
string tenantKey = $"{audience.Value}:{policy.Value}";
// check if this audience:policy is known
if (!AudiencePolicyMap.ContainsKey(tenantKey)) {
throw new SecurityTokenInvalidAudienceException("Security token has unknown audience/policy id");
}
// get the security token provider
OpenIdConnectCachingSecurityTokenProvider tokenProvider = AudiencePolicyMap[tenantKey];
// clone the token validation parameters so we can update
tvps = tvps.Clone();
// we now need to prime the validation parameters for this audience
tvps.ValidIssuer = tokenProvider.Issuer;
tvps.ValidAudience = audience.Value;
tvps.AuthenticationType = policy.Value;
tvps.IssuerSigningTokens = tokenProvider.SecurityTokens;
// and call real validator with updated parameters
return base.ValidateToken(securityToken, tvps, out validatedToken);
}
For our B2C tenants, it is the case that not all available policies are defined for a tenant. We need to handle that in OpenIdConnectCachingSecurityTokenProvider
:
/// <summary>
/// Retrieve the metadata from the endpoint.
/// </summary>
private void RetrieveMetadata() {
metadataLock.EnterWriteLock();
try {
// retrieve the metadata
OpenIdConnectConfiguration config = Task.Run(configManager.GetConfigurationAsync).Result;
// and update
issuer = config.Issuer;
securityTokens = config.SigningTokens;
} catch (Exception ex) when (CheckHttp404(ex)) {
// ignore 404 errors as they indicate that the policy does not exist for a tenant
logger.Warn($"Policy endpoint not found for {metadataEndpoint} - ignored");
throw ex;
} catch (Exception ex) {
logger.Fatal(ex, $"System error in retrieving token metadatafor {metadataEndpoint}");
throw ex;
} finally {
metadataLock.ExitWriteLock();
}
}
/// <summary>
/// Check if the inner most exception is a HTTP response with status code of Not Found.
/// </summary>
/// <param name="ex">The exception being examined for a 404 status code</param>
/// <returns></returns>
private bool CheckHttp404(Exception ex) {
// get the inner most exception
while(ex.InnerException != null) {
ex = ex.InnerException;
}
// check if a HttpWebResponse with a 404
return (ex is WebException webex) && (webex.Response is HttpWebResponse response) && (response.StatusCode == HttpStatusCode.NotFound);
}