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 whoseRelayStatearrives without it is refused (correlation_failed). Until then theRelayStatealone correlated, so a response captured with itsRelayStatesigned in whichever browser posted it — login CSRF. Duende's service provider keeps its request state in a cookie named for theRelayStateto the same end. - Once. An assertion's
ID, under its issuer, is kept in the server'sIReplayCacheuntil the assertion would be refused anyway (itsNotOnOrAfterplusClockSkew), and a second use is refused (assertion_replayed) — solicited or not, at whichever instance it reaches when the host registers a sharedIDistributedCache. - Addressed to this service provider, here, and bounded in time. An
AudienceRestrictionnamingSpEntityId(no_audience_restriction); abearerSubjectConfirmation(no_bearer_confirmation) whose data names the reply address asRecipient(no_recipient) and carriesNotOnOrAfter(no_subject_confirmation_expiry); and a responseDestination, 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'sNameIdClaimType(compared ignoring case) — is dropped and logged. The NameID is the only subject, and theAuthnStatementthe 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,Recipientor the confirmation'sNotOnOrAfter, or confirms by a method other thanbearer, 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 plainhttpthe cookie needsCorrelationCookie.SecurePolicyset toSameAsRequest, as browsers will not send aSecurecookie 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
Destinationis this scheme'sSignedOutCallbackPath(destination_required,invalid_destination; Bindings §3.4.5.2); - be within its time: before its
NotOnOrAfter, and issued no further ahead thanClockSkewand no longer ago thanSamlOptions.DefaultRequestMaxAgeplusClockSkew(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.