Table of Contents

Return URLs and the interactive pages

The login and consent pages are handed a return URL naming the protocol request that is waiting, and send the browser back to it once the user has done what the protocol needed. The code on this page is included from samples/Interaction.Prompts, whose test checks that the login redirect carries the renamed parameter and that the host's own parser describes its endpoint and nothing else.

Which return URLs a page may resume

Deciding whether a return URL is one this server issued is IReturnUrlParser, one implementation per protocol: IsValidReturnUrl answers whether a URL is one the parser speaks for, and ParseAsync describes the waiting request. AddIdentityServer registers the OpenID Connect parser and AddSaml registers the SAML one, so a host running both has one login page that resumes either. A host adding a protocol of its own registers a parser next to its endpoints:

// Beside the OpenID Connect parser AddIdentityServer registers (and the SAML one AddSaml does): every parser is asked,
// and the first to describe a return URL wins.
builder.Services.AddTransient<IReturnUrlParser, InvitationReturnUrlParser>();

Every parser is asked; the first to describe the request wins. What a parser must not do is accept a local path that is not its own endpoint — that turns the login page into a redirector to anywhere on the host — so build the check with the shared comparison, Matches, rather than by hand:

/// <summary>
/// Lets the login page resume this host's own protocol: an invitation link, <c>/invitations/accept</c>, that needs a
/// signed-in user before it can be accepted.
/// </summary>
public sealed class InvitationReturnUrlParser(
    IHttpContextAccessor httpContextAccessor, IClientStore clients, IOptions<IdentityServerOptions> options)
    : IReturnUrlParser
{
    /// <summary>The endpoint this parser speaks for, and the only path it accepts.</summary>
    public const string EndpointPath = "/invitations/accept";

    /// <summary>
    /// True only for this endpoint. Accepting any other local path would make the login page a redirector to anywhere
    /// on the host, so the comparison is the shared one rather than a hand-written <c>StartsWith</c>.
    /// </summary>
    public bool IsValidReturnUrl(string? returnUrl)
    {
        var context = httpContextAccessor.HttpContext;

        // An absolute URL naming this server is honoured only where the host allows it, as the shipped parsers do.
        if (options.Value.UserInteraction.AllowOriginInReturnUrl)
        {
            returnUrl = ReturnUrlPath.WithoutOrigin(returnUrl, context);
        }

        return ReturnUrlPath.Matches(returnUrl, EndpointPath, context);
    }

    /// <summary>Describes the waiting request, so the login page can name the application that sent the invitation.</summary>
    public async Task<IAuthenticationContext?> ParseAsync(string? returnUrl, CancellationToken cancellationToken = default)
    {
        if (!IsValidReturnUrl(returnUrl))
        {
            return null;
        }

        var queryStart = returnUrl!.IndexOf('?', StringComparison.Ordinal);
        var query = QueryHelpers.ParseQuery(queryStart < 0 ? null : returnUrl[queryStart..]);

        var client = await clients.FindClientByIdAsync(query["client_id"].ToString(), cancellationToken);
        return client is { Enabled: true } ? new InvitationContext(client, query["email"].ToString()) : null;
    }

    /// <summary>What the login page is told about an invitation.</summary>
    private sealed class InvitationContext(IConnectedApplication application, string email) : IAuthenticationContext
    {
        // Application.DisplayName is what the page shows: Client.ClientName for an OAuth client.
        public IConnectedApplication Application { get; } = application;

        // The invited address fills the login page's username field.
        public string? LoginHint { get; } = string.IsNullOrEmpty(email) ? null : email;

        public IReadOnlyCollection<string> PromptModes { get; } = [];

        public string? IdP => null;

        public string? Tenant => null;
    }
}
// The host's own protocol endpoint. A visitor who is not signed in is sent to the login page with this request as the
// return URL; the login page asks InvitationReturnUrlParser whether that is a request worth resuming.
app.MapGet(InvitationReturnUrlParser.EndpointPath, async (HttpContext context, string client_id, string? email) =>
{
    if (context.User.Identity?.IsAuthenticated != true)
    {
        await context.ChallengeAsync();
        return Results.Empty;
    }

    return Results.Text($"Invitation from {client_id} accepted by {context.User.FindFirst(JwtClaimTypes.Subject)?.Value}.");
});

A return URL is relative, unless the page was configured as an absolute URL and was handed this server's origin with it. AllowOriginInReturnUrl (Duende's, off by default) lets both shipped parsers accept such a URL: the origin, compared ignoring case, is taken off and the rest judged as before. A parser of your own does the same with WithoutOrigin, as above.

ParseAsync answers with an IAuthenticationContext, which is what lets one page name whichever application is waiting — context.Application.DisplayName, mapped from Client.ClientName for an OAuth client and from SamlServiceProvider.DisplayName for a service provider — and fill in a LoginHint.

The login page reads the request as your validator left it

Changed in 0.164.0, Duende 8.0.1's OidcReturnUrlParser, measured from its assembly: the return URL's parameters and the session's user (IUserSession.GetUserAsync) go to IAuthorizeRequestValidator as an authorize request, a refusal is no context (null), and AuthorizationRequest is built from the validated request. Until 0.164.0 this parser rebuilt the request from the query string and checked only the client and the redirect URI.

