Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
<PackageVersion Include="SimpleFeedReader" Version="2.0.4" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.53.0" />
<PackageVersion Include="Spectre.Console.Json" Version="0.53.0" />
<PackageVersion Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
Expand Down
1 change: 1 addition & 0 deletions identity-server/aspire/AppHosts/All/All.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
1 change: 1 addition & 0 deletions identity-server/aspire/AppHosts/All/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions identity-server/aspire/AppHosts/All/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"MvcHybridBackChannel": true,
"MvcJarJwt": true,
"MvcJarUriJwt": true,
"MvcSaml": true,
"Web": true,
"WindowsConsoleSystemBrowser": false
},
Expand Down
1 change: 1 addition & 0 deletions identity-server/clients/src/MvcSaml/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
saml-sp.pfx
23 changes: 23 additions & 0 deletions identity-server/clients/src/MvcSaml/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
@@ -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);
}
106 changes: 106 additions & 0 deletions identity-server/clients/src/MvcSaml/HostingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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;
using Sustainsys.Saml2.Configuration;
using Sustainsys.Saml2.Metadata;

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.
var idpBaseUrl = builder.Configuration["is-host"]
?? throw new InvalidOperationException("is-host configuration is required");

builder.Services.AddControllersWithViews();

var spCert = LoadSpCertificate();

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;

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.
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;
}

// 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);
}
}
29 changes: 29 additions & 0 deletions identity-server/clients/src/MvcSaml/MvcSaml.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<UserSecretsId>3a8b2c1d-4e5f-6a7b-8c9d-0e1f2a3b4c5d</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<None Update="saml-sp.pfx" Condition="Exists('saml-sp.pfx')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<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>
36 changes: 36 additions & 0 deletions identity-server/clients/src/MvcSaml/Program.cs
Original file line number Diff line number Diff line change
@@ -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();
}
12 changes: 12 additions & 0 deletions identity-server/clients/src/MvcSaml/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"profiles": {
"Host": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:44350",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
41 changes: 41 additions & 0 deletions identity-server/clients/src/MvcSaml/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# MvcSaml

This client demonstrates SAML 2.0 single sign-on and single logout against IdentityServer.

## SP Certificate

The SP certificate is required for three 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.
- **Encrypted assertions** — the IdP encrypts assertions using the SP's public key, so assertion content is protected in transit and only this SP can decrypt it.

Without the certificate, the SSO login flow still works, but AuthnRequest signing is disabled, SP-initiated single logout will fail, and assertions are transmitted in plaintext.

### 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 and assertion decryption. The IdentityServer host also reads the same certificate file at startup to register the SP's public key for signature validation and assertion encryption. Both must be restarted whenever the certificate is regenerated.

## Without the certificate

| Feature | Without certificate | With certificate |
|---|---|---|
| SSO (login) | Works | Works |
| Encrypted assertions | Disabled (plaintext) | Enabled |
| 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 |
9 changes: 9 additions & 0 deletions identity-server/clients/src/MvcSaml/Views/Home/Index.cshtml
Original file line number Diff line number Diff line change
@@ -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>
28 changes: 28 additions & 0 deletions identity-server/clients/src/MvcSaml/Views/Home/Secure.cshtml
Original file line number Diff line number Diff line change
@@ -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>
36 changes: 36 additions & 0 deletions identity-server/clients/src/MvcSaml/Views/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
@@ -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>
2 changes: 2 additions & 0 deletions identity-server/clients/src/MvcSaml/Views/_ViewImports.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@using MvcSaml
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
3 changes: 3 additions & 0 deletions identity-server/clients/src/MvcSaml/Views/_ViewStart.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
4 changes: 3 additions & 1 deletion identity-server/hosts/Main10/IdentityServerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio
Scope = "openid profile"
}
])
.AddLicenseSummary();
.AddLicenseSummary()
.AddSaml()
.AddInMemorySamlServiceProviders(SamlServiceProviders.Get());

builder.Services.AddIdentityServerConfiguration(opt => { })
.AddInMemoryClientConfigurationStore();
Expand Down
Loading