Table of Contents

Overriding one step of a default

New in 0.147.0, Duende 8.0.1's, measured. Four defaults that were sealed are open, each in Duende's protected virtual steps, so a host with one rule of its own derives from the default, overrides that step and keeps the rest. The code on this page is included from samples/Interaction.DerivedDefaults, whose test validates a token signed by a stand-in key service and reads the header its response writer adds.

Default Its steps, in the order they run
DefaultTokenCreationService CreatePayloadAsync, CreateHeaderElementsAsync, CreateJwtAsync (signs)
DefaultClaimsService GetStandardSubjectClaims, GetOptionalClaims, FilterRequestedClaimTypes, FilterProtocolClaims
DefaultRefreshTokenService AcceptConsumedTokenAsync
DefaultBackChannelLogoutService SendLogoutNotificationsAsync(requests), then per client CreateTokenAsync (with CreateClaimsForTokenAsync) and PostLogoutJwt

Each derived default is registered over the service it implements:

var identityServer = builder.Services
    .AddIdentityServer(options => options.KeyManagement.Enabled = false)
    .AddHttpWriter<JsonResult, TraceIdJsonWriter>();

// Each derived default is registered over the service it implements.
builder.Services.AddTransient<ITokenCreationService, KeyServiceTokenCreationService>();
builder.Services.AddTransient<IRefreshTokenService, RaceTolerantRefreshTokenService>();

Signing with an HSM or a cloud key service

Signing is CreateJwtAsync, as in Duende: the payload and header are this server's, the signature is the key service's. The sample's key service is a stand-in holding a P-256 key in the process; a real one is a call to the HSM or the cloud key service's sign operation.

/// <summary>
/// The default token creation service with its signing step handed to a key service. The payload and the header are
/// the server's; the signature is the key service's.
/// </summary>
public sealed class KeyServiceTokenCreationService(
    IKeyMaterialService keys, TimeProvider time, IOptions<IdentityServerOptions> options, IKeyService keyService)
    : DefaultTokenCreationService(keys, time, options)
{
    /// <summary>Signs <paramref name="payload"/> as the server's registered credential names: its algorithm and key id.</summary>
    protected override async Task<string> CreateJwtAsync(
        Token token, string payload, Dictionary<string, object> headerElements, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(token);
        ArgumentNullException.ThrowIfNull(headerElements);

        // The algorithm the token allows, as the default step would choose it. The identity token's at_hash and c_hash
        // were sized for this algorithm, so sign with it and no other.
        var allowed = token.AllowedSigningAlgorithms.Count > 0 ? token.AllowedSigningAlgorithms : null;
        var credentials = await Keys.GetSigningCredentialsAsync(allowed, cancellationToken);

        var header = new Dictionary<string, object>(headerElements, StringComparer.Ordinal)
        {
            ["alg"] = credentials.Algorithm,
            ["kid"] = credentials.Key.KeyId,
        };
        header.TryAdd("typ", "JWT");

        var signingInput = Base64UrlEncoder.Encode(JsonSerializer.Serialize(header)) + "." + Base64UrlEncoder.Encode(payload);
        var signature = await keyService.SignAsync(
            credentials.Key.KeyId, credentials.Algorithm, Encoding.ASCII.GetBytes(signingInput), cancellationToken);

        return signingInput + "." + Base64UrlEncoder.Encode(signature);
    }
}

Register the key service's public half as the signing credential: it names the algorithm and key id tokens are signed under, and publishes the key in the key set — a credential registered with AddSigningCredential is published the way AddValidationKey publishes a key. Sign with the algorithm IKeyMaterialService names: the hashes in an identity token (at_hash, c_hash) are sized for it.

var keyService = new InProcessKeyService();
builder.Services.AddSingleton<IKeyService>(keyService);

// The key service's public half, registered as the signing credential: it names the algorithm and key id tokens are
// signed under, and is what the key set publishes. The private half never leaves the key service.
identityServer.AddSigningCredential(keyService.PublicKey, SecurityAlgorithms.EcdsaSha256);

Accepting a refresh token presented twice

