Customization
The server's behaviour is a set of services, and the ones a deployment is expected to replace have a builder method or
an interface of their own. The code on this page is included from
samples/Customization, which a
test boots and redeems the extension grant against.
Registering them
var identityServer = builder.Services
.AddIdentityServer(options =>
{
// The sink receives only the kinds of event switched on here; all four are off by default.
options.Events.RaiseSuccessEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseErrorEvents = true;
})
.AddProfileService<EmployeeProfileService>()
.AddCustomTokenRequestValidator<TenantTokenRequestValidator>();
// Neither has a builder method: an extension grant and an event sink are plain service registrations.
builder.Services.AddTransient<IExtensionGrantValidator, InstallationGrantValidator>();
builder.Services.AddSingleton<IEventSink, AuditEventSink>();
Each replaces a default. The builder methods are on IdentityServerBuilderExtensionsCore; an extension grant and an event sink are ordinary service registrations, and there may be several of each.
The profile service
/// <summary>Issues claims from the employee directory, and stops issuing tokens to employees who have left.</summary>
public sealed class EmployeeProfileService(EmployeeDirectory directory) : IProfileService
{
/// <summary>Adds the claims the token is asked to carry: the requested scopes decide which those are.</summary>
public Task GetProfileDataAsync(ProfileDataRequestContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var employee = directory.Find(SubjectId(context.Subject));
if (employee is not null)
{
// Filters to context.RequestedClaimTypes, so a token carries only what its scopes release.
context.AddRequestedClaims(employee.Claims);
}
return Task.CompletedTask;
}
/// <summary>Asked before a token is issued or refreshed, and by the local API: is this user still allowed in?</summary>
public Task IsActiveAsync(IsActiveContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
context.IsActive = directory.Find(SubjectId(context.Subject))?.IsActive ?? false;
return Task.CompletedTask;
}
private static string SubjectId(ClaimsPrincipal subject) =>
subject.FindFirst(JwtClaimTypes.Subject)?.Value ?? string.Empty;
}
IProfileService is where the claims in a token come from. It is asked for the
claim types the requested scopes release (RequestedClaimTypes),
and AddRequestedClaims adds only those. IsActiveAsync is asked before a token is issued or refreshed: answering
false for a user who has left stops their refresh tokens working at the next refresh. The EmployeeDirectory it reads
stands in for wherever your users live.
A check on every token request
/// <summary>
/// Runs after the token endpoint has validated a request, for every grant: refuses clients whose tenant is suspended,
/// and tells the others which tenant the token was issued in.
/// </summary>
public sealed class TenantTokenRequestValidator : ICustomTokenRequestValidator
{
/// <summary>Replaces <c>context.Result</c> to refuse the request or add members to the token response.</summary>
public Task ValidateAsync(CustomTokenRequestValidationContext context, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(context);
var request = context.Result.ValidatedRequest!;
var properties = request.Client.Properties;
if (properties.TryGetValue("tenant_suspended", out var suspended) && suspended == "true")
{
context.Result = new TokenRequestValidationResult(
request, "unauthorized_client", "The client's tenant is suspended.");
}
else if (properties.TryGetValue("tenant", out var tenant))
{
context.Result = new TokenRequestValidationResult(
request, new Dictionary<string, object> { ["tenant"] = tenant });
}
return Task.CompletedTask;
}
}
ICustomTokenRequestValidator runs once the token endpoint has accepted a request,
for every grant. Replacing context.Result with an error refuses it; a result carrying a custom response adds members
to the token response — here tenant, read from the client's
Properties. A custom member never replaces one of the server's own.
ICustomAuthorizeRequestValidator is the same hook at the authorize endpoint.
A grant of your own
/// <summary>
/// A grant of the host's own: a registered kiosk presents its installation id and gets a token for the employee who
/// registered it. Registering the validator is what makes the grant supported — the token endpoint routes
/// <see cref="GrantType"/> here and the discovery document lists it.
/// </summary>
public sealed class InstallationGrantValidator(EmployeeDirectory directory) : IExtensionGrantValidator
{
/// <summary>The <c>grant_type</c> a client sends; a URI, as an extension grant's is.</summary>
public string GrantType => "urn:samples:params:oauth:grant-type:installation";
/// <summary>Decides who the token is for. The client's registration still decides what it may reach.</summary>
public Task<ExtensionGrantValidationResult> ValidateAsync(
ExtensionGrantValidationContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Parameters.TryGetValue("installation_id", out var installationId)
|| string.IsNullOrEmpty(installationId))
{
return Task.FromResult(ExtensionGrantValidationResult.Failure(
OidcConstants.Errors.InvalidRequest, "installation_id is required."));
}
var employee = directory.FindByInstallation(installationId);
if (employee is not { IsActive: true })
{
return Task.FromResult(ExtensionGrantValidationResult.Failure(OidcConstants.Errors.InvalidGrant));
}
var subject = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(JwtClaimTypes.Subject, employee.SubjectId)], authenticationType: "installation"));
return Task.FromResult(ExtensionGrantValidationResult.Success(subject));
}
}
An IExtensionGrantValidator adds a grant_type this server does not have.
Registering it is all it takes: the token endpoint sends requests for its
GrantType to it, and the discovery document lists it.
The validator decides who the token is for (ExtensionGrantValidationResult); the
client's registration still decides what the token may reach:
// A client allowed the grant. Properties feed TenantTokenRequestValidator.
new Client
{
ClientId = "kiosk",
ClientSecrets = { new Secret("kiosk-secret".Sha256()) },
AllowedGrantTypes = { "urn:samples:params:oauth:grant-type:installation" },
AllowedScopes = { "kiosk" },
Properties = { ["tenant"] = "north" },
},
Where events go
/// <summary>
/// Receives every event the server raises — those whose kind is switched on in <c>IdentityServerOptions.Events</c> —
/// and sends it on. Here that is a structured log line; a deployment would write to a table, a queue or a SIEM.
/// </summary>
public sealed partial class AuditEventSink(ILogger<AuditEventSink> logger) : IEventSink
{
/// <summary>Takes one event.</summary>
public Task PersistAsync(Event evt, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evt);
LogEvent(logger, evt.Id, evt.Name, evt.EventType, evt.Message);
return Task.CompletedTask;
}
[LoggerMessage(Level = LogLevel.Information, Message = "Audit {EventId} {EventName} ({EventType}): {EventMessage}")]
private static partial void LogEvent(ILogger logger, int eventId, string eventName, EventTypes eventType, string? eventMessage);
}
The server raises an Event for sign-ins, tokens issued and refused, consent, revocation, replayed credentials and its own faults; an IEventSink receives them. None is raised until EventsOptions switches its kind on — events carry client ids, subject ids and addresses, and where those go is the host's decision. Without a sink of your own they are written to the log.