Table of Contents

Signing out

A session can end two ways here: a user confirms it on the logout page, or a relying party ends it at /connect/endsession with an id_token_hint this server can verify. The code on this page is included from samples/Interaction.SignOut, a host with its own logout page, whose test follows an end-session request to it. The protocol exchange — the end-session request, the back-channel logout token, the signed-out page — is recorded in RP-initiated logout.

One sequence, whoever asks

Both run the same sequence, ISignOutService:

  1. drop the session cookie,
  2. raise UserLogoutSuccessEvent,
  3. clear the check-session browser state, so every relying party polling /connect/checksession sees a change — including ones this server was never told about,
  4. post a back-channel logout token to every client the session reached — on a client that does not follow redirects, so a relying party answering 302 raises BackChannelLogoutFailureEvent instead of being recorded as notified,
  5. land on the signed-out page, which loads each client's front-channel logout URL in a hidden iframe — written as Duende writes it, allow="" and no sandbox — under a Content-Security-Policy whose frame-src names those clients' origins.

A host that replaces the shipped logout page runs the same sequence rather than reproducing it. Its page sits at LogoutUrl:

var identityServer = builder.Services
    .AddIdentityServer(options =>
    {
        // Where the end-session endpoint and SAML single logout send the browser to confirm a sign-out.
        options.UserInteraction.LogoutUrl = "/signout";
        options.KeyManagement.Enabled = false;
    });

// Over the default: the end-session endpoint asks it about every request.
builder.Services.AddTransient<IEndSessionRequestValidator, ConfirmingEndSessionRequestValidator>();

// Multi-instance hosting: SAML single logout's state must be shared. Registered before AddSaml, whose stores are TryAdd.
builder.Services.AddDistributedMemoryCache();   // a shared cache in production: Redis, SQL Server, …
builder.Services.AddSingleton<ISamlLogoutSessionStore, DistributedSamlLogoutSessionStore>();
/// <summary>Runs the server's sign-out sequence rather than reproducing it.</summary>
public async Task<IActionResult> OnPostAsync()
{
    var ct = HttpContext.RequestAborted;
    if (User.Identity?.IsAuthenticated == true)
    {
        // Before the sign-out: the logout id is the only place the session's client list survives it.
        LogoutId ??= await interaction.CreateLogoutContextAsync(ct);

        // Drops the cookie, raises the event, clears check-session state and posts the back-channel logout tokens.
        // What it returns is the external scheme the session began at, still to be signed out of, or null.
        var upstream = await signOut.SignOutAsync(HttpContext, LogoutId, ct);
        if (upstream is not null)
        {
            // The caller owns the browser: send it to the provider, which sends it back to the signed-out page.
            var properties = new AuthenticationProperties { RedirectUri = Url.Page(SignedOutPage, new { logoutId = LogoutId }) };
            return SignOut(properties, upstream);
        }
    }

    // The signed-out page loads the front-channel logout iframes and offers the client's post-logout redirect.
    return RedirectToPage(SignedOutPage, new { logoutId = LogoutId });
}

Before 0.106.0 the last step did nothing in a browser. The page carried this library's interactive policy, default-src 'none' with no frame-src, and a browser applies default-src to frames — so every iframe was blocked and no client was notified through the front channel. The HTML was right, which is what the tests read. AuthenticationOptions.RequireCspFrameSrcForSignout (on, as in Duende) is the policy; turning it off sends the page with no policy at all, as Duende does (the options).

Which requests end a session

A request that cannot be verified — no id_token_hint, an unreadable one, or a post_logout_redirect_uri that is not registered for the client — ends nothing. It is handed to the logout page, which asks the user, because a cross-site GET must never end a session.

Since 0.123.0 the hint must name the session it ends, as Duende's end-session validator requires. It is validated by the token validator — signature, issuer, audience and your ICustomTokenValidator — where only its signature was checked, and for a signed-in user its sid must be their session's (or, without a sid, its sub must be them). A valid hint from another browser's session, or another user's, now goes to the prompt; until 0.123.0 it ended whatever session the browser held. The seam is Duende's IEndSessionRequestValidator, and EndSessionRequestValidator's ValidateIdTokenHintAsync is virtual, as Duende's is: override it to ask the user to confirm (EndSessionHintValidationResult.RequiresConfirmation()) or to refuse more.

