12

I need to create a framework for coworkers that allows for multiple authentication schemes and correlating authorization policies (since our IDP has multiple allowed approaches), but those schemes require dependency injection because the IDP information is provided from a cloud-based configuration source. The authentication is done with a JWT Bearer token in the cases that I'm working with so far.

I want to stick to the most current practices to be as current as possible. So it appears that I should be using AddAuthentication, AddJwtBearer, and AddAuthorization on my IServiceCollection.

I am expecting a controller or endpoint can be decorated with the AuthorizeAttribute and it will employ the designated default policy, which will use the designated default authN scheme. If the attribute is given constructor parameters of Policy = <Some Non Default Policy> or AuthenticationSchemes = <Some other scheme>, it will switch to those instead.

First, I established that I needed to use dependency injection, so I'm using IConfigureNamedOptions<JwtBearerOptions>.

So I created the options class like this for auth code

public class AuthCodeJwtBearerOptions : IConfigureNamedOptions<JwtBearerOptions>
{
    private readonly IClaimsTransformation _claimsTransformation;
    private readonly ConfigurationManager<OpenIdConnectConfiguration> _configurationManager;

    public AuthCodeJwtBearerOptions(IOptions<TidV4OAuthSettings> tidV4OAuthOptions,
        IClaimsTransformation claimsTransformation)
    {
        _claimsTransformation = claimsTransformation;

        _configurationManager =
            new ConfigurationManager<OpenIdConnectConfiguration>(tidV4OAuthOptions.Value.WellknownUrl,
                new OpenIdConnectConfigurationRetriever());
    }

    public void Configure(JwtBearerOptions options) => Configure("AuthCode", options);

    public void Configure(string name, JwtBearerOptions options)
    {
        var task = Task.Run(async () => await GetTokenValidationParametersAsync());
        options.TokenValidationParameters = task.Result;
        options.Events = new JwtBearerEvents { OnTokenValidated = OnTokenValidated };
    }

    private async Task<TokenValidationParameters> GetTokenValidationParametersAsync()
    {
        var cancellationToken = new CancellationToken();
        var openIdConnectConfiguration = await _configurationManager.GetConfigurationAsync(cancellationToken);
        return new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKeys = openIdConnectConfiguration?.SigningKeys,
            ValidateAudience = false,
            ValidateIssuer = true,
            ValidIssuer = openIdConnectConfiguration?.Issuer,
            ValidateLifetime = true
        };
    }

    private async Task OnTokenValidated(TokenValidatedContext context)
    {
        if (context.Principal == null)
        {
            return;
        }

        context.Principal = await _claimsTransformation.TransformAsync(context.Principal);
    }
}

And I then repeated this pattern for a class called ClientCredentialsJwtBearerOptions, but I have this variation

public void Configure(JwtBearerOptions options) => Configure("ClientCredentials", options);

I register all of this by setting the default authentication scheme, Adding the JWT Bearers by name with an empty delegate, and then I call to configure the options.

Then I assign policies. I may be wrong, but I don't think the policy creation is the issue, but I'll include the code in case it is a problem.

serviceCollection
            .AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = "AuthCode";
                options.DefaultChallengeScheme = "AuthCode";
            })
            .AddJwtBearer("AuthCode", _ => { })
            .AddJwtBearer("ClientCredentials", _ => { });

        serviceCollection.ConfigureOptions<ClientCredentialsJwtBearerOptions>();
        serviceCollection.ConfigureOptions<AuthCodeJwtBearerOptions>();

        serviceCollection.AddAuthorization(options =>
        {
            var authCodePolicy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .AddAuthenticationSchemes("AuthCode")
                .Build();

            var clientCredentialsPolicy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .AddAuthenticationSchemes("ClientCredentials")
                .Build();

            var allPolicy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .AddAuthenticationSchemes("AuthCode", "ClientCredentials")
                .Build();

            options.AddPolicy("AuthCodeOnly", authCodePolicy);
            options.AddPolicy("ClientCredentialsOnly", clientCredentialsPolicy);
            options.AddPolicy( "AllPolicies", allPolicy);

            options.DefaultPolicy = authCodePolicy;
        });

I am using constants for the strings here, but I wrote literal strings for ease of reading the example.

When I test this, everything works fine if I leave it exactly like this. And it uses the AuthCodeJwtBearerOptions and the request goes through.

But if I change the Authorize attribute to

[Authorize(Policy = "ClientCredentialsOnly")]

Authentication still uses AuthCodeJwtBearerOptions. I can get it to switch by simply reversing the order of the Configure call

serviceCollection.ConfigureOptions<AuthCodeJwtBearerOptions>();
serviceCollection.ConfigureOptions<ClientCredentialsJwtBearerOptions>();        

Suggesting to me that it's just using the last one to be registered and it is not respecting the "Named" functionality of named configurations.

And if I change the default policy, nothing changes.

I feel like I've got most of the pieces that I need to get this working and I'm just misunderstanding what ConfigureOptions does.

Any help is appreciated. Thank you.

1

2 Answers 2

10

There were two problems with my approach, and I think I have a good resolution now.

First, with the help of Jeremy Lakeman I was made aware of the reversed usage of IConfigureNamedOptions<JwtBearerOptions>'s Configure methods. Instead, you perform a name check for the scheme and if it passes, you configure it.

public void Configure(JwtBearerOptions options)
{
    var task = Task.Run(async () => await GetTokenValidationParametersAsync());
    options.TokenValidationParameters = task.Result;
    options.Events = new JwtBearerEvents { OnTokenValidated = OnTokenValidated };
}

public void Configure(string name, JwtBearerOptions options)
{
    if(!name.Equals("AuthCode"))
    {
        return
    }

    Configure(options);    
}

