Implementing Level of Authentication (LoA) with ASP.NET Core Identity and Duende

Passwords and OTPs get you in the door, but they do not tell an application how confident it should be about who just walked through it. That is the gap between authentication and level of authentication. This post walks through a working ASP.NET Core Identity and Duende IdentityServer setup that returns a loa claim (level of authentication), a loi claim (level of identity), and an amr claim (authentication method reference), and then uses that loa claim to gate access to an API that expects phishing resistant sign in.

The sample builds on a passkeys based identity provider and adds three claims that downstream applications and APIs can trust: loa tells you how strong the authentication was, loi tells you how strong the identity verification behind that authentication was, and amr tells you which method was actually used. amr already exists in the OpenID Connect and Identity specifications, but in practice it is not very useful for authorization decisions. Every identity provider fills it with its own values and ASP.NET Core Identity’s own implementation is not fully consistent either, so you cannot write a generic policy against amr across providers. loa solves that by giving you a small, ordered set of levels you control yourself.

Why loa and not amr

The amr claim and the loa claim end up carrying similar information, but for different reasons. amr is a specification defined list of authentication method identifiers, and every identity provider is free to populate it however it wants. I have seen the same login method reported under different amr values depending on which library or provider generated the token, which makes it a poor fit for cross cutting authorization rules. loa is not standardized in the same way, but that is exactly what makes it practical here: you define the ordering and the meaning for your own system, and every client and API downstream can rely on it without knowing anything about the identity provider’s internals.

In this sample the levels are defined from least to most secure, and anything below loa.300 should not be treated as good enough for sensitive operations in most systems.

  • loa.400: passkeys, or public and private key certificate based authentication
  • loa.300: authenticator apps, OpenID verifiable credentials such as E-ID or swiyu
  • loa.200: SMS, email, TOTP, or other two step methods
  • loa.100: single factor methods such as SAS keys, API keys, passwords, or OTP alone
Authentication level (loa) hierarchy from single factor to passkeys
Authentication level (loa) hierarchy from single factor to passkeys

Anything at loa.100 or loa.200 is fine for low risk actions like viewing a profile page. The moment an action can move money, change security settings, or expose personal data, I would push the requirement to loa.300 or loa.400 and force a step up if the current session does not already meet it. That step up flow is not covered in this specific sample, it is handled in a follow up post in the same series, but the claim itself is what makes step up possible in the first place.

Solution layout

The sample is wired up with .NET Aspire, and it is made of three pieces. The STS is an OpenID Connect server built on Duende IdentityServer with ASP.NET Core Identity as the user store, a Blazor web application acts as the confidential OpenID Connect client, and a separate API requires DPoP bound access tokens plus a minimum level of authentication before it will respond. The web application signs in using authorization code flow with PKCE and Pushed Authorization Requests (PAR), and DPoP (Demonstrating Proof of Possession) is used end to end so that a stolen access token is not enough on its own to call the API.

Web client, Duende STS with ASP.NET Core Identity, and DPoP protected API
Web client, Duende STS with ASP.NET Core Identity, and DPoP protected API

Using Aspire here is mostly a developer experience choice, it wires up service discovery between the three projects locally and saves you from hardcoding ports and URLs everywhere. None of the loa or loi logic depends on Aspire, you could just as well run this with three separate app registrations pointed at each other over plain HTTPS.

OpenID Connect web client

The Blazor application needs two NuGet packages to talk to the STS: Duende.AccessTokenManagement.OpenIdConnect for automatic token handling and DPoP proof generation, and the standard Microsoft.AspNetCore.Authentication.OpenIdConnect package for the OIDC handshake itself. Cookies are used to store the session, and the cookie is configured as HTTP only so client side script cannot read it. In this demo the client authenticates using a shared secret, but do not carry that into production. A client secret is a bearer credential in its own right, and if your STS supports client assertions with a certificate or a managed identity, use that instead so the client’s own identity cannot be stolen from configuration or a key vault misconfiguration.

var oidcConfig = builder.Configuration.GetSection("OpenIDConnectSettings");
 
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    options.DefaultSignOutScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
    options.Cookie.Name = "__Host-idp-swiyu-passkeys-web";
    options.Cookie.SameSite = SameSiteMode.Lax;
})
.AddOpenIdConnect(options =>
{
    builder.Configuration.GetSection("OpenIDConnectSettings").Bind(options);
 
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.ResponseType = OpenIdConnectResponseType.Code;
 
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.MapInboundClaims = false;
 
    options.ClaimActions.MapUniqueJsonKey("loa", "loa");
    options.ClaimActions.MapUniqueJsonKey("loi", "loi");
    options.ClaimActions.MapUniqueJsonKey(JwtClaimTypes.Email, JwtClaimTypes.Email);
 
    options.Scope.Add("scope2");
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "name"
    };
});
 