/// <summary>
/// The default end-session validator, stricter by one rule: a hint that names the user but not their session — an
/// identity token issued without <c>sid</c> — asks the user to confirm instead of ending the session at once.
/// </summary>
public sealed class ConfirmingEndSessionRequestValidator(
    IOptions<IdentityServerOptions> options,
    ITokenValidator tokenValidator,
    IRedirectUriValidator uriValidator,
    IUserSession userSession,
    IClientStore clients,
    IIssuerNameService issuers,
    IHttpContextAccessor httpContextAccessor)
    : EndSessionRequestValidator(options, tokenValidator, uriValidator, userSession, clients, issuers, httpContextAccessor)
{
    /// <summary>Asks the default first: a hint it refuses stays refused.</summary>
    protected override async Task<EndSessionHintValidationResult> ValidateIdTokenHintAsync(
        EndSessionHintValidationContext context, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(context);

        var result = await base.ValidateIdTokenHintAsync(context, ct);
        var namesSession = context.TokenValidationResult.Claims.Any(claim => claim.Type == JwtClaimTypes.SessionId);

        return result.Outcome == EndSessionHintValidationOutcome.Valid && !namesSession
            ? EndSessionHintValidationResult.RequiresConfirmation()
            : result;
    }
}

A verified request from a browser with no session here — one that already expired, or signed out in another tab — still has its client's registered return offered on the signed-out page, as in Duende, since 0.106.0; until then it was dropped. AuthenticationOptions.RequireAuthenticatedUserForSignOutMessage = true ignores such a request's parameters, which is how this server behaved before.

A verified client is sent to the signed-out page, not straight back to itself. If it supplied a registered post_logout_redirect_uri, that address is offered there as a link, with any state it sent already appended. This is the ending RP-Initiated Logout 1.0 §2 describes second, and it is the only one this server can carry out completely: steps 3 to 5 need the browser to still be on a page of ours, and a 302 to the client is not one.

New in 0.40.0, and it is a behaviour change. Before it, a verified client with a registered post_logout_redirect_uri was answered with a 302 to that URI — so no page of ours rendered and nothing at all was sent: no back-channel token, no front-channel iframes, no browser-state clear. That is the ordinary RP-initiated logout, and it is the path most sign-outs in a deployed system take, so every other client in the session went on believing the user was signed in. A client expecting an immediate redirect now sees the signed-out page instead.

What the logout page is told

The request's ui_locales, as the login page is told the authorize request's: a LogoutUrl at a local path is handed the end_session request's ui_locales through IUiLocalesService, whose default writes the first supported one to the request-culture cookie — so a host that uses request localization opens the logout page, and the signed-out page after it, in the language the relying party asked for (the same setup as the login page). A logout page on another host is not told: a cookie written here would never reach it. The page also reads the value as LogoutRequest.UiLocales.

End-session ui_locales — new in 0.156.0, Duende 8.0.1's EndSessionRequestValidator, LogoutMessage, LogoutRequest and EndSessionHttpWriter, measured.

  • ValidatedEndSessionRequest.UiLocales: the request's ui_locales, read before anything can refuse the request; one longer than InputLengths.UiLocale is ignored, not refused.
  • The end-session endpoint tells a local logout page the validated request's ui_locales — whether or not the request was refused, as Duende's writer reads it — on both of its paths: the prompt, and the signed-out page after a verified client's sign-out.
  • LogoutRequest.UiLocales, carried in the logout id for a request that stands, as Duende writes its logout message only for one; it stays out of Parameters.
  • EndSessionEndpoint keeps its constructor, and gains one taking IUiLocalesService, which the container uses. The old one tells the page no locale.

Ours, recorded: Duende logs a ui_locales it ignores as too long, and this validator has no logger, so it ignores it silently; a local page is a path starting with one /, as the authorize endpoint reads it, where Duende's IsLocalUrl also takes ~/ and refuses control characters.

A logout id from another session — changed in 0.177.0. The logout id travels in a link, so the shipped page checks that it describes the session signed in now — the same subject, and the same session when both are known — and sets one that does not aside for a logout id of this session's own; DefaultSignOutService does the same for the back-channel notifications whatever page calls it, and notifies the ending session's clients when the logout id names none. Until then the link's logout id decided who was told: one minted in somebody else's session had their relying parties notified, and the clients of the session actually ending heard nothing and kept it alive. Duende's template and its notifications use whatever the link carried; this is ours. A logout id set aside takes its client's return with it — a return the end-session endpoint offers only for the session that asked.

Who the session reached, and what else was asked. LogoutRequest carries Duende's ClientIds and SamlSessions — who the session reached — and Parameters: the end_session request's own parameters, less id_token_hint, post_logout_redirect_uri, state and ui_locales, which this server has acted on (ui_locales is UiLocales, since 0.156.0). A host's page can read a parameter of its own a relying party sent. Duende's is a NameValueCollection; this server reads end_session parameters one value each and hands them on as an IReadOnlyDictionary<string, string>.

