Table of Contents

This server as a SAML service provider

The other direction: users sign in at a business identity provider over SAML 2.0 and arrive here signed in, as they would from an OpenID Connect provider — native, with no third-party library. External providers registers one with the essentials; this page is the rest of what a registration can say, and what the handler does with logout. The code is included from samples/Server.Saml, which a test boots and asks for this side's metadata. Every setting is under SamlSpOptions.

/// <summary>
/// Federates users out to a business identity provider. The two certificates of this server's own carry private
/// keys — from a certificate store or a vault, never a file in the repository.
/// </summary>
public static AuthenticationBuilder AddBusinessIdentityProvider(
    this AuthenticationBuilder authentication,
    X509Certificate2 idpSigning,
    X509Certificate2 ourSigning,
    X509Certificate2 ourDecryption)
    => authentication.AddExternalSaml2("business-idp", "Business IdP", options =>
    {
        options.SpEntityId = "https://localhost:5001/saml/business";
        options.IdpEntityId = "https://idp.business.example.com/saml";
        options.IdpSingleSignOnUrl = "https://idp.business.example.com/sso";
        options.IdpSingleLogoutUrl = "https://idp.business.example.com/slo";   // enables logout started here
        options.IdpSigningCertificate = idpSigning;
        options.CallbackPath = "/signin-saml";
        options.SignedOutCallbackPath = "/signout-saml";

        // Signs the AuthnRequest, LogoutRequest and LogoutResponse this server sends, and is published in its
        // metadata with AuthnRequestsSigned="true". Without it they go out unsigned.
        options.SigningCertificate = ourSigning;

        // Send the AuthnRequest by HTTP-POST (signed inside the XML) rather than HTTP-Redirect, and sign with a
        // method other than the one the key implies — never SHA-1, and only one the key can produce.
        options.IdpSingleSignOnBinding = SamlConstants.Bindings.HttpPost;
        options.OutboundSigningAlgorithm = SamlConstants.SignatureAlgorithms.RsaSha512;

        // Assertions encrypted to this server's key, and a provider that stops encrypting refused.
        options.DecryptionCertificate = ourDecryption;
        options.RequireEncryptedAssertion = true;
    });

AddExternalSaml2 registers the handler against this server's external cookie, as AddExternalOpenIdConnect does. It stores its request correlation in a data-protected RelayState and, since 0.178.0, a correlation cookie in the browser that was sent to the provider; it validates the returned assertion (signature, issuer, audience, recipient, InResponseTo, time conditions with configurable skew), accepts it once, and signs the mapped ClaimsPrincipal into the external cookie scheme. The provider's subject travels as ClaimTypes.NameIdentifier — who signs in where.

What an assertion has to be

Since 0.178.0 the service provider holds an assertion to the Web SSO profile's bearer rules (SAML Profiles §4.1.4.2–§4.1.4.5) rather than checking each value only when the assertion happens to carry it:

  • The browser that started the sign-on finishes it. The challenge sets the correlation cookie ASP.NET Core's remote handlers set (RemoteAuthenticationOptions.CorrelationCookie: SameSite=None, Secure, HttpOnly), and a response whose RelayState arrives without it is refused (correlation_failed). Until then the RelayState alone correlated, so a response captured with its RelayState signed in whichever browser posted it — login CSRF. Duende's service provider keeps its request state in a cookie named for the RelayState to the same end.
  • Once. An assertion's ID, under its issuer, is kept in the server's IReplayCache until the assertion would be refused anyway (its NotOnOrAfter plus ClockSkew), and a second use is refused (assertion_replayed) — solicited or not, at whichever instance it reaches when the host registers a shared IDistributedCache.
  • Addressed to this service provider, here, and bounded in time. An AudienceRestriction naming SpEntityId (no_audience_restriction); a bearer SubjectConfirmation (no_bearer_confirmation) whose data names the reply address as Recipient (no_recipient) and carries NotOnOrAfter (no_subject_confirmation_expiry); and a response Destination, when it has one, that is the reply address (destination_mismatch).
  • No longer than SamlOptions.MaxMessageSize (message_too_large), as the identity provider's endpoints already bound what they read.
  • Attributes are statements about the subject, not the subject. An attribute named for a claim the server or the handler sets — sub, idp, amr, auth_time, acr, sid, ClaimTypes.NameIdentifier, ClaimTypes.AuthenticationMethod, ClaimTypes.AuthenticationInstant, ClaimTypes.Actor, or the registration's NameIdClaimType (compared ignoring case) — is dropped and logged. The NameID is the only subject, and the AuthnStatement the only record of how the user signed in.