This gets the options to register correctly. However, the second issue I was having was that if you set a default authentication scheme, it will always run, even when another scheme or policy is requested explicitly

I found, however, that if you do not assign a default authentication scheme and only define a default authentication policy, you can have [Authorize] default to the default policy, but [Authorize("ClientCredentialsOnly")] will perform only the scheme and policy for client credentials.

serviceCollection.AddAuthentication()
     .AddJwtBearer("AuthCode", _ => { })
     .AddJwtBearer("ClientCredentials", _ => { });
serviceCollection.AddAuthorization(options =>
   {
       var authCodePolicy = new AuthorizationPolicyBuilder()
           .RequireAuthenticatedUser()
           .AddAuthenticationSchemes("AuthCode")
           .Build();
       var clientCredentialsPolicy = new AuthorizationPolicyBuilder()
           .RequireAuthenticatedUser()
           .AddAuthenticationSchemes("ClientCredentials")
           .Build();
       var allPolicy = new AuthorizationPolicyBuilder()
           .RequireAuthenticatedUser()
           .AddAuthenticationSchemes("AuthCode", "ClientCredentials")
           .Build();
       options.AddPolicy("AuthCodeOnly", authCodePolicy);
       options.AddPolicy("ClientCredentialsOnly", clientCredentialsPolicy);
       options.AddPolicy( "AllPolicies", allPolicy);
       options.DefaultPolicy = options.GetPolicy("AuthCodeOnly")!;
   });

So, let's sum up.


public class AuthCodeJwtBearerOptions : IConfigureNamedOptions<JwtBearerOptions>
{
    private readonly IClaimsTransformation _claimsTransformation;
    private readonly ConfigurationManager<OpenIdConnectConfiguration> _configurationManager;
    private readonly string _name;

    public AuthCodeJwtBearerOptions(IOptions<TidV4OAuthSettings> tidV4OAuthOptions,
        IClaimsTransformation claimsTransformation)
    {
        _name = "AuthCode";
        _claimsTransformation = claimsTransformation;

        _configurationManager =
            new ConfigurationManager<OpenIdConnectConfiguration>(tidV4OAuthOptions.Value.WellknownUrl,
                new OpenIdConnectConfigurationRetriever());
    }

    public void Configure(JwtBearerOptions options)
    {
        var task = Task.Run(async () => await GetTokenValidationParametersAsync());
        options.TokenValidationParameters = task.Result;
        options.Events = new JwtBearerEvents { OnTokenValidated = OnTokenValidated };
    }

    public void Configure(string name, JwtBearerOptions options)
    {
        if(!name.Equals(_name))
        {
            return;
        } 

        Configure(options);
    }

    private async Task<TokenValidationParameters> GetTokenValidationParametersAsync()
    {
        var cancellationToken = new CancellationToken();
        var openIdConnectConfiguration = await _configurationManager.GetConfigurationAsync(cancellationToken);
        return new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKeys = openIdConnectConfiguration?.SigningKeys,
            ValidateAudience = false,
            ValidateIssuer = true,
            ValidIssuer = openIdConnectConfiguration?.Issuer,
            ValidateLifetime = true
        };
    }

    private async Task OnTokenValidated(TokenValidatedContext context)
    {
        if (context.Principal == null)
        {
            return;
        }

        //whatever you need to do once validated including claims transformation
        context.Principal = await _claimsTransformation.TransformAsync(context.Principal);
    }
}

Repeat the above for other schemes that you want to support, switching the _name, token validation parameters, and event logic as needed. I did this for "ClientCredentials" for now.

Now wire it up in your pipeline

serviceCollection.AddAuthentication()
    .AddJwtBearer("AuthCode", _ => { })
    .AddJwtBearer("ClientCredentials", _ => { });

serviceCollection.ConfigureOptions<ClientCredentialsJwtBearerOptions>();
serviceCollection.ConfigureOptions<AuthCodeJwtBearerOptions>();

serviceCollection.AddAuthorization(options =>
{
    var authCodePolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .AddAuthenticationSchemes("AuthCode")
        .Build();
    var clientCredentialsPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .AddAuthenticationSchemes("ClientCredentials")
        .Build();
    var allPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .AddAuthenticationSchemes("AuthCode", "ClientCredentials")
        .Build();
    options.AddPolicy("AuthCodeOnly", authCodePolicy);
    options.AddPolicy("ClientCredentialsOnly", clientCredentialsPolicy);
    options.AddPolicy( "AllPolicies", allPolicy);
    options.DefaultPolicy = options.GetPolicy("AuthCodeOnly")!;
});

Again, huge thanks to Jeremy for the guidance on the options. And also to Marc for correcting the botch job formatting for my first Stack Overflow post.

Sign up to request clarification or add additional context in comments.

2 Comments

Does this enable you to have two authentication providers supported so that one app could use one and another app could use the other?
The missing key thing in MS doc (learn.microsoft.com/en-us/aspnet/core/security/authentication/…) for me was that an authorization policy must be configured with the certificate authentication scheme in order to trigger the configuration callback of CertificateAuthenticationOptions.
0

I had the same issue with multiple named schemes registered with AddJwtBearer in .net 8, and going through the completed answer helped me solve the problem. It looks like even though you decorate your API with Authorize attribute and named scheme, the auth stack still runs through all registered JwtBearer options trying to validate the token with each one of them. The simple solution was to add a check in the Configure method to validate the provided name matches the named scheme name.

public void Configure(string name, JwtBearerOptions options)
{
    // Only configure the JWT Bearer options if the AuthenticationScheme matches the specified scheme
    if (!name.Equals(JwtBearerDefaults.AuthenticationScheme))
        return;

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.