What a host may notice:

  • Your ICustomAuthorizeRequestValidator now runs on every parse — every GetAuthorizationContextAsync and GetAuthenticationContextAsync for an authorize return URL, so each render and post-back of the login and consent pages — not only at the authorize endpoint. A validator with side effects (a counter, a write, a call out) has them once more per page.
  • What it changes reaches the page: a LoginHint it sets, an idp: hint it adds with AddAcrValue (the shipped login page then goes straight to that provider), a DisplayMode it rewrites. What it refuses describes no request.
  • A return URL the authorize endpoint would refuse is null, where it used to be described: an unregistered redirect_uri (it was described with RedirectUri empty), a disabled client, a missing or unsupported response_type, a missing PKCE challenge, an invalid scope or prompt, and a client that must push (RequirePushedAuthorization, PushedAuthorization.Required) or sign (RequireRequestObject) and did neither. The return URLs this server issues are never among them.
  • AuthorizationRequest.AcrValues no longer holds the idp: and tenant: hints — Duende's GetAcrValues(), each entry once; read IdP and Tenant, or Parameters["acr_values"] for the list as sent. Until 0.164.0 the hints stayed in the list, which the documentation called Duende's; its IL says otherwise. A bare idp: is now an empty IdP (Duende's), which the shipped page treats as none.
  • DisplayMode is page, popup, touch or wap, any other value null, as the validator reads it; and AuthorizationRequest.Parameters is filled — the validated request's parameters, with what the validator wrote (it was declared and left empty).

A pushed request and a request object are resolved before the validator is asked, as the authorize endpoint resolves them (this server's validator reads neither, Duende's reads both), and a pushed request is validated as AuthorizeWithPushedParameters. The container builds the parser through a constructor taking the validator and IUserSession; the two kept constructors build this server's own validator, with no custom one, and take the HTTP request's user; AllowOriginInReturnUrl is honoured as before. A session written before 0.158.0, which cannot answer GetUserAsync, is answered by the HTTP request's user. Ours: PromptModes is still the values left to act on, where Duende's AuthorizationRequest takes the values the client sent — so a page is not told prompt=login on its own return — and RequestObjectValues stays a map of the signed values.

New with it, Duende's, measured: ValidatedAuthorizeRequest.LoginHint, DisplayMode and AuthenticationContextReferenceClasses (acr_values, each once), read by the validator before a custom validator is asked, an idp: hint outside the client's IdentityProviderRestrictions removed (ours: without Duende's warning); RequestObject (the JWT) and PushedAuthorizationReferenceValue (what follows urn:ietf:params:oauth:request_uri:), written by the authorize endpoint beside IssuerName, so a custom validator does not see them (ours: here the endpoint resolves both before the validator is asked; RequestObject is null for an object fetched by reference). And ValidatedAuthorizeRequestExtensions' eight: GetPrefixedAcrValue, RemovePrefixedAcrValue, GetIdP, RemoveIdP, GetTenant, GetAcrValues, RemoveAcrValue and AddAcrValue — prefixes idp: and tenant:, matched ordinally, the first match answering; each change rewrites the request's acr_values parameter, removed once the list is empty; AddAcrValue refuses a blank value.

Renaming the query parameters

UserInteractionOptions decides the names this server puts on those redirects — LoginReturnUrlParameter, ConsentReturnUrlParameter, CreateAccountReturnUrlParameter, CustomRedirectReturnUrlParameter, LogoutIdParameter, ErrorIdParameter and DeviceFlowOptions.UserCodeParameter — and the shipped pages read whichever name is configured, so a host may rename any of them without replacing the pages (their defaults are in the configuration reference):

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

What the options do not govern is the names the pages use among themselves — the logout page's link to the signed-out page, and the external-login page's returnUrl, which is also an authentication-properties key. Those are written and read in the same assembly.

Fixed in 0.56.0, and it is the second visit to 0.19.0's defect. The pages bound a hardcoded parameter while the redirects carried the configured one, so a host that renamed what the documentation recommended sent the value under a name nothing read: the user signed in successfully and was sent nowhere. The same release unified the SAML single sign-on redirect onto LoginReturnUrlParameter — it had used ReturnUrlParameter, so one destination had two names and renaming either broke one protocol and not the other. ReturnUrlParameter is still accepted by the login page as a fallback, so a host that had set only it keeps working.

Fixed in 0.19.0. Until this existed the login page recognised the OpenID Connect authorize endpoint alone. The SAML single sign-on endpoint redirected to that page with a return URL naming itself, the page refused it, and the user was sent to the site root signed in while the service provider that started the flow was never answered — so a SAML sign-on with no active session did not actually complete.

And in 0.22.0, the case that left open: a service provider registered with a request signing certificate that posts its AuthnRequest. The endpoint rebuilt a posted request as a redirect query to put it in the return URL, which drops the signature — a posted request carries one inside the XML, a redirect-bound one beside the message. The validated request now stays on the server (ISamlSigninStateStore) and the return URL carries an opaque, single-use reference to it.

And in 0.179.0, the case 0.22.0 left: a signed request on the redirect binding, which is how most service providers send one. Its signature is on the query string, not in the XML, so the kept request had none, and checked again on its return it was refused as invalid_signature — every such sign-in that needed the login page failed. The query it arrived with is now kept with it (SamlSignInState.RedirectQuery) and verified again on the way back. A store of your own keeps the new property with the rest; Duende's callback trusts its stored state instead.