Table of Contents

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.