Extension grants
Client.AllowedGrantTypes is a collection of plain strings, so a host could always register a client for any grant it
liked. IExtensionGrantValidator is what makes that honest — registering one makes
its grant type supported: the token endpoint routes to it, and discovery names it. Customization
shows a grant that issues a token for a user; this page is the rest. Its code is included from
samples/Protocols.Grants,
which a test boots and redeems the grant against, accepted and refused.
Who the token is for, and nothing more
/// <summary>
/// A grant that names no user: a gateway presents a sensor's attestation and gets a short-lived token for the
/// gateway itself, shaped like a client_credentials token.
/// </summary>
public sealed class SensorAttestationGrant(SensorRegistry sensors) : IExtensionGrantValidator
{
/// <inheritdoc />
public string GrantType => "urn:samples:params:oauth:grant-type:sensor-attestation";
/// <inheritdoc />
public Task<ExtensionGrantValidationResult> ValidateAsync(
ExtensionGrantValidationContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Parameters.TryGetValue("attestation", out var attestation) || string.IsNullOrEmpty(attestation))
{
return Task.FromResult(ExtensionGrantValidationResult.Failure(
OidcConstants.Errors.InvalidRequest, "attestation is required."));
}
if (sensors.FindByAttestation(attestation) is not { } sensorId)
{
// A refusal can carry members of its own, at the top level of the error response.
var refused = ExtensionGrantValidationResult.Failure(OidcConstants.Errors.InvalidGrant);
refused.CustomResponse = new Dictionary<string, object> { ["retry_after"] = 30 };
return Task.FromResult(refused);
}
// The request the token will be issued for: the scopes are already validated, and what is set here
// reaches the token.
context.Request.AccessTokenLifetime = 300;
// Nobody, said so: a token for the client, with no sub and no identity token.
var accepted = ExtensionGrantValidationResult.SuccessForClient();
accepted.CustomResponse = new Dictionary<string, object> { ["sensor_id"] = sensorId };
return Task.FromResult(accepted);
}
}
// The extension grant: registering it is what makes its grant_type supported. Token exchange is registered by
// AddIdentityServer already; the delegation policy is what lets an actor_token through.
builder.Services.AddSingleton<SensorRegistry>();
builder.Services.AddTransient<IExtensionGrantValidator, SensorAttestationGrant>();
builder.Services.AddTransient<IDelegationPolicy, ActingServices>();
new Client
{
ClientId = "sensor-gateway",
ClientSecrets = { new Secret("sensor-gateway-secret".Sha256()) },
AllowedGrantTypes = { "urn:samples:params:oauth:grant-type:sensor-attestation" },
AllowedScopes = { "telemetry" },
},
A validator decides who the token is for. It does not decide what the token may reach: the requested scopes go
through the client's registration and the resource store exactly as they do for every other grant, so writing a grant
cannot widen a client. A grant that resolves nobody returns
SuccessForClient() and gets a token shaped like
client_credentials — stated rather than inferred from a null subject, because "nobody" and "this failed silently" are
different answers. openid is refused for such a grant, the rule client_credentials already follows: an identity token
is a statement about a user, and there is none.
As of 0.148.0, as Duende 8.0.1's ValidateExtensionGrantRequestAsync: the requested scopes are validated before
the grant is asked, and the grant is handed the request the tokens will be issued for as
Request: its client, scopes, resources, Raw form,
options and the client's Secret. What it sets there reaches the tokens — the sample's AccessTokenLifetime = 300 is
the token's expires_in. And CustomResponse,
settable as Duende's, goes back with the answer — at the top level of the token response, or of the error when the grant
refuses, as the sample's sensor_id and retry_after do.
Ours, recorded: a request out of scope is still refused after the grant is asked, so a grant that refuses it answers in
its own terms, as it always did here; Duende answers it invalid_scope first. A grant that accepts it still cannot widen
the client: the request is then refused invalid_scope.
Two collisions fail at startup rather than quietly. A validator whose grant type matches a built-in is shadowed by the token endpoint's own arm for it; two validators claiming one grant type mean whichever registration loses is dead code that reads as live configuration. Both would be registered, advertised, and never called.
The demo host's src/Fdyay.IdentityServer.Host/DemoDelegationGrant.cs is another worked example. It is deliberately
not a partial RFC 8693 — a half-implemented token exchange would get copied, and the parts left out (actor_token,
the act claim, the difference between impersonation and delegation) are the parts that make it safe. This server ships
token exchange itself, as an extension grant registered by default.
A grant this server does not have
Asking for one answers unsupported_grant_type, and that is a fix rather than a description. Until 0.28.0 the
permission check ran first, so a client asking for a grant this server does not have was told unauthorized_client —
"the grant exists, you may not use it" — which sends whoever wrote the client to their registration when the fault is the
grant name. RFC 6749 §5.2 distinguishes the two, and the grants this most misdirected are password and implicit: the
ones removed here on purpose (see OAuth 2.1 posture), which is to say the ones a client migrating from a
server that had them tries first.
grant_types_supported is derived from the same list the support check reads, plus whatever validators a host
registered — so it cannot promise a grant nothing services, or hide one that works.