Table of Contents

Your own checks on requests and tokens

New in 0.115.0, Duende's three host hooks. Each runs last, once everything this server checks has passed — a request or token this server refused is never handed to one — and each has a default that lets everything through. Customization shows the token-request hook adding a member to the token response and refusing a client; this page covers the rest of what the three can do. The code is included from samples/Interaction.Checks, whose test sends it requests each hook refuses or changes.

var identityServer = builder.Services
    .AddIdentityServer(options =>
    {
        // A client_credentials or extension grant request naming no scope asks for what the resource store has among
        // the client's AllowedScopes, as in Duende — rather than for AllowedScopes as registered.
        options.TokenRequestDefaultScopesFromResourceStore = true;

        // Added to the schemes this server will never send a browser to: these clients are all web applications.
        options.Validation.InvalidRedirectUriPrefixes.Add("intent:");

        options.KeyManagement.Enabled = false;
    })
    .AddCustomAuthorizeRequestValidator<ClosedTenantAuthorizeValidator>()
    .AddCustomTokenRequestValidator<QuotaTokenRequestValidator>();

// As in Duende there is no builder method for the token hook: register it over the default.
builder.Services.AddTransient<ICustomTokenValidator, SuspendedClientsTokenValidator>();

At the authorize endpoint

ICustomAuthorizeRequestValidator sees an authorize request with every parameter the client sent (Raw) beside what this server made of them, and may change it or refuse it. A refusal goes back to the client's redirect URI with the error and description you give — invalid_request when you give none — and either way it is the request this server validated that goes on, as you changed it:

/// <summary>
/// Runs last at the authorize endpoint, once every check of the server's own has passed: refuses a request naming a
/// tenant the operator has closed.
/// </summary>
public sealed class ClosedTenantAuthorizeValidator(OperatorDirectory directory) : ICustomAuthorizeRequestValidator
{
    /// <summary>Replaces <c>context.Result</c> to refuse; changing the validated request changes what is acted on.</summary>
    public Task ValidateAsync(CustomAuthorizeRequestValidationContext context, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(context);

        // Raw is every parameter the client sent, beside what the server made of them.
        var request = context.Result.ValidatedRequest!;
        if (request.Raw.TryGetValue("tenant", out var tenant) && directory.IsClosed(tenant))
        {
            // Back to the client's redirect URI with this error and description; invalid_request when none is given.
            context.Result = new AuthorizeRequestValidationResult(request, "access_denied", "The tenant is closed.");
        }

        return Task.CompletedTask;
    }
}

It is asked wherever an authorize request is validated: at the authorize endpoint; when a request is pushed, validated as the authorize request it will become (pushed authorization); and on every parse of a return URL by the login and consent pages (return URLs). ValidatedRequest.AuthorizeRequestType says which — Authorize, PushedAuthorization or AuthorizeWithPushedParameters. What it changes — PromptModes, MaxAge, a LoginHint, an acr_values hint — is what the interaction generator and the pages act on.

At the token endpoint

ICustomTokenRequestValidator does the same at the token endpoint, for every grant, once the request is valid and its DPoP proof or certificate has settled what the token will be bound to. It may add members to the response through CustomResponse — to the token response, or to the error when it refuses, where the failure event records the refusal:

/// <summary>
/// Runs last at the token endpoint, for every grant: refuses a client whose quota is spent — telling it when to come
/// back — and otherwise shapes this one request's token without touching the client's registration.
/// </summary>
public sealed class QuotaTokenRequestValidator(ClientQuota quota) : ICustomTokenRequestValidator
{
    /// <summary>Replaces <c>context.Result</c>, or changes the validated request it carries.</summary>
    public Task ValidateAsync(CustomTokenRequestValidationContext context, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(context);

        var request = context.Result.ValidatedRequest!;
        if (!quota.TryTake(request.Client.ClientId, out var left))
        {
            // A refusal may carry members of its own beside error and error_description.
            context.Result = new TokenRequestValidationResult(
                request, "invalid_grant", "The quota is spent.", new Dictionary<string, object> { ["retry_after"] = 60 });
            return Task.CompletedTask;
        }

        // Per request: a claim on this token alone (under the client's claim prefix), and a shorter life for it.
        if (request.Raw.TryGetValue("tenant", out var tenant))
        {
            request.ClientClaims.Add(new Claim("tenant", tenant));
            request.AccessTokenLifetime = 300;
        }

        if (left != int.MaxValue)
        {
            context.Result = new TokenRequestValidationResult(request, new Dictionary<string, object> { ["quota_left"] = left });
        }

        return Task.CompletedTask;
    }
}

