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,
promptwas consumed forloginand dropped otherwise, so aprompt=nonerequest 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.0select_accountwas 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,InteractionResponseandAuthorizeEndpointBase, measured. Until 0.142.0 these decisions were private to the endpoint, and changing one meant replacing the endpoint.
- Breaking: a
promptvalue outsidePromptValuesSupportedis refused, where it was accepted and ignored; and a malformedmax_ageis refused, where it re-authenticated the user.- Breaking: the return URL keeps
promptandmax_ageand addssuppressed_prompt/suppressed_max_age, where it stripped them.- Breaking for a host constructing
AuthorizeEndpointitself: it takes the generator in place ofIConsentServiceandIProfileService.prompt=noneerrors carry no description, as Duende's carry none; they said which check failed before.Found doing it: a pushed request with
prompt=loginormax_age=0asked 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 carriessuppressed_promptbeside therequest_uriand 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
GrantConsentAsyncalready has; a session with no readableauth_timeis too old formax_ageand the SSO lifetime; tenant andidp:hints are read fromacr_valuesas 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,DefaultUiLocalesServiceand 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 carriedsession_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 absoluteLoginUrlwould stop resuming requests on upgrading. Here, with the option off, every page is handed the relative return URL it always was.AuthorizeEndpointkeeps its constructor, and gains one takingIAuthorizeResponseGeneratorandIUiLocalesServicein place ofIAuthorizationCodeStore, 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
LoginUrlorLogoutUrlwas handed to the session cookie'sLoginPath/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'sExtractLocalUrldoes (~/loses its tilde).Ours, recorded: the code flow only — Duende's implicit and hybrid steps are non-goals, and
CreateResponseAsyncchooses byresponse_typerather than grant type; the code's scopes are the request'sRequestedScopesas the interaction generator narrowed them, where Duende's readsValidatedResources.RawScopeValues; the session id comes fromIUserSession, where Duende's request carries it;issis on error answers too (RFC 9207 §2), where Duende's sets the issuer only on a code; Duende's codeStateHashandWasConsentShownare not here; and the token-issued events carry no token, where Duende's success event carries the code.