Table of Contents

Consent

Three screens ask a person to approve scopes — the consent page, the device page and the CIBA page — and what each shows is built from the resources the request validated, so what a user reads is what the host configured. The code on this page is included from samples/Interaction.Consent, whose test starts device flows against it and records, lists and removes a user's grant.

new ApiScope("orders", "Your orders")
{
    Description = "See and place orders on your behalf.",  // the line under the row's name
    Emphasize = true,                                      // shown as one of the wider permissions
    Required = true,                                       // shown, and not declinable
},

DisplayName names the row and falls back to the scope value when the host set none; Description is the line under it; Emphasize distinguishes the row visually; Required renders it without a working checkbox. offline_access is the one row with no resource behind it, so its wording is the server's and a host cannot rename it. Identity resources take the same settings.

Optional scopes can be declined individually, and a declined scope is absent from the grant and from the issued tokens. What comes back from the browser is treated as a claim about which boxes were ticked and not as the grant itself: it is intersected with the scopes the request actually validated, so a hand-written post cannot widen it, and the Required scopes are added back afterwards, because a disabled checkbox posts nothing and an omission must not narrow it either. Allowing with every box unticked reaches the client as access_denied — a grant of no scopes is not a grant.

Approving a device

The device approval page at /device asks the same question about the same rows, and as of 0.23.0 it is the same rows: one implementation describes a request's scopes and one turns a post about them into a grant, so a scope a host named is named on both screens and a Required scope cannot be declined on either. Before that, the device page rendered raw scope values under a single Allow — a host that named its scopes saw them named on one screen and not the other, and the type the page was handed carried only the names, so it could not have done better. The protocol itself is recorded in device authorization.

A user code is read off one screen and typed into another, so it is canonicalised — upper case, and without the separators a person adds — where it enters the server rather than left to the store to compare. The three stores answer that differently on their own: the in-memory one compares ordinal, SQLite by binary collation, and SQL Server's default collation is case-insensitive. Generation is canonicalised too, so a host generator producing another form still stores what a lookup will compare against.

Per-client device codes

New in 0.83.0, with Duende's names: a client can choose how long its devices wait and what kind of code they show.

new Client
{
    ClientId = "set-top-box",
    ClientName = "Living-room TV",
    RequireClientSecret = false,
    AllowedGrantTypes = { OidcConstants.GrantTypes.DeviceCode },
    AllowedScopes = { "openid", "profile", "history" },

    DeviceCodeLifetime = 120,                                      // seconds; null takes DeviceFlow.Lifetime
    UserCodeType = IdentityServerConstants.UserCodeTypes.Numeric,  // nine digits, for a remote control
},
Type Code Registered
Readable (the default, DeviceFlow.DefaultUserCodeType) twelve characters from an alphabet without O/0, I/L/1, U/V — a little over 51 bits yes
Numeric nine digits, never a leading zero — about 30 bits, as Duende's yes
anything else whatever the IUserCodeGenerator you register under that name makes by you

The default is this server's readable code rather than Duende's nine digits, because a user code names a pending authorization to anybody who guesses it; a client opts into the weaker one.

Security: the device page counts no wrong guesses. RFC 8628 §5.1 asks that code entry be rate-limited, and Duende's templates do not limit it either. Guessing needs a signed-in account (the page requires one), and a twelve-character readable code against a five-minute lifetime is out of reach; nine digits are not, for a deployment with many devices waiting at once. A host that registers Numeric codes limits posts to the device page per user, with ASP.NET Core's rate limiter or at its gateway.

A code already held by another device is generated again, up to the generator's RetryLimit, and the request fails after that rather than give two devices one code. A generator of your own:

/// <summary>
/// A third kind of user code, for clients registered with <c>UserCodeType = "WordPair"</c>: two words and two digits,
/// easy to read aloud across a shop counter.
/// </summary>
public sealed class WordPairUserCodeGenerator : IUserCodeGenerator
{
    /// <summary>The name clients ask for this generator by.</summary>
    public const string Type = "WordPair";

    private static readonly string[] Words =
    [
        "AMBER", "BIRCH", "CEDAR", "DELTA", "EMBER", "FROST", "GROVE", "HAZEL",
        "IVORY", "JUNIPER", "KESTREL", "LINDEN", "MAPLE", "NUTMEG", "OCEAN", "PEBBLE",
    ];

