Table of Contents

Silent authorization and forced consent

prompt says what interaction the client will accept, and max_age how old a session may be. What the authorize endpoint does with them — sign the user in, ask for consent, send them to a page of the host's own, or answer the client with an error — is a service a host can change one step of. The code on this page is included from samples/Interaction.Prompts, which a test boots and sends prompt=none, prompt=login, prompt=create and ui_locales requests to. The requests and answers themselves are recorded in the authorization code flow.

What each prompt does

prompt=none forbids interacting at all — it is what a silent-renew iframe sends. Every point that would otherwise show the user a page answers the client instead:

Situation Answer
No session error=login_required
Session not fresh enough for max_age error=login_required
Consent would be asked for error=consent_required
A page of the host's own would be shown error=interaction_required

The state comes back with the error, because a response an iframe cannot correlate is one it drops. The errors carry no description, as Duende's carry none. prompt=none combined with any other value is refused as invalid_request ("Invalid prompt", Duende's description), per OpenID Connect Core §3.1.2.1 — a request cannot both forbid an interaction and demand one.

prompt=consent demands the consent screen whatever the store says. It is the only way back to a decision the user asked to be remembered, since remembered consent otherwise makes the page unreachable for the life of the grant.

prompt=login and select_account send the user to the login page — where a user picks the account they sign in with — and max_age does when the session is older. A max_age that is not a non-negative 32-bit integer is refused as invalid_request, "Invalid max_age".

prompt=create (OpenID Connect Prompt Create 1.0) is supported once CreateAccountUrl is set: UseIdentityServer adds create to PromptValuesSupported, discovery publishes it in prompt_values_supported, and such a request goes to that page, the return URL under CreateAccountReturnUrlParameter. Without one it is refused as an unsupported prompt.

Any value outside PromptValuesSupported — none, login, consent, select_account, and create with a create-account page — is refused (invalid_request, "Unsupported prompt mode"), as Duende refuses it. A value added to the option needs an interaction generator that acts on it. The options and their defaults are in the configuration reference.

Fixed in 0.21.0. Before it, prompt was consumed for login and dropped otherwise, so a prompt=none request was answered with the interaction it had said it could not take — a login form in a frame the user never opened, or an iframe that hangs on one. This server has honoured the SAML equivalent, IsPassive, since 0.16.0; the OIDC side of the same demand was still being dropped. Until 0.142.0 select_account was accepted and ignored, as was any unknown value.

The interaction generator

What the authorize endpoint does with a validated request before answering it is IAuthorizeInteractionResponseGenerator, Duende's. The default, AuthorizeInteractionResponseGenerator, is open: a denial from the consent page is answered first, then ProcessCreateAccountAsync, ProcessLoginAsync and ProcessConsentAsync — each a protected virtual step — are asked in that order, the first that wants a page winning; under prompt=none a page becomes the error that names it.

A host with one rule of its own derives from the default and overrides that step. Here, a signed-in user who has not accepted the current terms is sent to the host's terms page before the request is answered:

/// <summary>
/// The default interaction generator, with one step changed: a signed-in user who has not accepted the current terms
/// is sent to the host's terms page before the request is answered.
/// </summary>
public sealed class TermsInteractionGenerator(
    IOptions<IdentityServerOptions> options,
    TimeProvider time,
    ILogger<AuthorizeInteractionResponseGenerator> logger,
    IConsentService consent,
    IProfileService profile)
    : AuthorizeInteractionResponseGenerator(options, time, logger, consent, profile)
{
    /// <summary>The claim the sign-in writes once the user has accepted <see cref="CurrentVersion"/>.</summary>
    public const string AcceptedClaimType = "terms_accepted";

    /// <summary>The version of the terms a user must have accepted.</summary>
    public const string CurrentVersion = "2026-10";

    /// <summary>Asks the default first; only a request it would answer now is sent to the terms page.</summary>
    protected override async Task<InteractionResponse> ProcessLoginAsync(
        ValidatedAuthorizeRequest request, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(request);

        var response = await base.ProcessLoginAsync(request, ct);
        if (response.ResponseType != InteractionResponseType.None
            || request.Subject?.HasClaim(AcceptedClaimType, CurrentVersion) == true)
        {
            return response;
        }

        // The page gets the return URL under UserInteraction.CustomRedirectReturnUrlParameter, and sends the browser
        // back to it once the user has accepted. Under prompt=none this becomes interaction_required instead.
        return new InteractionResponse { RedirectUrl = "/terms" };
    }
}

