Table of Contents

Customizing the SAML identity provider

The SAML identity provider answers through Duende 8.0.1's seams: a sign-in page can decline a sign-on, and a host can replace how a sign-on decides what to ask of the user, how the assertion is built, how a LogoutResponse is written and how an inbound message is checked. Each seam keeps what must not change out of a replacement's reach. The code on this page is included from samples/Server.Saml, which a test boots and checks each seam is the sample's.

// Each replaces one of AddSaml's defaults, whichever order they are registered in.
builder.Services.AddTransient<ISaml2SsoResponseGenerator, TierGenerator>();
builder.Services.AddTransient<ISaml2SsoInteractionResponseGenerator, StaffOnlyInteraction>();
builder.Services.AddTransient<ISaml2SloResponseGenerator, ExplainingLogout>();
builder.Services.AddTransient<ILogoutRequestValidator, ExpiringLogoutRequestValidator>();

AddSaml registers each default, transient, unless a host registered its own first; one registered after it wins at resolution, as the sample's do.

A sign-in page can decline a SAML sign-in

/// <summary>
/// A login page declining a sign-in: for a SAML sign-on the service provider is sent a signed SAML error (AuthnFailed
/// for access denied) instead of an assertion; for an authorization request the client is sent access_denied.
/// </summary>
public static async Task<IResult> DeclineAsync(IIdentityServerInteractionService interaction, string returnUrl, CancellationToken ct)
{
    if (await interaction.GetAuthenticationContextAsync(returnUrl, ct) is { } context)
    {
        await interaction.DenyAuthenticationAsync(context, InteractionError.AccessDenied, ct);
    }

    return Results.Redirect(returnUrl);
}