var privatePem = File.ReadAllText(Path.Combine(
	builder.Environment.ContentRootPath, "ecdsa384-private.pem"));
var publicPem = File.ReadAllText(Path.Combine(
	builder.Environment.ContentRootPath, "ecdsa384-public.pem"));
	
var ecdsaCertificate = X509Certificate2
	.CreateFromPem(publicPem, privatePem);
	
var ecdsaCertificateKey = new ECDsaSecurityKey(
	$ecdsaCertificate.GetECDsaPrivateKey());
 
// add automatic token management
builder.Services.AddOpenIdConnectAccessTokenManagement(options =>
{
    var jwk = JsonWebKeyConverter.ConvertFromSecurityKey(ecdsaCertificateKey);
    jwk.Alg = "ES384";
    options.DPoPJsonWebKey = DPoPProofKey
		.ParseOrDefault(JsonSerializer.Serialize(jwk));
});
 
builder.Services.AddUserAccessTokenHttpClient("dpop-api-client", 
	configureClient: client =>
	{
		client.BaseAddress = new("https+http://apiservice");
	});

Two lines matter most in this block if you are adapting it into your own application. MapInboundClaims is set to false, which stops ASP.NET Core from silently renaming standard claim types into legacy Microsoft URIs, a default that trips up almost everyone the first time they inspect a token and cannot find the claim they expected under its real name. The two MapUniqueJsonKey calls for loa and loi are what actually pull the custom claims out of the ID token and into the ClaimsPrincipal, without them the claims exist in the token but never reach User.Claims in your Razor or Blazor code. The ECDSA key and DPoPJsonWebKey setup further down is what lets Duende’s access token management library generate DPoP proofs automatically on every outgoing request, so you are not hand rolling proof of possession headers yourself.

OpenID Connect server: Duende client registration

On the STS side, the web application is registered as a confidential client that requires DPoP and PAR. Authorization code flow with PKCE is used, and profile claims are included directly in the ID token so the Blazor app does not need a separate call to the userinfo endpoint just to render a name on screen.

// interactive client using code flow + pkce + par + DPoP
new Client
{
    ClientId = "web-client",
    ClientSecrets = { new Secret("super-secret-$123".Sha256()) },
 
    RequireDPoP = true,
    RequirePushedAuthorization = true,
 
    AllowedGrantTypes = GrantTypes.Code,
    AlwaysIncludeUserClaimsInIdToken = true,
 
    RedirectUris = { "https://localhost:7019/signin-oidc" },
    FrontChannelLogoutUri = "https://localhost:7019/signout-oidc",
    PostLogoutRedirectUris = { "https://localhost:7019/signout-callback-oidc" },
 
    AllowOfflineAccess = true,
    AllowedScopes = { "openid", "profile", "scope2" }
},

RequireDPoP and RequirePushedAuthorization are the two settings doing the real security work here. PAR moves the authorization request itself to a back channel POST instead of a query string redirect, which closes off a class of attacks where a manipulated authorization request is sent to the browser. RequireDPoP means an access token issued to this client is useless without the matching private key, so even if a token leaks out of logs or a compromised proxy, it cannot be replayed from a different machine. The scope2 scope tied to an ApiResource further down is only there as a demo scope for the sample API, in a real system you would name scopes after the actual resource they protect.

Adding loa, loi, and amr during passkey sign in

The claims do not come from Duende automatically, they get added at the point where the user actually completes passkey sign in, inside the login page code behind. ASP.NET Core Identity’s claims are immutable once issued for a session, so the pattern here is to sign the user out immediately after a successful passkey check and sign them back in with the extra claims attached.

if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson))
{
    // When performing passkey sign-in, don't perform form validation.
    ModelState.Clear();
 
    result = await _signInManager.PasskeySignInAsync(Input.Passkey.CredentialJson);
    if (result.Succeeded)
    {
        user = await _userManager.GetUserAsync(User);
 
        // Sign out first to clear the existing cookie
        await _signInManager.SignOutAsync();
 
        // Create additional claims
        var additionalClaims = new List<Claim>
        {
            new Claim(Consts.LOA, Consts.LOA_400),
            new Claim(Consts.LOI, Consts.LOI_100),
            // ASP.NET Core bug workaround:
            // https://github.com/dotnet/aspnetcore/issues/64881
            new Claim(JwtClaimTypes.AuthenticationMethod, Amr.Pop)
        };
 
        // Sign in again with the additional claims
        await _signInManager.SignInWithClaimsAsync(user!, isPersistent: false, additionalClaims);
    }
}