It is registered with AddAuthorizeInteractionResponseGenerator:

var identityServer = builder.Services
    .AddIdentityServer(options =>
    {
        // A create-account page: prompt=create is accepted, published in discovery, and sent here.
        options.UserInteraction.CreateAccountUrl = "/account/create";

        // The shipped pages read whichever names are configured here.
        options.UserInteraction.LoginReturnUrlParameter = "ret";
        options.UserInteraction.LogoutIdParameter = "sid";

        options.KeyManagement.Enabled = false;
    })
    .AddAuthorizeInteractionResponseGenerator<TermsInteractionGenerator>();

// The response generator has no builder method: register it over the default.
builder.Services.AddTransient<IAuthorizeResponseGenerator, RegionResponseGenerator>();

An InteractionResponse answers one of these ways:

Set The endpoint
Error (+ ErrorDescription) answers the client when it is an interaction error — access_denied, login_required, consent_required, interaction_required, account_selection_required, temporarily_unavailable, unmet_authentication_requirements — and shows any other on the error page
IsLogin, IsConsent sends the user to LoginUrl or ConsentUrl
RedirectUrl sends the user there, the return URL under UserInteraction.CustomRedirectReturnUrlParameter (returnUrl)
IsCreateAccount sends the user to UserInteraction.CreateAccountUrl, under CreateAccountReturnUrlParameter (returnUrl)
nothing issues the code

InteractionError.UnmetAuthenticationRequirements is answered unmet_authentication_requirements, from DenyAuthorizationAsync on the interaction service or from a generator.

The generator reads the validated request — PromptModes and MaxAge among it — so a custom authorize request validator that changes them is obeyed. OriginalPromptModes is what the client sent, ProcessedPromptModes what has already been acted on, and RemovePrompt() and RemoveMaxAge() are for a generator of your own. Each return URL keeps the client's prompt and max_age as it sent them and carries what was already acted on as suppressed_prompt and suppressed_max_age, which the validator subtracts on the way back in — so a login page reading PromptModes from the interaction service sees what is still to be acted on, and the return from the page does not ask for it again.

The interaction seam — new, and breaking, in 0.142.0, Duende 8.0.1's AuthorizeInteractionResponseGenerator, InteractionResponse and AuthorizeEndpointBase, measured. Until 0.142.0 these decisions were private to the endpoint, and changing one meant replacing the endpoint.

  • Breaking: a prompt value outside PromptValuesSupported is refused, where it was accepted and ignored; and a malformed max_age is refused, where it re-authenticated the user.
  • Breaking: the return URL keeps prompt and max_age and adds suppressed_prompt / suppressed_max_age, where it stripped them.
  • Breaking for a host constructing AuthorizeEndpoint itself: it takes the generator in place of IConsentService and IProfileService.
  • prompt=none errors carry no description, as Duende's carry none; they said which check failed before.

Found doing it: a pushed request with prompt=login or max_age=0 asked for the login page again on every return, because stripping the return URL's query string removed nothing from parameters held in the store. Duende carries suppressed_prompt beside the request_uri and lays it over the pushed parameters; so does this server now.