    /// <inheritdoc />
    public string UserCodeType => Type;

    /// <summary>How many times a code already held by another device is generated again before the request fails.</summary>
    public int RetryLimit => 10;

    /// <inheritdoc />
    public Task<string> GenerateAsync(CancellationToken cancellationToken = default)
    {
        // Canonicalised where it enters the server: upper case, without the separators a person types. Generate it in
        // that form and the code shown is the code a lookup compares against.
        var first = Words[RandomNumberGenerator.GetInt32(Words.Length)];
        var second = Words[RandomNumberGenerator.GetInt32(Words.Length)];
        return Task.FromResult($"{first}{second}{RandomNumberGenerator.GetInt32(10, 100)}");
    }
}
// The consent policy, replaced.
builder.Services.AddSingleton<ConsentReviewList>();
builder.Services.AddTransient<IConsentService, ReviewedConsentService>();

// A third kind of user code beside Readable and Numeric. Registering one under an existing name replaces that one:
// the last registration of a type wins.
builder.Services.AddSingleton<IUserCodeGenerator, WordPairUserCodeGenerator>();

builder.Services.AddTransient<ConnectedApplications>();

Breaking in 0.83.0. IUserCodeService has Duende's shape — GetGenerator(userCodeType) over registered IUserCodeGenerators — instead of GenerateUserCode(), and NumericUserCodeService, which was never numeric, is ReadableUserCodeGenerator. A host that replaced the old service to change the alphabet registers an IUserCodeGenerator instead: under Readable to replace the default (the last registration of a type wins), or under a name of its own for the clients that ask for it.

The device flow's validators

New in 0.120.0, as Duende's device flow answers:

  • No scope asks for everything the client may have: its allowed scopes, plus offline_access when it may have refresh tokens. Before, it asked for nothing.
  • Identity scopes without openid — profile alone, say — are invalid_scope. A scope longer than InputLengths.Scope is invalid_request.
  • An absolute DeviceFlow.VerificationUrl is used as configured, so a host can send users to a page elsewhere; a path is still put on this server. An empty DeviceFlow.UserCodeParameter leaves out verification_uri_complete (the options).
  • A device_code longer than InputLengths.DeviceCode (100) is invalid_grant before the store is asked.
  • Approving a device records the browser's session on the device code, so the device's tokens carry its sid and end with that session.
  • The profile service is asked with the caller DeviceCodeValidation, which a profile service can switch on; it was AuthorizationCodeValidation.

The seams are Duende's: IDeviceAuthorizationRequestValidator, IDeviceAuthorizationResponseGenerator and IDeviceCodeValidator, each with an open default. A device whose user has already decided collects its answer however fast it polls; throttling answers only a device that is still waiting.

Remembering a decision

Client.AllowRememberConsent gates the "remember my decision" checkbox and vetoes a posted value the client forbids. What that veto means is that nothing is stored: a decision the user declines to remember, or one a client forbids remembering, reaches the request in flight and leaves nothing behind, so the next authorization asks again.

new Client
{
    ClientId = "payments",
    ClientName = "Payments",
    ClientSecrets = { new Secret("payments-secret".Sha256()) },
    AllowedGrantTypes = { OidcConstants.GrantTypes.AuthorizationCode },
    RedirectUris = { "https://payments.example.com/signin-oidc" },
    AllowedScopes = { "openid", "profile", "orders", "history" },

    RequireConsent = true,
    AllowRememberConsent = true,           // the "remember my decision" box; false stores nothing, ever
    ConsentLifetime = 60 * 60 * 24 * 30,   // ask again after thirty days; null (the default) never expires
},

Unticking the box also withdraws whatever an earlier tick stored — not remembering and un-remembering are the same act from the user's side. A parameterized scope and offline_access are asked about every time, whatever was remembered: a decision about one transaction is not one about the next, and offline access outlives the session it is granted in.

Fixed in 0.20.0. Until then an unremembered decision was stored anyway, with a short expiration, so for those few minutes it was remembered — including for a client registered never to have its decisions remembered. ConsentResponse.Description, documented as being for the user's grant list, also reached the store and stopped there, so every row in that list was unnamed.

