Table of Contents

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.