Each refusal is a SamlAssertionRejectedEvent naming its code, and the remote-failure path a remote handler has.

Security. A partner identity provider that omits AudienceRestriction, Recipient or the confirmation's NotOnOrAfter, or confirms by a method other than bearer, is refused from 0.178.0 — the profile requires all of them, and the major identity providers send them. A host that posts responses to the assertion consumer itself (a test harness, a proxy) sends the correlation cookie with them; over plain http the cookie needs CorrelationCookie.SecurePolicy set to SameAsRequest, as browsers will not send a Secure cookie there.

How the provider says the user signed in travels too, since 0.175.0: the assertion's AuthnContextClassRef as ClaimTypes.AuthenticationMethod, and its AuthnInstant as ClaimTypes.AuthenticationInstant (UTC, to the millisecond, typed xsd:dateTime) — what Duende 8.0.1's service provider adds through Microsoft.IdentityModel's Saml2SecurityTokenHandler, measured. A context stated only by declaration reference adds no method, as there. Neither becomes the session's acr or amr, in Duende's sign-in template or here: a federated sign-in is amr=external, and an IUserSignInService of your own finds both among the external claims it is given. Until 0.175.0 the handler read neither, and SamlMessageReader did not read the class reference at all — a parsed assertion always said PasswordProtectedTransport; it now says what the assertion states, or nothing.

A partner that arrives while the server is running — a tenant's own identity provider — can be a stored row instead of a registration (AddSamlDynamicProvider, since 0.110.0).

Signing what this server sends

SigningCertificate signs all three messages this service provider sends, as of 0.70.0: the AuthnRequest that starts a sign-on, the LogoutRequest that starts a logout, and the LogoutResponse that answers the provider's. On the HTTP-Redirect binding the signature is the detached one on the query string — a DEFLATEd message in a URL has nowhere to carry an enveloped one; since 0.109.0 the AuthnRequest can go by HTTP-POST instead (IdpSingleSignOnBinding), signed inside the XML, and with a method other than the one the key implies (OutboundSigningAlgorithm) — RSA-SHA384/512 or ECDSA-SHA*, never SHA-1, and only one the key can produce; both as Duende's service provider has them.

Setting it also changes what the metadata claims, because the two cannot honestly disagree: AuthnRequestsSigned becomes true and the certificate is published as a signing KeyDescriptor. Either without the other is a lie a partner acts on — the flag alone tells it to expect a signature it has no way to verify, and the key alone invites it to ignore one it could have checked.

Without it all three go out unsigned and the metadata says AuthnRequestsSigned="false", which is what a host that has configured none still gets; an identity provider that requires signatures refuses them. Before 0.70.0 there was no key here at all. What made that easy to miss is that the machinery existed: BuildSignedRequestQuery had thirty callers and every one was a test, signing messages into this server as a partner would, while nothing this server sent was ever signed by it.

Assertions encrypted to DecryptionCertificate, and RequireEncryptedAssertion, are on signatures and encryption.

Logout

IdpSingleLogoutUrl is optional, and leaving it out is a supported registration rather than a half-finished one: signing out ends the session here, logs a warning naming the scheme, and carries the browser on. Until 0.44.0 it threw, which since 0.42.0 meant an ordinary sign-out answered with a 500 — and, because the response carrying the deleted cookie was replaced by the error response, left the user signed in.