Because this is a passkey login, loa is hardcoded to loa.400 and the amr value is forced to Amr.Pop (proof of possession) rather than trusting whatever ASP.NET Core Identity set on its own. That comment about a bug workaround is worth paying attention to: at the time of writing, ASP.NET Core Identity’s own passkey sign in does not always set an accurate amr value, so this sample overrides it explicitly rather than trusting the framework default. If you are building something similar with a mixed set of authentication methods, this sign out and sign in again pattern is the piece to copy: figure out which method the user actually used, map it to your own loa and loi constants, and reissue the session rather than trying to mutate claims on an existing principal, which ASP.NET Core will not let you do cleanly.

Passing the claims through Duende’s profile service

Adding claims to the local ASP.NET Core Identity session is only half the job, Duende needs to know to forward loa, loi, and amr into the tokens it issues. That is what the IProfileService implementation does, it is Duende’s extension point for deciding exactly which claims go into access tokens, ID tokens, and the userinfo response.

public class ProfileService : IProfileService
{
    public Task GetProfileDataAsync(ProfileDataRequestContext context)
    {
        // context.Subject is the user for whom the result is being made
        // context.Subject.Claims is the claims collection from the user's session cookie at login time
        // context.IssuedClaims is the collection of claims that your logic has decided to return in the response
 
        if (context.Caller == IdentityServerConstants.ProfileDataCallers.ClaimsProviderAccessToken)
        {
            // Access token - add custom claims
            AddCustomClaims(context);
        }
 
        if (context.Caller == IdentityServerConstants.ProfileDataCallers.ClaimsProviderIdentityToken)
        {
            // Identity token - add custom claims and standard profile claims
            AddCustomClaims(context);
            AddProfileClaims(context);
        }
 
        if (context.Caller == IdentityServerConstants.ProfileDataCallers.UserInfoEndpoint)
        {
            // UserInfo endpoint - add custom claims and standard profile claims
            AddCustomClaims(context);
            AddProfileClaims(context);
        }
 
        return Task.CompletedTask;
    }
 
    public Task IsActiveAsync(IsActiveContext context)
    {
        context.IsActive = true;
        return Task.CompletedTask;
    }
 
    private void AddCustomClaims(ProfileDataRequestContext context)
    {
        // Add OID claim
        var oid = context.Subject.Claims.FirstOrDefault(t => t.Type == "oid");
        if (oid != null)
        {
            context.IssuedClaims.Add(new Claim("oid", oid.Value));
        }
 
        // Add LOA (Level of Authentication) claim
        var loa = context.Subject.Claims.FirstOrDefault(t => t.Type == Consts.LOA);
        if (loa != null)
        {
            context.IssuedClaims.Add(new Claim(Consts.LOA, loa.Value));
        }
 
        // Add LOI (Level of Identification) claim
        var loi = context.Subject.Claims.FirstOrDefault(t => t.Type == Consts.LOI);
        if (loi != null)
        {
            context.IssuedClaims.Add(new Claim(Consts.LOI, loi.Value));
        }
 
        // Add AMR (Authentication Method Reference) claim
        var amr = context.Subject.Claims.FirstOrDefault(t => t.Type == JwtClaimTypes.AuthenticationMethod);
        if (amr != null)
        {
            context.IssuedClaims.Add(new Claim(JwtClaimTypes.AuthenticationMethod, amr.Value));
        }
    }
 
    private void AddProfileClaims(ProfileDataRequestContext context)
    {
        // Add Name claim (required for User.Identity.Name to work)
        var name = context.Subject.Claims.FirstOrDefault(t => t.Type == JwtClaimTypes.Name);
        if (name != null)
        {
            context.IssuedClaims.Add(new Claim(JwtClaimTypes.Name, name.Value));
        }
 
        var email = context.Subject.Claims.FirstOrDefault(t => t.Type == JwtClaimTypes.Email);
        if (email != null)
        {
            context.IssuedClaims.Add(new Claim(JwtClaimTypes.Email, email.Value));
        }
    }
}

The context.Caller check is the part people usually get wrong the first time they implement IProfileService. Skip it and you either leak profile information into an access token that an API should not need to see, or you find the claims missing from the ID token because you only added them for the access token caller. Splitting AddCustomClaims and AddProfileClaims into separate private methods also keeps the access token lean, it only gets oid, loa, loi, and amr, while the ID token and userinfo endpoint additionally get name and email since a UI actually needs those to render something for the user.

What shows up in the Blazor UI