Ours, recorded: the generator reads a consent decision whenever there is one (Duende's only while consent is still required), and does not record it — the consent page's GrantConsentAsync already has; a session with no readable auth_time is too old for max_age and the SSO lifetime; tenant and idp: hints are read from acr_values as before. The return URL is the request as the browser sent it, relative, for any page, where Duende's is absolute for a page on another origin.

The response generator

What the authorize endpoint answers once nothing more is needed from the user is IAuthorizeResponseGenerator, Duende's. The default, AuthorizeResponseGenerator, is open: CreateResponseAsync picks the flow, CreateCodeFlowResponseAsync creates the code, stores it and answers with its handle, and CreateCodeAsync builds the code before it is stored — each a virtual step. Its answer is an AuthorizeResponse: the request it answers, the Code, SessionState and Issuer, Error/ErrorDescription, and CustomParameters, which are sent back beside this server's.

/// <summary>
/// The default authorize response generator, with one step changed: the answer that carries the code also tells the
/// client which region's data centre issued it.
/// </summary>
public sealed class RegionResponseGenerator(
    IAuthorizationCodeStore codes,
    IUserSession session,
    ISessionStateService sessionState,
    IIssuerNameService issuer,
    TimeProvider time,
    ILogger<AuthorizeResponseGenerator> logger,
    IConfiguration configuration)
    : AuthorizeResponseGenerator(codes, session, sessionState, issuer, time, logger)
{
    /// <summary>Creates and stores the code as the default does, then adds <c>region</c> to the answer.</summary>
    protected override async Task<AuthorizeResponse> CreateCodeFlowResponseAsync(
        ValidatedAuthorizeRequest request, CancellationToken ct)
    {
        var response = await base.CreateCodeFlowResponseAsync(request, ct);

        // Sent beside code, state, session_state and iss; a custom parameter never replaces one of those.
        response.CustomParameters["region"] = configuration["Region"] ?? "eu";
        return response;
    }
}

It has no builder method, and is registered over the default (in the registration above). A custom parameter never replaces one this server writes (code, error, error_description, state, session_state, iss), so a generator can add to the answer and cannot forge it. An answer with an Error goes back to the client when it is one of the interaction errors above, and to the error page otherwise. An error answered to the client carries session_state when there is a session, as Duende's does.

The endpoint raises TokenIssuedSuccessEvent for each code — client, subject, authorization_code, the scopes granted — and TokenIssuedFailureEvent for every refusal, whether it goes back to the client or to the error page. Both carry Duende's members, measured from its authorize constructors: Endpoint is Authorize, with the client's ClientName and the RedirectUri (null from every other endpoint); the refusal adds the Scopes asked for and the signed-in user's SubjectId. The code is in the success event's Tokens as code and its fingerprint, as every token in an event is — never the code itself (events). A pushed-request refusal raises both PushedAuthorizationFailureEvent and the token-issued failure.

The language of the pages

The pages the user is sent to are told the request's ui_locales (also on UiLocales) through IUiLocalesService. The default, DefaultUiLocalesService, writes the first tag that names one of RequestLocalizationOptions.SupportedUICultures — matched exactly — to ASP.NET Core's request-culture cookie, so a host that uses request localization opens the login page in that language:

// The login page opens in the language of the request's ui_locales: the server writes the first supported tag to the
// request-culture cookie, which the cookie provider (on by default) reads on the page's request.
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    string[] cultures = ["en", "fr", "de"];
    options.SetDefaultCulture("en").AddSupportedCultures(cultures).AddSupportedUICultures(cultures);
});

Without the cookie provider, or a supported tag, it writes nothing. A page configured as an absolute URL (on another host) is not told — a cookie written here would never reach it — and, under AllowOriginInReturnUrl, is handed an absolute return URL, this server's origin in front, which it can send the browser back to (return URLs).

The response seam — new in 0.149.0, Duende 8.0.1's AuthorizeResponseGenerator, AuthorizeResponse, AuthorizeEndpointBase, AuthorizeHttpWriter, AuthorizeInteractionPageHttpWriter, DefaultUiLocalesService and the return-URL parsers, measured. Until 0.149.0 the code was issued privately by the endpoint, and the authorize endpoint raised neither token-issued event: a host's sink saw codes redeemed and never issued, and no authorize refusal at all. Until 0.149.0 only a code carried session_state.

  • Ours, decided 2026-10-14: a page configured as an absolute URL is handed an absolute return URL only under AllowOriginInReturnUrl. Duende hands one whatever the option — and its parsers then refuse it unless the option is on — so a login page on this server written as an absolute LoginUrl would stop resuming requests on upgrading. Here, with the option off, every page is handed the relative return URL it always was.
  • AuthorizeEndpoint keeps its constructor, and gains one taking IAuthorizeResponseGenerator and IUiLocalesService in place of IAuthorizationCodeStore, which the container uses. The old one answers through the default generator and tells no page a locale. The two return-URL parsers keep theirs too and take the options the flag is read from through an overload.

Found doing it: an absolute LoginUrl or LogoutUrl was handed to the session cookie's LoginPath/LogoutPath, which threw the first time the cookie's options were built — the first request that read the session. Both now take a local path only, as Duende's ExtractLocalUrl does (~/ loses its tilde).

Ours, recorded: the code flow only — Duende's implicit and hybrid steps are non-goals, and CreateResponseAsync chooses by response_type rather than grant type; the code's scopes are the request's RequestedScopes as the interaction generator narrowed them, where Duende's reads ValidatedResources.RawScopeValues; the session id comes from IUserSession, where Duende's request carries it; iss is on error answers too (RFC 9207 §2), where Duende's sets the issuer only on a code; Duende's code StateHash and WasConsentShown are not here; and the token-issued events carry no token, where Duende's success event carries the code.