Custom members sit at the top level of the response beside this server's own and never replace one: a custom access_token is dropped, not served. Duende writes both, giving a response with the name twice.

What a token-request hook can reach

New in 0.148.0, Duende 8.0.1's ValidatedRequest members, measured from its SetClient, token service, claims service and response generator. A ValidatedTokenRequest carries, for this one request:

Member Starts as Read by
AccessTokenLifetime the client's the access token's lifetime, expires_in, and the lifetime a refresh renews a kept token for
AccessTokenType the client's whether the access token is a JWT or a reference
ClientClaims a copy of the client's Claims the client claims the access token carries, under the client's prefix and AlwaysSendClientClaims rule
Secret the credential the client authenticated with nothing here — there for a hook to inspect; null for a CIBA push, which this server redeems itself
AuthorizationCode the code being redeemed, on a code grant nothing here — there for a hook

So a hook can shorten one request's token, or put a claim on it, without touching the client — the tenant branch above, whose token carries client_tenant and lives five minutes.

The three settings are copied onto TokenCreationRequest (Duende reads them off the validated request it carries), so a replaced token service reads them there; built without them, a TokenCreationRequest answers the client's. Ours: the validated request reads through to the client until a member is set, where Duende copies the values in SetClient — the same answer, and a request built without the validator still has one. The credential reaches the request through an ITokenRequestValidator.ValidateAsync(client, secret, parameters) overload, which the token endpoint calls; its default calls the existing overload and sets Secret on the result, so a validator written before it keeps working.

offline_access, measured with it: granted to a client whose AllowOfflineAccess is on, whether or not it also lists the scope in AllowedScopes — Duende's IsClientAllowedOfflineAccessAsync reads the switch alone. Until 0.148.0 it had to be listed too, so a device or authorize request from a client that only set the switch was invalid_scope. client_credentials refuses offline_access (invalid_scope, as Duende's "Client cannot request a refresh token in client credentials flow"), where it was granted and written into the token's scope; with no scope parameter it is left out of what the client asks for — breaking for a client that named it explicitly.

A token request that names no scope

A client_credentials or extension grant request with no scope asks for the client's AllowedScopes, as registered: every name, so a client_credentials client allowed openid is refused (openid is not valid for client credentials), a name the resource store does not have is invalid_scope, and a client allowed nothing is issued a token for nothing. Duende 8.0.1 asks for what the store has instead. Its rules are here behind one option, TokenRequestDefaultScopesFromResourceStore, off by default, because each changes what an existing client receives:

// A client_credentials or extension grant request naming no scope asks for what the resource store has among
// the client's AllowedScopes, as in Duende — rather than for AllowedScopes as registered.
options.TokenRequestDefaultScopesFromResourceStore = true;

With the option on, a request naming no scope:

Grant Asks for Off (default), as before
client_credentials the API scopes the store has among AllowedScopes (FindApiScopesByNameAsync): no identity scope, no offline_access AllowedScopes less offline_access; openid among them refused
extension grant the identity resources and API scopes the store has among AllowedScopes, and offline_access when the client lists it or allows offline access — so it is issued a refresh token AllowedScopes verbatim; no refresh token unless offline_access is listed
either, from a client with no AllowedScopes refused, invalid_scope ("No allowed scopes configured for client") a token for no scope

A name the store does not have is left out rather than refused — the sample's reporting client, allowed orders and the retired retired, is issued orders. A disabled scope the store has is asked for, and so refused, as in Duende, whose store lookups do not leave it out. And a request of either grant granted no scope at all is invalid_scope — Duende's Succeeded requires one — including one whose defaults came to nothing. A request that names its scopes is validated as before.

New in 0.160.0, Duende 8.0.1's TokenRequestValidator.ValidateRequestedScopesAndResourcesAsync and ResourceValidationResult.Succeeded, measured. DefaultTokenRequestValidator reads the store through a constructor overload taking an IResourceStore, which the container chooses; its earlier constructor stays, and a validator built through it throws InvalidOperationException when asked with the option on, rather than answering as if it were off.

