Question
How do I make it so that tokens for both schemes work with roles regardless of whether a "Default" scheme token or a "Secondary" scheme token is used?
At the moment, either token will work (200 response) for any API that has .RequireAuthorization(), but only tokens belonging to the scheme identified by DefaultAuthenticateScheme will work for APIs that have .RequireAuthorization(a => a.RequireRole(roleName));.
Changing which scheme DefaultAuthenticateScheme points at changes which tokens work with APIs requiring the Administrator role, even though both tokens have this role, despite belonging to different schemes.
So what is the solution here?
Reproduction case
Code
This should be everything needed to reproduce the problem. The JWT tokens were generated using a testing service (Don't worry! There's no credential leaks here!).
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace AuthorizeProblemSample
{
public class Program
{
public static void Main(string[] args)
{
const string roleName = "Administrator";
const string defaultScheme = "Default";
const string secondaryScheme = "Secondary";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = defaultScheme;
o.DefaultScheme = defaultScheme;
})
// JWT credentials generated for this sample using Jamie Kurtz's JWT Builder.
// No credentials have been harmed in the making of this sample.
// Default scheme token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTUyODkzMjMsImV4cCI6MTcyNjgyNTMyMywiYXVkIjoiZGVmYXVsdEF1ZGllbmNlIiwic3ViIjoidXNlcjFAZXhhbXBsZS5jb20ifQ.SH3mxkdJCjdQ4HUX7sRPLJ2_7baW2OwNhB39fnGduD8
.AddJwtBearer(defaultScheme, o => o.Audience = "defaultAudience")
// Secondary scheme token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTUyODkzMjMsImV4cCI6MTcyNjgyNTMyMywiYXVkIjoic2Vjb25kYXJ5QXVkaWVuY2UiLCJzdWIiOiJ1c2VyMUBleGFtcGxlLmNvbSJ9.TNamLBog9qxLiebI7F8hu0dX09MjZlGoydKYeDve0ig
.AddJwtBearer(secondaryScheme, o => o.Audience = "secondaryAudience");
builder.Services.ConfigureAll<JwtBearerOptions>(o =>
{
o.TokenValidationParameters = new TokenValidationParameters()
{
ValidateActor = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = false,
ValidateLifetime = false,
ValidateAudience = true,
ValidateTokenReplay = false,
SignatureValidator = (t, v) => new JwtSecurityToken(t)
};
o.Events = new JwtBearerEvents()
{
OnTokenValidated = context =>
{
var claims = context.Principal!.Claims.Append(new Claim(ClaimTypes.Role, roleName));
var claimsIdentity = new ClaimsIdentity(claims, context.Principal!.Identity!.AuthenticationType, ClaimTypes.Name, ClaimTypes.Role);
context.Principal = new ClaimsPrincipal(claimsIdentity);
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization(opts =>
{
const string policyName = "myPolicy";
opts.AddPolicy(policyName, policy =>
{
policy.RequireAuthenticatedUser();
policy.AddAuthenticationSchemes(defaultScheme, secondaryScheme);
});
opts.DefaultPolicy = opts.GetPolicy(policyName)!;
});
var app = builder.Build();
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.MapGet("/one", () => "hello").RequireAuthorization();
app.MapGet("/two", () => "world").RequireAuthorization(a => a.RequireRole(roleName));
app.MapGet("/info", (HttpRequest req) =>
{
var result = new StringBuilder();
result.AppendFormat("User is in {0} role?: {1}", roleName, req.HttpContext.User.IsInRole(roleName));
result.AppendLine();
result.AppendFormat("User is authenticated?: {0}", req.HttpContext.User.Identity.IsAuthenticated);
result.AppendLine();
var roleClaims = req.HttpContext.User.Claims.Where(c => c.Type == ClaimTypes.Role);
foreach (var roleClaim in roleClaims)
{
result.AppendFormat("Role: {0}", roleClaim.Value);
}
return result.ToString();
}).RequireAuthorization();
app.Run();
}
}
}
As you can see, both schemes work in the same way when it comes to adding the Administrator role to inbound requests via the OnTokenValidated event.
Steps to create demo project
- Create a new empty ASP.NET 7.0 web API (using top-level statements, no HTTPS or Docker needed).
- Add the following packages:
<ItemGroup>
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.11" />
</ItemGroup>
- Paste the entire code above into Program.cs
- Run it and make requests
Endpoints
/info
If you make a request for /info with either JWT token, you'll see that the user the token represents does indeed have the Administrator role, that it's considered to be authenticated, and that is the only role.
The output looks like this:
User is in Administrator role?: True
User is authenticated?: True
Role: Administrator
/one
Either token will work (200 response) when it comes to making a request against /one, presumably because it doesn't require checking roles. You should get the result "hello" when calling it.
/two
/two requires the caller to have the role Administrator. The behaviour you'll observe is that it works for tokens belonging to the DefaultScheme scheme (200 response), but not for tokens belonging to the SecondaryScheme scheme (403 response).
But if you then change DefaultAuthenticateScheme = SecondaryScheme, the reverse will become true: /two will work for tokens belonging to SecondaryScheme (200 response), but not for tokens belonging to DefaultScheme (403 response). Herein lies the problem.
Notes
- The same problem occurs in the same way on non-minimal APIs decorated with the
[Authorize]or[Authorize(Roles = "Administrator")]attributes, but I used minimal APIs here to reduce the size of the example code. - I wondered if my code in
OnTokenValidatedwas perhaps not actually working how I expect it to, but commenting the entire event out leads to the user of course not havingAdministratoror any roles when calling/info, and denies both tokens when calling/two, so clearly it is the part of the code that makes theDefaultAuthenticateScheme's token successful when calling/two. - I'm not sure where the idea came from that these are Windows permissions, or OS permissions of any sort. The users are application level users, the permissions are application level permissions, intended to control access to certain functionalities within the API. Although my project is not using ASP.NET Core Identity, roles are based on having role claims against the authenticated user and, again, roles are part of application's domain (see this question) on how roles can be created for those who are using ASP.NET Core Identity to get a better idea of what they are.
(original answer below)
Somehow I didn't think that
OnAuthenticationFailedwill be invoked even in case of normal auth flow - if you have several schemas - some will fail and one might succeed, so forcing auth of all available audiences obviously makes the second token to succeed for the first (default) schema (security issue).Indeed, the root of the problem was the
.RequireAuthorization(a => { /*... */ })call for the/twoendpoint - auth policies are not simply combined but defined from scratch, so default policy was ignored completely. Passed delegate was missing anAddAuthenticationSchemescall, thus accepting the default scheme only.Original answer:
If you set
JwtBearerEvents.OnAuthenticationFailedhandler, you will quickly discover the reason:setting both audiences in
TokenValidationParametersshould do the trick:Why it is not populated by default? I don't know.
Also it seems like empty
.RequireAuthorization()just requires auth processing, but does not require it to succeed (/oneendpoint also fails to validate the token).Changing to
.RequireAuthorization(a => a.RequireAuthenticatedUser())will make/onefail just as/two.