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 asLogoutTokenJwtTypesays (logout+jwtby default). Anything else is anInvalidOperationException, not a token. - The protocol claims stay the token's own (0.127.0). Whatever
FilterRequestedClaimTypesandFilterProtocolClaimsreturn is held to the nineteen protocol claim types again: an override may filter more, never less, so a profile service'sscopecannot widen a token, nor itscnforsidrebind one. - A consumed refresh token presented again revokes its lineage unless
AcceptConsumedTokenAsyncaccepts 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 theBackChannelLogoutRequestof that client alone, so no step is handed one to give away. Duende's request carries it to every client, and its token writessidwhenever there is one.
The defaults, opened — new in 0.147.0, Duende 8.0.1's
DefaultTokenCreationService,DefaultClaimsService,DefaultRefreshTokenService,DefaultBackChannelLogoutService,BackChannelLogoutRequest,IHttpResponseWriter<T>andAddHttpWriter, measured.
- The four classes are unsealed, with Duende's protected members:
Keys,TimeProvider,Options;Profile;RefreshTokenStore,TimeProvider;TimeProvider,HttpClient,IssuerNameService.- Every
amrreaches the token. MeasuringGetStandardSubjectClaimsfound 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
BackChannelLogoutFailureEventfor that client; until 0.147.0 the token was sent withoutsid. A logout token carryingnonceis 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 —
audan 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;
acrand this server'sactare the optional claims (Duende's:acr), andsub,auth_timeandidpare copied when the subject has them, where Duende's requiressubandauth_time; the logout token's lifetime isBackChannelLogout.LogoutTokenLifetime, not Duende's fixed 300 seconds, and itstypisLogoutTokenJwtType(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;PostLogoutJwtposts through this server'sIBackChannelLogoutHttpClient(the token, not Duende's form dictionary); a writer is asked by the middleware, where Duende's results derive fromEndpointResult<T>, which is not taken.ITokenCreationService,IClaimsServiceandIRefreshTokenServicekeep this server's shapes.