← Files Duende SkillsARCHIVED FILE

skills/identityserver-saml/SKILL.md

22.4 KB · Oct 5, 2026 · 18:31 UTC

↓ Download file

---
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<string, string>
    {
        ["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<string, string>  // 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 <saml:Attribute>
```

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<string, string>
        {
            ["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<string, string>
        {
            ["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<MySamlSpStore>()

public class MySamlSpStore : ISamlServiceProviderStore
{
    public Task<SamlServiceProvider?> FindByEntityIdAsync(
        string entityId, CancellationToken ct)
    { /* lookup from your backend */ }

    public IAsyncEnumerable<SamlServiceProvider> 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<MySamlSpStore>()
```

Cache duration is controlled by `IdentityServerOptions.Caching.SamlServiceProviderStoreExpiration` (default: 15 minutes):

```csharp
builder.Services
    .AddIdentityServer(options =>
    {
        options.Caching.SamlServiceProviderStoreExpiration = TimeSpan.FromMinutes(30);
    })
    .AddSaml()
    .AddSamlServiceProviderStoreCache<MySamlServiceProviderStore>();
```

All stores are automatically wrapped with `ValidatingSamlServiceProviderStore<T>` 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<IdpInitiatedSsoResult> 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<NameIdGenerationResult> 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 = ["<base64>"];   // 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

SHA-256: bdda406ec1f1181e8668aad0f69b80d7827fdcf884335d4ac6d27040bbadc68b