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:
- When
LoadClaimsFromClaimsFillerProvideristrue, 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. - Builds the
Authorizationwith IClaimAuthorizationFiller and theUserProfilewith IClaimProfileFiller. - Creates the
AppPrincipal, stores it in IApplicationContext and returns it, soHttpContext.Useris theAppPrincipaltoo.
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
- Server authentication
- AppPrincipal and claims filler in the glossary
- ScopedOperationsExtension, ClaimsFillerOptions and AppPrincipal in the API reference