Table of Contents

Claims and authorization

After authentication, Arc4u turns the ASP.NET Core ClaimsPrincipal into an AppPrincipal that carries a user profile and an Arc4u authorization: roles, operations and scopes. This page explains how the principal is built, how to add the user's rights with a claims filler, and how to check them with ASP.NET Core authorization policies. It applies to the three scenarios of the overview.

How the principal is built

AppPrincipalTransform is an IClaimsTransformation: ASP.NET Core runs it after every successful authentication. It:

  1. When LoadClaimsFromClaimsFillerProvider is true, loads extra claims for the user: from the cache if present, otherwise from the registered IClaimsFiller (the result is then cached). Claims whose type is already on the identity are not added.
  2. Builds the Authorization with IClaimAuthorizationFiller and the UserProfile with IClaimProfileFiller.
  3. Creates the AppPrincipal, stores it in IApplicationContext and returns it, so HttpContext.User is the AppPrincipal too.

Inject IApplicationContext wherever you need the current user:

using Arc4u.Security.Principal;

app.MapGet("/orders", (IApplicationContext context) =>
{
    var principal = context.Principal!;
    return principal.IsAuthorized("ReadOrders")
        ? Results.Ok($"Orders of {principal.Profile.DisplayName}")
        : Results.Forbid();
}).RequireAuthorization();

Claims filler options

Authentication:ClaimsMiddleWare:ClaimsFiller (ClaimsFillerOptions), read by the configuration overloads (key ClaimsFillerSectionPath) or by AddClaimsFiller(configuration):

Key Type Default Description
LoadClaimsFromClaimsFillerProvider bool true Loads extra claims with the IClaimsFiller. When true, IClaimsFiller, ICacheKeyGenerator and IUserObjectIdentifier must be registered.
ClaimsToExclude string array aud, iss, iat, nbf, acr, aio, appidacr, ipaddr, scp, tid, uti, unique_name, apptype, appid, ver Claim types returned by the claims filler that are not added. The claims already on the identity are never removed. The default list applies when the section or the key is missing.
ExpireClaim string exp Claim type holding the expiration of the token. It cannot be part of ClaimsToExclude.

The extra claims are cached in the cache named by Authentication:TokenCache:CacheName, for Authentication:TokenCache:MaxTime (50 minutes by default), under the key <user identifier>_ClaimsCache. The user identifier is the value of the first claim of the identity whose type is listed in Authentication:ClaimsIdentifier (by default the object identifier claims http://schemas.microsoft.com/identity/claims/objectidentifier and oid).

Important

When the token has none of the ClaimsIdentifier claim types, every bearer request fails with a 401 and the log says The token has no claim identifying the user; a cookie (OpenID Connect) request fails with a 500 (NullReferenceException: No distinguish key found for the identity). The default claim types are Microsoft identity platform claims; other providers usually identify the user with sub: set the identifier, for example "ClaimsIdentifier": [ "sub" ].

Load the user's rights with a claims filler

The default claims filler, ClaimsBearerTokenExtractor, returns the claims of the user's access token (from the BootstrapContext, or from the token provider of the identity's settings). With JWT bearer these claims are already on the identity; with a cookie session, it adds the claims of the access token to those of the id token.