New in 0.151.0, Duende 8.0.1's DenyAuthenticationAsync for a SamlAuthenticationContext and its callback's MapDenialToSamlStatus, measured from the IL:

  • The denial is recorded on the stored sign-in the return URL names — SamlAuthenticationContext.StateId is that reference — as SamlSignInState.DenialError and DenialErrorDescription, and the browser goes back to the return URL as after a sign-in. Until 0.151.0 the interaction service refused a SAML context with an ArgumentException, so a page could answer a provider it would not sign in to with nothing but silence.
  • The sign-on endpoint answers it before it asks whether anyone is signed in — so a user who declines is not answered from a session they already had — with the signed failure response the endpoint already sends for IsPassive, addressed as an assertion would have been, with the RelayState: Responder, and NoPassive for InteractionRequired, RequestDenied for ConsentRequired, NoAuthnContext for UnmetAuthenticationRequirements, and AuthnFailed for every other error, AccessDenied among them (the three codes are on SamlConstants.StatusCodes). The description is not sent, as Duende does not send it; it is the SamlSignOnFailureEvent's message, whose Error is Duende's access_denied (ConsentRequired).
  • Both doors: the IdP-initiated door reads a denial back at its own path and answers with no InResponseTo (CreateSignedFailureResponse's inResponseTo is nullable). A sign-in no longer waiting — expired, or already answered — is logged at Warning and left, as Duende's.
  • ISamlSignInDenialService (Fdyay.IdentityServer.Services) is the seam: the interaction service hands it any context that is not an authorization request, as it asks ISamlLogoutNotificationService for logout requests, and AddSaml registers SamlSignInDenialService. DefaultIdentityServerInteractionService takes it through a constructor overload (both SAML services, neither optional); the constructor it had is kept unchanged, so nothing compiled against it breaks, and AddIdentityServer registers the service by a factory that calls the new overload with whichever SAML services are registered, rather than leaving the container to choose between the two. A store of your own must let ISamlSigninStateStore.StoreAsync replace the state under a key, which is how the denial is stored back.

Ours: one place answers each door, where Duende answers both from its callback, and a denial is answered after the stored request is re-checked (an unknown or disabled provider, or a reply address no longer registered, is the error page, as in Duende's three checks); StateId is this server's opaque handle, where Duende's is a Guid; a context that is neither an authorization request nor a SAML sign-in is still refused with ArgumentException, where Duende throws InvalidOperationException (and, with no AddSaml, Duende's message names the missing store). The failure response is signed unless the provider says DoNotSign, as every failure here is; the not-found warning drops Duende's "the callback will redirect to login", the callback with nothing to resume being the error page here. The bundled sign-in page does not offer to decline.

Answering a sign-on in steps of your own

/// <summary>The default assertion, with one attribute of this deployment's own added at the attribute step.</summary>
public sealed class TierGenerator(
    TimeProvider time, IProfileService profile, ISamlSigningService signing, IOptions<SamlOptions> saml, ISamlNameIdGenerator names)
    : Saml2SSoResponseGenerator(time, profile, signing, saml, names)
{
    /// <inheritdoc />
    protected override async Task<IList<SamlAttribute>> CreateAttributesAsync(ValidatedAuthnRequest validatedAuthnRequest, CancellationToken ct)
    {
        var attributes = await base.CreateAttributesAsync(validatedAuthnRequest, ct);
        attributes.Add(new SamlAttribute { Name = "urn:example:tier", Values = ["gold"] });
        return attributes;
    }
}

New in 0.152.0, Duende 8.0.1's ISaml2SsoResponseGenerator, Saml2SSoResponseGenerator and Saml2InteractionResponse, measured from the IL. Both sign-on doors answer through the generator — the assertion once the user is signed in, the failure for a passive request nobody can answer and for a declined sign-in (now CreateErrorResponse). Derive from Saml2SSoResponseGenerator and override one step:

  • The steps, in Duende's order: CreateSubjectNameIdAsync (through ISamlNameIdGenerator), then CreateSaml2ResponseAsync, which asks CreateAssertionAsync, which asks CreateSubject, CreateConditions, CreateAuthnStatement and CreateAttributesAsync (with MapClaimsToAttributes) — each the rule it was, moved: what the assertion said before 0.152.0 it says now. Two models carry what Duende's steps return, SamlSubject and SamlAuthnStatement, and ValidatedAuthnRequest gains Duende's RequestId and NameIdPolicyFormat, read from its request.
  • CreateErrorResponse answers a Saml2InteractionResponse — Login(), NoInteraction() or Error(code, subCode, message) — that is an error, else ArgumentException (Duende's message). IsSafeError decides whether it goes to the service provider or to the error page; every error does by default, as in Duende. Measured: Duende's response carries the status codes alone and never the message, so nothing said on this side reaches the provider whichever errors are "safe" — and so here. An error a host's IsSafeError keeps back is shown on the error page (the sub-code as its error, the message as its description), recorded by the one failure event it always had.
  • The endpoints take the generator through a constructor each; their 0.151.0 constructors are kept exactly and answer through the default's steps over the SamlResponseGenerator they are given, so nothing compiled against them breaks. AddSaml builds them by factories calling the new constructors. SamlResponseGenerator is kept, its failure response the one CreateErrorResponse writes rather than a copy.

What a step cannot change. As for tokens, CreateResponse and CreateErrorResponse run the steps and are not overridable, as Duende's are not; but where Duende lets one step change what the next writes, the run method here reads each of these before the first step and refuses, with an InvalidOperationException rather than a response, a response whose steps changed it: the response's Issuer, Destination (the endpoint the request chose, checked against the host's refusal list before any form is built) and InResponseTo (absent, for an unsolicited response) and its success status; the assertion's Issuer, its subject's NameID (the generated one, which single logout records), its Recipient and InResponseTo, its audience (the provider alone), its SessionIndex (the one single logout records), and its lifetime, which a step may shorten and never lengthen. What is signed — the provider's SigningBehavior with the certificate ISamlSigningService answers — and what is encrypted are the run method's alone, as in Duende. The refusals made before the generator is asked (a provider whose scopes do not resolve, a user the profile service calls inactive) stay there; what is released is the attribute steps', as Duende's.

Ours, recorded: the guards above, where Duende's steps may change each; Saml2FrontChannelResult carries the signed XML (ResponseXml), Destination and RelayState, where Duende's carries an OutboundSaml2Message the binding signs, and also ErrorStatus, the NameID generator's refusal, so the error page names its sub-code as it did; the issuer is the request's IssuerName (Duende asks its issuer name service); the steps take a DateTimeOffset and this server's models (SamlResponse, SamlAssertion, SamlConditions, SamlNameId) where Duende's take a DateTime and its own; the constructor takes IOptions<SamlOptions> and no XML writer or issuer name service; an error response cannot say Success (ArgumentException); a failure is signed unless the provider says DoNotSign, where Duende's only under SignResponse; and the ACS binding is not checked again for HTTP-POST — the configuration validator refuses any other, where Duende's generator throws.

What a sign-on asks of the user, and the LogoutResponse

New in 0.163.0, Duende 8.0.1's ISaml2SsoInteractionResponseGenerator and ISaml2SloResponseGenerator, measured from the IL with their callers and registrations. Each was this server's own code in an endpoint, lifted out unchanged: no request is answered differently.

/// <summary>The default decision — sign in, answer, or refuse a passive request — and one rule of this deployment's.</summary>
public sealed class StaffOnlyInteraction(IProfileService profile, ILogger<Saml2SsoInteractionResponseGenerator> logger)
    : ISaml2SsoInteractionResponseGenerator
{
    private readonly Saml2SsoInteractionResponseGenerator _default = new(profile, logger);

    /// <inheritdoc />
    public async Task<Saml2InteractionResponse> ProcessInteractionAsync(ValidatedAuthnRequest request, CancellationToken ct)
        => request.ServiceProvider.EntityId == "https://payroll.example.com/saml"
            && request.Subject.Identity?.IsAuthenticated == true
            && !request.Subject.HasClaim("role", "staff")
                ? Saml2InteractionResponse.Error(
                    SamlConstants.StatusCodes.Responder, SamlConstants.StatusCodes.RequestDenied, "Staff only.")
                : await _default.ProcessInteractionAsync(request, ct);
}
  • ISaml2SsoInteractionResponseGenerator.ProcessInteractionAsync(ValidatedAuthnRequest, ct) answers a Saml2InteractionResponse: Login(), NoInteraction() or Error(...). The sign-on service asks it for a request that arrives, once it is validated and its reply address checked, as Duende's SingleSignOnServiceEndpoint does; the callback a user returns to from the sign-in page checks for itself, as Duende's does (signed in and still active, ForceAuthn not asked again). An error is answered through ISaml2SsoResponseGenerator.CreateErrorResponse and recorded by the one SamlSignOnFailureEvent (its sub-code, else its code); Login() is the sign-in page. The generator is shown the session's user, or an anonymous principal, with the session id, the provider, the request and its resolved claim types.
  • Saml2SsoInteractionResponseGenerator, the default (sealed, as Duende's, so the sample wraps it): nobody signed in, or a user the profile service calls inactive (asked as SamlSsoEndpoint), is sent to sign in, and so is a signed-in user when the request says ForceAuthn; a request that says IsPassive gets Duende's Responder/NoPassive error, with its two messages, instead of the sign-in page. The two reasons for the sign-in page are logged at Information with Duende's messages.
/// <summary>The default LogoutResponse, with a status message when not every application could be signed out.</summary>
public sealed class ExplainingLogout(TimeProvider time, ISamlSigningService signing) : Saml2SloResponseGenerator(time, signing)
{
    /// <inheritdoc />
    protected override Task<SamlLogoutResponse> CreateResponseAsync(
        ValidatedLogoutRequest request, string statusCode, string? subStatusCode, string? statusMessage, CancellationToken ct)
        => base.CreateResponseAsync(
            request, statusCode, subStatusCode, subStatusCode is null ? statusMessage : "Some applications are still signed in.", ct);
}
  • ISaml2SloResponseGenerator: CreateSuccessResponse and CreatePartialLogoutResponse (Success, then PartialLogout under it), which the single logout endpoint asks when the sequence finishes, for the provider that asked. Saml2SloResponseGenerator, the default, is open at Duende's one step, protected virtual CreateResponseAsync(request, statusCode, subStatusCode, statusMessage, ct); it builds the response the endpoint built. ValidatedLogoutRequest gains Duende's RelayState and its Saml2IdpEntityId as IssuerName; at the end of a sequence the generator is handed the request as the sequence recorded it (its ID, sender, NameID, RelayState), as Duende's callback rebuilds it.
  • AddSaml registers both defaults, transient, unless a host registered its own first (Duende registers the logout one in its core services; SAML is a package of its own here).
  • Not breaking: each endpoint keeps every constructor it had and gains one taking its generator — the sign-on endpoint's 0.152.0 constructor asks the default over the profile service it is given; the logout endpoint's take the default over the signing service — and AddSaml builds them over the registered generators.

What a generator cannot do — held where it cannot be overridden, each an InvalidOperationException rather than an answer. The sign-on endpoint refuses an interaction answer that would issue an assertion with nobody signed in, or from the session a ForceAuthn request says not to rely on, or send a passive request to the sign-in page; an error must still be a SAML error status (CreateErrorResponse's ArgumentException). The logout generator's two methods are not overridable, as Duende's are not, and hold the response's Issuer, Destination, InResponseTo and status codes; the step returns the response unsigned and the method signs it with the certificate ISamlSigningService answers, so no override sends one unsigned. A StatusMessage is the step's to add. Whatever a generator answers, the endpoint posts to the address it checked, with the RelayState it kept.

Ours, recorded: the default keeps this server's message for a signed-in user the profile service calls inactive on a request that says both ForceAuthn and IsPassive ("…when force auth is required"; Duende's inactive branch comes first) — the message only, which never reaches the provider; "signed in" is Duende's test, an authenticated identity, so a session scheme that answered a principal with none would be sent to sign in where it was passed on — no scheme this server registers can (the cookie handler refuses to sign in such a principal); Duende's "service provider context is missing" log has no counterpart, a request always having its provider here. The logout generator's step returns SamlLogoutResponse unsigned, where Duende's returns the Saml2FrontChannelResult its binding signs; its constructor takes the time provider and signing service, with the issuer the request's IssuerName (Duende asks its issuer name service and takes no IssuerName); the destination is the provider's HTTP-POST logout endpoint, else its first, as before, where Duende takes its first and falls back to the request's issuer — with none, Duende's message without that fallback. A host's whole logout generator answering no XML leaves the provider untold ("You have been logged out."), as an unreachable one is.

What an inbound message must be

New in 0.153.0, Duende 8.0.1's ILogoutRequestValidator and the read-error inspectors, measured.

/// <summary>
/// The default checks in their order, and one more: a LogoutRequest must say when it stops being valid. Chained to the
/// constructor that takes the user's session, so MatchLogoutRequestToSession has a session to match.
/// </summary>
public sealed class ExpiringLogoutRequestValidator(
    ISamlServiceProviderStore serviceProviders,
    IOptions<SamlOptions> options,
    TimeProvider time,
    IServerUrls urls,
    IUserSession userSession,
    ILogger<SamlLogoutRequestValidator> logger)
    : SamlLogoutRequestValidator(serviceProviders, options, time, urls, userSession, logger)
{
    /// <inheritdoc />
    protected override SamlLogoutRequestValidationResult ValidateNotOnOrAfter(ValidatedLogoutRequest request)
    {
        var result = base.ValidateNotOnOrAfter(request);
        if (result.IsError || request.LogoutRequest.NotOnOrAfter is not null)
        {
            return result;
        }

        return SamlLogoutRequestValidationResult.InValid(
            request, "not_on_or_after_required", "The sign-out request does not say when it expires.");
    }
}
  • ILogoutRequestValidator, default SamlLogoutRequestValidator, registered by AddSaml (TryAdd, so a host's own registered first wins): the checks the single logout endpoint made itself, lifted into Duende's protected virtual steps and run in Duende's order — ValidateSpAsync, ValidateSignatureTrust, ValidateVersion, ValidateDestination, ValidateNotOnOrAfter, ValidateSessionAsync — by a ValidateAsync that is not virtual, as Duende's is not. Every check is made as before, with the same error codes and pages. ValidatedLogoutRequest carries the request, the document and detached signature it arrived with, and the provider (Duende's Saml2Sp); the result is SamlLogoutRequestValidationResult with Duende's Valid/InValid and SessionFound.
  • What no override reaches. HasTrustedSignature is computed from the message as received and the provider it is from, never set; an untrusted request is read no further and never ends a session by itself — the endpoint sends it to the logout page for the user to confirm, as it always has — whatever a step or a validator of your own answers. A request passes only with a provider found. (In Duende the trust is the reader's settable TrustLevel, and an override of ValidateSignatureTrust accepts an unsigned request.)
  • The constructor to chain to is the one taking the user's session, SamlLogoutRequestValidator(store, options, time, urls, userSession, logger = null) (new in 0.159.0), as the sample's does: a validator deriving through the old constructor has no session to match, and with MatchLogoutRequestToSession on it throws (InvalidOperationException) rather than pass a request it was asked to match (logout requests matched to the session).
// A partner that writes IssueInstant in a format of its own: ignore that one error and the request is
// validated as any other. The signature is still checked afterwards, over the message as received.
options.AuthnRequestErrorInspector = context =>
{
    foreach (var error in context.Errors.Where(error =>
        error.Reason == ErrorReason.ConversionFailed && error.LocalName == "IssueInstant"))
    {
        error.Ignore = true;
    }
};
  • SamlOptions.AuthnRequestErrorInspector, LogoutRequestErrorInspector, LogoutResponseErrorInspector, with ReadErrorInspectorContext<T> (Data, XmlSource, Errors), Error and ErrorReason: the reader collects every error in a message rather than throwing at the first, and an inspector set here is handed them — only when there is one — and may remove or Ignore them; the read fails, with the first one's message as before, on any left standing. Null by default: nothing changes until you set one. An inspector cannot make a message trusted: the signature is checked after it returns, over the message as received, against the provider the request finally names.
  • Changed: an AuthnRequest, LogoutRequest or LogoutResponse whose IssueInstant or NotOnOrAfter is not a date is refused as malformed — the error page, or a 400 — with a failure event; until 0.153.0 a request escaped the endpoint as a 500. The federation service provider's logout endpoint likewise answers 400, no longer a 500, for a LogoutRequest with no ID or NameID.
  • Not breaking: the endpoint keeps its constructor and gains one taking the validator; the reader keeps its methods and gains overloads taking an inspector.

Ours: ValidateSessionAsync passes by default — Duende's refuses a NameID that matches none of the provider's sessions for a signed-in user, and answers without ending the session a request naming no session here; each changes what this server does with a verified request today, so they are opt-in (decided at 0.159.0; see signatures and encryption). An unsigned request from a provider with no certificate still goes to the logout page, where Duende refuses it; a provider with no single logout endpoint is still signed out, where Duende refuses it; an inspector fixes a value by setting a corrected message as Data, which is read back (this server's messages are immutable); a document that is not the expected message is refused whatever the inspector says, where Duende's reader returns none; the reader reports six of Duende's twelve ErrorReasons — it does not verify signatures or hold a message to the schema as closely.