--- name: identityserver-saml description: "Configuring Duende IdentityServer as a SAML 2.0 Identity Provider (IdP): service provider registration, SSO and SLO flows, claim mappings, extensibility interfaces, and production deployment patterns." invocable: false --- # SAML 2.0 Identity Provider ## When to Use This Skill - Setting up IdentityServer as a SAML 2.0 Identity Provider (IdP) - Registering SAML Service Providers with the `SamlServiceProvider` model - Configuring SP-initiated SSO and Single Logout (SLO) flows - Customizing claim-to-attribute mappings via `ClaimMappings` or extensibility interfaces - Implementing production SP stores (EF Core, custom `ISamlServiceProviderStore`) - Extending SAML behavior (custom NameID generation, signing, metadata, multi-tenant issuer) - Linking an external SAML IdP as a federated authentication source (SP mode) ## Core Principles - SAML 2.0 IdP support is **built into Duende.IdentityServer** (v8.0+) — no separate NuGet package - Requires **Standard (add-on), Advanced, or Custom Edition** license - SP-initiated SSO is the default; IdP-initiated SSO is opt-in per service provider - `SignAssertion` is the default signing behavior; `SignResponse` is recommended for most deployments - Use EF Core stores for service providers in production; in-memory is for development only - Front-channel SLO uses iframes (not redirect chains); partial logout is expected behavior - The claim pipeline flows: AllowedScopes → RequestedClaimTypes → ClaimMappings Docs: https://docs.duendesoftware.com/identityserver/saml ## Setup ```csharp builder.Services.AddIdentityServer() .AddInMemoryClients(Config.Clients) .AddInMemoryIdentityResources(Config.IdentityResources) .AddSaml() .AddInMemorySamlServiceProviders(Config.SamlServiceProviders); ``` Update the login page to call `DenyAuthenticationAsync` for SAML cancellation support (when user cancels login during a SAML flow). ## Endpoints | Endpoint | Path | Purpose | |----------|------|---------| | Metadata | `/Saml2` | IdP metadata (certificates, endpoints, NameID formats) | | Sign-in | `/Saml2/SSO` | Receives AuthnRequest (GET/POST) | | Sign-in Callback | `/Saml2/SSO/Callback` | Builds SAML Response after authentication | | Logout | `/Saml2/SLO` | Handles LogoutRequest/LogoutResponse | | Logout Callback | `/Saml2/SLO/Callback` | Completes SLO round-trip | Paths are customizable via `SamlOptions.Endpoints`. ### Profile Active Check `IProfileService.IsActiveAsync` is called on every SSO request, including when the user already has an active session. If `IsActive` returns `false`: passive requests (`IsPassive=true`) receive a SAML `NoPassive` error response; all other requests are redirected to the login page. This is the recommended mechanism for blocking disabled or locked accounts without waiting for session expiry. ### Observability All SAML endpoints emit audit events and OpenTelemetry telemetry counters. SSO and SLO endpoints participate in distributed tracing via the `Duende.IdentityServer` activity source. See docs for SAML audit events and `TelemetryMetricsCounters.SamlSso`. ## SamlServiceProvider Model ```csharp new SamlServiceProvider { // Required EntityId = "https://sp.example.com", DisplayName = "Example SP", // ACS endpoints (HTTP-POST only, indexed) AssertionConsumerServiceUrls = [ new IndexedEndpoint { Location = "https://sp.example.com/acs", Binding = SamlBinding.HttpPost, Index = 0, IsDefault = true } ], // Single Logout (HTTP-Redirect only) SingleLogoutServiceUrls = [ new SamlEndpointType { Location = "https://sp.example.com/saml/slo", Binding = SamlBinding.HttpRedirect } ], // Security SigningBehavior = SamlSigningBehavior.SignAssertion, RequireSignedAuthnRequests = true, Certificates = [ new ServiceProviderCertificate { Certificate = spCert, Use = KeyUse.Signing } ], // Claims (identity resources the SP can access) AllowedScopes = ["openid", "profile", "email"], RequestedClaimTypes = ["email", "name"], // optional narrowing ClaimMappings = new Dictionary { ["email"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", ["name"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" }, // NameID DefaultNameIdFormat = SamlNameIdFormat.EmailAddress, // IdP-Initiated SSO (opt-in) AllowIdpInitiated = false, // Lifecycle / display Enabled = true, // false → reject all requests from this SP Description = "Optional notes", // human-readable, not sent in SAML responses // Per-SP overrides (null = fall back to SamlOptions global default) AssertionLifetime = TimeSpan.FromMinutes(5), // overrides SamlOptions.DefaultAssertionLifetime EmailNameIdClaimType = "email", // overrides SamlOptions.EmailNameIdClaimType RequireSignedLogoutResponses = true, // overrides SamlOptions.RequireSignedLogoutResponses AllowedSignatureAlgorithms = ["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"], // null → IdP default AuthnContextMappings = new Dictionary // overrides SamlOptions.DefaultAuthnContextMappings { ["pwd"] = "urn:oasis:names:tc:SAML:2.0:ac:classes:Password", ["mfa"] = "urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract" } } ``` ### Claim Pipeline ``` AllowedScopes (identity resources) → filters available claim types ↓ RequestedClaimTypes (optional narrowing) → selects specific claims ↓ ClaimMappings (OIDC claim name → SAML attribute URI) → output as ``` Use `SamlOptions.DefaultClaimMappings` for global defaults; per-SP `ClaimMappings` override them. ## Configuration (SamlOptions) ```csharp builder.Services.AddIdentityServer() .AddSaml(saml => { saml.EntityId = "https://idp.example.com/Saml2"; // default: {host}/Saml2 saml.EntityIdPath = "/Saml2"; // path appended to host URL to form default EntityId saml.WantAuthnRequestsSigned = true; // default: true saml.RequireSignedLogoutResponses = true; // default: true saml.DefaultSigningBehavior = SamlSigningBehavior.SignAssertion; saml.DefaultClockSkew = TimeSpan.FromMinutes(5); saml.DefaultRequestMaxAge = TimeSpan.FromMinutes(5); saml.DefaultAssertionLifetime = TimeSpan.FromMinutes(5); saml.SupportedNameIdFormats = [SamlNameIdFormat.EmailAddress, SamlNameIdFormat.Unspecified]; saml.MaxRelayStateLength = 80; // SAML spec requirement saml.MaxMessageSize = 1_048_576; // max chars of inbound SAML messages (default: 1 MB) // Session/state lifetimes saml.SigninStateLifetime = TimeSpan.FromMinutes(15); // how long sign-in request state is retained saml.LogoutSessionLifetime = TimeSpan.FromMinutes(5); // how long SLO session tracking state is retained // NameID claim type for email-format NameIDs (default: "email") saml.EmailNameIdClaimType = "email"; // Global claim mappings saml.DefaultClaimMappings = new Dictionary { ["name"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", ["email"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", ["role"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/role" }; // AuthnContext mappings (acr/amr → SAML AuthnContext URIs) saml.DefaultAuthnContextMappings = new Dictionary { ["pwd"] = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" }; // Optional error inspector callbacks for debugging interoperability issues // These can inspect or suppress parse errors on inbound SAML messages saml.AuthnRequestErrorInspector = (context, exception) => { /* inspect/suppress */ }; saml.LogoutRequestErrorInspector = (context, exception) => { /* inspect/suppress */ }; saml.LogoutResponseErrorInspector = (context, exception) => { /* inspect/suppress */ }; }); ``` ### Metadata Options ```csharp builder.Services.AddIdentityServer() .AddSaml(saml => { saml.Metadata.CacheDuration = TimeSpan.FromHours(12); saml.Metadata.ExpiryDuration = TimeSpan.FromDays(5); }); ``` ### Endpoint Options | Property | Default | Description | |----------|---------|-------------| | `SingleSignOnServicePath` | `"/Saml2/SSO"` | Path for the SSO endpoint | | `SingleSignOnServiceBindings` | `[HttpRedirect, HttpPost]` | Bindings advertised in metadata (not which the endpoint accepts) | | `SingleSignOnCallbackPath` | `"/Saml2/SSO/Callback"` | Internal callback path after user authenticates | | `SingleLogoutServicePath` | `"/Saml2/SLO"` | Path for the SLO endpoint | | `SingleLogoutServiceBindings` | `[HttpRedirect, HttpPost]` | Bindings advertised in metadata for SLO | | `SingleLogoutCallbackPath` | `"/Saml2/SLO/Callback"` | Internal callback path for SLO completion | | `StateIdParameterName` | `"samlStateId"` | Query string param name for the SAML sign-in state ID | ```csharp builder.Services.AddIdentityServer() .AddSaml(saml => { saml.Endpoints.SingleSignOnServicePath = "/Saml2/SSO"; saml.Endpoints.SingleLogoutServicePath = "/Saml2/SLO"; }); ``` ## SAML Signing Keys (X.509) SAML signing **requires an X.509 certificate**. OIDC and SAML share the same signing credentials; rotation timing is governed by Automatic Key Management `PropagationTime` and `RetentionDuration`. - **Automatic Key Management (RSA)**: auto-generated RSA keys are auto-wrapped into a self-signed X.509 container. You do **not** need `UseX509Certificate` just to enable SAML. - **Manual / raw RSA keys — including `AddDeveloperSigningCredential()`**: **cannot** be auto-wrapped. Register an X.509 certificate **with a private key** instead. - The default SAML signing service is **RSA-only**. `UseX509Certificate` is **not** supported for EC keys — implement a custom `ISamlSigningService` for EC (or HSM/Key Vault) scenarios. ## Service Provider Stores ### In-Memory (Development) ```csharp .AddInMemorySamlServiceProviders(new[] { new SamlServiceProvider { EntityId = "...", /* ... */ } }); ``` ### EF Core (Production — Recommended) ```csharp .AddConfigurationStore(options => { options.ConfigureDbContext = b => b.UseSqlServer(connectionString); }) ``` Run EF migrations: `dotnet ef migrations add Update_DuendeIdentityServer_v8_0` ### Custom Store ```csharp .AddSamlServiceProviderStore() public class MySamlSpStore : ISamlServiceProviderStore { public Task FindByEntityIdAsync( string entityId, CancellationToken ct) { /* lookup from your backend */ } public IAsyncEnumerable GetAllSamlServiceProvidersAsync( CancellationToken ct) { /* stream all SPs */ } } ``` > **Note — Operational store auto-registration**: `AddOperationalStore()` automatically registers EF Core implementations of **both** `ISamlSigninStateStore` **and** `ISamlLogoutSessionStore`. When using the EF operational store, these do not need to be registered separately. ### Caching & Validation ```csharp // Add HybridCache layer to any custom store .AddSamlServiceProviderStoreCache() ``` Cache duration is controlled by `IdentityServerOptions.Caching.SamlServiceProviderStoreExpiration` (default: 15 minutes): ```csharp builder.Services .AddIdentityServer(options => { options.Caching.SamlServiceProviderStoreExpiration = TimeSpan.FromMinutes(30); }) .AddSaml() .AddSamlServiceProviderStoreCache(); ``` All stores are automatically wrapped with `ValidatingSamlServiceProviderStore` that checks: EntityId required, ≥1 ACS URL (HTTP-POST only), ≥1 AllowedScopes, positive lifetimes. Invalid SPs are treated as non-existent. ## Single Logout (SLO) SLO uses **front-channel logout via iframes** (not redirect chains): 1. SP sends LogoutRequest to `/Saml2/SLO` 2. IdentityServer ends local session 3. Renders iframes sending LogoutRequests to all other active SPs 4. Collects LogoutResponses from SPs 5. Sends final LogoutResponse to originating SP **Key points:** - Partial logout is normal (some SPs may not respond) - User must stay on logout page for iframes to complete - Use `ISamlLogoutSessionStore` for distributed deployments (tracks which SPs have active sessions) - Short session lifetimes serve as SLO fallback ## IdP-Initiated SSO > ⚠️ **CSRF Warning**: IdP-initiated SSO is inherently vulnerable to CSRF. There is no SAML-compliant way to implement it without CSRF exposure. Only enable it after careful security review. **Recommended alternative**: Mimic OIDC third-party initiated login — create a dedicated SP endpoint that accepts a target application hint and redirects the user to the IdP with a standard SP-initiated AuthnRequest. This avoids the CSRF risk entirely. **Enabling per SP**: If IdP-initiated SSO is genuinely required, set `AllowIdpInitiated = true` on the `SamlServiceProvider`. **No built-in endpoint**: There is no built-in IdP-initiated SSO endpoint. Implement your own Razor Page or controller and inject `IIdpInitiatedSsoService`: ```csharp // Key method on IIdpInitiatedSsoService: Task CreateResponseAsync( HttpContext httpContext, string spEntityId, string? relayState, CancellationToken ct); ``` Call `CreateResponseAsync` from your custom endpoint to generate and return the SAML Response to the SP. The SP must have `AllowIdpInitiated = true`; otherwise the call will fail. ## Extensibility | Interface | Purpose | |-----------|---------| | `ISamlNameIdGenerator` | Custom NameID value derivation (e.g., from employee_id claim) | | `ISamlSigningService` | HSM/Key Vault signing certificate integration | | `ISaml2MetadataResponseGenerator` | Custom metadata extensions (org info, federation) | | `ISaml2IssuerNameService` | Multi-tenant: dynamic entity ID per tenant | | `ISaml2SsoInteractionResponseGenerator` | Custom step-up auth logic during SSO | | `ISaml2SsoResponseGenerator` | Custom SAML Response generation | | `ISamlLogoutNotificationService` | Selective SLO targeting; returns `SamlLogoutNotificationResult` (`Messages`: collection of `SamlLogoutRequestContext`, `SkippedCount`: int) | | `ISaml2SloResponseGenerator` | Custom SLO `LogoutResponse` generation (success vs partial logout) | | `ISamlLogoutSessionStore` | Distributed SLO state (Redis, EF Core); key method: `TryRecordResponseAsync(string requestId, string issuer, bool success, CancellationToken ct)`; `SamlLogoutSession` has `SkippedSpCount` (int), `ExpiresAtUtc` (DateTime), `ExpectedResponses` dictionary | | `ISaml2FrontChannelLogoutRequestBuilder` | Custom logout request structure; `BuildLogoutRequestAsync` returns `SamlLogoutRequestContext` (wraps outbound message + `RequestId` + `SpEntityId` for response correlation) | | `ISamlResourceResolver` | Dynamic scope filtering per SP | | `IIdpInitiatedSsoService` | Portal "My Apps" dashboard for IdP-initiated flows | | `IAuthnRequestValidator` | Custom SP access rules, IP/time-based controls | | `ILogoutRequestValidator` | Custom SLO authorization rules | | `ISamlSigninStateStore` | Distributed sign-in state (for multi-node deployments); methods include `UpdateSigninRequestStateAsync` | | `ISamlServiceProviderConfigurationValidator` | Custom SP config validation rules | > **DI ordering is NOT required**: Custom SAML services do **not** need to be registered before `AddSaml()`. Defaults are registered with `TryAdd*` (e.g. `TryAddScoped`), so a custom scoped registration takes precedence regardless of order. > **State serializer & `Extensions`**: The default `ISamlSigninStateSerializer` **ignores** the `Extensions` property. To persist custom SAML extension data across the sign-in round-trip, implement a custom serializer. ### Example: Custom NameID Generator ```csharp public class EmployeeNameIdGenerator : ISamlNameIdGenerator { public Task GenerateAsync( NameIdGenerationContext context, CancellationToken ct) { var employeeId = context.Subject.FindFirst("employee_id")?.Value; if (employeeId is null) return Task.FromResult(NameIdGenerationResult.Failure( StatusCodes.Responder, StatusCodes.UnknownPrincipal, "Employee ID claim not found.")); return Task.FromResult(NameIdGenerationResult.Success( new NameId(employeeId, context.ResolvedFormat))); } } ``` ### SAML Authentication Context in Login UI Inject `IIdentityServerInteractionService` and call `GetAuthenticationContextAsync(returnUrl)`; pattern-match the result to `SamlAuthenticationContext` for customizing login flows per SP. `SamlAuthenticationContext` properties: - `ServiceProvider` — the SP that initiated the request - `IdP` (string?) — IdP entity ID from `Scoping`, null if multiple IdPs listed - `LoginHint` (string?) — login hint from NameID in AuthnRequest - `Tenant` (string?) — tenant identifier from RequestedAuthnContext - `PromptModes` — derived from `ForceAuthn` and `IsPassive` flags - `RelayState` (string?) — relay state from the AuthnRequest - `IsIdpInitiated` (bool) — whether this is an IdP-initiated SSO flow - `RequestedAuthnContext` — authentication context requirements from the SP - `StateId` (Guid) — identifier for sign-in state entry; needed when calling `DenyAuthenticationAsync` ## Using IdentityServer as a SAML Service Provider (SP Mode) IdentityServer can consume SAML assertions from external IdPs via federation. Add a SAML authentication handler and configure it as an external provider in IdentityServer's login UI — same pattern as any external authentication scheme. ### Native SAML SP handler (`AddSamlServiceProvider`) Register the built-in Duende SAML SP handler as an external scheme feeding the IdentityServer external cookie: ```csharp builder.Services.AddAuthentication() .AddSamlServiceProvider("corporate-idp", options => { options.SpEntityId = "https://sp.example.com"; options.IdpEntityId = "https://idp.example.com"; options.SingleSignOnServiceUrl = "https://idp.example.com/sso"; options.SigningCertificatesBase64 = [""]; // LIST → supports IdP cert rollover options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; // IdP-initiated (unsolicited) SSO — opt-in options.AllowUnsolicitedAuthnResponse = true; // default false options.IdpInitiatedCallbackUrl = "/ExternalLogin/Callback"; // REQUIRED when above is true }); ``` | Property | Default | Notes | |----------|---------|-------| | `AllowUnsolicitedAuthnResponse` | `false` | Accept IdP-initiated (unsolicited) `AuthnResponse`. When `true`, you **must** set `IdpInitiatedCallbackUrl`. | | `IdpInitiatedCallbackUrl` | `null` | **Required** when unsolicited responses are allowed. Relative path (e.g. `/ExternalLogin/Callback`) or absolute http/https URL; redirect target after processing an unsolicited response. | | `MaxRelayStateLength` | `1024` | Max bytes of RelayState persisted in auth properties; oversized values are **silently dropped** to avoid cookie bloat. | **Callback handling (IdP-initiated):** authenticate against `IdentityServerConstants.ExternalCookieAuthenticationScheme`. The IdP-supplied RelayState surfaces at `AuthenticationProperties.Items["relayState"]` (only when ≤ `MaxRelayStateLength`); `"scheme"` and `"returnUrl"` items are also populated. > ⚠️ **Security**: treat RelayState as **untrusted input**. Always validate it before using it as a redirect target. **Dynamic providers**: the dynamic `SamlProvider` model gains the same `AllowUnsolicitedAuthnResponse` and `IdpInitiatedCallbackUrl` properties. Note the dynamic model uses `SigningCertificateBase64` (**singular** string), whereas the static handler uses `SigningCertificatesBase64` (**list**, supports rollover). ### Third-party handlers Alternatively, use a third-party handler (e.g., `Sustainsys.Saml2` or `ITfoxtec.Identity.Saml2`) configured as an external scheme. For step-by-step setup instructions, see the official docs: https://docs.duendesoftware.com/identityserver/ui/login/saml-provider/ > **Managing many SAML IdPs?** For scenarios with a large or changing set of external SAML identity providers, consider using **dynamic providers** instead of static registration. Dynamic providers allow you to manage IdP configurations at runtime without redeployment. See: https://docs.duendesoftware.com/identityserver/ui/login/dynamicproviders/#saml-providers ## Common Anti-Patterns ❌ Enabling `AllowIdpInitiated` on all SPs — only enable where explicitly required (less secure) ❌ Using `DoNotSign` outside of local testing ❌ Using in-memory SP stores in production ❌ Omitting `AllowedScopes` — SP gets no claims in the assertion ❌ Configuring ACS URLs with HTTP-Redirect binding (only HTTP-POST is supported) ## Common Pitfalls 1. **Edition requirement**: `AddSaml()` requires Standard (add-on), Advanced, or Custom Edition license. 2. **ACS binding**: Only HTTP-POST is supported for AssertionConsumerServiceUrls. HTTP-Redirect will fail validation. 3. **Clock skew**: Default 5 minutes. Increase if SPs report "response not yet valid" errors. 4. **Partial SLO**: Front-channel logout via iframes means some SPs may not respond. This is expected — don't treat it as an error. 5. **DenyAuthenticationAsync**: Login page must call this for SAML cancellation. Without it, users get stuck if they cancel. 6. **Operational stores**: For multi-node deployments, configure `ISamlSigninStateStore` and `ISamlLogoutSessionStore` (e.g., EF Core, Redis). Without them, SSO/SLO state is lost across nodes. 7. **Certificate rotation**: Metadata is cached (default 12h). SPs may not pick up new signing certs until cache expires. 8. **ClaimMappings vs AllowedScopes**: If `AllowedScopes` doesn't include a resource containing a claim type, that claim won't reach `ClaimMappings`. ## Related Skills - `identityserver-configuration` — IdentityServer host configuration and options - `identityserver-stores` — Persistent store patterns (EF Core, custom stores) - `identity-security-hardening` — Key rotation, HTTPS enforcement - `identityserver-ui-flows` — Login/logout UI flows that SAML integrates with - `identityserver-upgrade-v7-to-v8` — Migration guide including SAML EF migrations