To give users Arc4u rights, implement IClaimsFiller and return the Arc4u authorization claim. The claim type is ClaimTypes.Authorization of Arc4u.IdentityModel.Claims (http://schemas.arc4u.net/ws/2012/05/identity/claims/authorization), and its value is the JSON of an Authorization:

// RightsClaimsFiller.cs
using System.Security.Principal;
using System.Text.Json;
using Arc4u.IdentityModel.Claims;
using Arc4u.Security.Principal;

public sealed class RightsClaimsFiller : IClaimsFiller
{
    public Task<IEnumerable<ClaimDto>> GetAsync(IIdentity identity)
    {
        // Read the rights of the user from your own store; hard-coded here.
        var authorization = new Authorization
        {
            AllOperations = [new Operation { ID = 1, Name = "ReadOrders" }, new Operation { ID = 2, Name = "WriteOrders" }],
            Operations = [new ScopedOperations { Scope = "", Operations = [1] }],
            Roles = [new ScopedRoles { Scope = "", Roles = ["Reader"] }],
        };

        IEnumerable<ClaimDto> claims =
        [
            new ClaimDto(ClaimTypes.Authorization, JsonSerializer.Serialize(authorization)),
        ];

        return Task.FromResult(claims);
    }
}

Register it instead of ClaimsBearerTokenExtractor:

using Arc4u.Security.Principal;

builder.Services.AddTransient<IClaimsFiller, RightsClaimsFiller>();

The claims filler runs once per user and cache lifetime, inside the request that authenticates the user: a slow store slows down that request only.

The authorization model

Member Description
AllOperations Every operation of the application: an ID (integer) and a Name.
Operations Per scope, the identifiers of the operations granted to the user.
Roles Per scope, the roles of the user.
Scopes The scopes the user has access to.

A scope is a free string that partitions the rights, for example a tenant or a business unit. The default scope is the empty string. Each entry of roles and operations must have a scope (use "" for the default scope): an entry without scope makes the creation of the principal fail. The claim of the example above is (JsonSerializer also writes an empty "scopes": []):

{
  "roles": [ { "scope": "", "roles": [ "Reader" ] } ],
  "operations": [ { "scope": "", "operations": [ 1 ] } ],
  "allOperations": [ { "name": "ReadOrders", "id": 1 }, { "name": "WriteOrders", "id": 2 } ]
}

Only the operations listed in AllOperations are granted. In code, AppPrincipal.IsAuthorized accepts operation identifiers or names, with or without a scope, and enum values (default scope); IsInRole(scope, role) checks a role in a scope. IsAuthorized("ReadOrders") checks a single operation name in the default scope. When you pass two or more names, the first one is the scope: IsAuthorized("ReadOrders", "WriteOrders") checks WriteOrders in the scope ReadOrders, so pass the scope explicitly (IsAuthorized(string.Empty, "ReadOrders", "WriteOrders")).

Roles and IsInRole

AppPrincipal.IsInRole(role) checks the roles of the Arc4u authorization in the default scope, not the role claims of the token. So [Authorize(Roles = "Reader")] and RequireRole("Reader") succeed only when the authorization claim grants the role, even if the token has a role claim with that value.

Protect endpoints with operation policies

AddScopedOperationsPolicy of Arc4u.Authorization registers one policy per operation, named as the operation, and one per scope and operation, named <scope>:<operation name>:

using Arc4u.Authorization;
using Arc4u.Security.Principal;

Operation[] operations =
[
    new Operation { ID = 1, Name = "ReadOrders" },
    new Operation { ID = 2, Name = "WriteOrders" },
];

builder.Services.AddScopedOperationsPolicy(["Sales"], operations)
                .AddAnyOperations("OrdersAccess", 1, 2)
                .AddAllOperations("OrdersAdmin", 1, 2);
app.MapGet("/orders", () => "orders").RequireAuthorization("ReadOrders");
app.MapGet("/sales/orders", () => "orders").RequireAuthorization("Sales:ReadOrders");

With controllers, use [Authorize(Policy = "ReadOrders")].

Policy Satisfied when
ReadOrders Operation 1 is granted in the default scope.
Sales:ReadOrders Operation 1 is granted in the Sales scope. An operation granted only in the default scope does not satisfy it.
OrdersAccess (AddAnyOperations) At least one of the operations is granted.
OrdersAdmin (AddAllOperations) All the operations are granted.

PoliciesBuilder also has overloads with a scope or with (scope, operation) pairs, AddPolicy(name, configure) for any other policy, and ConfigureAuthorization to change the AuthorizationOptions (default policy, fallback policy). The handlers read the principal from IApplicationContext: a request without principal fails every operation policy.

Check a policy from code

Register IApplicationAuthorizationPolicy to evaluate a policy in a service that is not an endpoint:

using Arc4u.Authorization;

builder.Services.AddScoped<IApplicationAuthorizationPolicy, ApplicationAuthorizationPolicy>();

IsAuthorizeAsync(policyName) returns false when there is no principal or the policy is not satisfied; AuthorizeAsync(policyName, message) throws UnauthorizedAccessException instead. Catch it and answer 403 yourself:

using Arc4u.Authorization;

app.MapPost("/orders", async (IApplicationAuthorizationPolicy policy) =>
{
    try
    {
        await policy.AuthorizeAsync("WriteOrders");
        return Results.Ok();
    }
    catch (UnauthorizedAccessException)
    {
        return Results.Forbid();
    }
}).RequireAuthorization();
Note

In MVC controllers, ManageExceptionsFilter of Arc4u.OAuth2.AspNetCore (AddControllers(o => o.Filters.Add<ManageExceptionsFilter>())) turns this exception into a 403 ProblemDetails. It logs the exception with the trace id of the current activity as ActivityId and the request context (HTTP method, path, action and route values); for any other exception it answers a 500 whose detail gives that id. For minimal APIs and middlewares, which the filter does not see, call services.AddManageExceptionsHandler() and app.UseExceptionHandler() to get the same responses. ManageExceptionsFilterOptions controls the exception extension with the exception details (IncludeExceptionDetails, by default only in the Development environment) and the logging of the action arguments (LogActionArguments, off by default, truncated to MaxActionArgumentLength). Never return exception details to untrusted callers, and log action arguments only when they carry no sensitive data.

Protect resource paths from configuration

UseResourcesRightValidationFor protects paths, typically an OpenAPI document, with a policy. When the current principal does not satisfy the policy, the response is a 200 with a replacement content (by default an empty Swagger 2.0 document titled "You are not authorized!"). Requests without principal pass through: combine it with authentication on those paths.

{
  "Authentication": {
    "ResourcesRights": {
      "ResourcesPolicies": {
        "OpenApi": {
          "Path": "/swagger/v1/swagger.json",
          "AuthorizationPolicy": "WriteOrders"
        }
      }
    }
  }
}
using Arc4u.OAuth2.Middleware;

app.UseAuthentication();
app.UseResourcesRightValidationFor();
app.UseAuthorization();
Key Type Default Description
ResourcesPolicies:<name>:Path string none (required) Request path, compared ignoring case and leading or trailing slashes. The same path with another extension is protected too (swagger.yaml for swagger.json), since Swashbuckle serves the document in YAML and, up to 6.5, for any extension. The entry name is only a label.
ResourcesPolicies:<name>:AuthorizationPolicy string none (required) Policy the principal must satisfy.
ResourcesPolicies:<name>:ContentToDisplay string DefaultContent Content returned when access is denied.
DefaultContent string an empty Swagger 2.0 document Default content returned when access is denied.

The method reads Authentication:ResourcesRights; pass sectionName to use another section.

When the section does not exist, UseResourcesRightValidationFor throws a ConfigurationException at startup, so a misspelled section path is reported instead of leaving the resources unprotected.

User profile

ClaimsProfileFiller fills the UserProfile from these claims. For the name, the given name, the surname, the email and the upn, the long claim types of System.Security.Claims.ClaimTypes are accepted too.

Profile member Claim
DisplayName name; when absent, the account name derived from upn, or given_name family_name
GivenName, SurName given_name, family_name
Email email
PrincipalName upn
SamAccountName, Domain Derived from upn (user@domain or DOMAIN\user). The domain is mapped with Authentication:DomainsMapping. Otherwise, for user@domain the first label of the domain is used (contoso for contoso.com), and for DOMAIN\user the domain is kept as is.
Sid The Arc4u sid claim, S-1-0-0 when absent
Company, Culture The Arc4u company and culture claims

Replace IClaimProfileFiller to build the profile differently.

Troubleshooting

403 with [Authorize(Roles = ...)] although the token has the role

See Roles and IsInRole: the roles come from the Arc4u authorization claim.

401 "The token has no claim identifying the user"

Set Authentication:ClaimsIdentifier to a claim type present in the tokens, see Claims filler options.

Every operation policy fails

The principal has an empty Authorization: the claims filler returned no authorization claim, or the claim could not be deserialized (the error is logged). Check the claims of HttpContext.User, and that LoadClaimsFromClaimsFillerProvider is true.

See also