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.ValidateRequestedScopesAndResourcesAsyncandResourceValidationResult.Succeeded, measured.DefaultTokenRequestValidatorreads the store through a constructor overload taking anIResourceStore, which the container chooses; its earlier constructor stays, and a validator built through it throwsInvalidOperationExceptionwhen 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.Succeededis unchanged, and the granted-scope rule is the token request's: the authorize and backchannel requests refuse a request with noscope, 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 failedSucceededwithinvalid_targetonce 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 answersinvalid_scopebefore asking it; Duende bounds the defaults byInputLengths.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
typmust beIdentityServerOptions.AccessTokenJwtType(at+jwtby 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 —
AccessTokenJwtTypeempty orJWT(the type an identity token carries), or equal toLogoutTokenJwtType— the JWT must name its client inclient_idand carry nononce,azporevents. 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 withoutclient_idthat has nosub, 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+jwthas nothing to do. A host that setAccessTokenJwtTypetoJWTor empty, and presents to this server's own endpoints a JWT it minted itself — withIIdentityServerTools.IssueJwtAsync, which writes only the claims it is given — must putclient_idin it (IssueClientJwtAsyncdoes), or the token is now refused. A resource server of your own that validates this server's access tokens should hold them to the sametyp; 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.