Both halves of the policy — whether to ask, and what to record — are IConsentService. Replace it to make consent conditional on something this server does not know about, such as a per-tenant policy or an administrator's review list. The default, DefaultConsentService, can be kept for everything else:

/// <summary>
/// The consent policy with one rule added: a client under administrative review is asked about every time. Everything
/// else — what a client's registration asks for, what is remembered and for how long — is the default's.
/// </summary>
public sealed class ReviewedConsentService(IUserConsentStore store, TimeProvider time, ConsentReviewList review)
    : IConsentService
{
    private readonly DefaultConsentService _default = new(store, time);

    /// <summary>Whether to show the consent page.</summary>
    public Task<bool> RequiresConsentAsync(
        string subjectId, Client client, IEnumerable<ParsedScopeValue> parsedScopes, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(client);

        return client.RequireConsent && review.Contains(client.ClientId)
            ? Task.FromResult(true)
            : _default.RequiresConsentAsync(subjectId, client, parsedScopes, cancellationToken);
    }

    /// <summary>What to record of the user's decision: the default's rule, which stores nothing unremembered.</summary>
    public Task UpdateConsentAsync(
        string subjectId, Client client, ConsentResponse consent, CancellationToken cancellationToken = default)
        => _default.UpdateConsentAsync(subjectId, client, consent, cancellationToken);
}

Managing a subject's grants

IIdentityServerInteractionService's grant members act for the user on this request, which is what an account-management page needs and no use to anything else. IPersistedGrantService takes the subject:

/// <summary>
/// An account-management back end acting for any subject — a support tool, or a job reacting to a deactivated user —
/// rather than for the user signed in on this request.
/// </summary>
public sealed class ConnectedApplications(IPersistedGrantService grants)
{
    /// <summary>What the user has connected: one row per client, consents and live tokens together.</summary>
    public Task<IReadOnlyCollection<Grant>> ListAsync(string subjectId, CancellationToken ct)
        => grants.GetAllGrantsAsync(subjectId, ct);

    /// <summary>Disconnects one application: its consent and every token issued under it.</summary>
    public Task DisconnectAsync(string subjectId, string clientId, CancellationToken ct)
        => grants.RemoveAllGrantsAsync(subjectId, clientId: clientId, cancellationToken: ct);

    /// <summary>Takes everything, for a user who has been deactivated.</summary>
    public Task RevokeEverythingAsync(string subjectId, CancellationToken ct)
        => grants.RemoveAllGrantsAsync(subjectId, cancellationToken: ct);
}

Removal takes every kind of grant the filter matches — consent and the tokens issued under it — because withdrawing consent while leaving a refresh token alive lets a client the user just disconnected keep acting for them until it expires. Narrowing to a session (sessionId) takes that session's tokens and leaves the consent standing, since a consent row belongs to no session: that is the difference between signing out of a browser and disconnecting an application. Each removal raises GrantsRevokedEvent (events).

Added in 0.24.0. Until then there was no way to do either for a subject who was not the one signed in, and the ambient members did not say so: the grant list came back empty and the revocation did nothing, both silently. A host wiring "this user was deactivated, revoke what they hold" into a background job got no exception, no log, and no revocation. Those two members now refuse rather than return, which is the refusal GrantConsentAsync next to them had always made.

What the list holds, since 0.114.0, as Duende's does: every grant the user holds that has not been consumed — consents, authorization codes, refresh tokens and reference tokens — one row per client (Grant), with their scopes together, the earliest creation, the latest expiry (a grant that never expires makes the row never expire) and the name the user gave the grant. Until 0.114.0 it held consents alone, so an application used without a consent screen held tokens the user's list did not show.

The name is what the user types into the optional "Description or name of device" field on the consent, device and backchannel approval pages — or what your own page puts in ConsentResponse.Description. It travels with the grant: onto the authorization code, device code or backchannel request, onto the access and refresh tokens they yield and every refresh after, and onto each stored row, where Grant.Description reads it back. "Kitchen tablet" is how a user tells two of their devices apart when deciding which to disconnect. The shipped pages keep it to 200 characters.