Why the session ended. The back-channel logout token carries Duende's logout_reason (IdentityServerConstants.ClaimTypes.BackChannelLogoutReason): user_logout when the user signed out, by any page or protocol; session_expiration when the server-side session sweep removed the session, which calls ISessionCoordinationService.ProcessExpirationAsync; terminated when ISessionManagementService.RemoveSessionsAsync ended it. LogoutNotificationContext.LogoutReason says which; with none, the claim is left out, as Duende leaves it. An ISessionCoordinationService written before 0.129.0 keeps compiling: ProcessExpirationAsync calls its ProcessLogoutAsync unless implemented.

SAML service providers

A SAML service provider ending the session runs the same sequence, through /saml/slo with a signed LogoutRequest. The clients that authenticated a session cannot observe which protocol carried the request to end it, and have no reason to. The one difference is step 5: the response to a LogoutRequest hands the browser straight to the service provider's own logout endpoint as an auto-submitting form, so there is no page of ours left for the front-channel iframes to load in. Steps 1 to 4 all happen; the front channel is a property of the binding rather than something skipped.

And the other service providers that session reached are asked to sign out too. SAML single logout is a sequence rather than a message: end the session, ask every other registered service provider holding a session from this sign-on to end its own, and answer the one that asked once they have. Each hop is a browser POST, so what is in flight lives in ISamlLogoutSessionStore — who asked, which of their requests is owed an answer, which participant has an outstanding request, and who is left — and the browser carries an opaque RelayState reference to it and nothing else. TakeAsync consumes the reference, so a link left in history cannot walk a logout a second time.

The default store is in memory, with a five-minute lifetime, swept on read. Each browser hop may land on a different instance, so a multi-instance deployment registers a shared one — before AddSaml, whose stores are all TryAdd (the registration above):

/// <summary>
/// SAML single logout's state in flight, kept where every instance can read it: each browser hop of the sequence may
/// land on a different one. The default store is in memory, for a single instance.
/// </summary>
public sealed class DistributedSamlLogoutSessionStore(IDistributedCache cache) : ISamlLogoutSessionStore
{
    private const string Prefix = "saml-logout:";

    /// <summary>Keeps the sequence until it expires: its own expiry, or the in-memory store's five minutes.</summary>
    public Task StoreAsync(string key, SamlLogoutSession session, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(session);

        var expires = session.ExpiresAt ?? session.Created + InMemorySamlLogoutSessionStore.Lifetime;
        return cache.SetAsync(
            Prefix + key,
            JsonSerializer.SerializeToUtf8Bytes(session),
            new DistributedCacheEntryOptions { AbsoluteExpiration = expires },
            cancellationToken);
    }

    /// <summary>Reads the sequence and removes it, so a link left in history cannot walk a logout a second time.</summary>
    public async Task<SamlLogoutSession?> TakeAsync(string key, CancellationToken cancellationToken = default)
    {
        var bytes = await cache.GetAsync(Prefix + key, cancellationToken);
        if (bytes is null)
        {
            return null;
        }

        await cache.RemoveAsync(Prefix + key, cancellationToken);
        return JsonSerializer.Deserialize<SamlLogoutSession>(bytes);
    }
}

Which providers a session reached is recorded on the session itself as each assertion is issued, by both sign-on doors. It is what the session reached, not what the registry contains: a partner that never held a session from this sign-on is never told one ended. A participant with no SingleLogoutServiceUrl, or one whose URL this server will not post to, is skipped rather than waited on — it can no more be told than an unregistered partner can, and holding the sequence open for it would leave the requester unanswered forever.

The sign-out itself happens on the first pass and is not conditional on the rest. A partner that never answers, or answers with something that does not verify, leaves the sequence unfinished; it does not leave the user signed in here.

Every participant, wherever the sign-out starts

