Search code examples
.netasp.net-coreoauthjwtopenid

C#/.NET6: Build Policies for an API Controller from Scopes in an Access Token (OAuth/OpenID)


I have an API controller that I want consuming services to be authorized using a bearer token. The API endpoints should make sure that this service has the required scopes in its token - and that is what does not work for me.

I have an access token that was issued by some authority. Its payload looks like this (dummy values, obviously):

{
  "iss": "https://someauthority.com",
  "nbf": 1699891816,
  "iat": 1699891816,
  "exp": 1699895416,
  "aud": "https://myapi.com",
  "scope": [
    "myapi:user-read"
  ],
  "client_id": "MyApiConsumer",
  "tenant_id": "fcbebe85-5e17-4986-dffd-ede94e9b6a07",
  "tenant_external_id": "7123",
  "tenant_owner_client_id": "SomeTenantOwnerApp",
  "jti": "ADE83169F38F3EA14B5E99AF998821EF"
}

First, I am validating the token using the Microsoft.AspNetCore.Authentication.JwtBearer library:

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
    .AddJwtBearer(options =>
    {
        options.Authority = "https://someauthority.com";
        options.Audience = "https://myapi.com";
        options.SaveToken = true; // Tried both, with and without
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.Zero,
            IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
            {
                var json = new WebClient().DownloadString("https://myapi.com/.well-known/openid-configuration/jwks");
                var keys = JsonConvert.DeserializeObject<JwksKeys>(json);
                return keys?.Keys;
            }
        };
        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context =>
            {
                var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("AuthenticationFailed");
                logger.LogError("Token validation failed", context.Exception);
                return Task.CompletedTask;
            },
            OnTokenValidated = context =>
            {
                var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("TokenValidated");
                logger.LogInformation("Token validated successfully.");
                logger.LogInformation("Claims:");
                foreach (var claim in context.Principal.Claims)
                {
                    logger.LogInformation($"{claim.Type}: {claim.Value}");
                }
                return Task.CompletedTask;
            }
        };
    });

In the logging output that I added I can clearly see that the scope claim is there. Also, the token validation worked. So I thought I could go ahead and build some policies that I can use as decorators in my controller, like this:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ScopeUserRead", policy => policy.RequireClaim("scope", "myapi:user-read"));
    options.AddPolicy("ScopeUserCreate", policy => policy.RequireClaim("scope", "myapi:user-create"));
    options.AddPolicy("ScopeUserWrite", policy => policy.RequireClaim("scope", "myapi:user-read-write"));
});

In my controller, use the [Authorize] decorator on the class, and [Authorize("ScopeUserRead")] on a method.

Still, calling the API with the token above does yield in a 401 error.

I learned that the RequireClaim method I use to build my policies takes the scope claim from the User object of the HttpContext - which is empty as this is an access token, not an ID token. However, several sources say that the claims should be copied over during validation from the Principal object in the token to the HttpContext User object. This I found not to be true in my case as I checked the User object like this:

app.Use(async (context, next) =>
{
    var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("ClaimsMiddleware");
    var user = context.User;

    if (user.Identity.IsAuthenticated)
    {
        logger.LogInformation("Authenticated User Claims:");
        foreach (var claim in user.Claims)
        {
            logger.LogInformation($"{claim.Type}: {claim.Value}");
        }
    }
    else
    {
        logger.LogInformation("User is not authenticated.");
    }
    await next.Invoke();
});

So I wonder what I am doing wrong, if my entire approach is just rubbish, should I use a different library or should I attempt to copy the claims manually to the User object (if that is possible at all)?

Also, this is supposed to be a multitenant API, thus the controller methods need to know the value of the tenant_id claim. Is there any way to make the tenant_id available as a parameter to my controller methods, maybe by using some decorator [FromToken] or is this just me dreaming? Or do I have to retrieve it from HttpContext like

var tenantId = HttpContext.User.FindFirst("tenant_id").Value;

In that case it needs to be present in the User object, which it is not...


Solution

  • From your description,you haven't authenticated at the correct time and got a 401 error, make sure your middlewares are in the correct order:

    app.UseAuthentication();
    app.UseRouting();
    app.UseAuthorization();