Table of Contents

SAML identity provider

With Fdyay.IdentityServer.Saml, partner applications sign their users in here over SAML 2.0 — native, on the framework's XML-DSig, with no third-party SAML library. External providers registers one partner; this page is what a registration holds, what an inbound request must be, what an assertion says, and where the endpoints are. The code is included from samples/Server.Saml, which a test boots and asks for its metadata; the SAML 2.0 single sign-on flow is recorded end to end. Every server-wide setting is under SamlOptions.

dotnet add package Fdyay.IdentityServer.Saml

Registering a partner

new SamlServiceProvider
{
    EntityId = "https://sp.partner.example.com/saml",
    DisplayName = "Partner portal",

    // Where assertions may go. A request names one by URL or by index; naming neither, it goes to the default.
    AssertionConsumerServiceUrls =
    {
        new IndexedEndpoint { Location = "https://sp.partner.example.com/saml/acs", Binding = SamlBinding.HttpPost, Index = 0, IsDefault = true },
        new IndexedEndpoint { Location = "https://reports.partner.example.com/saml/acs", Binding = SamlBinding.HttpPost, Index = 1 },
    },
    SingleLogoutServiceUrls =
    {
        new SamlEndpointType { Location = "https://sp.partner.example.com/saml/slo", Binding = SamlBinding.HttpRedirect },
    },

    // Its signing certificates verify its requests (any one of them, so a new key can sit beside the old);
    // assertions are encrypted to the first encryption certificate.
    Certificates =
    {
        new ServiceProviderCertificate { Certificate = requestSigning, Use = KeyUse.Signing },
        new ServiceProviderCertificate { Certificate = encryption, Use = KeyUse.Encryption },
    },

    // Required: the identity scopes whose user claims the profile service is asked for, and the assertion
    // may carry as attributes — narrowed to these claim types, and named as the partner expects them.
    AllowedScopes = { "openid", "profile", "email", "roles" },
    RequestedClaimTypes = { "name", "email", "role" },
    ClaimMappings = { ["email"] = "email", ["role"] = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" },

    // Named by email address unless a request asks for another listed format.
    DefaultNameIdFormat = SamlConstants.NameIdFormats.EmailAddress,

    SigningBehavior = SamlSigningBehavior.SignBoth,
    AssertionLifetime = TimeSpan.FromMinutes(2),
    AllowIdpInitiated = true,
},

The service provider registration — new, and breaking, in 0.143.0, Duende 8.0.1's SamlServiceProvider, measured with the validator, the stores and the readers that use it:

  • Several assertion consumer services, AssertionConsumerServiceUrls — each an IndexedEndpoint with a Location, a Binding, an Index and IsDefault, and each SamlBinding.HttpPost (Duende's message otherwise: Assertion Consumer Service at index 3 uses an unsupported binding 'HttpRedirect'. …). A request names one by AssertionConsumerServiceURL — compared as Uri.AbsoluteUri spells it, so a capitalised host matches — or by AssertionConsumerServiceIndex; naming neither is answered at the endpoint marked default, else the first. Both at once, a relative URL, or an index nobody registered is refused before sign-in (acs_url_and_index, invalid_acs_url, acs_index_not_registered, with Duende's messages on the error page); an unregistered URL is still acs_url_mismatch. The response's Destination, the assertion's Recipient and the form all name the chosen endpoint.
  • Several logout services, SingleLogoutServiceUrls — SamlEndpointTypes, with GetSingleLogoutServiceEndpoint(binding). A logout begun at another sign-out goes on HTTP-Redirect; one answered at /saml/slo goes on HTTP-POST; each to the endpoint on its binding, else the first.
  • Several certificates, Certificates — ServiceProviderCertificate { Certificate, Use }, Use a KeyUse (Signing unless set, Encryption, Both). A signature any signing certificate verifies is accepted, at sign-on and at all three logout checks, so a partner rolling its key over registers the new one beside the old. Assertions are encrypted to the first encryption certificate (encryption).
  • Enabled (true) and Description. A disabled provider reads as unregistered — from the in-memory store, as Duende's, and from the validating store that wraps every store, so your own store need not filter it; it is neither logged nor raised as broken.
  • AssertionLifetime (null takes SamlOptions.AssertionLifetime, five minutes), refused when not positive (AssertionLifetime must be positive, ahead of the clock checks, as Duende orders them).
  • SigningBehavior (null takes SamlOptions.DefaultSigningBehavior, SignAssertion): SignAssertion, SignResponse (the Response, around the assertion — encrypted, when it is), SignBoth, or DoNotSign (for testing).
  • AllowIdpInitiated, false by default — breaking. A provider answers only sign-ons it asked for unless it says so; see IdP-initiated sign-on.
  • ISamlServiceProviderStore.GetAllSamlServiceProvidersAsync — breaking for your own store, which must implement it: every provider, as an IAsyncEnumerable. The in-memory store yields them as given, a disabled one included (and its duplicate-entity-id message is Duende's); the validating store yields each it would serve and raises InvalidSamlServiceProviderConfigurationEvent for the rest.
  • IConnectedApplicationStore (Fdyay.IdentityServer.Stores): FindByIdentifierAsync and GetAllAsync over every registered application — clients, then service providers, the client first when an id is both. The core default, ConnectedApplicationStore, knows the clients; AddSaml replaces it with SamlConnectedApplicationStore unless you registered your own. IConnectedApplication gains Description, Enabled, ProtocolType and RequireConsent — breaking for your own implementation; a Client has all four, a provider answers saml (Duende's saml2p since 0.157.0) and no consent. Nothing on a request path reads either, as nothing in Duende does.

AssertionConsumerServiceUrl, SingleLogoutServiceUrl, RequestSigningCertificate and EncryptionCertificate stay, as this server's shorthands over the collections, so a registration written before 0.143.0 means what it meant: setting one registers an HTTP-POST consumer, an HTTP-Redirect logout endpoint, a signing or an encryption certificate; reading one gives the default consumer, the redirect logout endpoint, the first signing or encryption certificate. AssertionConsumerServiceUrl is no longer required.

Ours: those four shorthands, which Duende does not have; a failure response is signed unless the provider says DoNotSign, where Duende signs one only under SignResponse; Duende reads no encryption certificate (it does not encrypt assertions); its logout goes on HTTP-Redirect only, where /saml/slo here answers on HTTP-POST; ProtocolType is saml, this server's constant, where Duende's is saml2p (decision 3 of the re-measure; taken at 0.157.0); the server's lifetime option keeps its name, AssertionLifetime, where Duende's is DefaultAssertionLifetime; every endpoint URL is held to this server's https rule, which Duende does not check; the members are init, as the rest of the registration is. RequireSignedLogoutResponses and AllowedSignatureAlgorithms followed at 0.154.0 (who must sign).

A partner that publishes metadata can be registered from it — see encrypted assertions.

What an inbound request must be

Breaking in 0.133.0, Duende 8.0.1's, measured from its AuthnRequestValidator and LogoutRequestValidator:

  • An AuthnRequest must be signed, by one of the partner's signing certificates, unless the partner opts out — SamlOptions.WantAuthnRequestsSigned is true by default, as Duende's is, and metadata says WantAuthnRequestsSigned="true". A partner registered without a certificate is refused until you say so, per partner:

    // A partner integrated before signed requests were required: its AuthnRequests and its answers from the
    // signed-out page's iframe are accepted unsigned. Say so per partner, and only for the partner that needs it.
    new SamlServiceProvider
    {
        EntityId = "https://legacy.example.com/saml",
        AssertionConsumerServiceUrl = "https://legacy.example.com/saml/acs",
        AllowedScopes = { "openid", "profile" },
        RequireSignedAuthnRequests = false,
        RequireSignedLogoutResponses = false,
    },
    
  • Its IssueInstant may be no more than the clock skew ahead and no older than the maximum age — the partner's ClockSkew and RequestMaxAge, else SamlOptions.ClockSkew and DefaultRequestMaxAge, five minutes each. A request resumed after the user signs in is aged to when it arrived, not to when they finished.

  • Its Destination, when it names one, must be this server's sign-on URL (case ignored), and a signed request must name one. A signed LogoutRequest likewise must name the logout URL, be SAML 2.0, and not be past its NotOnOrAfter.

  • Size: SamlOptions.MaxMessageSize (1 MiB) bounds a POST before it is decoded, a redirect-bound message while it is inflated, and the XML as it is loaded; MaxRelayStateLength (80 bytes, SAML's own limit) bounds RelayState. Until 0.133.0 a few kilobytes of deflated input in a URL could be made to inflate without end.

Each refusal is the error page and a SamlSignOnFailureEvent or SamlLogoutFailureEvent naming it — where Duende lets an oversized message become a 500. An unsigned LogoutRequest still goes to the logout page for the user to confirm, where Duende refuses it. The checks live in IAuthnRequestValidator (Duende's), whose default's steps are protected virtual; the logout checks are a seam of their own (SAML customization).

What an assertion names the user by

Breaking in 0.134.0, Duende 8.0.1's, measured from its DefaultSamlNameIdGenerator and Saml2SSoResponseGenerator:

  • The NameID format is the one the request's NameIDPolicy asks for, else the partner's DefaultNameIdFormat, else unspecified, and it must be one of SamlOptions.SupportedNameIdFormats — emailAddress and unspecified by default, listed in metadata as NameIDFormat. Any other is refused with InvalidNameIDPolicy before the user is sent to sign in. SamlServiceProvider.NameIdFormat is renamed DefaultNameIdFormat, Duende's name, because a partner may ask for any listed format; a partner whose default is not listed is refused.

  • The NameID value comes from ISamlNameIdGenerator. The default issues emailAddress from the user's email claim — the partner's EmailNameIdClaimType, else SamlOptions.EmailNameIdClaimType (email) — and every other format from sub. Until 0.134.0 an emailAddress NameID carried the subject id. The claim is read from the session, as Duende reads it, and the bundled sign-in page signs in with no email, so issue one with the sign-in for a partner that wants email addresses:

    /// <summary>
    /// A sign-in that carries the user's email address, which a partner asking for emailAddress NameIDs needs: the
    /// NameID is read from the session, and a user without the claim is refused.
    /// </summary>
    public static Task SignInWithEmailAsync(this HttpContext http, string subjectId, string username, string email)
        => http.SignInAsync(new IdentityServerUser(subjectId)
        {
            DisplayName = username,
            AdditionalClaims = { new Claim(JwtClaimTypes.Email, email) },
        });
    

    A user without the claim is refused with InvalidNameIDPolicy and the error page, not named by something else. Neither Duende's default nor this one computes a persistent or transient identifier: list either format and the subject id is issued under it. A pairwise or one-time identifier is a generator of your own, registered before AddSaml.

  • AuthnContextClassRef is the user's acr, else the first of their amr values, looked up in the partner's AuthnContextMappings when it has any, else SamlOptions.DefaultAuthnContextMappings — pwd as PasswordProtectedTransport and external as unspecified — and unspecified when none maps. The partner's mappings replace the server's rather than adding to them. Until 0.134.0 every assertion said PasswordProtectedTransport.

  • RequestedAuthnContext is read — its Comparison as sent, its class or declaration references — and handed to the sign-in page as SamlAuthenticationContext.RequestedAuthnContext, from GetAuthenticationContextAsync; a tenant: class reference becomes the context's Tenant. It is not enforced, as Duende does not enforce it.

What an assertion says about the user

Breaking in 0.141.0, Duende 8.0.1's, measured from its Saml2SSoResponseGenerator, DefaultSamlResourceResolver and attribute writer:

  • The profile service is asked. At both sign-on doors, once the NameID is issued, IProfileService.GetProfileDataAsync is called with Caller "Saml2SsoResponseGenerator" (IdentityServerConstants.ProfileDataCallers.Saml2SsoResponseGenerator — Duende's value, which Duende declares no constant for) and the sign-on as ProtocolRequest: a ValidatedAuthnRequest carrying the service provider, the AuthnRequest (null for an IdP-initiated sign-on, IsIdpInitiated true), the relay state, session index and resolved claim types. Until 0.141.0 the profile service was not asked, and every partner was sent the session's name and email claims under those names.
  • What it is asked for is the partner's AllowedScopes, now required: each must name an enabled identity resource, and their user claims are the RequestedClaimTypes — narrowed to the partner's own RequestedClaimTypes when it lists any, each of which must be one of them. A registration with no scopes reads as unregistered (at least one allowed scope is required, in the log at Error); one whose scopes or claim types do not resolve is refused before sign-in with Responder and Service provider configuration error on the error page, the offender logged. Every partner needs at least one.
  • Issued claims become attributes named by the partner's ClaimMappings when it has any, else SamlOptions.DefaultClaimMappings — Duende's three: name and email as http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name and …/claims/emailaddress, role as http://schemas.xmlsoap.org/ws/2005/05/identity/role (without claims/, as Duende ships it). The two are not merged; a claim type in neither goes out under its own name; claims of one type are one attribute with each value in order. An attribute that was email is now …/claims/emailaddress unless the partner maps it back: ClaimMappings = { ["email"] = "email" }, as the sample's first partner does.
  • ProfileDataRequestContext.SamlAttributes are issued as they are, ahead of the mapped claims, which join one of the same name; it is the way to send a NameFormat or FriendlyName, both of which SamlAttribute carries and the writer emits only when set (and this server's service-provider side reads). SamlAttribute moved to Fdyay.IdentityServer.Core, same namespace, forwarded from the SAML assembly.

Ours: one SamlAttribute where Duende has two of the same shape; a request validator or IdP-initiated endpoint built without an ISamlResourceResolver (not the case under AddSaml) resolves nothing, so only SamlAttributes are issued; the IdP-initiated door's refusal is the error page, as its others are.

What SAML signs with, and how long its metadata lasts

New in 0.144.0, Duende 8.0.1's, measured from its SamlSigningService, RsaCertificateFactory, Saml2MetadataResponseGenerator and metadata result writer:

  • ISamlSigningService answers the certificate every assertion, response and logout message is signed with, and the ones metadata publishes. The default signs with this server's key material — the credential IKeyMaterialService signs tokens with: an X.509 key as its certificate, an RSA key wrapped in a self-signed certificate made for it (CN= the entity id, valid from the key's creation for its retirement age, serial from the key id, made once per key) — so a key rotation reaches SAML as it reaches tokens. Metadata publishes every current RSA and X.509 key, one signing KeyDescriptor each; EC keys are left out, and an EC signing key is refused, as in Duende. A manually registered RSA key cannot be wrapped (its creation date is unknown), and is refused with Duende's message: register a certificate instead (AddSigningCredential with an X509Certificate2), or set SamlOptions.SigningCertificate. Measured again for 0.175.0: Duende's key material answers a registered credential before its managed keys, so Duende refuses a manual RSA key whether or not automatic key management is on; here the managed key signs while it is on and registered keys only validate, so the refusal is met only with it off.
  • SamlOptions.SigningCertificate is no longer required. Set, it still signs everything and is the one key published, ahead of the key material — ours; Duende has no such option, and a partner that pinned your certificate keeps working. Until 0.144.0 a server without one could not serve SAML at all, and none of its signatures followed a key rotation.
  • ISamlMetadataResponseGenerator (Duende's ISaml2MetadataResponseGenerator) writes the document; the default, SamlMetadataResponseGenerator, has Duende's one protected virtual step, GenerateEntityDescriptorAsync, for adding to it. The document carries an ID, and cacheDuration and validUntil from SamlOptions.Metadata (SamlMetadataOptions): 12 hours and 5 days by default, as Duende's. A browser asking for HTML is sent it as text/xml.
  • Breaking for code that constructs them: SamlSingleLogoutEndpoint and SamlLogoutNotificationService take an ISamlSigningService; SamlMetadataEndpoint takes it and the generator; SamlResponseGenerator is registered transient over the signing service, and its CreateSignedResponseAsync/CreateSignedFailureResponseAsync are what the endpoints call (the synchronous methods sign only for a generator constructed over a certificate).

Ours: the metadata ID is _ and a GUID, as every other identifier this server writes; the key manager is asked only while automatic key management is on. How signatures are made and checked is signatures and encryption.

Where the endpoints are

// Move the sign-on and logout services; the IdP-initiated door follows to /idp/sso/initiate. The route,
// the Destination a message must name, the return URL and the metadata move together.
options.Endpoints.SingleSignOnServicePath = "/idp/sso";
options.Endpoints.SingleLogoutServicePath = "/idp/slo";

// Serve, and advertise, sign-on on HTTP-POST alone.
options.Endpoints.SingleSignOnServiceBindings = [SamlConstants.Bindings.HttpPost];

// How long a sign-on waits for its user at the login page (15 minutes by default).
options.SigninStateLifetime = TimeSpan.FromMinutes(10);

New in 0.145.0, Duende 8.0.1's SamlEndpointOptions, EndpointsOptions SAML switches, SigninStateLifetime/LogoutSessionLifetime and SamlAuthenticationContext, measured (SamlEndpointOptions):

  • Paths — SingleSignOnServicePath, SingleSignOnCallbackPath, SingleLogoutServicePath — move the route, the Destination an inbound message must name, the return URL and the metadata together, read per request, so a Configure<SamlOptions> after AddSaml moves them too. The defaults stay this server's (/saml/sso, /saml/slo), not Duende's /Saml2/SSO: partners have them configured. The metadata path stays fixed, as Duende's does.
  • Bindings — SingleSignOnServiceBindings, SingleLogoutServiceBindings, both HTTP-Redirect and HTTP-POST by default — are what metadata advertises, as in Duende, and what the endpoint serves: a message on a binding not listed falls through as a disabled endpoint does, where Duende's only leaves it out of metadata. Empty switches the service off.
  • A sign-on resumes at the callback — /saml/sso/callback?samlStateId=… (SingleSignOnCallbackPath, StateIdParameterName), as Duende's does. Changed: until 0.145.0 it resumed at /saml/sso?resume=…; the sign-on service now answers requests only, and the callback with nothing to resume is the error page. A sign-in in flight across the upgrade is asked to start again. The IdP-initiated door still resumes at its own path.
  • EndpointsOptions.EnableSamlSigninEndpoint, EnableSamlSigninCallbackEndpoint, EnableSamlLogoutEndpoint, EnableSamlMetadataEndpoint are off by default and switched on by AddSaml, as Duende's are; switch one off with a Configure<IdentityServerOptions> after AddSaml. The sign-on switch closes the IdP-initiated door too. Every path is listed on endpoints.
  • Lifetimes — SigninStateLifetime (15 minutes) and LogoutSessionLifetime (5) are written onto each stored state as ExpiresAt, so a store of your own can expire it without reading the options. Changed: a sign-in waits 15 minutes for its user, where the in-memory store kept it 20.
  • The sign-in page is told more: SamlAuthenticationContext.LoginHint (the request's Subject/NameID), IdP (an IDPList naming exactly one provider — the bundled page sends the user straight to the external scheme of that name), RelayState and IsIdpInitiated.

Ours: no SingleLogoutCallbackPath or EnableSamlLogoutCallbackEndpoint — this server's single logout is a sequence of browser hops answered at the logout service itself, and has no callback to place or switch; a request carrying Scoping is accepted and its one-entry IDPList used as a hint, where Duende's default validator refuses any Scoping.

A SAML-only identity provider registers no clients, as the sample registers none. Since 0.175.0 AddIdentityServer() registers an empty client store and an empty resource store, as Duende 8.0.1's AddCoreServices registers its EmptyClientStore and EmptyResourceStore, and any store registered after it replaces them. Until then such a host had to register AddInMemoryClients([]), or it refused to start in Development — the protocol endpoints AddIdentityServer() registers need a client store — and every client that asks its token endpoint is now refused as an unknown one is, invalid_client.

IdP-initiated sign-on

New in 0.59.0, and off until you switch it on:

// Sign-ons nobody asked for, started from a link in your own portal. Off by default; each provider sent
// one must also say AllowIdpInitiated.
options.EnableIdpInitiatedSignOn = true;

and, since 0.143.0, on each service provider you start sign-ons to — AllowIdpInitiated = true, false by default as Duende's is; any other is sent to the error page (idp_initiated_not_allowed, "Service provider does not allow IdP-initiated SSO"). Then a link in your own portal starts a sign-on the service provider never asked for:

https://localhost:5001/saml/sso/initiate?sp=https%3A%2F%2Fpartner.example.com%2Fsaml&RelayState=/reports/42

(/idp/sso/initiate in the sample, whose sign-on service has moved.) The entity id selects a registered service provider and nothing else — an unsolicited request carries no message this server can authenticate, so everything the assertion names comes from the registration. An unknown entity id, or a registered reply address your own ValidationOptions refuses, goes to the error page rather than anywhere near a form. A browser with no session is sent to sign in and comes back to the same service provider. RelayState is handed to the service provider untouched and is never a URL this server navigates to: it belongs to their site, and following it here would make this an open redirector for anyone who can write a link.

The assertion names no request, at either of the two places InResponseTo can appear — that absence is what the profile (§4.1.5) requires, and it is what stops a receiver treating somebody else's answer as this one.

Off by default because the link is the whole request. Anyone who can get a browser to that URL starts a sign-on; what bounds it is that the target must be registered and the user must already be, or become, signed in. Take it deliberately.

A registration that cannot be served

As of 0.71.0, a registration that cannot be served reads as unregistered. The reply and logout addresses have to be absolute http or https URLs, and https unless the host is loopback; the entity id has to be non-empty. A registration failing any of those is refused where it is read, with the field named in the log at Error, and the sign-on is answered the way an unknown service provider is.

The reply address is why. A SAML response carries a signed assertion — a bearer credential for whoever holds it — and the registration is the only thing that decides where it goes, because an AuthnRequest naming a reply address is required to name the registered one. Before 0.71.0 the only check that address met was the server-wide scheme deny-list, which answers allowed for the empty string, for a relative path, and for http://.

Loopback is exempt whatever you set, because a partner on your own machine is the case https cannot be asked of. For a deployment that terminates TLS somewhere this server cannot see:

/// <summary>
/// For a deployment that terminates TLS somewhere this server cannot see: partner addresses may be http. Empty and
/// relative addresses stay refused, and loopback is allowed either way.
/// </summary>
public static IServiceCollection AllowPlaintextPartnerUrls(this IServiceCollection services)
    => services.Configure<SamlOptions>(options => options.RequireHttpsServiceProviderUrls = false);

That reaches the plaintext rule only. An empty or relative address stays refused, because neither can be a partner's endpoint under any deployment. The checks are a seam, ISamlServiceProviderConfigurationValidator — see registrations that cannot work.

If you register your own ISamlServiceProviderStore after AddSaml(), you replace the validating wrapper along with the store. Call services.ValidateSamlServiceProviders() afterwards to put it back; it is idempotent, so calling it when it is already there does nothing:

/// <summary>
/// Registered after AddSaml, the store replaces the validating wrapper along with the in-memory store; putting the
/// wrapper back keeps a registration that cannot be served reading as unregistered.
/// </summary>
public static IServiceCollection UsePartnerDirectory(IServiceCollection services, IReadOnlyList<SamlServiceProvider> partners)
{
    services.AddSingleton<ISamlServiceProviderStore>(new PartnerDirectoryStore(partners));
    services.ValidateSamlServiceProviders();
    return services;
}