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 anIndexedEndpointwith aLocation, aBinding, anIndexandIsDefault, and eachSamlBinding.HttpPost(Duende's message otherwise:Assertion Consumer Service at index 3 uses an unsupported binding 'HttpRedirect'. …). A request names one byAssertionConsumerServiceURL— compared asUri.AbsoluteUrispells it, so a capitalised host matches — or byAssertionConsumerServiceIndex; 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 stillacs_url_mismatch. The response'sDestination, the assertion'sRecipientand the form all name the chosen endpoint. - Several logout services,
SingleLogoutServiceUrls—SamlEndpointTypes, withGetSingleLogoutServiceEndpoint(binding). A logout begun at another sign-out goes on HTTP-Redirect; one answered at/saml/slogoes on HTTP-POST; each to the endpoint on its binding, else the first. - Several certificates,
Certificates—ServiceProviderCertificate { Certificate, Use },UseaKeyUse(Signingunless 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) andDescription. 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 takesSamlOptions.AssertionLifetime, five minutes), refused when not positive (AssertionLifetime must be positive, ahead of the clock checks, as Duende orders them).SigningBehavior(null takesSamlOptions.DefaultSigningBehavior,SignAssertion):SignAssertion,SignResponse(theResponse, around the assertion — encrypted, when it is),SignBoth, orDoNotSign(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 anIAsyncEnumerable. 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 raisesInvalidSamlServiceProviderConfigurationEventfor the rest.IConnectedApplicationStore(Fdyay.IdentityServer.Stores):FindByIdentifierAsyncandGetAllAsyncover every registered application — clients, then service providers, the client first when an id is both. The core default,ConnectedApplicationStore, knows the clients;AddSamlreplaces it withSamlConnectedApplicationStoreunless you registered your own.IConnectedApplicationgainsDescription,Enabled,ProtocolTypeandRequireConsent— breaking for your own implementation; aClienthas all four, a provider answerssaml(Duende'ssaml2psince 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
AuthnRequestmust be signed, by one of the partner's signing certificates, unless the partner opts out —SamlOptions.WantAuthnRequestsSignedis true by default, as Duende's is, and metadata saysWantAuthnRequestsSigned="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
IssueInstantmay be no more than the clock skew ahead and no older than the maximum age — the partner'sClockSkewandRequestMaxAge, elseSamlOptions.ClockSkewandDefaultRequestMaxAge, 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 signedLogoutRequestlikewise must name the logout URL, be SAML 2.0, and not be past itsNotOnOrAfter.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) boundsRelayState. 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
NameIDPolicyasks for, else the partner'sDefaultNameIdFormat, elseunspecified, and it must be one ofSamlOptions.SupportedNameIdFormats—emailAddressandunspecifiedby default, listed in metadata asNameIDFormat. Any other is refused withInvalidNameIDPolicybefore the user is sent to sign in.SamlServiceProvider.NameIdFormatis renamedDefaultNameIdFormat, 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 issuesemailAddressfrom the user's email claim — the partner'sEmailNameIdClaimType, elseSamlOptions.EmailNameIdClaimType(email) — and every other format fromsub. Until 0.134.0 anemailAddressNameID 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
InvalidNameIDPolicyand the error page, not named by something else. Neither Duende's default nor this one computes apersistentortransientidentifier: list either format and the subject id is issued under it. A pairwise or one-time identifier is a generator of your own, registered beforeAddSaml.AuthnContextClassRefis the user'sacr, else the first of theiramrvalues, looked up in the partner'sAuthnContextMappingswhen it has any, elseSamlOptions.DefaultAuthnContextMappings—pwdasPasswordProtectedTransportandexternalasunspecified— andunspecifiedwhen none maps. The partner's mappings replace the server's rather than adding to them. Until 0.134.0 every assertion saidPasswordProtectedTransport.RequestedAuthnContextis read — itsComparisonas sent, its class or declaration references — and handed to the sign-in page asSamlAuthenticationContext.RequestedAuthnContext, fromGetAuthenticationContextAsync; atenant:class reference becomes the context'sTenant. 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.GetProfileDataAsyncis called withCaller"Saml2SsoResponseGenerator"(IdentityServerConstants.ProfileDataCallers.Saml2SsoResponseGenerator— Duende's value, which Duende declares no constant for) and the sign-on asProtocolRequest: aValidatedAuthnRequestcarrying the service provider, theAuthnRequest(null for an IdP-initiated sign-on,IsIdpInitiatedtrue), 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'snameandemailclaims 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 theRequestedClaimTypes— narrowed to the partner's ownRequestedClaimTypeswhen 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 atError); one whose scopes or claim types do not resolve is refused before sign-in withResponderandService provider configuration erroron the error page, the offender logged. Every partner needs at least one. - Issued claims become attributes named by the partner's
ClaimMappingswhen it has any, elseSamlOptions.DefaultClaimMappings— Duende's three:nameandemailashttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameand…/claims/emailaddress,roleashttp://schemas.xmlsoap.org/ws/2005/05/identity/role(withoutclaims/, 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 wasemailis now…/claims/emailaddressunless the partner maps it back:ClaimMappings = { ["email"] = "email" }, as the sample's first partner does. ProfileDataRequestContext.SamlAttributesare issued as they are, ahead of the mapped claims, which join one of the same name; it is the way to send aNameFormatorFriendlyName, both of whichSamlAttributecarries and the writer emits only when set (and this server's service-provider side reads).SamlAttributemoved toFdyay.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
IKeyMaterialServicesigns 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 signingKeyDescriptoreach; 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 (AddSigningCredentialwith anX509Certificate2), or setSamlOptions.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.SigningCertificateis 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'sISaml2MetadataResponseGenerator) writes the document; the default,SamlMetadataResponseGenerator, has Duende's oneprotected virtualstep,GenerateEntityDescriptorAsync, for adding to it. The document carries anID, andcacheDurationandvalidUntilfromSamlOptions.Metadata(SamlMetadataOptions): 12 hours and 5 days by default, as Duende's. A browser asking for HTML is sent it astext/xml.- Breaking for code that constructs them:
SamlSingleLogoutEndpointandSamlLogoutNotificationServicetake anISamlSigningService;SamlMetadataEndpointtakes it and the generator;SamlResponseGeneratoris registered transient over the signing service, and itsCreateSignedResponseAsync/CreateSignedFailureResponseAsyncare 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, theDestinationan inbound message must name, the return URL and the metadata together, read per request, so aConfigure<SamlOptions>afterAddSamlmoves 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,EnableSamlMetadataEndpointare off by default and switched on byAddSaml, as Duende's are; switch one off with aConfigure<IdentityServerOptions>afterAddSaml. The sign-on switch closes the IdP-initiated door too. Every path is listed on endpoints.- Lifetimes —
SigninStateLifetime(15 minutes) andLogoutSessionLifetime(5) are written onto each stored state asExpiresAt, 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'sSubject/NameID),IdP(anIDPListnaming exactly one provider — the bundled page sends the user straight to the external scheme of that name),RelayStateandIsIdpInitiated.
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;
}