AcceptConsumedTokenAsync (Duende's) decides whether a refresh token already rotated away under OneTimeOnly is accepted again. No, by default — this server's behaviour since rotation shipped — unless PersistentGrants.ConsumedRefreshTokenGracePeriod is set (0.167.0; see the configuration reference), which is all a plain grace period needs. Accepting weakens replay detection (RFC 9700 §4.14): an accepted token is redeemed again and rotated into a second successor, and a stolen handle used within whatever it accepts goes unnoticed. A host with a rule of its own overrides the hook, replacing the option, and keeps it narrow:

/// <summary>
/// Accepts a rotated-away refresh token presented again within ten seconds of its rotation — two instances of one
/// client refreshing with the same token — and treats any later reuse as the replay it is.
/// </summary>
public sealed class RaceTolerantRefreshTokenService(
    IRefreshTokenStore store,
    TimeProvider time,
    IEventService events,
    IEnumerable<ISessionCoordinationService> sessions,
    IOptions<IdentityServerOptions> options,
    ILogger<DefaultRefreshTokenService> logger)
    : DefaultRefreshTokenService(store, time, events, sessions, options, logger)
{
    private static readonly TimeSpan Race = TimeSpan.FromSeconds(10);

    /// <summary>
    /// Asked only for a token issued to the client presenting it. Accepting weakens replay detection: keep it narrow.
    /// </summary>
    protected override Task<bool> AcceptConsumedTokenAsync(RefreshToken refreshToken)
    {
        ArgumentNullException.ThrowIfNull(refreshToken);

        return Task.FromResult(TimeProvider.GetUtcNow().UtcDateTime - refreshToken.ConsumedTime < Race);
    }
}

Writing a response yourself

IHttpResponseWriter<T>, registered for one result type with AddHttpWriter (transient, as Duende registers it), is asked in place of the result writing itself. The results here are this server's own — JsonResult, StatusCodeResult, RedirectResult and the rest, where Duende has one per endpoint — and the first three expose what they write (Value, StatusCode, Url). A writer can decorate the result's own answer by calling its ExecuteAsync:

/// <summary>
/// Writes every JSON answer of the protocol endpoints — tokens, introspection, userinfo, pushed requests — as the
/// result would, with the request's trace id in a header a support ticket can quote.
/// </summary>
public sealed class TraceIdJsonWriter : IHttpResponseWriter<JsonResult>
{
    /// <summary>The header added.</summary>
    public const string HeaderName = "X-Trace-Id";

    /// <summary>Asked in place of the result writing itself.</summary>
    public Task WriteHttpResponse(JsonResult result, HttpContext context)
    {
        ArgumentNullException.ThrowIfNull(result);
        ArgumentNullException.ThrowIfNull(context);

        context.Response.Headers[HeaderName] = context.TraceIdentifier;

        // The result's own answer, decorated rather than reproduced. Value and StatusCode are there to read too.
        return result.ExecuteAsync(context);
    }
}

What an override cannot change

The method that runs the steps is not virtual — where Duende's is — and every check where this server is stricter than Duende is made there, after the steps, where no override reaches it:

  • The token is read back before it leaves. Signed with one of the nine RSA and EC algorithms this server signs with (never none, never a shared secret), with one the token allows when the client or resource names any (AllowedIdentityTokenSigningAlgorithms, AllowedAccessTokenSigningAlgorithms), and a logout token typed as LogoutTokenJwtType says (logout+jwt by default). Anything else is an InvalidOperationException, not a token.
  • The protocol claims stay the token's own (0.127.0). Whatever FilterRequestedClaimTypes and FilterProtocolClaims return is held to the nineteen protocol claim types again: an override may filter more, never less, so a profile service's scope cannot widen a token, nor its cnf or sid rebind one.
  • A consumed refresh token presented again revokes its lineage unless AcceptConsumedTokenAsync accepts it; an override cannot refuse without revoking. The hook is asked only for a token issued to the client presenting it.
  • The session id goes only to a client that asked for it (BackChannelLogoutSessionRequired): it is on the BackChannelLogoutRequest of that client alone, so no step is handed one to give away. Duende's request carries it to every client, and its token writes sid whenever there is one.

The defaults, opened — new in 0.147.0, Duende 8.0.1's DefaultTokenCreationService, DefaultClaimsService, DefaultRefreshTokenService, DefaultBackChannelLogoutService, BackChannelLogoutRequest, IHttpResponseWriter<T> and AddHttpWriter, measured.

  • The four classes are unsealed, with Duende's protected members: Keys, TimeProvider, Options; Profile; RefreshTokenStore, TimeProvider; TimeProvider, HttpClient, IssuerNameService.
  • Every amr reaches the token. Measuring GetStandardSubjectClaims found the first alone was copied, so a sign-in with a password and a second factor reached an API as a password; Duende's copies each, and so does this one.
  • A client registered for the session id is not sent a token without one, and a session naming neither a subject nor a session id tells nobody — Duende's two refusals, measured ("Client requires SessionId", "Either a SubjectId or SessionId is required."). Each is raised as BackChannelLogoutFailureEvent for that client; until 0.147.0 the token was sent without sid. A logout token carrying nonce is refused, as Duende's is.
  • The payload is written by the payload step as JSON, where the token handler wrote it from a descriptor; what a relying party reads is unchanged — aud an array even for one audience (Duende's writes a string), the times in seconds, nothing escaped that JSON does not require.

Ours, recorded: the run methods are not virtual; the guardrails above; acr and this server's act are the optional claims (Duende's: acr), and sub, auth_time and idp are copied when the subject has them, where Duende's requires sub and auth_time; the logout token's lifetime is BackChannelLogout.LogoutTokenLifetime, not Duende's fixed 300 seconds, and its typ is LogoutTokenJwtType (Duende's option, here since 0.150.0); each client is told concurrently and on its own, where Duende's builds every token before posting any; PostLogoutJwt posts through this server's IBackChannelLogoutHttpClient (the token, not Duende's form dictionary); a writer is asked by the middleware, where Duende's results derive from EndpointResult<T>, which is not taken. ITokenCreationService, IClaimsService and IRefreshTokenService keep this server's shapes.