Ours, recorded: the option is this server's — Duende has the rules alone. ResourceValidationResult.Succeeded is unchanged, and the granted-scope rule is the token request's: the authorize and backchannel requests refuse a request with no scope, and the device request one whose client allows none, before their scopes are validated, so there it would change nothing — and the authorize and backchannel validators answer a failed Succeeded with invalid_target once the scopes are known valid. The extension grant is still asked before a request out of scope is refused (0.148.0), so a client with no allowed scopes hears the grant's own refusal first, where Duende answers invalid_scope before asking it; Duende bounds the defaults by InputLengths.Scope, which this token request does not read.

Tokens the server has validated

ICustomTokenValidator is handed every token this server has validated — an access token at userinfo, introspection, token exchange or a local API, and an identity token presented as a CIBA id_token_hint or as the end-session endpoint's id_token_hint — and what it returns stands: a refusal, or the claims it answers with. As in Duende there is no builder method; register yours over the default (above), or derive from DefaultCustomTokenValidator, whose methods are virtual:

/// <summary>
/// Handed every access token the server has validated — at userinfo, introspection, token exchange and a local API —
/// and refuses one issued to a client the operator has since suspended. What it returns stands.
/// </summary>
public sealed class SuspendedClientsTokenValidator(OperatorDirectory directory) : DefaultCustomTokenValidator
{
    /// <summary>Only a token the server accepted reaches this; a refusal here is final.</summary>
    public override Task<AccessTokenValidationResult> ValidateAccessTokenAsync(
        AccessTokenValidationResult result, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(result);

        var clientId = result.Claims.FirstOrDefault(claim => claim.Type == JwtClaimTypes.ClientId)?.Value;
        return clientId is not null && directory.IsSuspended(clientId)
            ? Task.FromResult(AccessTokenValidationResult.Failure("invalid_token"))
            : base.ValidateAccessTokenAsync(result, ct);
    }
}

At introspection a refused token is active: false, as the sample's test sees once the client is suspended.

Only an access token is an access token

New in 0.176.0, a security fix. ITokenValidator accepts a JWT as an access token only when it is one. This server signs three kinds of JWT — access tokens, identity tokens and back-channel logout tokens — with the same keys and the same issuer, and until 0.176.0 nothing told them apart here: an identity token a relying party had been given, or a logout token sent to its back channel, validated as an access token at userinfo, introspection, a local API and token exchange, and token exchange then minted an access token for its user. Now, as Duende 8.0.1's TokenValidator does, measured:

  • the JWT's typ must be IdentityServerOptions.AccessTokenJwtType (at+jwt by default; RFC 9068 §4, RFC 8725 §3.11) when that option is set;
  • ours, stricter: where that type cannot tell an access token from the others — AccessTokenJwtType empty or JWT (the type an identity token carries), or equal to LogoutTokenJwtType — the JWT must name its client in client_id and carry no nonce, azp or events. Every access token the token endpoint issues does; the claims service keeps those three out of it. (Duende with such a type accepts a JWT without client_id that has no sub, and fails with an exception on one that has.)

A refused token is invalid_token, answered by each caller as below.

Security — who has to act. A host that keeps the default at+jwt has nothing to do. A host that set AccessTokenJwtType to JWT or empty, and presents to this server's own endpoints a JWT it minted itself — with IIdentityServerTools.IssueJwtAsync, which writes only the claims it is given — must put client_id in it (IssueClientJwtAsync does), or the token is now refused. A resource server of your own that validates this server's access tokens should hold them to the same typ; RFC 9068 §4 asks it to.

What a refused token is answered with

ITokenValidator refuses an access token with Duende 8.0.1's codes, measured from its TokenValidator: expired_token for a JWT past its lifetime, and invalid_token for anything else wrong with it — a signature this server's keys do not verify, another issuer, a JWT that does not parse, a reference handle the store does not have (an expired reference token among them), a session that is gone or a user who is no longer active. Only a call with no token at all is invalid_request. Until 0.174.0 a JWT that failed validation, expired or not, and an unknown handle were invalid_request; that reached no response here — every caller below maps the refusal — but a host calling ITokenValidator itself, or reading the result in an ICustomTokenValidator of its own, saw it.

Caller A refused token
userinfo 401, WWW-Authenticate: Bearer error="invalid_token" — with error_description="The access token expired" for an expired one, as Duende's writes it (new in 0.174.0)
local API 401, WWW-Authenticate naming the scheme with error="invalid_token" (Duende's writes the scheme alone)
introspection active: false
token exchange invalid_grant

Breaking in 0.115.0: the validated request on AuthorizeRequestValidationResult and TokenRequestValidationResult is ValidatedRequest, Duende's name, where it was Request.