Table of Contents

Local API authentication

An API the host serves in the identity server's own process — an administration surface, a configuration endpoint, an internal operation — is a resource server with no network between it and the token authority. It should not have to point a JWT handler back at the server it is running inside, so it does not. APIs shows the registration, the endpoint, the IdentityServerApi scope and a client allowed to call; this page is what to know before relying on it. Its code is included from samples/Protocols.Dpop, which a test calls with a bearer token, which its API refuses, and with a DPoP-bound one, which it takes.

One registration, a scheme and a policy

AddLocalApiAuthentication registers an authentication scheme and the matching authorization policy together, both named IdentityServerAccessToken (AuthenticationScheme and PolicyName, Duende's; the policy was IdentityServerApi until 0.165.0 — see Breaking in 0.165.0). Either alone is a half a host has to notice is missing: a scheme with no policy protects nothing until somebody writes one, and a policy names a scheme that has to exist. The policy pins the scheme explicitly rather than trusting the default, which in a host also serving this server's pages is a browser cookie. Both registration methods are safe to call twice, so a library adding the local API for an application that also adds it does not take the host down at startup.

The token is validated in this process by the same ITokenValidator the protocol endpoints use, against the same keys and the same issuer — and it runs the sender-constraint check, so a DPoP- or certificate-bound token is only accepted from the caller that can prove it holds the binding; see presenting a bound token back to this server.

Four things worth knowing before you rely on it:

ExpectedScope is required There is no way to say "any token this server issued". That would be a door opened by every token in the deployment, including one minted for an unrelated API by a client you never meant near your administration surface
A valid token without the scope gets 401, not 403 The scope is required by the handler, so AddLocalApi() on its own still guards the API. The cost is that a caller you can identify is told you cannot
A refused call is answered by the header alone 401 with WWW-Authenticate naming the scheme and error="invalid_token" (or the DPoP error, with a DPoP-Nonce), and no body — even in a host that calls UseIdentityServerErrorPages(), which since 0.174.0 leaves any response carrying WWW-Authenticate alone; before, the local API's challenge was given the HTML status page (see the DPoP flow)
Read the saved token back by naming the scheme The overload of GetTokenAsync without one reads the default authenticate scheme — the browser cookie — and answers null
// A local API that calls on to something else reads the token it was called with back by naming the scheme.
// GetTokenAsync without one reads the default scheme, the browser cookie, and answers null.
app.MapGet("/api/field-report", async (HttpContext context) =>
    {
        var token = await context.GetTokenAsync(IdentityServerConstants.LocalApi.AuthenticationScheme, "access_token");
        return Results.Json(new { forwarded = token is not null });
    })
    .RequireAuthorization(IdentityServerConstants.LocalApi.PolicyName);

Changed in 0.128.0: IProfileService.IsActiveAsync is consulted, as Duende's local API does. Until 0.128.0 it was not — a decision that a resource server checks only the token it was given — but the local API lives inside the server that knows the user, and a token that outlives its account should stop opening it there first. See Users the profile service no longer calls active.

Which schemes it takes

Since 0.138.0, TokenMode, Duende 8.0.1's, measured:

// The local API takes DPoP-bound tokens only: a Bearer token is ignored, and the challenge is DPoP.
builder.Services.AddLocalApiAuthentication(options => options.TokenMode = LocalApiTokenMode.DPoPOnly);
LocalApiTokenMode Takes
DPoPAndBearer (our default) a token under either scheme
DPoPOnly DPoP only; a Bearer token is ignored and the challenge is DPoP
BearerOnly Bearer only; a DPoP token is ignored and the challenge is Bearer

An ignored token is no result rather than a failure, as Duende's, so another scheme on the endpoint can still answer, and Duende's debug line is logged. Ours, recorded: the default takes both, where Duende's is BearerOnly, because this handler has taken both since 0.45.0; a token bound to a key is refused under Bearer in every mode, where Duende's BearerOnly accepts it; and the challenge names one scheme where Duende's lists every scheme the mode takes.

The rest of what the handler lets a host set — the expected scope, whether the token is saved, and the hook that shapes the principal the API sees — is LocalApiAuthenticationOptions.