I have a C#
API that uses OWIN JWT for authentication.
My startup.cs
(of my resource server) configures OAuth vis the code:
public void ConfigureOAuth(IAppBuilder app)
{
var issuer = "<the_same_issuer_as_AuthenticationServer.Api>";
// Api controllers with an [Authorize] attribute will be validated with JWT
var audiences = DatabaseAccessLayer.GetAllowedAudiences(); // Gets a list of audience Ids, secrets, and names (although names are unused)
// List the
List<string> audienceId = new List<string>();
List<IIssuerSecurityTokenProvider> providers = new List<IIssuerSecurityTokenProvider>();
foreach (var aud in audiences) {
audienceId.Add(aud.ClientId);
providers.Add(new SymmetricKeyIssuerSecurityTokenProvider(issuer, TextEncodings.Base64Url.Decode(aud.ClientSecret)));
}
app.UseJwtBearerAuthentication(
new JwtBearerAuthenticationOptions
{
AuthenticationMode = AuthenticationMode.Active,
AllowedAudiences = audienceId.ToArray(),
IssuerSecurityTokenProviders = providers.ToArray(),
Provider = new OAuthBearerAuthenticationProvider
{
OnValidateIdentity = context =>
{
context.Ticket.Identity.AddClaim(new System.Security.Claims.Claim("newCustomClaim", "newValue"));
return Task.FromResult<object>(null);
}
}
});
}
which allows authenticated bearer tokens to be checked agains multiple ClientIDs. This works well.
However, my web application allows for a user to create a new Application audience (i.e., a new ClientID, ClientSecret, and ClientName combination), but after this happens, I don't know how to get the resource server's JwtBearerAuthenticationOptions
to recognize the newly created audience.
I can restart the server after a new audience so that ConfigureOAuth()
reruns after, but this is not a good approach in the long run.
Does anyone have any idea how to add audiences (i.e., a new **ClientID, ClientSecret, and ClientName combination) to the OWIN application JwtBearerAuthenticationOptions
outside of startup.cs and ConfigureOAuth()
?**
I have been looking to: https://docs.auth0.com/aspnetwebapi-owin-tutorial and http://bitoftech.net/2014/10/27/json-web-token-asp-net-web-api-2-jwt-owin-authorization-server/ for help, but both code examples display the same issue described above.
The following works when using the X509CertificateSecurityTokenProvider. It has been modified to use the SymmetricKeyIssuerSecurityTokenProvider but has not been yet been tested.
public void ConfigureOAuth(IAppBuilder app)
{
var issuer = "<the_same_issuer_as_AuthenticationServer.Api>";
// Api controllers with an [Authorize] attribute will be validated with JWT
Func<IEnumerable<Audience>> allowedAudiences = () => DatabaseAccessLayer.GetAllowedAudiences();
var bearerOptions = new OAuthBearerAuthenticationOptions
{
AccessTokenFormat = new JwtFormat(new TokenValidationParameters
{
AudienceValidator = (audiences, securityToken, validationParameters) =>
{
return allowedAudiences().Select(x => x.ClientId).Intersect(audiences).Count() > 0;
},
ValidIssuers = new ValidIssuers { Audiences = allowedAudiences },
IssuerSigningTokens = new SecurityTokensTokens(issuer) { Audiences = allowedAudiences }
})
};
app.UseOAuthBearerAuthentication(bearerOptions);
}
public abstract class AbstractAudiences<T> : IEnumerable<T>
{
public Func<IEnumerable<Audience>> Audiences { get; set; }
public abstract IEnumerator<T> GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
throw new NotImplementedException();
}
}
public class SecurityTokensTokens : AbstractAudiences<SecurityToken>
{
private string issuer;
public SecurityTokensTokens(string issuer)
{
this.issuer = issuer;
}
public override IEnumerator<SecurityToken> GetEnumerator()
{
foreach (var aud in Audiences())
{
foreach (var securityToken in new SymmetricKeyIssuerSecurityTokenProvider(issuer, TextEncodings.Base64Url.Decode(aud.ClientSecret)).SecurityTokens)
{
yield return securityToken;
};
}
}
}
public class ValidIssuers : AbstractAudiences<string>
{
public override IEnumerator<string> GetEnumerator()
{
foreach (var aud in Audiences())
{
yield return aud.ClientSecret;
}
}
}
}
I will try to help out however :D bare in mind I'm a beginner so it might not be the best one :D
I also would like to have dynamic audiences without restarting my services as in the end it about flexibility and ease of use.
Therefore I have my validation as follow :
var bearerOptions = new OAuthBearerAuthenticationOptions
{
AccessTokenFormat = new JwtFormat(new TokenValidationParameters
{
AudienceValidator = AudienceValidator,
IssuerSigningToken = x509SecToken,
ValidIssuer = issuer,
RequireExpirationTime = true,
ValidateLifetime = true,
})
};
app.UseOAuthBearerAuthentication(bearerOptions);
As you can see above I do have a delegate that is validating my audiences. What basically that means - every time you fire off request to your server this method is called to verify audience.
At this moment I have only small debug methods and I'm validating ANY audience coming in :
private bool AudienceValidator(IEnumerable<string> audiences, SecurityToken securityToken, TokenValidationParameters validationParameters)
{
Trace.Write("would be validating audience now");
return true;
}
Now next step is what to do here ? Well for sure you do not want to query DB every time the audience would be validated as that would be omitting purpose of using those tokens :D You might come up with some nice idea - please share then!
So what I have done is using https://github.com/jgeurts/FluentScheduler and I have scheduled update of AllowedAudiences from DB every 1 hour. And that works well. I'm registering new audiences with set of rights and in best case they are ready to go on fly or I have to wait like ~59 minutes :)
Hope this helps!
Now on the second hand I have added claim to JWT token which defines authorized resources. And then I'm checking if in security token I have resource that matches my resource server. If so we consider audience to be validated :D
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:
UseOAuthBearerAuthentication()
call with a no OAuthBearerAuthenticationProvider()
- just passing the TokenValidationParameters;JwtSecurityTokenHandler
class and override the ValidateToken
to dynamically manage the audiences;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);
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With