Once the claims are flowing end to end, the Blazor application can read User.Claims directly and display the loa and loi values next to the signed in user’s name. In this sample the default Windows style claim type mapping is disabled, which is what lets the raw loa and loi claim names show up instead of being rewritten into unreadable namespaced URIs.

Blazor UI showing the loa, loi, and amr claims after passkey sign in, alongside a DPoP protected API call
Blazor UI showing the loa, loi, and amr claims after passkey sign in, alongside a DPoP protected API call

Clicking through to the Weather tab in the screenshot triggers an HTTP call from the Blazor app to the separate API project, carrying the DPoP bound access token. That is the piece that ties this whole claim design back to something concrete: the API on the other end is going to check that token’s claims before it returns any data, which is the next section.

Enforcing loa in the DPoP protected API

The API validates two things before it trusts a request: that the access token is a genuine DPoP bound at+jwt for the expected audience, and that the loa claim inside it meets the API’s minimum bar. JWT bearer authentication is configured first, then Duende’s DPoP extensions are layered on top of the same scheme.

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.Authority = "https://localhost:5001";
        options.Audience = "dpop-api";
 
        options.TokenValidationParameters.ValidateAudience = true;
        options.TokenValidationParameters.ValidateIssuer = true;
        options.TokenValidationParameters.ValidAudience = "dpop-api";
 
        options.MapInboundClaims = false;
        options.TokenValidationParameters.ValidTypes = ["at+jwt"];
    });
 
// layers DPoP onto the "token" scheme above
builder.Services.ConfigureDPoPTokensForScheme("Bearer", opt =>
{
    opt.ValidationMode = ExpirationValidationMode.IssuedAt; // IssuedAt is the default.
});
 
builder.Services.AddAuthorization();
 
builder.Services.AddSingleton<IAuthorizationHandler, AuthzLoaLoiHandler>();
 
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("authz_checks", policy => policy
        .RequireAuthenticatedUser()
        .AddRequirements(new AuthzLoaLoiRequirement()));

ValidTypes being restricted to at+jwt rejects any token that is not explicitly typed as an OAuth access token, which stops a handful of token confusion attacks where an ID token gets replayed against an API expecting an access token. ConfigureDPoPTokensForScheme is what actually enforces proof of possession on incoming requests, without it the Authority and Audience checks alone would accept a plain bearer token even though the client was told to request a DPoP bound one. The custom AuthzLoaLoiRequirement and its handler are registered the same way you would register any ASP.NET Core authorization requirement, nothing special there, the interesting logic is inside the handler itself.

The authorization handler

AuthzLoaLoiHandler reads the loa and loi claims straight off the ClaimsPrincipal that JWT bearer authentication built from the validated token, and only succeeds the requirement if loa is exactly loa.400.

using Microsoft.AspNetCore.Authorization;
 
public class AuthzLoaLoiHandler : AuthorizationHandler<AuthzLoaLoiRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, 
         AuthzLoaLoiRequirement requirement)
    {
        var loa = context.User.FindFirst(c => c.Type == "loa");
        var loi = context.User.FindFirst(c => c.Type == "loi");
 
        if (loa is null || loi is null)
        {
            return Task.CompletedTask;
        }
 
        // Lets require passkeys to use this API
        // DPoP is required to use the API
        if (loa.Value != "loa.400")
        {
            return Task.CompletedTask;
        }
 
        context.Succeed(requirement);
 
        return Task.CompletedTask;
    }
}

Notice that the handler never calls context.Fail, it just returns without succeeding when the checks do not pass. That is deliberate and matches how ASP.NET Core authorization handlers are meant to behave when multiple handlers might evaluate the same requirement, calling Fail short circuits every other handler for that requirement and is normally reserved for cases where you want to guarantee denial regardless of other logic. For a production system I would extend this handler to accept a minimum loa passed in through the requirement itself, rather than hardcoding loa.400, so the same handler class can back different policies for different endpoints instead of writing a near duplicate handler every time the bar changes.

Production considerations

This sample intentionally keeps a few things simple that you should not carry forward as is. The client secret on the web client should become a client assertion backed by a certificate or workload identity, PEM files loaded from disk should move to a proper key store, and the loa.400 check hardcoded in the handler should come from configuration or the requirement object so policy changes do not need a code deployment.

It is also worth deciding early where loi fits into your own system, since this sample defines it but the enforcement shown here only checks loa. loi matters most when a relying party cares not just about how the user authenticated, but how strongly their identity was verified in the first place, for example whether a passkey was bound to a government verified E-ID credential versus a self registered account. Combining both claims is exactly what an eIDAS style step up scenario needs, and that is the direction the rest of the series takes this sample.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading