diff --git a/identity-server/aspire/AppHosts/All/All.csproj b/identity-server/aspire/AppHosts/All/All.csproj index 77ca2773b..454b3eab2 100644 --- a/identity-server/aspire/AppHosts/All/All.csproj +++ b/identity-server/aspire/AppHosts/All/All.csproj @@ -47,6 +47,7 @@ + diff --git a/identity-server/aspire/AppHosts/All/Program.cs b/identity-server/aspire/AppHosts/All/Program.cs index cf61a4c57..fd091b320 100644 --- a/identity-server/aspire/AppHosts/All/Program.cs +++ b/identity-server/aspire/AppHosts/All/Program.cs @@ -118,6 +118,7 @@ void ConfigureWebClients() RegisterClientIfEnabled("mvc-hybrid-backchannel"); RegisterClientIfEnabled("mvc-jar-jwt"); RegisterClientIfEnabled("mvc-jar-uri-jwt"); + RegisterClientIfEnabled("mvc-saml"); RegisterClientIfEnabled("web"); RegisterTemplateIfEnabled("template-is", 7001); RegisterTemplateIfEnabled("template-is-empty", 7002); diff --git a/identity-server/aspire/AppHosts/All/appsettings.json b/identity-server/aspire/AppHosts/All/appsettings.json index 2951eb626..e58383f61 100644 --- a/identity-server/aspire/AppHosts/All/appsettings.json +++ b/identity-server/aspire/AppHosts/All/appsettings.json @@ -42,6 +42,7 @@ "MvcHybridBackChannel": true, "MvcJarJwt": true, "MvcJarUriJwt": true, + "MvcSaml": true, "Web": true, "WindowsConsoleSystemBrowser": false }, diff --git a/identity-server/clients/src/MvcSaml/Controllers/HomeController.cs b/identity-server/clients/src/MvcSaml/Controllers/HomeController.cs new file mode 100644 index 000000000..0a5eb9767 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Controllers/HomeController.cs @@ -0,0 +1,23 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Sustainsys.Saml2.AspNetCore2; + +namespace MvcSaml.Controllers; + +public class HomeController : Controller +{ + [AllowAnonymous] + public IActionResult Index() => View(); + + public IActionResult Secure() => View(); + + public IActionResult Logout() => SignOut( + new AuthenticationProperties { RedirectUri = "/" }, + Saml2Defaults.Scheme, + CookieAuthenticationDefaults.AuthenticationScheme); +} diff --git a/identity-server/clients/src/MvcSaml/HostingExtensions.cs b/identity-server/clients/src/MvcSaml/HostingExtensions.cs new file mode 100644 index 000000000..e62ea2344 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/HostingExtensions.cs @@ -0,0 +1,75 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Sustainsys.Saml2; +using Sustainsys.Saml2.AspNetCore2; +using Sustainsys.Saml2.Configuration; +using Sustainsys.Saml2.Metadata; + +namespace MvcSaml; + +internal static class HostingExtensions +{ + public static WebApplication ConfigureServices(this WebApplicationBuilder builder) + { + // The IdentityServer base URL is injected by Aspire at runtime via the "is-host" environment variable. + var idpBaseUrl = builder.Configuration["is-host"] + ?? throw new InvalidOperationException("is-host configuration is required"); + + builder.Services.AddControllersWithViews(); + + builder.Services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = Saml2Defaults.Scheme; + }) + .AddCookie(options => + { + options.Cookie.Name = "mvcsaml"; + }) + .AddSaml2(options => + { + // SP entity ID — must match the EntityId registered in the IdP's SamlServiceProviders config. + // By convention, Sustainsys uses /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:

+ + + + + + + + + + @foreach (var claim in User.Claims) + { + + + + + } + +
TypeValue
@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 @@ +