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?
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.
<ItemGroup>
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.11" />
</ItemGroup>
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
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
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.
[Authorize]
or [Authorize(Roles = "Administrator")]
attributes, but I used minimal APIs here to reduce the size of the example code.OnTokenValidated
was perhaps not actually working how I expect it to, but commenting the entire event out leads to the user of course not having Administrator
or any roles when calling /info
, and denies both tokens when calling /two
, so clearly it is the part of the code that makes the DefaultAuthenticateScheme
's token successful when calling /two
.(original answer below)
Somehow I didn't think that OnAuthenticationFailed
will 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 /two
endpoint - auth policies are not simply combined but defined from scratch, so default policy was ignored completely. Passed delegate was missing an AddAuthenticationSchemes
call, thus accepting the default scheme only.
Original answer:
If you set JwtBearerEvents.OnAuthenticationFailed
handler, you will quickly discover the reason:
IDX10214: Audience validation failed.
Audiences: 'secondaryAudience'.
Did not match:
validationParameters.ValidAudience: 'defaultAudience'
or validationParameters.ValidAudiences: 'null'.
setting both audiences in TokenValidationParameters
should do the trick:
o.TokenValidationParameters = new TokenValidationParameters()
{
/* ... */
ValidateAudience = true,
ValidAudiences = new[] { "defaultAudience", "secondaryAudience" },
/* ... */
};
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 (/one
endpoint also fails to validate the token).
Changing to .RequireAuthorization(a => a.RequireAuthenticatedUser())
will make /one
fail just as /two
.