/Saml2 as the entity ID.
+ options.SPOptions.EntityId = new EntityId("https://localhost:44350/Saml2");
+
+ // 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;
+
+ // Load the IdP configuration from the metadata endpoint published by IdentityServer.
+ // This automatically picks up signing certificates, endpoints, and capabilities.
+ options.IdentityProviders.Add(
+ new IdentityProvider(new EntityId(idpBaseUrl), options.SPOptions)
+ {
+ MetadataLocation = $"{idpBaseUrl}/saml/metadata",
+ LoadMetadata = true
+ });
+ });
+
+ builder.Services.AddAuthorization();
+
+ return builder.Build();
+ }
+
+ public static WebApplication ConfigurePipeline(this WebApplication app)
+ {
+ app.UseDeveloperExceptionPage();
+ app.UseHttpsRedirection();
+ app.UseStaticFiles();
+
+ app.UseRouting();
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
+ app.MapDefaultControllerRoute()
+ .RequireAuthorization();
+
+ return app;
+ }
+}
diff --git a/identity-server/clients/src/MvcSaml/MvcSaml.csproj b/identity-server/clients/src/MvcSaml/MvcSaml.csproj
new file mode 100644
index 000000000..526c3941c
--- /dev/null
+++ b/identity-server/clients/src/MvcSaml/MvcSaml.csproj
@@ -0,0 +1,23 @@
+
+
+
+ 3a8b2c1d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/identity-server/clients/src/MvcSaml/Program.cs b/identity-server/clients/src/MvcSaml/Program.cs
new file mode 100644
index 000000000..ff53ab8ef
--- /dev/null
+++ b/identity-server/clients/src/MvcSaml/Program.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using MvcSaml;
+using Serilog;
+using Serilog.Events;
+
+Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .MinimumLevel.Override("MvcSaml", LogEventLevel.Debug)
+ .Enrich.FromLogContext()
+ .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}")
+ .CreateLogger();
+
+try
+{
+ var builder = WebApplication
+ .CreateBuilder(args);
+
+ builder
+ .AddServiceDefaults();
+
+ builder
+ .ConfigureServices()
+ .ConfigurePipeline()
+ .Run();
+}
+catch (Exception ex)
+{
+ Log.Fatal(ex, messageTemplate: "Unhandled exception");
+}
+finally
+{
+ Log.Information(messageTemplate: "Shut down complete");
+ Log.CloseAndFlush();
+}
diff --git a/identity-server/clients/src/MvcSaml/Properties/launchSettings.json b/identity-server/clients/src/MvcSaml/Properties/launchSettings.json
new file mode 100644
index 000000000..f2d96e3d3
--- /dev/null
+++ b/identity-server/clients/src/MvcSaml/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "Host": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:44350",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/identity-server/clients/src/MvcSaml/Views/Home/Index.cshtml b/identity-server/clients/src/MvcSaml/Views/Home/Index.cshtml
new file mode 100644
index 000000000..2eec34068
--- /dev/null
+++ b/identity-server/clients/src/MvcSaml/Views/Home/Index.cshtml
@@ -0,0 +1,9 @@
+@{
+ ViewData["Title"] = "Home";
+}
+
+MvcSaml Sample
+This sample demonstrates SAML 2.0 single sign-on via Duende IdentityServer using the Sustainsys.Saml2 library.
+
+ Sign in
+
diff --git a/identity-server/clients/src/MvcSaml/Views/Home/Secure.cshtml b/identity-server/clients/src/MvcSaml/Views/Home/Secure.cshtml
new file mode 100644
index 000000000..a17a3532e
--- /dev/null
+++ b/identity-server/clients/src/MvcSaml/Views/Home/Secure.cshtml
@@ -0,0 +1,28 @@
+@{
+ ViewData["Title"] = "Secure";
+}
+
+Authenticated User
+You are signed in via SAML 2.0. Your claims:
+
+
+
+
+ | Type |
+ Value |
+
+
+
+ @foreach (var claim in User.Claims)
+ {
+
+ | @claim.Type |
+ @claim.Value |
+
+ }
+
+
+
+
+ Logout
+
diff --git a/identity-server/clients/src/MvcSaml/Views/Shared/_Layout.cshtml b/identity-server/clients/src/MvcSaml/Views/Shared/_Layout.cshtml
new file mode 100644
index 000000000..e3bd55e13
--- /dev/null
+++ b/identity-server/clients/src/MvcSaml/Views/Shared/_Layout.cshtml
@@ -0,0 +1,36 @@
+
+
+
+
+
+ @ViewData["Title"] - MvcSaml
+
+
+
+
+
+ @RenderBody()
+
+
+
diff --git a/identity-server/clients/src/MvcSaml/Views/_ViewImports.cshtml b/identity-server/clients/src/MvcSaml/Views/_ViewImports.cshtml
new file mode 100644
index 000000000..d9abef7ec
--- /dev/null
+++ b/identity-server/clients/src/MvcSaml/Views/_ViewImports.cshtml
@@ -0,0 +1,2 @@
+@using MvcSaml
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/identity-server/clients/src/MvcSaml/Views/_ViewStart.cshtml b/identity-server/clients/src/MvcSaml/Views/_ViewStart.cshtml
new file mode 100644
index 000000000..820a2f6e0
--- /dev/null
+++ b/identity-server/clients/src/MvcSaml/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_Layout";
+}
diff --git a/identity-server/hosts/Main10/IdentityServerExtensions.cs b/identity-server/hosts/Main10/IdentityServerExtensions.cs
index f42ddb663..f2dc047da 100644
--- a/identity-server/hosts/Main10/IdentityServerExtensions.cs
+++ b/identity-server/hosts/Main10/IdentityServerExtensions.cs
@@ -1,12 +1,12 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
+using Duende.IdentityServer.Hosts.Shared.Configuration;
using System.Security.Cryptography.X509Certificates;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Configuration.RequestProcessing;
-using Duende.IdentityServer.Hosts.Shared.Configuration;
using Duende.IdentityServer.Hosts.Shared.Customization;
using Microsoft.AspNetCore.Authentication.Certificate;
using Microsoft.IdentityModel.Tokens;
@@ -78,7 +78,9 @@ internal static class IdentityServerExtensions
Scope = "openid profile"
}
])
- .AddLicenseSummary();
+ .AddLicenseSummary()
+ .AddSaml()
+ .AddInMemorySamlServiceProviders(SamlServiceProviders.Get());
builder.Services.AddIdentityServerConfiguration(opt => { })
.AddInMemoryClientConfigurationStore();
diff --git a/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs b/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs
new file mode 100644
index 000000000..496697bc6
--- /dev/null
+++ b/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer.Models;
+
+namespace Duende.IdentityServer.Hosts.Shared.Configuration;
+
+public static class SamlServiceProviders
+{
+ public static IEnumerable Get() =>
+ [
+ new SamlServiceProvider
+ {
+ EntityId = "https://localhost:44350/Saml2",
+ DisplayName = "MvcSaml Sample Client",
+ Enabled = true,
+ // ACS URL follows the Sustainsys.Saml2 convention: /Saml2/Acs
+ AssertionConsumerServiceUrls = [new Uri("https://localhost:44350/Saml2/Acs")],
+ // 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
+ }
+ ];
+}
diff --git a/identity-server/hosts/Shared/Customization/HostProfileService.cs b/identity-server/hosts/Shared/Customization/HostProfileService.cs
index 1508e0761..d0c7a1367 100644
--- a/identity-server/hosts/Shared/Customization/HostProfileService.cs
+++ b/identity-server/hosts/Shared/Customization/HostProfileService.cs
@@ -14,7 +14,7 @@ public class HostProfileService(TestUserStore users, ILogger x.ParsedName == "transaction");
+ var transaction = context.RequestedResources?.ParsedScopes.FirstOrDefault(x => x.ParsedName == "transaction");
if (transaction?.ParsedParameter != null)
{
context.IssuedClaims.Add(new Claim("transaction_id", transaction.ParsedParameter));
diff --git a/identity-server/identity-server.slnf b/identity-server/identity-server.slnf
index 51f061c3a..03cbaf756 100644
--- a/identity-server/identity-server.slnf
+++ b/identity-server/identity-server.slnf
@@ -40,6 +40,7 @@
"identity-server\\clients\\src\\MvcHybridBackChannel\\MvcHybridBackChannel.csproj",
"identity-server\\clients\\src\\MvcJarJwt\\MvcJarJwt.csproj",
"identity-server\\clients\\src\\MvcJarUriJwt\\MvcJarUriJwt.csproj",
+ "identity-server\\clients\\src\\MvcSaml\\MvcSaml.csproj",
"identity-server\\clients\\src\\Web\\Web.csproj",
"identity-server\\clients\\src\\WindowsConsoleSystemBrowser\\WindowsConsoleSystemBrowser.csproj",
"identity-server\\hosts\\AspNetIdentity10\\Host.AspNetIdentity10.csproj",
diff --git a/products.slnx b/products.slnx
index 0740d8b8d..3707b838f 100644
--- a/products.slnx
+++ b/products.slnx
@@ -132,6 +132,7 @@
+