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.StateIdis that reference — asSamlSignInState.DenialErrorandDenialErrorDescription, 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 anArgumentException, 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 theRelayState:Responder, andNoPassiveforInteractionRequired,RequestDeniedforConsentRequired,NoAuthnContextforUnmetAuthenticationRequirements, andAuthnFailedfor every other error,AccessDeniedamong them (the three codes are onSamlConstants.StatusCodes). The description is not sent, as Duende does not send it; it is theSamlSignOnFailureEvent's message, whoseErroris Duende'saccess_denied (ConsentRequired). - Both doors: the IdP-initiated door reads a denial back at its own path and answers with no
InResponseTo(CreateSignedFailureResponse'sinResponseTois nullable). A sign-in no longer waiting — expired, or already answered — is logged atWarningand 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 asksISamlLogoutNotificationServicefor logout requests, andAddSamlregistersSamlSignInDenialService.DefaultIdentityServerInteractionServicetakes it through a constructor overload (both SAML services, neither optional); the constructor it had is kept unchanged, so nothing compiled against it breaks, andAddIdentityServerregisters 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 letISamlSigninStateStore.StoreAsyncreplace 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(throughISamlNameIdGenerator), thenCreateSaml2ResponseAsync, which asksCreateAssertionAsync, which asksCreateSubject,CreateConditions,CreateAuthnStatementandCreateAttributesAsync(withMapClaimsToAttributes) — 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,SamlSubjectandSamlAuthnStatement, andValidatedAuthnRequestgains Duende'sRequestIdandNameIdPolicyFormat, read from its request. CreateErrorResponseanswers aSaml2InteractionResponse—Login(),NoInteraction()orError(code, subCode, message)— that is an error, elseArgumentException(Duende's message).IsSafeErrordecides 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'sIsSafeErrorkeeps 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
SamlResponseGeneratorthey are given, so nothing compiled against them breaks.AddSamlbuilds them by factories calling the new constructors.SamlResponseGeneratoris kept, its failure response the oneCreateErrorResponsewrites 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 aSaml2InteractionResponse:Login(),NoInteraction()orError(...). The sign-on service asks it for a request that arrives, once it is validated and its reply address checked, as Duende'sSingleSignOnServiceEndpointdoes; the callback a user returns to from the sign-in page checks for itself, as Duende's does (signed in and still active,ForceAuthnnot asked again). An error is answered throughISaml2SsoResponseGenerator.CreateErrorResponseand recorded by the oneSamlSignOnFailureEvent(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 asSamlSsoEndpoint), is sent to sign in, and so is a signed-in user when the request saysForceAuthn; a request that saysIsPassivegets Duende'sResponder/NoPassiveerror, 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:
CreateSuccessResponseandCreatePartialLogoutResponse(Success, thenPartialLogoutunder 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.ValidatedLogoutRequestgains Duende'sRelayStateand itsSaml2IdpEntityIdasIssuerName; 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. AddSamlregisters 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
AddSamlbuilds 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 byAddSaml(TryAdd, so a host's own registered first wins): the checks the single logout endpoint made itself, lifted into Duende'sprotected virtualsteps and run in Duende's order —ValidateSpAsync,ValidateSignatureTrust,ValidateVersion,ValidateDestination,ValidateNotOnOrAfter,ValidateSessionAsync— by aValidateAsyncthat is not virtual, as Duende's is not. Every check is made as before, with the same error codes and pages.ValidatedLogoutRequestcarries the request, the document and detached signature it arrived with, and the provider (Duende'sSaml2Sp); the result isSamlLogoutRequestValidationResultwith Duende'sValid/InValidandSessionFound. - What no override reaches.
HasTrustedSignatureis 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 settableTrustLevel, and an override ofValidateSignatureTrustaccepts 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 withMatchLogoutRequestToSessionon 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, withReadErrorInspectorContext<T>(Data,XmlSource,Errors),ErrorandErrorReason: 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 orIgnorethem; 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
IssueInstantorNotOnOrAfteris 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 noIDorNameID. - 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.