Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dynamically append OWIN JWT resource server Application clients (audiences)

Tags:

c#

oauth

jwt

owin

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.

like image 688
Brett Avatar asked Dec 01 '14 21:12

Brett


3 Answers

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;
        }
    }
}

}

like image 117
Danny Avatar answered Oct 12 '22 11:10

Danny


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!

First approach :

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!

Second approach :

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

like image 22
erPe Avatar answered Oct 12 '22 11:10

erPe


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:

  1. Use only one UseOAuthBearerAuthentication() call with a no OAuthBearerAuthenticationProvider() - just passing the TokenValidationParameters;
  2. Use a subclassed JwtSecurityTokenHandler class and override the ValidateToken to dynamically manage the audiences;
  3. 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);
}
like image 30
G Mac Avatar answered Oct 12 '22 12:10

G Mac