SignedOutCallbackPath receives logout in both directions, and as of 0.60.0 both are acted on. A LogoutResponse answers a logout this server started. A LogoutRequest is the identity provider ending the session at its end and telling us — IdP-initiated single logout — and it ends the local session here, notifies the relying parties that session reached, and answers the provider with a LogoutResponse naming its request. Before 0.60.0 the request was decoded, found not to be our own state, and answered with a redirect to /: the path this service provider publishes as its SingleLogoutService accepted logout requests and signed nobody out.

The signature is checked on both bindings. An inbound LogoutRequest arrives at a published URL, over a binding anyone can use, naming a subject anyone can guess — unsigned, or signed by a key this scheme does not trust, it is a request from whoever sent it to end somebody else's session, and it is refused before anything is dropped. On the redirect binding the signature must cover the SAMLRequest the handler reads, alone and once (SAML Bindings §3.4.4.1): since 0.178.0 a query that also carries a SAMLResponse, or repeats a signed parameter, does not verify.

Since 0.178.0 the signature is not the whole of it. A genuine request is still somebody's to deliver, and delivered through the wrong browser, or again, or late, it ended whichever session that browser held — logout CSRF. So the request must also:

  • be addressed here: its Destination is this scheme's SignedOutCallbackPath (destination_required, invalid_destination; Bindings §3.4.5.2);
  • be within its time: before its NotOnOrAfter, and issued no further ahead than ClockSkew and no longer ago than SamlOptions.DefaultRequestMaxAge plus ClockSkew (request_expired, request_not_yet_valid; Core §3.7.1);
  • be new: its ID, under its issuer, is remembered as an assertion's is (replayed_request);
  • name the session this browser holds (Core §3.7.3.2): a session that says it came through another provider, or names another subject there, is not ended, and the provider is answered Requester/UnknownPrincipal (unknown_principal). A session that names neither is not contradicted and ends as before.

The message itself is bounded by SamlOptions.MaxMessageSize while it is inflated (message_too_large). Each refusal is a SamlLogoutFailureEvent and a 400.

Accepting an unsolicited assertion

/// <summary>Accepts assertions the identity provider sends unasked, landing the browser at the root.</summary>
public static AuthenticationBuilder AddPortalIdentityProvider(this AuthenticationBuilder authentication, X509Certificate2 idpSigning)
    => authentication.AddExternalSaml2("portal-idp", "Portal", options =>
    {
        options.SpEntityId = "https://localhost:5001/saml/portal";
        options.IdpEntityId = "https://portal.example.com/saml";
        options.IdpSingleSignOnUrl = "https://portal.example.com/sso";
        options.IdpSigningCertificate = idpSigning;
        options.CallbackPath = "/signin-portal";

        options.AllowIdpInitiatedSignOn = true;
        options.IdpInitiatedReturnPath = "/";   // there is no return URL to honour
    });

The mirror image of IdP-initiated sign-on, on SamlSpOptions, and also off by default. IdpInitiatedReturnPath is where the browser lands, as there is no return URL to honour.

A solicited response is correlated against state this handler protected into RelayState when it sent the request — that state is what says this browser started this sign-on, and an unsolicited assertion has none of it. Accepting them never means ignoring InResponseTo: an unsolicited response must name no request, so a captured solicited assertion replayed without its state is still refused.

The sign-in is recorded against the scheme that received it (since 0.178.0): the scheme item the external sign-in page reads is this registration's name, so the provider's subject is looked up among this provider's users and no other's. Until then an unsolicited sign-in recorded no scheme, and the page took the provider from the external identity's own idp claim — which an assertion attribute could supply — and the subject from its sub: an identity provider trusted only for its own users could sign in as a user of any other provider, past a client's IdentityProviderRestrictions, and every unsolicited provider shared one namespace, external. Each unsolicited assertion is also accepted once. IdpInitiatedReturnPath is where the browser lands with the identity in the external cookie; with the shipped pages it is the external sign-in callback (/Account/ExternalLogin?handler=Callback) that turns it into a session. These two options were documented as inert until 0.178.0; they have been read since 0.59.0.