From e178273d94a1f3dbf3e2d58e54b514d17565ef3a Mon Sep 17 00:00:00 2001 From: Brett Hazen Date: Mon, 23 Feb 2026 08:33:03 -0600 Subject: [PATCH] Add directions for configuring cert for SAML sample --- .../clients/src/MvcSaml/.gitignore | 1 + .../clients/src/MvcSaml/HostingExtensions.cs | 37 ++++++++++++++++-- .../clients/src/MvcSaml/MvcSaml.csproj | 6 +++ identity-server/clients/src/MvcSaml/README.md | 39 +++++++++++++++++++ .../Configuration/SamlServiceProviders.cs | 37 +++++++++++++++++- 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 identity-server/clients/src/MvcSaml/.gitignore create mode 100644 identity-server/clients/src/MvcSaml/README.md diff --git a/identity-server/clients/src/MvcSaml/.gitignore b/identity-server/clients/src/MvcSaml/.gitignore new file mode 100644 index 000000000..4361d819f --- /dev/null +++ b/identity-server/clients/src/MvcSaml/.gitignore @@ -0,0 +1 @@ +saml-sp.pfx diff --git a/identity-server/clients/src/MvcSaml/HostingExtensions.cs b/identity-server/clients/src/MvcSaml/HostingExtensions.cs index e62ea2344..134fcbcc7 100644 --- a/identity-server/clients/src/MvcSaml/HostingExtensions.cs +++ b/identity-server/clients/src/MvcSaml/HostingExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Authentication.Cookies; using Sustainsys.Saml2; using Sustainsys.Saml2.AspNetCore2; @@ -11,6 +12,12 @@ namespace MvcSaml; internal static class HostingExtensions { + // The SP certificate is used to sign AuthnRequests and LogoutRequests sent to the IdP. + // Generate it with the commands in README.md, then restart both this app and the IdP host. + // Without the certificate, AuthnRequest signing and SP-initiated single logout are unavailable. + private const string SpCertificatePath = "saml-sp.pfx"; + private const string SpCertificatePassword = "changeit"; + public static WebApplication ConfigureServices(this WebApplicationBuilder builder) { // The IdentityServer base URL is injected by Aspire at runtime via the "is-host" environment variable. @@ -19,6 +26,8 @@ internal static class HostingExtensions builder.Services.AddControllersWithViews(); + var spCert = LoadSpCertificate(); + builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; @@ -37,9 +46,19 @@ internal static class HostingExtensions // Best practice: require the IdP to sign assertions. options.SPOptions.WantAssertionsSigned = true; - // Best practice: the SP does not sign AuthnRequests for this sample (no SP cert needed). - // Set to Always and add a ServiceCertificate to enable signed AuthnRequests. - options.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Never; + if (spCert != null) + { + // Best practice: sign AuthnRequests and LogoutRequests with the SP's certificate. + // The IdP validates the signature using the public key registered in SamlServiceProviders. + options.SPOptions.ServiceCertificates.Add(spCert); + options.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Always; + } + else + { + // No certificate available — AuthnRequest signing and SP-initiated SLO are unavailable. + // See README.md for instructions on generating saml-sp.pfx. + options.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Never; + } // Load the IdP configuration from the metadata endpoint published by IdentityServer. // This automatically picks up signing certificates, endpoints, and capabilities. @@ -72,4 +91,16 @@ internal static class HostingExtensions return app; } + + // Returns null if the certificate file has not been generated yet. + // See README.md for generation instructions. + private static X509Certificate2 LoadSpCertificate() + { + if (!File.Exists(SpCertificatePath)) + { + return null; + } + + return X509CertificateLoader.LoadPkcs12FromFile(SpCertificatePath, SpCertificatePassword); + } } diff --git a/identity-server/clients/src/MvcSaml/MvcSaml.csproj b/identity-server/clients/src/MvcSaml/MvcSaml.csproj index 526c3941c..6544cf0eb 100644 --- a/identity-server/clients/src/MvcSaml/MvcSaml.csproj +++ b/identity-server/clients/src/MvcSaml/MvcSaml.csproj @@ -4,6 +4,12 @@ 3a8b2c1d-4e5f-6a7b-8c9d-0e1f2a3b4c5d + + + PreserveNewest + + + diff --git a/identity-server/clients/src/MvcSaml/README.md b/identity-server/clients/src/MvcSaml/README.md new file mode 100644 index 000000000..53652d71d --- /dev/null +++ b/identity-server/clients/src/MvcSaml/README.md @@ -0,0 +1,39 @@ +# MvcSaml + +This client demonstrates SAML 2.0 single sign-on and single logout against IdentityServer. + +## SP Certificate + +The SP certificate is required for two SAML best practices: + +- **Signed AuthnRequests** — the SP signs every authentication request it sends to the IdP, proving the request originated from this SP and has not been tampered with. +- **SP-initiated Single Logout (SLO)** — the SP signs logout requests sent to the IdP. The IdP always requires signed logout requests. + +Without the certificate, the SSO login flow still works, but AuthnRequest signing is disabled and SP-initiated single logout will fail. + +### Generating the certificate + +Create a self-signed certificate with `openssl`: + +```sh +openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 3650 -nodes -subj "/CN=MvcSaml SP" +openssl pkcs12 -export -out saml-sp.pfx -inkey key.pem -in cert.pem -passout pass:changeit +rm key.pem cert.pem +``` + +Place `saml-sp.pfx` in this project directory (`clients/src/MvcSaml/`). The file is excluded from source control. + +After generating the certificate, **restart both this app and the IdentityServer host** so both sides pick up the new public key. + +### Why both sides need to restart + +The MvcSaml SP reads the certificate at startup to configure request signing. The IdentityServer host also reads the same certificate file at startup to register the SP's public key for signature validation. Both must be restarted whenever the certificate is regenerated. + +## Without the certificate + +| Feature | Without certificate | With certificate | +|---|---|---| +| SSO (login) | Works | Works | +| AuthnRequest signing | Disabled | Enabled (always signed) | +| SP-initiated Single Logout | Fails (unsigned logout request rejected by IdP) | Works | +| IdP-initiated Single Logout | Works (IdP signs its own requests) | Works | diff --git a/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs b/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs index 496697bc6..c72ac61a5 100644 --- a/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs +++ b/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs @@ -1,12 +1,18 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using System.Security.Cryptography.X509Certificates; using Duende.IdentityServer.Models; namespace Duende.IdentityServer.Hosts.Shared.Configuration; public static class SamlServiceProviders { + // Path to the MvcSaml SP certificate, relative to the host's working directory. + // Must match the file generated by following README.md in the MvcSaml project. + private const string SpCertificatePath = "../../clients/src/MvcSaml/saml-sp.pfx"; + private const string SpCertificatePassword = "changeit"; + public static IEnumerable Get() => [ new SamlServiceProvider @@ -16,10 +22,37 @@ public static class SamlServiceProviders Enabled = true, // ACS URL follows the Sustainsys.Saml2 convention: /Saml2/Acs AssertionConsumerServiceUrls = [new Uri("https://localhost:44350/Saml2/Acs")], + // SLO URL follows the Sustainsys.Saml2 convention: /Saml2/Logout + SingleLogoutServiceUrl = new SamlEndpointType + { + Location = new Uri("https://localhost:44350/Saml2/Logout"), + Binding = SamlBinding.HttpRedirect + }, // Sign the assertion (not the response envelope) — the Sustainsys default expectation SigningBehavior = SamlSigningBehavior.SignAssertion, - // No RequireSignedAuthnRequests — keeps the sample self-contained without distributing an SP cert - // No EncryptAssertions — plain HTTP is fine for local development + // When the SP certificate is present, require signed AuthnRequests and register the + // SP's public key so the IdP can validate the signatures. + RequireSignedAuthnRequests = SpCertificateExists(), + SigningCertificates = LoadSpSigningCertificates(), } ]; + + private static bool SpCertificateExists() => File.Exists(SpCertificatePath); + + private static ICollection LoadSpSigningCertificates() + { + if (!SpCertificateExists()) + { + return []; + } + + // For ease during development, we load the public key directly from the SP's certificate file. + // In a deployed application, you would want to distribute the public key through PKI or + // another secure channel rather than reading the SP's private key material here. +#pragma warning disable SYSLIB0057 + // Only obsolete in .NET 9+; keeping the older API while we support .NET 8. + var cert = new X509Certificate2(SpCertificatePath, SpCertificatePassword); +#pragma warning restore SYSLIB0057 + return [cert]; + } }