New in 0.129.0, Duende 8.0.1's, measured from its logout pipeline. Until 0.129.0 a session's SAML service providers were told it had ended only when the sign-out started at one of them: the logout page and end_session — the ways a user or a relying party ends a session — left every SAML session open.

  • The signed-out page tells the SAML service providers too. Each provider the session reached gets a LogoutRequest signed with SamlOptions.SigningCertificate over the query string (HTTP-Redirect binding), to its SingleLogoutServiceUrl, naming the NameID and SessionIndex its assertion carried. The URLs join LogoutRequest.FrontChannelLogoutUrls, so a signed-out page that loads those in hidden iframes — the shipped one, and any host's written against the model — reaches the providers without a change. The page's policy gains frame-src 'self', because a provider answers inside the iframe, to /saml/slo here, which acknowledges a correctly signed answer and does nothing else: the session ended before the page was drawn.
  • A provider that cannot be told — no longer registered, no SingleLogoutServiceUrl, or one this server will not navigate to — is recorded with SamlLogoutNotificationFailureEvent, as at /saml/slo; a page reloaded records it again. The requests are built by ISamlLogoutNotificationService, which AddSaml registers; without the SAML package nothing can sign them and nothing is sent.
  • A service provider's own sign-out asks each other provider about its own session — its NameID and session index — rather than sending every one the requester's NameID and no index, as it did until 0.129.0. And the requester is answered PartialLogout (a second-level status under Success) when any participant was passed over or answered with anything but success; the status was never read before, and the answer was always Success.
  • One session index per provider per session. A second sign-on to the same provider repeats the index of the first, as Duende's does, so the provider never holds an index a logout does not name.

Breaking in 0.129.0. IUserSession.AddSamlServiceProviderAsync(string) and GetSamlServiceProviderListAsync() are Duende's AddSamlSessionAsync(SamlSpSessionData) and GetSamlSessionListAsync(), kept under Duende's saml_session_list key: a logout can name a provider's session only if its NameID and session index are kept with it. SamlLogoutSession.Pending holds those records, and gains SkippedSpCount. A session signed in before the upgrade has the old list, which is not read — its SAML providers are not told when it ends.

Not done, as Duende does not: an expired or administratively ended session tells no SAML service provider — there is no browser to carry the request. Only the OpenID Connect clients' back channel reaches them.

Upstream providers

When the provider a session came from ends it, this server notices. An upstream handler told at its own remote endpoint that the session ended there signs out this server's cookie directly — no code of ours is on that path. The cookie handler is handed out wrapped, so a sign-out this server did not start still runs steps 1 to 4 above. Nothing to configure; it applies to any scheme, and to anything else that drops the cookie without going through ISignOutService.

A session that began at another provider is ended there too. The external sign-in records which provider authenticated the session, as the idp claim on the local principal, and the sign-out reads it: if that scheme is registered and its handler can sign out, the browser is sent to the provider's own end-session endpoint and asked to come back to the signed-out page. Everything above happens first — the upstream is the last hop, not the first. A local sign-in writes no idp, so nothing is propagated; a plain OAuth provider has no sign-out to call and is not asked for one. SignOutAsync returns the scheme still to be reached, and the caller owns the browser — the upstream branch of the logout page above.

An OpenID Connect provider with no end-session endpoint is skipped (since 0.179.0). Discovery makes end_session_endpoint optional and Google publishes none; ASP.NET Core's handler throws when asked to sign out of such a provider, which made the sign-out a 500 after the relying parties had been told — and left the user signed in, because the error replaced the response that deleted the cookie. A registration made with AddExternalOpenIdConnect, or a stored OpenID Connect row, now ends the session here, logs a warning naming the scheme and carries the browser on to the signed-out page, as a SAML provider with no IdpSingleLogoutUrl does. The end-session request carries no id_token_hint: the shipped external sign-in page does not keep the provider's identity token, where Duende's template does — the gateway guide shows what a host does about it.

Without this, signing out ends the session here and leaves it live at the provider — so the next click on sign in returns the user with no prompt at all, which on a shared machine is the thing signing out was for. The one place that does not propagate is the SAML single-logout endpoint: that sign-out arrived from a federation partner and owes it a LogoutResponse, so an upstream session survives a sign-out that came in that way.

How it got here.

  • 0.41.0: until then the SAML path was the fourth way to end a session and the one nothing notified: it dropped the cookie and raised the event, so a service provider could sign a user out while every OpenID Connect client went on believing they were signed in.
  • 0.42.0: propagation to the upstream provider, and ISignOutService.SignOutAsync returns Task<string?> rather than Task. The signature changed one release after the interface shipped because the alternative — a second member a caller is free to forget — is the exact shape that produced the two defects before it.
  • 0.43.0: before it, an upstream provider could end the session here and every relying party went on believing the user was signed in. It is closed structurally rather than one path at a time: the detection is on the cookie's sign-out, so a path nobody has written yet is covered too.
  • 0.44.0: the propagation had never been driven through a real external provider — nothing in the repository called AddExternalOpenIdConnect, AddExternalOAuth or AddExternalSaml2, so every test that federated did it through a hand-rolled handler. Driven through the real SAML SP handler, it sent a LogoutRequest whose NameID was empty, naming a user the identity provider could not act on.