Base SAML test client

This commit is contained in:
Brett Hazen 2026-02-22 13:58:56 -06:00
parent ca9e3a360f
commit 481eb372e6
18 changed files with 282 additions and 3 deletions

View file

@ -47,6 +47,7 @@
<ProjectReference Include="..\..\..\clients\src\MvcHybridBackChannel\MvcHybridBackChannel.csproj" />
<ProjectReference Include="..\..\..\clients\src\MvcJarJwt\MvcJarJwt.csproj" />
<ProjectReference Include="..\..\..\clients\src\MvcJarUriJwt\MvcJarUriJwt.csproj" />
<ProjectReference Include="..\..\..\clients\src\MvcSaml\MvcSaml.csproj" />
<ProjectReference Include="..\..\..\clients\src\Web\Web.csproj" />
<ProjectReference Include="..\..\..\clients\src\WindowsConsoleSystemBrowser\WindowsConsoleSystemBrowser.csproj" />
<ProjectReference Include="..\..\..\hosts\AspNetIdentity10\Host.AspNetIdentity10.csproj" />

View file

@ -118,6 +118,7 @@ void ConfigureWebClients()
RegisterClientIfEnabled<Projects.MvcHybridBackChannel>("mvc-hybrid-backchannel");
RegisterClientIfEnabled<Projects.MvcJarJwt>("mvc-jar-jwt");
RegisterClientIfEnabled<Projects.MvcJarUriJwt>("mvc-jar-uri-jwt");
RegisterClientIfEnabled<Projects.MvcSaml>("mvc-saml");
RegisterClientIfEnabled<Projects.Web>("web");
RegisterTemplateIfEnabled<Projects.IdentityServerTemplate>("template-is", 7001);
RegisterTemplateIfEnabled<Projects.IdentityServerEmpty>("template-is-empty", 7002);

View file

@ -42,6 +42,7 @@
"MvcHybridBackChannel": true,
"MvcJarJwt": true,
"MvcJarUriJwt": true,
"MvcSaml": true,
"Web": true,
"WindowsConsoleSystemBrowser": false
},

View file

@ -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);
}

View file

@ -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 <base-url>/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;
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>3a8b2c1d-4e5f-6a7b-8c9d-0e1f2a3b4c5d</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" />
<ProjectReference Include="..\..\..\aspire\ServiceDefaults\ServiceDefaults.csproj" />
<ProjectReference Include="..\Constants\Constants.csproj" />
</ItemGroup>
</Project>

View file

@ -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();
}

View file

@ -0,0 +1,12 @@
{
"profiles": {
"Host": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:44350",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,9 @@
@{
ViewData["Title"] = "Home";
}
<h1>MvcSaml Sample</h1>
<p>This sample demonstrates SAML 2.0 single sign-on via Duende IdentityServer using the <a href="https://github.com/Sustainsys/Saml2" target="_blank">Sustainsys.Saml2</a> library.</p>
<p>
<a class="btn" asp-controller="Home" asp-action="Secure">Sign in</a>
</p>

View file

@ -0,0 +1,28 @@
@{
ViewData["Title"] = "Secure";
}
<h1>Authenticated User</h1>
<p>You are signed in via SAML 2.0. Your claims:</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
@foreach (var claim in User.Claims)
{
<tr>
<td>@claim.Type</td>
<td>@claim.Value</td>
</tr>
}
</tbody>
</table>
<p style="margin-top:1rem">
<a class="btn" asp-controller="Home" asp-action="Logout">Logout</a>
</p>

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - MvcSaml</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; }
nav { background: #1a1a2e; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; }
nav a { color: #eee; text-decoration: none; font-size: 0.95rem; }
nav a:hover { color: #fff; text-decoration: underline; }
nav .brand { font-weight: 600; font-size: 1.1rem; color: #fff; }
main { padding: 2rem 1.5rem; max-width: 900px; margin: 0 auto; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 0.5rem 0.75rem; text-align: left; font-size: 0.875rem; }
th { background: #f5f5f5; font-weight: 600; }
tr:nth-child(even) td { background: #fafafa; }
.btn { display: inline-block; padding: 0.4rem 1rem; background: #1a1a2e; color: #fff; border-radius: 4px; text-decoration: none; font-size: 0.875rem; }
.btn:hover { background: #2d2d50; }
</style>
</head>
<body>
<nav>
<a class="brand" asp-controller="Home" asp-action="Index">MvcSaml</a>
<a asp-controller="Home" asp-action="Index">Home</a>
<a asp-controller="Home" asp-action="Secure">Secure</a>
@if (Context.User.Identity?.IsAuthenticated == true)
{
<a asp-controller="Home" asp-action="Logout">Logout</a>
}
</nav>
<main>
@RenderBody()
</main>
</body>
</html>

View file

@ -0,0 +1,2 @@
@using MvcSaml
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View file

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View file

@ -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();

View file

@ -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<SamlServiceProvider> Get() =>
[
new SamlServiceProvider
{
EntityId = "https://localhost:44350/Saml2",
DisplayName = "MvcSaml Sample Client",
Enabled = true,
// ACS URL follows the Sustainsys.Saml2 convention: <base>/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
}
];
}

View file

@ -14,7 +14,7 @@ public class HostProfileService(TestUserStore users, ILogger<TestUserProfileServ
ArgumentNullException.ThrowIfNull(context);
await base.GetProfileDataAsync(context, ct);
var transaction = context.RequestedResources.ParsedScopes.FirstOrDefault(x => 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));

View file

@ -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",

View file

@ -132,6 +132,7 @@
<Project Path="identity-server/clients/src/MvcHybridBackChannel/MvcHybridBackChannel.csproj" />
<Project Path="identity-server/clients/src/MvcJarJwt/MvcJarJwt.csproj" />
<Project Path="identity-server/clients/src/MvcJarUriJwt/MvcJarUriJwt.csproj" />
<Project Path="identity-server/clients/src/MvcSaml/MvcSaml.csproj" />
<Project Path="identity-server/clients/src/Web/Web.csproj" />
<Project Path="identity-server/clients/src/WindowsConsoleSystemBrowser/WindowsConsoleSystemBrowser.csproj" />
</Folder>