Initial SAML Drop (#2375)

* Initial import of SAML code

* Added missing SAML-relevant tests

* Fixed compilation errors after rebasing XUnit V3 changes

* Fixed subtle issues introduced in initial code import

* Reworked test to use Kestrel based test hosts to allow cross-server communication

* Eliminated odd value types to remain more consistent with code base

* Do not enable SAML by default, but provide mechnism to opt-in

* Base SAML test client

* Add directions for configuring cert for SAML sample

* Include encrypting assertions in SAML sample

* Apply CT alias to SAML code

* Dotnet format

* Updated necessary SAML code after rebasing cooperative cancellation support

* Updated SAML code to participate in cooperative cancellation

* Fix name of SAML sign in state cookie
This commit is contained in:
Brett Hazen 2026-02-26 09:52:44 -06:00 committed by GitHub
parent e556d5b314
commit 3485f44dab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
202 changed files with 20925 additions and 35 deletions

View file

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

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 @@
saml-sp.pfx

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

View file

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

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

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

@ -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,68 @@
// 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<SamlServiceProvider> Get()
{
// Load the SP certificate once. When present, it enables AuthnRequest signature
// validation and assertion encryption. The same certificate serves both purposes.
var spCert = LoadSpCertificate();
var spCerts = spCert != null ? new[] { spCert } : [];
return
[
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")],
// SLO URL follows the Sustainsys.Saml2 convention: <base>/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,
// When the SP certificate is present, require signed AuthnRequests, register the
// SP's public key for signature validation, and encrypt assertions with it.
// The same certificate serves both purposes — signing and encryption.
RequireSignedAuthnRequests = spCert != null,
SigningCertificates = spCerts,
EncryptAssertions = spCert != null,
EncryptionCertificates = spCerts,
}
];
}
// Returns null if the certificate file has not been generated yet.
// See README.md in the MvcSaml project for generation instructions.
private static X509Certificate2? LoadSpCertificate()
{
if (!File.Exists(SpCertificatePath))
{
return null;
}
// 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.
return new X509Certificate2(SpCertificatePath, SpCertificatePassword);
#pragma warning restore SYSLIB0057
}
}

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

@ -2,6 +2,8 @@
"solution": {
"path": "..\\products.slnx",
"projects": [
"conformance-report\\src\\ConformanceReport\\ConformanceReport.csproj",
"conformance-report\\test\\ConformanceReport.Tests\\ConformanceReport.Tests.csproj",
"identity-server\\aspire\\AppHosts\\All\\All.csproj",
"identity-server\\aspire\\AppHosts\\Dev\\Dev.csproj",
"identity-server\\aspire\\ServiceDefaults\\ServiceDefaults.csproj",
@ -38,15 +40,16 @@
"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",
"identity-server\\hosts\\EntityFramework10\\Host.EntityFramework10.csproj",
"identity-server\\hosts\\Main10\\Host.Main10.csproj",
"identity-server\\hosts\\Shared\\Host.Shared.csproj",
"identity-server\\hosts\\UI\\AspNetIdentity\\UI.AspNetIdentity.csproj",
"identity-server\\hosts\\UI\\EntityFramework\\UI.EntityFramework.csproj",
"identity-server\\hosts\\UI\\Main\\UI.Main.csproj",
"identity-server\\hosts\\AspNetIdentity10\\Host.AspNetIdentity10.csproj",
"identity-server\\hosts\\EntityFramework10\\Host.EntityFramework10.csproj",
"identity-server\\hosts\\Main10\\Host.Main10.csproj",
"identity-server\\migrations\\AspNetIdentityDb\\AspNetIdentityDb.csproj",
"identity-server\\migrations\\IdentityServerDb\\IdentityServerDb.csproj",
"identity-server\\src\\AspNetIdentity\\IdentityServer.AspNetIdentity.csproj",
@ -54,6 +57,7 @@
"identity-server\\src\\Configuration\\IdentityServer.Configuration.csproj",
"identity-server\\src\\EntityFramework.Storage\\IdentityServer.EntityFramework.Storage.csproj",
"identity-server\\src\\EntityFramework\\IdentityServer.EntityFramework.csproj",
"identity-server\\src\\IdentityServer.ConformanceReport\\IdentityServer.ConformanceReport.csproj",
"identity-server\\src\\IdentityServer\\IdentityServer.csproj",
"identity-server\\src\\Storage\\IdentityServer.Storage.csproj",
"identity-server\\templates\\src\\IdentityServerAspNetIdentity\\IdentityServerAspNetIdentity.csproj",
@ -69,4 +73,4 @@
"shared\\Xunit.Playwright\\Xunit.Playwright.csproj"
]
}
}
}

View file

@ -16,6 +16,7 @@ using Duende.IdentityServer.Hosting;
using Duende.IdentityServer.Hosting.DynamicProviders;
using Duende.IdentityServer.Hosting.FederatedSignOut;
using Duende.IdentityServer.Internal;
using Duende.IdentityServer.Internal.Saml;
using Duende.IdentityServer.Licensing;
using Duende.IdentityServer.Licensing.V2;
using Duende.IdentityServer.Licensing.V2.Diagnostics;
@ -23,6 +24,7 @@ using Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries;
using Duende.IdentityServer.Logging;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Services.Default;
using Duende.IdentityServer.Services.KeyManagement;
@ -210,6 +212,12 @@ public static class IdentityServerBuilderExtensionsCore
builder.Services.TryAddTransient<IBackchannelAuthenticationUserValidator, NopBackchannelAuthenticationUserValidator>();
// Register no-op SAML services for services used in logout paths
// These are replaced by actual implementations in AddSaml and ISamlServiceProviderStore
// can be replaced with a call to AddSamlServiceProviderStore
builder.Services.TryAddTransient<ISamlServiceProviderStore, EmptySamlServiceProviderStore>();
builder.Services.TryAddScoped<ISamlLogoutNotificationService, NopSamlLogoutNotificationService>();
builder.Services.TryAddSingleton(typeof(IConcurrencyLock<>), typeof(DefaultConcurrencyLock<>));
builder.Services.TryAddTransient<IClientStore, EmptyClientStore>();

View file

@ -190,4 +190,17 @@ public static class IdentityServerBuilderExtensionsInMemory
builder.Services.TryAddSingleton<IPushedAuthorizationRequestStore, InMemoryPushedAuthorizationRequestStore>();
return builder;
}
/// <summary>
/// Adds the in-memory SAML service provider store.
/// </summary>
/// <param name="builder">The builder.</param>
/// <param name="serviceProviders">The SAML service providers.</param>
/// <returns></returns>
public static IIdentityServerBuilder AddInMemorySamlServiceProviders(this IIdentityServerBuilder builder, IEnumerable<SamlServiceProvider> serviceProviders)
{
builder.Services.AddSingleton(serviceProviders);
builder.AddSamlServiceProviderStore<InMemorySamlServiceProviderStore>();
return builder;
}
}

View file

@ -0,0 +1,134 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Internal.Saml;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.Metadata;
using Duende.IdentityServer.Internal.Saml.SingleLogout;
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
using Duende.IdentityServer.Internal.Saml.SingleSignin;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.DependencyInjection.Extensions;
using static Duende.IdentityServer.IdentityServerConstants;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Builder extension methods for opting in to SAML 2.0 support.
/// </summary>
public static class IdentityServerBuilderExtensionsSaml
{
/// <summary>
/// Adds SAML 2.0 support to IdentityServer.
/// </summary>
/// <remarks>
/// Registers all SAML services and endpoints, and enables the SAML endpoints
/// in <see cref="EndpointsOptions"/>. The IdP-initiated SSO endpoint is not
/// enabled by default; set <see cref="EndpointsOptions.EnableSamlIdpInitiatedEndpoint"/>
/// to <c>true</c> explicitly if you need it.
/// </remarks>
/// <param name="builder">The builder.</param>
/// <returns></returns>
public static IIdentityServerBuilder AddSaml(this IIdentityServerBuilder builder)
{
builder.AddSamlServices();
builder.Services.Configure<IdentityServerOptions>(options =>
{
options.Endpoints.EnableSamlMetadataEndpoint = true;
options.Endpoints.EnableSamlSigninEndpoint = true;
options.Endpoints.EnableSamlSigninCallbackEndpoint = true;
options.Endpoints.EnableSamlLogoutEndpoint = true;
options.Endpoints.EnableSamlLogoutCallbackEndpoint = true;
// EnableSamlIdpInitiatedEndpoint intentionally left false — requires explicit opt-in.
});
return builder;
}
/// <summary>
/// Adds SAML 2.0 protocol services.
/// </summary>
/// <param name="builder">The builder.</param>
/// <returns></returns>
private static IIdentityServerBuilder AddSamlServices(this IIdentityServerBuilder builder)
{
// SAML 2.0 endpoints
builder.AddEndpoint<SamlMetaDataEndpoint>(EndpointNames.SamlMetadata, ProtocolRoutePaths.SamlMetadata.EnsureLeadingSlash());
builder.AddEndpoint<SamlSigninEndpoint>(EndpointNames.SamlSignin, ProtocolRoutePaths.SamlSignin.EnsureLeadingSlash());
builder.AddEndpoint<SamlSigninCallbackEndpoint>(EndpointNames.SamlSigninCallback, ProtocolRoutePaths.SamlSigninCallback.EnsureLeadingSlash());
builder.AddEndpoint<SamlIdpInitiatedEndpoint>(EndpointNames.SamlIdpInitiated, ProtocolRoutePaths.SamlIdpInitiated.EnsureLeadingSlash());
builder.AddEndpoint<SamlSingleLogoutEndpoint>(EndpointNames.SamlLogout, ProtocolRoutePaths.SamlLogout.EnsureLeadingSlash());
builder.AddEndpoint<SamlSingleLogoutCallbackEndpoint>(EndpointNames.SamlLogoutCallback, ProtocolRoutePaths.SamlLogoutCallback.EnsureLeadingSlash());
// Serializers (Transient)
builder.Services.AddTransient<ISamlResultSerializer<SamlErrorResponse>, SamlErrorResponseXmlSerializer>();
builder.Services.AddTransient<ISamlResultSerializer<SamlResponse>, SamlResponse.Serializer>();
builder.Services.AddTransient<ISamlResultSerializer<LogoutResponse>, LogoutResponse.Serializer>();
// HTTP response writers
builder.AddHttpWriter<SamlErrorResponse, SamlErrorResponse.ResponseWriter>();
builder.AddHttpWriter<SamlResponse, SamlResponse.ResponseWriter>();
builder.AddHttpWriter<LogoutResponse, LogoutResponse.ResponseWriter>();
// Processors (Scoped)
builder.Services.AddScoped<SamlSigninRequestProcessor>();
builder.Services.AddScoped<SamlSigninCallbackRequestProcessor>();
builder.Services.AddScoped<SamlIdpInitiatedRequestProcessor>();
builder.Services.AddScoped<SamlLogoutRequestProcessor>();
builder.Services.AddScoped<SamlLogoutCallbackProcessor>();
// Builders (Scoped)
builder.Services.AddScoped<SamlResponseBuilder>();
builder.Services.AddScoped<LogoutResponseBuilder>();
builder.Services.AddScoped<SamlFrontChannelLogoutRequestBuilder>();
// Parsers / Extractors (Scoped)
builder.Services.AddScoped<AuthNRequestParser>();
builder.Services.AddScoped<LogoutRequestParser>();
builder.Services.AddScoped<SamlSigninRequestExtractor>();
builder.Services.AddScoped<SamlLogoutRequestExtractor>();
// Infrastructure (Scoped)
builder.Services.AddScoped<SamlUrlBuilder>();
builder.Services.AddScoped<SamlClaimsService>();
builder.Services.AddScoped<SamlNameIdGenerator>();
builder.Services.AddScoped<SamlResponseSigner>();
builder.Services.AddScoped<SamlProtocolMessageSigner>();
builder.Services.AddScoped<SamlAssertionEncryptor>();
builder.Services.AddScoped<SamlRequestValidator>();
builder.Services.TryAddScoped(typeof(SamlRequestSignatureValidator<,>));
// Interface → Implementation (TryAddScoped for extensibility)
builder.Services.TryAddScoped<ISamlSigninInteractionResponseGenerator, DefaultSamlSigninInteractionResponseGenerator>();
builder.Services.TryAddScoped<ISamlSigningService, SamlSigningService>();
// Replace the no-op registered by AddCoreServices with the real implementation.
builder.Services.Replace(ServiceDescriptor.Scoped<ISamlLogoutNotificationService, SamlLogoutNotificationService>());
builder.Services.TryAddScoped<ISamlInteractionService, DefaultSamlInteractionService>();
// State management (Singleton)
builder.Services.TryAddSingleton<SamlSigninStateIdCookie>();
builder.Services.TryAddSingleton<ISamlSigninStateStore, DistributedCacheSamlSigninStateStore>();
return builder;
}
/// <summary>
/// Adds a custom SAML service provider store.
/// </summary>
/// <typeparam name="T">The type of the <see cref="ISamlServiceProviderStore"/> implementation.</typeparam>
/// <param name="builder">The builder.</param>
/// <returns></returns>
public static IIdentityServerBuilder AddSamlServiceProviderStore<T>(this IIdentityServerBuilder builder)
where T : class, ISamlServiceProviderStore
{
builder.Services.AddTransient<ISamlServiceProviderStore, T>();
return builder;
}
}

View file

@ -111,4 +111,52 @@ public class EndpointsOptions
/// <c>true</c> if the OAuth 2.0 discovery metadata is enabled; otherwise, <c>false</c>.
/// </value>
public bool EnableOAuth2MetadataEndpoint { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the SAML metadata endpoint is enabled.
/// </summary>
/// <value>
/// <c>true</c> if the SAML metadata endpoint is enabled; otherwise, <c>false</c>.
/// </value>
public bool EnableSamlMetadataEndpoint { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the SAML sign-in (SSO) endpoint is enabled.
/// </summary>
/// <value>
/// <c>true</c> if the SAML sign-in endpoint is enabled; otherwise, <c>false</c>.
/// </value>
public bool EnableSamlSigninEndpoint { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the SAML sign-in callback endpoint is enabled.
/// </summary>
/// <value>
/// <c>true</c> if the SAML sign-in callback endpoint is enabled; otherwise, <c>false</c>.
/// </value>
public bool EnableSamlSigninCallbackEndpoint { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the SAML IdP-initiated SSO endpoint is enabled.
/// </summary>
/// <value>
/// <c>true</c> if the SAML IdP-initiated endpoint is enabled; otherwise, <c>false</c>.
/// </value>
public bool EnableSamlIdpInitiatedEndpoint { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the SAML Single Logout (SLO) endpoint is enabled.
/// </summary>
/// <value>
/// <c>true</c> if the SAML logout endpoint is enabled; otherwise, <c>false</c>.
/// </value>
public bool EnableSamlLogoutEndpoint { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the SAML Single Logout callback endpoint is enabled.
/// </summary>
/// <value>
/// <c>true</c> if the SAML logout callback endpoint is enabled; otherwise, <c>false</c>.
/// </value>
public bool EnableSamlLogoutCallbackEndpoint { get; set; }
}

View file

@ -293,4 +293,9 @@ public class IdentityServerOptions
/// Options that control the diagnostic data that is logged by IdentityServer.
/// </summary>
public DiagnosticOptions Diagnostics { get; set; } = new DiagnosticOptions();
/// <summary>
/// Gets or sets the SAML 2.0 Identity Provider options.
/// </summary>
public SamlOptions Saml { get; set; } = new SamlOptions();
}

View file

@ -0,0 +1,155 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Collections.ObjectModel;
using System.Security.Claims;
using Duende.IdentityServer.Models;
using SamlConstants = Duende.IdentityServer.Internal.Saml.SamlConstants;
namespace Duende.IdentityServer.Configuration;
/// <summary>
/// Options for SAML 2.0 Identity Provider functionality.
/// </summary>
public class SamlOptions
{
/// <summary>
/// Gets or sets the metadata validity duration (optional).
/// If set, metadata will include a validUntil attribute.
/// Defaults to 7 days.
/// </summary>
public TimeSpan? MetadataValidityDuration { get; set; } = TimeSpan.FromDays(7);
/// <summary>
/// Gets or sets whether the IdP requires signed AuthnRequests.
/// Defaults to false.
/// </summary>
public bool WantAuthnRequestsSigned { get; set; }
/// <summary>
/// Default attribute name format to use when SP doesn't specify.
/// Common values:
/// - "urn:oasis:names:tc:SAML:2.0:attrname-format:uri" (for OID format)
/// - "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" (for simple names)
/// Default: Uri (most common)
/// </summary>
public string DefaultAttributeNameFormat { get; set; }
= SamlConstants.AttributeNameFormats.Uri;
/// <summary>
/// Default claim type to use when resolving a persistent name identifier based on where
/// the host application has populated the value. Persistent name identifiers will not be
/// generated and are the responsibility of the host application to create.
/// </summary>
public string DefaultPersistentNameIdentifierClaimType { get; set; } = ClaimTypes.NameIdentifier;
/// <summary>
/// Default mappings from claim types to SAML attribute names.
/// Key: claim type (e.g., "email", "name")
/// Value: SAML attribute name (e.g., "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")
///
/// Includes common OIDC to SAML attribute mappings by default.
/// Service providers can override these mappings via SamlServiceProvider.ClaimMappings.
///
/// If a claim type is not in this dictionary, the claim will be excluded from the SAML assertion.
/// </summary>
public ReadOnlyDictionary<string, string> DefaultClaimMappings { get; init; } =
new(new Dictionary<string, string>
{
["name"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
["email"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
["role"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/role",
});
/// <summary>
/// Gets or sets the supported NameID formats.
/// Defaults to EmailAddress, Persistent, Transient, and Unspecified.
/// </summary>
public Collection<string> SupportedNameIdFormats { get; init; } =
[
SamlConstants.NameIdentifierFormats.EmailAddress,
SamlConstants.NameIdentifierFormats.Persistent,
SamlConstants.NameIdentifierFormats.Transient,
SamlConstants.NameIdentifierFormats.Unspecified
];
/// <summary>
/// Gets or sets the default clock skew tolerance for SAML message validation.
/// Defaults to 5 minutes.
/// </summary>
public TimeSpan DefaultClockSkew { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the default maximum age for SAML authentication requests.
/// Defaults to 5 minutes.
/// </summary>
public TimeSpan DefaultRequestMaxAge { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the default signing behavior for SAML messages.
/// Defaults to <see cref="Models.SamlSigningBehavior.SignAssertion"/>.
/// </summary>
public SamlSigningBehavior DefaultSigningBehavior { get; set; } = SamlSigningBehavior.SignAssertion;
/// <summary>
/// Maximum length of the RelayState parameter, measured in bytes of its UTF-8 encoding.
/// SAML spec recommends 80 bytes, but can be increased for SPs that support longer values.
/// Default: 80 (UTF-8 bytes).
/// </summary>
public int MaxRelayStateLength { get; set; } = 80;
/// <summary>
/// Gets or sets the user interaction options for SAML endpoints.
/// </summary>
public SamlUserInteractionOptions UserInteraction { get; set; } = new();
}
/// <summary>
/// Options for SAML user interaction endpoint paths.
/// </summary>
public class SamlUserInteractionOptions
{
/// <summary>
/// Gets or sets the base route for all SAML endpoints.
/// Default: "/saml".
/// </summary>
public string Route { get; set; } = SamlConstants.Urls.SamlRoute;
/// <summary>
/// Gets or sets the path for the SAML metadata endpoint.
/// Default: "/metadata".
/// </summary>
public string Metadata { get; set; } = SamlConstants.Urls.Metadata;
/// <summary>
/// Gets or sets the path for the SAML sign-in endpoint.
/// Default: "/signin".
/// </summary>
public string SignInPath { get; set; } = SamlConstants.Urls.SignIn;
/// <summary>
/// Gets or sets the path for the SAML sign-in callback endpoint.
/// Default: "/signin_callback".
/// </summary>
public string SignInCallbackPath { get; set; } = SamlConstants.Urls.SigninCallback;
/// <summary>
/// Gets or sets the path for the IdP-initiated SSO endpoint.
/// Default: "/idp-initiated".
/// </summary>
public string IdpInitiatedPath { get; set; } = SamlConstants.Urls.IdpInitiated;
/// <summary>
/// Gets or sets the path for the SAML single logout endpoint.
/// Default: "/logout".
/// </summary>
public string SingleLogoutPath { get; set; } = SamlConstants.Urls.SingleLogout;
/// <summary>
/// Gets or sets the path for the SAML single logout callback endpoint.
/// Default: "/logout_callback".
/// </summary>
public string SingleLogoutCallbackPath { get; set; } = SamlConstants.Urls.SingleLogoutCallback;
}

View file

@ -9,8 +9,12 @@ using System.Text.Encodings.Web;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Hosting;
using Duende.IdentityServer.Internal.Saml;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Endpoints.Results;
@ -34,9 +38,14 @@ public class EndSessionCallbackResult : EndpointResult<EndSessionCallbackResult>
internal class EndSessionCallbackHttpWriter : IHttpResponseWriter<EndSessionCallbackResult>
{
public EndSessionCallbackHttpWriter(IdentityServerOptions options) => _options = options;
public EndSessionCallbackHttpWriter(IdentityServerOptions options, ILogger<EndSessionCallbackHttpWriter> logger)
{
_options = options;
_logger = logger;
}
private IdentityServerOptions _options;
private readonly IdentityServerOptions _options;
private readonly ILogger<EndSessionCallbackHttpWriter> _logger;
public async Task WriteHttpResponse(EndSessionCallbackResult result, HttpContext context)
{
@ -59,25 +68,36 @@ internal class EndSessionCallbackHttpWriter : IHttpResponseWriter<EndSessionCall
if (_options.Authentication.RequireCspFrameSrcForSignout)
{
var sb = new StringBuilder();
var origins = result.Result.FrontChannelLogoutUrls?.Select(x => x.GetOrigin());
if (origins != null)
var origins = result.Result.FrontChannelLogoutUrls?.Select(x => x.GetOrigin()) ?? [];
origins = origins.Concat(result.Result.SamlFrontChannelLogouts.Select(x => x.Destination.OriginalString));
foreach (var origin in origins.Distinct())
{
foreach (var origin in origins.Distinct())
sb.Append(origin);
if (sb.Length > 0)
{
sb.Append(origin);
if (sb.Length > 0)
{
sb.Append(' ');
}
sb.Append(' ');
}
}
// the hash matches the embedded style element being used below
context.Response.AddStyleCspHeaders(_options.Csp, IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle, sb.ToString());
if (result.Result.SamlFrontChannelLogouts.Any())
{
// the hash matches the embedded style element being used below
// and the SAML auto-post script hash allows the inline script in the iframe srcdoc
context.Response.AddStyleAndScriptCspHeaders(
_options.Csp,
IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle,
IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript,
sb.ToString());
}
else
{
// the hash matches the embedded style element being used below
context.Response.AddStyleCspHeaders(_options.Csp, IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle, sb.ToString());
}
}
}
private static string GetHtml(EndSessionCallbackResult result)
private string GetHtml(EndSessionCallbackResult result)
{
var sb = new StringBuilder();
sb.Append("<!DOCTYPE html><html><style>iframe{{display:none;width:0;height:0;}}</style><body>");
@ -91,6 +111,28 @@ internal class EndSessionCallbackHttpWriter : IHttpResponseWriter<EndSessionCall
}
}
if (result.Result.SamlFrontChannelLogouts.Any())
{
foreach (var samlFrontChannelLogout in result.Result.SamlFrontChannelLogouts)
{
switch (samlFrontChannelLogout.SamlBinding)
{
case SamlBinding.HttpPost:
var autoPostFormContent = HttpResponseBindings.GenerateAutoPostForm(SamlConstants.RequestProperties.SAMLRequest, samlFrontChannelLogout.EncodedContent, samlFrontChannelLogout.Destination, samlFrontChannelLogout.RelayState, includeCsp: true);
sb.Append(CultureInfo.InvariantCulture, $"<iframe sandbox='allow-forms allow-scripts allow-same-origin' srcdoc='{HtmlEncoder.Default.Encode(autoPostFormContent)}'></iframe>");
break;
case SamlBinding.HttpRedirect:
sb.Append(CultureInfo.InvariantCulture, $"<iframe loading='eager' allow='' src='{HtmlEncoder.Default.Encode($"{samlFrontChannelLogout.Destination}?{samlFrontChannelLogout.EncodedContent}")}'></iframe>");
break;
default:
_logger.LogDebug("Unknown SAML Binding: {SamlBinding}", samlFrontChannelLogout.SamlBinding);
break;
}
sb.AppendLine();
}
}
return sb.ToString();
}
}

View file

@ -4,6 +4,7 @@
using System.Buffers.Text;
using System.Text;
using Duende.IdentityServer.Saml.Models;
using Microsoft.AspNetCore.Authentication;
namespace Duende.IdentityServer.Extensions;
@ -15,6 +16,7 @@ public static class AuthenticationPropertiesExtensions
{
internal const string SessionIdKey = "session_id";
internal const string ClientListKey = "client_list";
internal const string SamlSessionListKey = "saml_session_list";
/// <summary>
/// Gets the user's session identifier.
@ -86,7 +88,6 @@ public static class AuthenticationPropertiesExtensions
}
}
private static IEnumerable<string> DecodeList(string value)
{
if (value.IsPresent())
@ -111,4 +112,100 @@ public static class AuthenticationPropertiesExtensions
return null;
}
/// <summary>
/// Gets the list of SAML SP sessions from the authentication properties.
/// </summary>
/// <param name="properties"></param>
/// <returns></returns>
/// <remarks>
/// For production deployments with many SAML service providers, enable server-side sessions
/// to avoid cookie size limitations. Without server-side sessions, the practical limit is
/// approximately 5-10 SAML sessions depending on the number of OIDC clients.
/// </remarks>
public static IEnumerable<SamlSpSessionData> GetSamlSessionList(this AuthenticationProperties properties)
{
if (properties?.Items.TryGetValue(SamlSessionListKey, out var value) == true && value != null)
{
return DecodeSamlSessionList(value);
}
return [];
}
/// <summary>
/// Sets the list of SAML SP sessions in the authentication properties.
/// </summary>
/// <param name="properties"></param>
/// <param name="sessions"></param>
public static void SetSamlSessionList(this AuthenticationProperties properties, IEnumerable<SamlSpSessionData> sessions)
{
var value = EncodeSamlSessionList(sessions);
if (value == null)
{
properties.Items.Remove(SamlSessionListKey);
}
else
{
properties.Items[SamlSessionListKey] = value;
}
}
/// <summary>
/// Adds a SAML session to the authentication properties.
/// This is an upsert operation - if a session for the same EntityId already exists, it is replaced.
/// </summary>
/// <param name="properties"></param>
/// <param name="session"></param>
public static void AddSamlSession(this AuthenticationProperties properties, SamlSpSessionData session)
{
ArgumentNullException.ThrowIfNull(session);
var sessions = properties.GetSamlSessionList().ToList();
// Remove existing session for this SP if present
sessions.RemoveAll(s => s.EntityId == session.EntityId);
// Add the (potentially updated) session
sessions.Add(session);
properties.SetSamlSessionList(sessions);
}
/// <summary>
/// Removes a SAML session from the authentication properties by EntityId.
/// </summary>
/// <param name="properties"></param>
/// <param name="entityId"></param>
public static void RemoveSamlSession(this AuthenticationProperties properties, string entityId)
{
var sessions = properties.GetSamlSessionList()
.Where(s => s.EntityId != entityId)
.ToList();
properties.SetSamlSessionList(sessions);
}
private static SamlSpSessionData[] DecodeSamlSessionList(string value)
{
if (value.IsPresent())
{
var bytes = Base64Url.DecodeFromChars(value);
var json = Encoding.UTF8.GetString(bytes);
return ObjectSerializer.FromString<SamlSpSessionData[]>(json) ?? [];
}
return [];
}
private static string EncodeSamlSessionList(IEnumerable<SamlSpSessionData> list)
{
if (list != null && list.Any())
{
var json = ObjectSerializer.ToString(list);
var bytes = Encoding.UTF8.GetBytes(json);
return Base64Url.EncodeToString(bytes);
}
return null;
}
}

View file

@ -22,6 +22,12 @@ internal static class EndpointOptionsExtensions
IdentityServerConstants.EndpointNames.UserInfo => options.EnableUserInfoEndpoint,
IdentityServerConstants.EndpointNames.PushedAuthorization => options.EnablePushedAuthorizationEndpoint,
IdentityServerConstants.EndpointNames.BackchannelAuthentication => options.EnableBackchannelAuthenticationEndpoint,
IdentityServerConstants.EndpointNames.SamlMetadata => options.EnableSamlMetadataEndpoint,
IdentityServerConstants.EndpointNames.SamlSignin => options.EnableSamlSigninEndpoint,
IdentityServerConstants.EndpointNames.SamlSigninCallback => options.EnableSamlSigninCallbackEndpoint,
IdentityServerConstants.EndpointNames.SamlIdpInitiated => options.EnableSamlIdpInitiatedEndpoint,
IdentityServerConstants.EndpointNames.SamlLogout => options.EnableSamlLogoutEndpoint,
IdentityServerConstants.EndpointNames.SamlLogoutCallback => options.EnableSamlLogoutCallbackEndpoint,
_ => true
};
}

View file

@ -57,23 +57,28 @@ public static class HttpContextExtensions
LogoutNotificationContext endSessionMsg = null;
// if we have a logout message, then that take precedence over the current user
if (logoutMessage?.ClientIds?.Any() == true)
if (logoutMessage?.ClientIds?.Any() == true || logoutMessage?.SamlSessions?.Any() == true)
{
var clientIds = logoutMessage.ClientIds;
var clientIds = logoutMessage.ClientIds ?? [];
var samlSessions = logoutMessage.SamlSessions?.ToList() ?? [];
// check if current user is same, since we might have new clients (albeit unlikely)
if (currentSubId == logoutMessage.SubjectId)
{
clientIds = clientIds.Union(await userSession.GetClientListAsync(context.RequestAborted));
var currentSamlSessions = await userSession.GetSamlSessionListAsync(context.RequestAborted);
samlSessions = samlSessions.Union(currentSamlSessions).ToList();
}
if (await AnyClientHasFrontChannelLogout(logoutMessage.ClientIds))
var samlEntityIds = samlSessions.Select(s => s.EntityId);
if (await AnyClientHasFrontChannelLogout(logoutMessage.ClientIds) || await AnySamlServiceProviderHasFrontChannelLogout(samlEntityIds, context.RequestAborted))
{
endSessionMsg = new LogoutNotificationContext
{
SubjectId = logoutMessage.SubjectId,
SessionId = logoutMessage.SessionId,
ClientIds = clientIds
ClientIds = clientIds,
SamlSessions = samlSessions
};
}
}
@ -81,13 +86,18 @@ public static class HttpContextExtensions
{
// see if current user has any clients they need to signout of
var clientIds = await userSession.GetClientListAsync(context.RequestAborted);
if (clientIds.Any() && await AnyClientHasFrontChannelLogout(clientIds))
var samlSessions = await userSession.GetSamlSessionListAsync(context.RequestAborted);
var samlEntityIds = samlSessions.Select(s => s.EntityId);
if ((clientIds.Any() && await AnyClientHasFrontChannelLogout(clientIds)) ||
(samlEntityIds.Any() && await AnySamlServiceProviderHasFrontChannelLogout(samlEntityIds, context.RequestAborted)))
{
endSessionMsg = new LogoutNotificationContext
{
SubjectId = currentSubId,
SessionId = await userSession.GetSessionIdAsync(context.RequestAborted),
ClientIds = clientIds
ClientIds = clientIds,
SamlSessions = samlSessions
};
}
}
@ -124,5 +134,20 @@ public static class HttpContextExtensions
return false;
}
async Task<bool> AnySamlServiceProviderHasFrontChannelLogout(IEnumerable<string> entityIds, Ct ct)
{
var serviceProviderStore = context.RequestServices.GetRequiredService<ISamlServiceProviderStore>();
foreach (var entityId in entityIds)
{
var sp = await serviceProviderStore.FindByEntityIdAsync(entityId, ct);
if (sp?.Enabled == true && sp.SingleLogoutServiceUrl != null)
{
return true;
}
}
return false;
}
}
}

View file

@ -98,6 +98,20 @@ public static class HttpResponseExtensions
AddCspHeaders(response.Headers, options, cspHeader);
}
public static void AddStyleAndScriptCspHeaders(this HttpResponse response, CspOptions options, string styleHash, string scriptHash, string frameSources)
{
var csp1part = options.Level == CspLevel.One ? "'unsafe-inline' " : string.Empty;
var cspHeader = $"default-src 'none'; style-src {csp1part}'{styleHash}'; script-src {csp1part}'{scriptHash}'";
if (!string.IsNullOrEmpty(frameSources))
{
cspHeader += $"; frame-src {frameSources}";
}
AddCspHeaders(response.Headers, options, cspHeader);
}
public static void AddCspHeaders(IHeaderDictionary headers, CspOptions options, string cspHeader)
{
if (!headers.ContainsKey("Content-Security-Policy"))

View file

@ -218,6 +218,12 @@ public static class IdentityServerConstants
public const string PushedAuthorization = "PushedAuthorization";
public const string OAuthMetadata = "OAuthMetadata";
public const string SamlMetadata = "SamlMetadata";
public const string SamlSignin = "SamlSignin";
public const string SamlSigninCallback = "SamlSigninCallback";
public const string SamlIdpInitiated = "SamlIdpInitiated";
public const string SamlLogout = "SamlLogout";
public const string SamlLogoutCallback = "SamlLogoutCallback";
}
public static class ContentSecurityPolicyHashes
@ -236,6 +242,11 @@ public static class IdentityServerConstants
/// The hash of the inline script used on the check session endpoint.
/// </summary>
public const string CheckSessionScript = "sha256-jyguj/c+mxOUX7TJrFnIkEQlj4jinO1nejo8qnuF1jc=";
/// <summary>
/// The hash of the inline script used for SAML auto-post form submissions.
/// </summary>
public const string SamlAutoPostScript = "sha256-x5thY6OTOhOhd8GSiineDdcCYxqXyCOfbLSHMWmHPjw=";
}
public static class ProtocolRoutePaths
@ -266,6 +277,14 @@ public static class IdentityServerConstants
public const string MtlsIntrospection = MtlsPathPrefix + "/introspect";
public const string MtlsDeviceAuthorization = MtlsPathPrefix + "/deviceauthorization";
public const string SamlPathPrefix = "saml";
public const string SamlMetadata = SamlPathPrefix + "/metadata";
public const string SamlSignin = SamlPathPrefix + "/signin";
public const string SamlSigninCallback = SamlPathPrefix + "/signin_callback";
public const string SamlIdpInitiated = SamlPathPrefix + "/idp-initiated";
public const string SamlLogout = SamlPathPrefix + "/logout";
public const string SamlLogoutCallback = SamlPathPrefix + "/logout_callback";
public static readonly string[] CorsPaths =
{
DiscoveryConfiguration,

View file

@ -0,0 +1,77 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Internal.Saml.SingleSignin;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml;
internal class DefaultSamlInteractionService(
ISamlSigninStateStore stateStore,
SamlSigninStateIdCookie stateIdCookie,
ISamlServiceProviderStore serviceProviderStore,
ILogger<DefaultSamlInteractionService> logger)
: ISamlInteractionService
{
public async Task<SamlAuthenticationRequest?> GetAuthenticationRequestContextAsync(Ct ct = default)
{
using var activity = Tracing.ServiceActivitySource.StartActivity("DefaultSamlInteractionService.GetAuthenticationRequestContext");
if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId))
{
logger.NoSamlAuthenticationStateFound(LogLevel.Warning);
return null;
}
var state = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct);
if (state == null)
{
logger.StateNotFound(LogLevel.Warning, stateId.Value);
return null;
}
var sp = await serviceProviderStore.FindByEntityIdAsync(state.ServiceProviderEntityId, ct);
if (sp == null)
{
logger.ServiceProviderNotFound(LogLevel.Warning, state.ServiceProviderEntityId);
return null;
}
logger.AuthenticationStateLoaded(LogLevel.Debug, sp.EntityId);
return new SamlAuthenticationRequest
{
ServiceProvider = sp,
AuthNRequest = state.Request,
RelayState = state.RelayState,
IsIdpInitiated = state.IsIdpInitiated
};
}
public async Task StoreRequestedAuthnContextResultAsync(bool requestedAuthnContextRequirementsWereMet, Ct ct = default)
{
using var activity = Tracing.ServiceActivitySource.StartActivity("DefaultSamlInteractionService.StoreRequestedAuthnContextResult");
if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId))
{
logger.NoSamlAuthenticationStateFound(LogLevel.Warning);
throw new InvalidOperationException("No active SAML authentication request found. Cannot store authentication error.");
}
var state = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct);
if (state == null)
{
logger.StateNotFound(LogLevel.Warning, stateId.Value);
throw new InvalidOperationException($"SAML signin state not found for state ID {stateId.Value}");
}
state.RequestedAuthnContextRequirementsWereMet = requestedAuthnContextRequirementsWereMet;
await stateStore.UpdateSigninRequestStateAsync(stateId.Value, state, ct);
logger.RequestedAuthnContextRequirementsWereMetUpdatedInState(LogLevel.Debug, requestedAuthnContextRequirementsWereMet);
}
}

View file

@ -0,0 +1,12 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
namespace Duende.IdentityServer.Internal.Saml;
internal class EmptySamlServiceProviderStore : ISamlServiceProviderStore
{
public Task<SamlServiceProvider> FindByEntityIdAsync(string entityId, Ct ct) => Task.FromResult<SamlServiceProvider>(null);
}

View file

@ -0,0 +1,43 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Text.Encodings.Web;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal static class HttpResponseBindings
{
internal static string GenerateAutoPostForm(string messageName, string encodedMessage, Uri destination, string? relayState, bool includeCsp = false)
{
var relayStateField = relayState == null
? string.Empty
: $@"<input type=""hidden"" name=""RelayState"" value=""{HtmlEncoder.Default.Encode(relayState)}"" />";
var cspMetaTag = includeCsp
? $@"<meta http-equiv=""Content-Security-Policy"" content=""script-src '{IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript}'"" />"
: string.Empty;
return $@"<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"" />
{cspMetaTag}
<title>SAML Response</title>
</head>
<body>
<noscript>
<p><strong>Note:</strong> Since your browser does not support JavaScript, you must press the button below to proceed.</p>
</noscript>
<form method=""post"" action=""{HtmlEncoder.Default.Encode(destination.ToString())}"">
<input type=""hidden"" name=""{messageName}"" value=""{HtmlEncoder.Default.Encode(encodedMessage)}"" />
{relayStateField}
<noscript>
<input type=""submit"" value=""Continue"" />
</noscript>
</form>
<script>window.addEventListener('load', function () {{ document.forms[0].submit(); }});</script>
</body>
</html>";
}
}

View file

@ -0,0 +1,17 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Interface for SAML requests that have common validation fields
/// </summary>
internal interface ISamlRequest
{
internal static abstract string MessageName { get; }
internal string Issuer { get; }
internal string Version { get; }
internal DateTime IssueInstant { get; }
internal Uri? Destination { get; }
}

View file

@ -0,0 +1,11 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Xml.Linq;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal interface ISamlResultSerializer<T>
{
XElement Serialize(T toSerialize);
}

View file

@ -0,0 +1,33 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Cryptography.X509Certificates;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Service for obtaining signing credentials for SAML operations.
/// </summary>
internal interface ISamlSigningService
{
/// <summary>
/// Gets the X509 certificate used for signing SAML messages.
/// </summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>The signing certificate with private key.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when no signing credential is available, when the credential is not an X509 certificate,
/// or when the certificate does not have a private key.
/// </exception>
Task<X509Certificate2> GetSigningCertificateAsync(Ct ct);
/// <summary>
/// Gets the X509 certificate as a base64-encoded string for inclusion in SAML metadata.
/// </summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>Base64-encoded certificate bytes.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when no signing credential is available or when the credential is not an X509 certificate.
/// </exception>
Task<string> GetSigningCertificateBase64Async(Ct ct);
}

View file

@ -0,0 +1,56 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal class LimitedReadStream(Stream innerStream, long maxBytes) : Stream
{
private long _bytesRead;
public override void Flush() => innerStream.Flush();
public override int Read(byte[] buffer, int offset, int count)
{
var bytesToRead = (int)Math.Min(count, maxBytes - _bytesRead);
if (bytesToRead <= 0)
{
throw new InvalidOperationException("Maximum stream size exceeded.");
}
var read = innerStream.Read(buffer, offset, bytesToRead);
_bytesRead += read;
return read;
}
public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin);
public override void SetLength(long value) => innerStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => innerStream.Write(buffer, offset, count);
public override bool CanRead => innerStream.CanRead;
public override bool CanSeek => innerStream.CanSeek;
public override bool CanWrite => innerStream.CanWrite;
public override long Length => innerStream.Length;
public override long Position
{
get => innerStream.Position;
set => innerStream.Position = value;
}
public override async ValueTask DisposeAsync()
{
await innerStream.DisposeAsync();
await base.DisposeAsync();
}
protected override void Dispose(bool disposing)
{
innerStream.Dispose();
base.Dispose(disposing);
}
}

View file

@ -0,0 +1,33 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Net;
using Duende.IdentityServer.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal class RedirectResult(Uri RedirectUri) : IEndpointResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
var logger = httpContext.RequestServices.GetRequiredService<ILogger<RedirectResult>>();
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(RedirectUri);
logger.Redirecting(LogLevel.Trace, RedirectUri);
httpContext.Response.StatusCode = (int)HttpStatusCode.Redirect;
httpContext.Response.Headers.Location = RedirectUri.ToString();
return Task.CompletedTask;
}
}
internal class ValidationProblemResult(string title, params KeyValuePair<string, string[]>[] errors) : IEndpointResult
{
public async Task ExecuteAsync(HttpContext context) =>
await Results.ValidationProblem(new Dictionary<string, string[]>(errors), title).ExecuteAsync(context);
}

View file

@ -0,0 +1,38 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Diagnostics.CodeAnalysis;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal record Result<TValue, TFailure>
{
[MemberNotNullWhen(true, nameof(Value))]
[MemberNotNullWhen(false, nameof(Error))]
internal bool Success { get; private init; }
internal TValue? Value { get; private init; }
internal TFailure? Error { get; private init; }
public static Result<TValue, TFailure> FromValue(TValue value) =>
new()
{
Success = true,
Value = value
};
public static Result<TValue, TFailure> FromError(TFailure error) =>
new()
{
Success = false,
Error = error
};
public static implicit operator Result<TValue, TFailure>(TFailure value) =>
FromError(value);
public static implicit operator Result<TValue, TFailure>(TValue value) =>
FromValue(value);
// Note: We can't have an implicit operator for TFailure when it's an interface
// because C# won't do double conversion (ConcreteType -> Interface -> Result)
}

View file

@ -0,0 +1,66 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal static partial class SamlAssertionEncryptorLoggingExtensions
{
private static class SamlAssertionEncryptorLogParameters
{
public const string EntityId = "entityId";
public const string ExpirationDate = "expirationDate";
public const string ValidFrom = "validFrom";
public const string KeySize = "keySize";
public const string ErrorMessage = "errorMessage";
}
[LoggerMessage(
EventName = nameof(EncryptingAssertion),
Message = $"Encrypting SAML assertion for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}}"
)]
internal static partial void EncryptingAssertion(this ILogger logger, LogLevel level, string entityId);
[LoggerMessage(
EventName = nameof(AssertionEncryptedSuccessfully),
Message = $"Successfully encrypted SAML assertion for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}}"
)]
internal static partial void AssertionEncryptedSuccessfully(this ILogger logger, LogLevel level, string entityId);
[LoggerMessage(
EventName = nameof(CertificateExpired),
Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} has expired (expiration: {{{SamlAssertionEncryptorLogParameters.ExpirationDate}}})"
)]
internal static partial void CertificateExpired(this ILogger logger, LogLevel level, string entityId, DateTime expirationDate);
[LoggerMessage(
EventName = nameof(CertificateNotYetValid),
Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} is not yet valid (valid from: {{{SamlAssertionEncryptorLogParameters.ValidFrom}}})"
)]
internal static partial void CertificateNotYetValid(this ILogger logger, LogLevel level, string entityId, DateTime validFrom);
[LoggerMessage(
EventName = nameof(CertificateHasNoPublicRsaKey),
Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} has no public RSA key")]
internal static partial void CertificateHasNoPublicRsaKey(this ILogger logger, LogLevel level, string entityId);
[LoggerMessage(
EventName = nameof(CertificateWeakKeySize),
Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} has weak RSA key size ({{{SamlAssertionEncryptorLogParameters.KeySize}}} bits). Minimum required: 2048 bits"
)]
internal static partial void CertificateWeakKeySize(this ILogger logger, LogLevel level, string entityId, int keySize);
[LoggerMessage(
EventName = nameof(CertificateValidated),
Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} validated successfully (expires: {{{SamlAssertionEncryptorLogParameters.ExpirationDate}}})"
)]
internal static partial void CertificateValidated(this ILogger logger, LogLevel level, string entityId, DateTime expirationDate);
[LoggerMessage(
EventName = nameof(FailedToEncryptAssertion),
Level = LogLevel.Error,
Message = $"Failed to encrypt SAML assertion for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}}: {{{SamlAssertionEncryptorLogParameters.ErrorMessage}}}"
)]
internal static partial void FailedToEncryptAssertion(this ILogger logger, Exception exception, string entityId, string errorMessage);
}

View file

@ -0,0 +1,116 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography.Xml;
using System.Xml;
using Duende.IdentityServer.Models;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal class SamlAssertionEncryptor(TimeProvider timeProvider, ILogger<SamlAssertionEncryptor> logger)
{
internal string EncryptAssertion(string responseXml, SamlServiceProvider serviceProvider)
{
ArgumentException.ThrowIfNullOrWhiteSpace(responseXml);
ArgumentNullException.ThrowIfNull(serviceProvider);
var encryptionCertificate = serviceProvider.EncryptionCertificates?.FirstOrDefault(cert => IsCertificateValid(cert, serviceProvider.EntityId));
if (encryptionCertificate == null)
{
throw new InvalidOperationException($"No valid encryption certificate found for {serviceProvider.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys.");
}
var doc = SecureXmlParser.LoadXmlDocument(responseXml);
var assertion = FindAssertion(doc);
if (assertion == null)
{
throw new InvalidOperationException($"SAML Response does not contain an Assertion element for {serviceProvider.EntityId}");
}
logger.EncryptingAssertion(LogLevel.Debug, serviceProvider.EntityId);
try
{
var encryptedAssertion = EncryptAssertionXml(assertion, encryptionCertificate, doc);
ReplaceAssertionWithEncrypted(assertion, encryptedAssertion);
logger.AssertionEncryptedSuccessfully(LogLevel.Debug, serviceProvider.EntityId);
return doc.OuterXml;
}
catch (Exception ex)
{
logger.FailedToEncryptAssertion(ex, serviceProvider.EntityId, ex.Message);
throw;
}
}
private bool IsCertificateValid(X509Certificate2 certificate, string serviceProviderEntityId)
{
var now = timeProvider.GetUtcNow();
if (certificate.NotAfter < now)
{
logger.CertificateExpired(LogLevel.Error, serviceProviderEntityId, certificate.NotAfter);
return false;
}
if (certificate.NotBefore > now)
{
logger.CertificateNotYetValid(LogLevel.Error, serviceProviderEntityId, certificate.NotBefore);
return false;
}
using var publicKey = certificate.GetRSAPublicKey();
if (publicKey == null)
{
logger.CertificateHasNoPublicRsaKey(LogLevel.Error, serviceProviderEntityId);
return false;
}
if (publicKey.KeySize < 2048)
{
logger.CertificateWeakKeySize(LogLevel.Error, serviceProviderEntityId, publicKey.KeySize);
return false;
}
logger.CertificateValidated(LogLevel.Debug, serviceProviderEntityId, certificate.NotAfter);
return true;
}
private static XmlElement? FindAssertion(XmlDocument doc)
{
var nsManager = new XmlNamespaceManager(doc.NameTable);
nsManager.AddNamespace("saml", SamlConstants.Namespaces.Assertion);
return doc.SelectSingleNode("//saml:Assertion", nsManager) as XmlElement;
}
private static void ReplaceAssertionWithEncrypted(XmlElement originalAssertion, XmlElement encryptedAssertion)
{
var parentNode = originalAssertion.ParentNode;
if (parentNode is null)
{
throw new InvalidOperationException(
"Cannot replace SAML Assertion because it has no parent node in the XML document.");
}
parentNode.ReplaceChild(encryptedAssertion, originalAssertion);
}
private static XmlElement EncryptAssertionXml(XmlElement assertion, X509Certificate2 encryptionCertificate, XmlDocument doc)
{
var encryptedXml = new EncryptedXml();
var encryptedData = encryptedXml.Encrypt(assertion, encryptionCertificate);
var encryptedAssertion = doc.CreateElement("saml", "EncryptedAssertion", SamlConstants.Namespaces.Assertion);
var encryptedDataElement = doc.ImportNode(encryptedData.GetXml(), true);
encryptedAssertion.AppendChild(encryptedDataElement);
return encryptedAssertion;
}
}

View file

@ -0,0 +1,34 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Models;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal static class SamlBindingExtensions
{
internal static SamlBinding? FromUrnOrDefault(string? urn)
{
if (urn == null)
{
return null;
}
return FromUrn(urn);
}
internal static SamlBinding FromUrn(string urn) => urn switch
{
SamlConstants.Bindings.HttpRedirect => SamlBinding.HttpRedirect,
SamlConstants.Bindings.HttpPost => SamlBinding.HttpPost,
_ => throw new ArgumentOutOfRangeException(nameof(urn), urn, "Unknown SAML binding")
};
internal static string ToUrn(this SamlBinding binding) => binding switch
{
SamlBinding.HttpRedirect => SamlConstants.Bindings.HttpRedirect,
SamlBinding.HttpPost => SamlConstants.Bindings.HttpPost,
_ => throw new ArgumentOutOfRangeException(nameof(binding), binding, "Unknown SAML binding")
};
}

View file

@ -0,0 +1,100 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Text;
using System.Xml;
using System.Xml.Linq;
using Duende.IdentityServer.Endpoints.Results;
using Duende.IdentityServer.Hosting;
using Duende.IdentityServer.Models;
using Microsoft.AspNetCore.Http;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Represents a SAML error response that will be sent to the Service Provider.
/// </summary>
internal class SamlErrorResponse : EndpointResult<SamlErrorResponse>
{
/// <summary>
/// Gets the SAML binding to use for sending the response (HTTP-POST or HTTP-Redirect).
/// </summary>
public required SamlBinding Binding { get; init; }
/// <summary>
/// Gets the SAML status code for the error.
/// </summary>
public required string StatusCode { get; init; }
/// <summary>
/// Gets the human-readable error message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Gets the Assertion Consumer Service URL to send the response to.
/// </summary>
public required Uri AssertionConsumerServiceUrl { get; init; }
/// <summary>
/// Gets the IdP issuer URI.
/// </summary>
public required string Issuer { get; init; }
/// <summary>
/// Gets the request ID this response is replying to (InResponseTo), or null for IdP-initiated.
/// </summary>
public string? InResponseTo { get; init; }
/// <summary>
/// Gets the RelayState to preserve across the response.
/// </summary>
public string? RelayState { get; init; }
/// <summary>
/// Gets an optional secondary status code for more specific error information.
/// </summary>
public string? SubStatusCode { get; init; }
/// <summary>
/// Gets or sets the Service Provider where the response will be sent.
/// </summary>
public required SamlServiceProvider ServiceProvider { get; init; }
internal class ResponseWriter(ISamlResultSerializer<SamlErrorResponse> serializer)
: IHttpResponseWriter<SamlErrorResponse>
{
public async Task WriteHttpResponse(SamlErrorResponse result, HttpContext httpContext)
{
var responseElement = serializer.Serialize(result);
var doc = new XDocument(new XDeclaration("1.0", "UTF-8", null), responseElement);
await using var stringWriter = new StringWriter();
await using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings
{
OmitXmlDeclaration = false,
Encoding = Encoding.UTF8,
Indent = false,
Async = true
}))
{
doc.Save(xmlWriter);
await xmlWriter.FlushAsync();
}
var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(stringWriter.ToString()));
// Generate HTML form that auto-submits to the ACS URL
var html = HttpResponseBindings.GenerateAutoPostForm(SamlConstants.RequestProperties.SAMLResponse, encodedResponse, result.AssertionConsumerServiceUrl,
result.RelayState);
httpContext.Response.ContentType = "text/html";
httpContext.Response.Headers.CacheControl = "no-cache, no-store";
httpContext.Response.Headers.Pragma = "no-cache";
await httpContext.Response.WriteAsync(html);
}
}
}

View file

@ -0,0 +1,59 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Globalization;
using System.Xml.Linq;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal class SamlErrorResponseXmlSerializer : ISamlResultSerializer<SamlErrorResponse>
{
public XElement Serialize(SamlErrorResponse result)
{
var responseId = SamlIds.NewResponseId();
var issueInstant = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
// Build Status element
var statusCodeElement = new XElement(protocolNs + "StatusCode",
new XAttribute("Value", result.StatusCode));
// Add sub-status code if provided
if (result.SubStatusCode != null)
{
statusCodeElement.Add(
new XElement(protocolNs + "StatusCode",
new XAttribute("Value", result.SubStatusCode)));
}
var statusElement = new XElement(protocolNs + "Status",
statusCodeElement);
// Add status message if provided
if (!string.IsNullOrEmpty(result.Message))
{
statusElement.Add(
new XElement(protocolNs + "StatusMessage", result.Message));
}
// Build Response element
var responseElement = new XElement(protocolNs + "Response",
new XAttribute("ID", responseId),
new XAttribute("Version", "2.0"),
new XAttribute("IssueInstant", issueInstant),
new XAttribute("Destination", result.AssertionConsumerServiceUrl.ToString()),
new XElement(assertionNs + "Issuer", result.Issuer.ToString()),
statusElement);
// Add InResponseTo if this is a response to a request
if (result.InResponseTo != null)
{
responseElement.Add(new XAttribute("InResponseTo", result.InResponseTo));
}
return responseElement;
}
}

View file

@ -0,0 +1,76 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Globalization;
using System.Xml.Linq;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Base class for SAML protocol message parsers.
/// Provides common XML parsing and validation utilities.
/// </summary>
internal abstract class SamlProtocolMessageParser
{
protected static string GetRequiredAttribute(XElement element, XName attributeName)
{
var value = element.Attribute(attributeName)?.Value;
if (string.IsNullOrWhiteSpace(value))
{
throw new FormatException($"Required attribute '{attributeName}' is missing or empty");
}
return value;
}
protected static string? GetOptionalAttribute(XElement element, XName attributeName)
{
var value = element.Attribute(attributeName)?.Value;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
protected static DateTime ParseDateTime(XElement element, XName attributeName)
{
var value = GetRequiredAttribute(element, attributeName);
if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var result))
{
throw new FormatException($"Invalid DateTime format for attribute '{attributeName}': {value}");
}
return result;
}
protected static bool ParseBooleanAttribute(XElement element, XName attributeName, bool defaultValue)
{
var value = GetOptionalAttribute(element, attributeName);
if (value == null)
{
return defaultValue;
}
if (bool.TryParse(value, out var result))
{
return result;
}
throw new FormatException($"Invalid boolean format for attribute '{attributeName}': {value}");
}
protected static string ParseIssuerValue(XElement root, XNamespace assertionNs, string messageType)
{
var issuerElement = root.Element(assertionNs + "Issuer");
if (issuerElement == null)
{
throw new InvalidOperationException($"Issuer element is required in {messageType}");
}
var issuer = issuerElement.Value?.Trim();
if (string.IsNullOrEmpty(issuer))
{
throw new InvalidOperationException("Issuer element cannot be empty");
}
return issuer;
}
}

View file

@ -0,0 +1,58 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Xml.Linq;
using Duende.IdentityServer.Models;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal class SamlProtocolMessageSigner(
ISamlSigningService samlSigningService,
ILogger<SamlProtocolMessageSigner> logger)
{
internal async Task<string> SignProtocolMessage(XElement messageElement, SamlServiceProvider serviceProvider, Ct ct)
{
ArgumentNullException.ThrowIfNull(messageElement);
ArgumentNullException.ThrowIfNull(serviceProvider);
var certificate = await samlSigningService.GetSigningCertificateAsync(ct);
logger.SigningSamlProtocolMessage(LogLevel.Debug, serviceProvider.EntityId, messageElement.Name.LocalName);
try
{
var signedXml = XmlSignatureHelper.SignProtocolElement(messageElement, certificate);
logger.SuccessfullySignedSamlProtocolMessage(LogLevel.Debug, serviceProvider.EntityId, messageElement.Name.LocalName);
return signedXml;
}
catch (Exception ex)
{
logger.FailedToSignSamlProtocolMessage(ex, serviceProvider.EntityId, messageElement.Name.LocalName, ex.Message);
throw;
}
}
internal async Task<string> SignQueryString(string queryString, Ct ct)
{
var certificate = await samlSigningService.GetSigningCertificateAsync(ct);
using var rsa = certificate.GetRSAPrivateKey();
if (rsa == null)
{
throw new InvalidOperationException("RSA private key not available for signing.");
}
queryString = $"{queryString}&SigAlg={Uri.EscapeDataString("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")}";
var bytesToSign = Encoding.UTF8.GetBytes(queryString);
var signature = rsa.SignData(bytesToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return $"{queryString}&Signature={Uri.EscapeDataString(Convert.ToBase64String(signature))}";
}
}

View file

@ -0,0 +1,30 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Xml.Linq;
using Duende.IdentityServer.Models;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Base record for SAML request wrappers that contain both the parsed request
/// and HTTP binding metadata.
/// </summary>
/// <typeparam name="TRequest">The type of the parsed SAML request</typeparam>
internal abstract record SamlRequestBase<TRequest> where TRequest : ISamlRequest
{
public required TRequest Request { get; init; }
public required XDocument RequestXml { get; init; }
public required SamlBinding Binding { get; init; }
public string? RelayState { get; init; }
public string? Signature { get; init; }
public string? SignatureAlgorithm { get; init; }
public string? EncodedSamlRequest { get; init; }
}

View file

@ -0,0 +1,26 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal enum SamlRequestErrorType
{
Validation,
Protocol
}
internal class SamlRequestError<TRequest>
{
internal SamlRequestErrorType Type { get; init; }
internal string? ValidationMessage { get; init; }
internal SamlProtocolError<TRequest>? ProtocolError { get; init; }
}
internal record SamlProtocolError<TRequest>(
SamlServiceProvider ServiceProvider,
TRequest Request,
SamlError Error);

View file

@ -0,0 +1,167 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.IO.Compression;
using System.Xml;
using System.Xml.Linq;
using Duende.IdentityServer.Models;
using Microsoft.AspNetCore.Http;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Base class for extracting and parsing SAML protocol messages from HTTP requests.
/// Handles common logic for both HTTP-Redirect and HTTP-POST bindings.
/// </summary>
/// <typeparam name="TRequest">The type of the parsed SAML request (e.g., AuthNRequest, LogoutRequest)</typeparam>
/// <typeparam name="TResult">The type of the result containing the parsed request and metadata</typeparam>
internal abstract class SamlRequestExtractor<TRequest, TResult>
where TRequest : ISamlRequest
where TResult : SamlRequestBase<TRequest>
{
private const int MaxRequestSize = 1024 * 1024; // 1MB limit
protected abstract TRequest ParseRequest(XDocument xmlDocument);
protected abstract TResult CreateResult(
TRequest parsedRequest,
XDocument requestXml,
SamlBinding binding,
string? relayState,
string? signature = null,
string? signatureAlgorithm = null,
string? encodedSamlRequest = null);
internal async ValueTask<TResult> ExtractAsync(HttpContext context)
{
var request = context.Request;
if (request.Method == HttpMethods.Get)
{
return ExtractRedirectRequest(request);
}
if (request.Method == HttpMethods.Post)
{
return await ExtractPostBindingRequest(request);
}
throw new BadHttpRequestException($"Unsupported HTTP method '{request.Method}' for {TRequest.MessageName}");
}
private TResult ExtractRedirectRequest(HttpRequest request)
{
var encodedRequest = request.Query[SamlConstants.RequestProperties.SAMLRequest].ToString();
if (string.IsNullOrEmpty(encodedRequest))
{
throw new BadHttpRequestException(
$"Missing '{SamlConstants.RequestProperties.SAMLRequest}' query parameter in {TRequest.MessageName}");
}
var relayState = request.Query[SamlConstants.RequestProperties.RelayState].ToString();
var signature = request.Query[SamlConstants.RequestProperties.Signature].ToString();
var sigAlg = request.Query[SamlConstants.RequestProperties.SigAlg].ToString();
// Normalize empty relay state to null (important for signature validation)
if (string.IsNullOrEmpty(relayState))
{
relayState = null;
}
// HTTP-Redirect uses deflate compression
byte[] compressedXmlBytes;
try
{
compressedXmlBytes = Convert.FromBase64String(encodedRequest);
}
catch (FormatException ex)
{
throw new BadHttpRequestException($"Invalid base64 encoding in {TRequest.MessageName}", ex);
}
using var compressedXmlStream = new MemoryStream(compressedXmlBytes);
using var xmlStream = new DeflateStream(compressedXmlStream, CompressionMode.Decompress);
using var limitedStream = new LimitedReadStream(xmlStream, MaxRequestSize);
var (parsedRequest, xmlDocument) = LoadRequestFromStream(limitedStream);
return CreateResult(
parsedRequest,
xmlDocument,
SamlBinding.HttpRedirect,
relayState,
string.IsNullOrEmpty(signature) ? null : signature,
string.IsNullOrEmpty(sigAlg) ? null : sigAlg,
encodedRequest);
}
private async Task<TResult> ExtractPostBindingRequest(HttpRequest request)
{
if (!request.HasFormContentType)
{
throw new BadHttpRequestException($"POST request does not have form content type for {TRequest.MessageName}");
}
var form = await request.ReadFormAsync();
var encodedRequest = form[SamlConstants.RequestProperties.SAMLRequest].ToString();
if (string.IsNullOrEmpty(encodedRequest))
{
throw new BadHttpRequestException(
$"Missing '{SamlConstants.RequestProperties.SAMLRequest}' form parameter in {TRequest.MessageName}");
}
var relayState = form[SamlConstants.RequestProperties.RelayState].ToString();
// Normalize empty relay state to null
if (string.IsNullOrEmpty(relayState))
{
relayState = null;
}
// HTTP-POST has no compression
byte[] xmlBytes;
try
{
xmlBytes = Convert.FromBase64String(encodedRequest);
}
catch (FormatException ex)
{
throw new BadHttpRequestException($"Invalid base64 encoding in {TRequest.MessageName}", ex);
}
using var xmlStream = new MemoryStream(xmlBytes);
await using var limitedStream = new LimitedReadStream(xmlStream, MaxRequestSize);
var (parsedRequest, xmlDocument) = LoadRequestFromStream(limitedStream);
return CreateResult(
parsedRequest,
xmlDocument,
SamlBinding.HttpPost,
relayState);
}
private (TRequest parsedRequest, XDocument xmlDocument) LoadRequestFromStream(LimitedReadStream limitedStream)
{
try
{
var xmlDocument = SecureXmlParser.LoadXDocument(limitedStream);
var parsedRequest = ParseRequest(xmlDocument);
return (parsedRequest, xmlDocument);
}
catch (FormatException ex)
{
throw new BadHttpRequestException($"Invalid SAMLRequest format in {TRequest.MessageName}", ex);
}
catch (InvalidOperationException ex)
{
throw new BadHttpRequestException($"Invalid SAMLRequest format in {TRequest.MessageName}", ex);
}
catch (XmlException ex)
{
throw new BadHttpRequestException($"Failed to parse SAMLRequest XML in {TRequest.MessageName}", ex);
}
}
}

View file

@ -0,0 +1,144 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal abstract class SamlRequestProcessorBase<TMessage, TRequest, TSuccess>(
ISamlServiceProviderStore serviceProviderStore,
IOptions<SamlOptions> options,
SamlRequestValidator requestValidator,
SamlRequestSignatureValidator<TRequest, TMessage> signatureValidator,
ILogger logger,
string expectedDestination)
where TMessage : ISamlRequest
where TRequest : SamlRequestBase<TMessage>
{
protected readonly ISamlServiceProviderStore ServiceProviderStore = serviceProviderStore;
protected readonly SamlOptions SamlOptions = options.Value;
protected readonly SamlRequestValidator RequestValidator = requestValidator;
protected readonly SamlRequestSignatureValidator<TRequest, TMessage> SignatureValidator = signatureValidator;
protected readonly ILogger Logger = logger;
protected readonly string ExpectedDestination = expectedDestination;
internal async Task<Result<TSuccess, SamlRequestError<TRequest>>> ProcessAsync(TRequest request, Ct ct = default)
{
var sp = await ServiceProviderStore.FindByEntityIdAsync(request.Request.Issuer, ct);
if (sp?.Enabled != true)
{
Logger.ServiceProviderNotFound(LogLevel.Warning, request.Request.Issuer);
return new SamlRequestError<TRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"Service Provider '{request.Request.Issuer}' is not registered or is disabled"
};
}
var validationError = ValidateRequest(sp, request);
if (validationError != null)
{
return validationError;
}
return await ProcessValidatedRequestAsync(sp, request, ct);
}
private SamlRequestError<TRequest>? ValidateRequest(SamlServiceProvider sp, TRequest request)
{
// Common validation (version, issue instant, destination)
var validationError = RequestValidator.ValidateCommonFields(
request.Request.Version,
request.Request.IssueInstant,
request.Request.Destination,
sp,
ExpectedDestination);
if (validationError != null)
{
return new SamlRequestError<TRequest>
{
Type = SamlRequestErrorType.Protocol,
ProtocolError = new SamlProtocolError<TRequest>(sp, request, new SamlError
{
StatusCode = validationError.StatusCode,
SubStatusCode = validationError.SubStatusCode,
Message = validationError.Message
})
};
}
// Signature validation
var signatureError = ValidateSignature(sp, request);
if (signatureError != null)
{
return signatureError;
}
// Message-specific validation
return ValidateMessageSpecific(sp, request);
}
protected abstract bool RequireSignature(SamlServiceProvider sp);
private SamlRequestError<TRequest>? ValidateSignature(SamlServiceProvider sp, TRequest request)
{
var requireSignature = RequireSignature(sp);
if (!requireSignature)
{
return null;
}
if (sp.SigningCertificates == null || sp.SigningCertificates.Count == 0)
{
return new SamlRequestError<TRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"Service Provider '{sp.EntityId}' has no signing certificates configured and has sent a {TMessage.MessageName} which requires signature validation"
};
}
Result<bool, SamlError> validationResult;
if (request.Binding == SamlBinding.HttpRedirect)
{
validationResult = SignatureValidator.ValidateRedirectBindingSignature(request, sp);
}
else if (request.Binding == SamlBinding.HttpPost)
{
validationResult = SignatureValidator.ValidatePostBindingSignature(request, sp);
}
else
{
return new SamlRequestError<TRequest>
{
Type = SamlRequestErrorType.Protocol,
ProtocolError = new SamlProtocolError<TRequest>(sp, request, new SamlError
{
StatusCode = SamlStatusCodes.Requester,
Message = $"Unsupported binding for signature validation: {request.Binding}"
})
};
}
if (!validationResult.Success)
{
return new SamlRequestError<TRequest>
{
Type = SamlRequestErrorType.Protocol,
ProtocolError = new SamlProtocolError<TRequest>(sp, request, validationResult.Error)
};
}
return null;
}
protected abstract SamlRequestError<TRequest>? ValidateMessageSpecific(SamlServiceProvider sp, TRequest request);
protected abstract Task<Result<TSuccess, SamlRequestError<TRequest>>> ProcessValidatedRequestAsync(SamlServiceProvider sp, TRequest request, Ct ct = default);
}

View file

@ -0,0 +1,191 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography.Xml;
using System.Text;
using System.Xml;
using System.Xml.Linq;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Validates signatures on SAML request messages for both HTTP-Redirect and HTTP-POST bindings.
/// </summary>
internal class SamlRequestSignatureValidator<TRequest, TSamlRequest>(TimeProvider timeProvider)
where TRequest : SamlRequestBase<TSamlRequest>
where TSamlRequest : ISamlRequest
{
private static readonly HashSet<string> SupportedAlgorithms =
[
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
];
/// <summary>
/// Validates signature on HTTP-Redirect binding request.
/// </summary>
internal Result<bool, SamlError> ValidateRedirectBindingSignature(
TRequest request,
SamlServiceProvider serviceProvider)
{
var signature = request.Signature;
var sigAlg = request.SignatureAlgorithm;
if (string.IsNullOrEmpty(signature))
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = "Missing signature parameter" });
}
if (string.IsNullOrEmpty(sigAlg))
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = "Missing signature algorithm parameter" });
}
if (!SupportedAlgorithms.Contains(sigAlg))
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = $"Unsupported signature algorithm: {sigAlg}" });
}
// re-create the querystring part that is signed. The spec dictates the exact way this is to be done:
// SAMLRequest=value&RelayState=value&SigAlg=value
// The parameters must be URL-encoded
var queryToVerify = $"SAMLRequest={Uri.EscapeDataString(request.EncodedSamlRequest!)}";
if (request.RelayState != null)
{
queryToVerify += $"&RelayState={Uri.EscapeDataString(request.RelayState)}";
}
queryToVerify += $"&SigAlg={Uri.EscapeDataString(sigAlg)}";
var bytesToVerify = Encoding.UTF8.GetBytes(queryToVerify);
var signatureBytes = Convert.FromBase64String(signature);
return ValidateWithCertificates(
serviceProvider,
cert => ValidateRedirectSignature(cert, bytesToVerify, signatureBytes, sigAlg));
}
private static bool ValidateRedirectSignature(X509Certificate2 cert, byte[] data, byte[] signature, string sigAlg)
{
using var rsa = cert.GetRSAPublicKey();
if (rsa == null)
{
return false;
}
var hashAlgorithm = sigAlg.Contains("sha512", StringComparison.OrdinalIgnoreCase)
? HashAlgorithmName.SHA512
: HashAlgorithmName.SHA256;
return rsa.VerifyData(data, signature, hashAlgorithm, RSASignaturePadding.Pkcs1);
}
/// <summary>
/// Validates signature on HTTP-POST binding request.
/// </summary>
internal Result<bool, SamlError> ValidatePostBindingSignature(
TRequest request,
SamlServiceProvider serviceProvider)
{
var requestXml = request.RequestXml;
// In order to use SignedXml, we need to work with XmlDocument, not an XDocument.
// So we convert the XDocument to string and then parse it securely into an XmlDocument.
var xmlString = requestXml.ToString(SaveOptions.DisableFormatting);
XmlDocument doc;
try
{
doc = SecureXmlParser.LoadXmlDocument(xmlString);
}
catch (XmlException ex)
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = $"Invalid XML: {ex.Message}" });
}
// Find signature element
var nsmgr = new XmlNamespaceManager(doc.NameTable);
nsmgr.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#");
var signatureNode = doc.SelectSingleNode("//ds:Signature", nsmgr);
if (signatureNode == null)
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = "Signature element not found" });
}
// Get the request ID that must be signed
var requestId = doc.DocumentElement?.GetAttribute("ID");
if (string.IsNullOrEmpty(requestId))
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = $"{TSamlRequest.MessageName} missing ID attribute" });
}
return ValidateWithCertificates(
serviceProvider,
cert => ValidateXmlSignature(cert, doc, signatureNode, requestId));
}
private static bool ValidateXmlSignature(X509Certificate2 cert, XmlDocument doc, XmlNode signatureNode, string expectedId)
{
var signedXml = new SignedXml(doc);
signedXml.LoadXml((XmlElement)signatureNode);
if (!signedXml.CheckSignature(cert, true))
{
return false;
}
// SECURITY: Verify the signature references the request element
var reference = signedXml.SignedInfo?.References.Cast<Reference>().FirstOrDefault();
if (reference == null)
{
return false;
}
var referencedId = reference.Uri?.TrimStart('#');
return referencedId == expectedId;
}
private Result<bool, SamlError> ValidateWithCertificates(
SamlServiceProvider serviceProvider,
Func<X509Certificate2, bool> validateSignature)
{
var validCertificates = serviceProvider.SigningCertificates?.Where(cert => ValidateCertificate(cert).Success).ToList();
if (validCertificates == null || validCertificates.Count == 0)
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCodes.Responder, Message = "No valid certificates configured for service provider" });
}
foreach (var cert in validCertificates)
{
if (validateSignature(cert))
{
return Result<bool, SamlError>.FromValue(true);
}
}
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = "Invalid signature" });
}
private Result<bool, string> ValidateCertificate(X509Certificate2 certificate)
{
var now = timeProvider.GetUtcNow();
if (certificate.NotBefore > now.UtcDateTime)
{
return Result<bool, string>.FromError($"Certificate is not yet valid (NotBefore: {certificate.NotBefore:u})");
}
if (certificate.NotAfter < now.UtcDateTime)
{
return Result<bool, string>.FromError($"Certificate has expired (NotAfter: {certificate.NotAfter:u})");
}
return Result<bool, string>.FromValue(true);
}
}

View file

@ -0,0 +1,88 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Helper class for common SAML request validation logic
/// </summary>
internal class SamlRequestValidator(TimeProvider timeProvider, IOptions<SamlOptions> options)
{
private readonly SamlOptions _samlOptions = options.Value;
/// <summary>
/// Validates version, issue instant, and destination for a SAML request
/// </summary>
internal SamlValidationError? ValidateCommonFields(
string version,
DateTime issueInstant,
Uri? destination,
SamlServiceProvider serviceProvider,
string expectedDestination)
{
// Version validation
if (version != SamlVersions.V2)
{
return new SamlValidationError
{
Message = "Only Version 2.0 is supported",
StatusCode = SamlStatusCodes.VersionMismatch
};
}
var now = timeProvider.GetUtcNow().UtcDateTime;
var clockSkew = serviceProvider.ClockSkew ?? _samlOptions.DefaultClockSkew;
// Issue instant not in future
if (issueInstant > now.Add(clockSkew))
{
return new SamlValidationError
{
StatusCode = SamlStatusCodes.Requester,
Message = "Request IssueInstant is in the future"
};
}
// Issue instant not too old
var maxAge = serviceProvider.RequestMaxAge ?? _samlOptions.DefaultRequestMaxAge;
if (issueInstant < now.Subtract(maxAge))
{
return new SamlValidationError
{
StatusCode = SamlStatusCodes.Requester,
Message = "Request has expired (IssueInstant too old)"
};
}
// Destination validation
if (destination != null)
{
if (!destination.ToString().Equals(expectedDestination, StringComparison.OrdinalIgnoreCase))
{
return new SamlValidationError
{
StatusCode = SamlStatusCodes.Requester,
Message = $"Invalid destination. Expected '{expectedDestination}'"
};
}
}
return null;
}
}
/// <summary>
/// Represents a SAML validation error
/// </summary>
internal class SamlValidationError
{
internal required string Message { get; init; }
internal string StatusCode { get; init; } = SamlStatusCodes.Requester;
internal string? SubStatusCode { get; init; }
}

View file

@ -0,0 +1,57 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Xml.Linq;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal class SamlResponseSigner(
ISamlSigningService samlSigningService,
IOptions<SamlOptions> samlOptions,
ILogger<SamlResponseSigner> logger)
{
internal async Task<string> SignResponse(XElement responseElement, SamlServiceProvider serviceProvider, Ct ct)
{
var signingBehavior = serviceProvider.SigningBehavior ?? samlOptions.Value.DefaultSigningBehavior;
if (signingBehavior == SamlSigningBehavior.DoNotSign)
{
logger.SigningDisabledForServiceProvider(LogLevel.Debug, serviceProvider.EntityId);
return responseElement.ToString(SaveOptions.DisableFormatting);
}
var certificate = await samlSigningService.GetSigningCertificateAsync(ct);
logger.SigningSamlResponse(LogLevel.Debug, serviceProvider.EntityId, signingBehavior);
try
{
var signedXml = signingBehavior switch
{
SamlSigningBehavior.SignResponse =>
XmlSignatureHelper.SignResponse(responseElement, certificate),
SamlSigningBehavior.SignAssertion =>
XmlSignatureHelper.SignAssertionInResponse(responseElement, certificate),
SamlSigningBehavior.SignBoth =>
XmlSignatureHelper.SignBoth(responseElement, certificate),
_ => throw new ArgumentException($"Unknown signing behavior: {signingBehavior}")
};
logger.SuccessfullySignedSamlResponse(LogLevel.Debug, serviceProvider.EntityId);
return signedXml;
}
catch (Exception ex)
{
logger.FailedToSignSamlResponse(ex, serviceProvider.EntityId, ex.Message);
throw;
}
}
}

View file

@ -0,0 +1,72 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography.X509Certificates;
using Duende.IdentityServer.Services;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Default implementation of <see cref="ISamlSigningService"/>.
/// </summary>
internal class SamlSigningService(
IKeyMaterialService keyMaterialService,
ILogger<SamlSigningService> logger) : ISamlSigningService
{
/// <inheritdoc/>
public async Task<X509Certificate2> GetSigningCertificateAsync(Ct ct)
{
var credential = await GetSigningCredentialsAsync(ct);
if (!TryExtractCertificateFromCredential(credential, out var certificate))
{
throw new InvalidOperationException(
"Signing credential must be an X509 certificate with private key.");
}
if (!certificate.HasPrivateKey)
{
throw new InvalidOperationException(
"Signing certificate must have a private key.");
}
return certificate;
}
/// <inheritdoc/>
public async Task<string> GetSigningCertificateBase64Async(Ct ct)
{
var credential = await GetSigningCredentialsAsync(ct);
if (TryExtractCertificateFromCredential(credential, out var certificate))
{
var certBytes = certificate.Export(X509ContentType.Cert);
return Convert.ToBase64String(certBytes);
}
throw new InvalidOperationException(
"Signing credential key is not an X509SecurityKey and cannot be used to extract an X509 certificate for SAML metadata.");
}
private async Task<SigningCredentials> GetSigningCredentialsAsync(Ct ct)
{
var credential = await keyMaterialService.GetSigningCredentialsAsync(null, ct);
return credential ?? throw new InvalidOperationException("No signing credential available. Configure a signing certificate.");
}
private bool TryExtractCertificateFromCredential(SigningCredentials credential, [NotNullWhen(returnValue: true)] out X509Certificate2? certificate)
{
certificate = null;
if (credential.Key is X509SecurityKey x509Key)
{
certificate = x509Key.Certificate;
return true;
}
logger.SigningCredentialIsNotX509Certificate(LogLevel.Warning, credential.Key);
return false;
}
}

View file

@ -0,0 +1,86 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal class SamlUrlBuilder(IServerUrls urls,
IOptions<IdentityServerOptions> identityServerOptions,
IOptions<SamlOptions> samlOptions)
{
private readonly SamlUserInteractionOptions _samlRoutes = samlOptions.Value.UserInteraction;
private readonly UserInteractionOptions _identityServerRoutes = identityServerOptions.Value.UserInteraction;
internal Uri SamlConsentUri()
{
var consentUrl = _identityServerRoutes.ConsentUrl
?? throw new InvalidOperationException("No consent url configured");
var returnUrlParameter = _identityServerRoutes.ConsentReturnUrlParameter
?? throw new InvalidOperationException("No Consent return url configured");
return BuildRedirectUrl(consentUrl, returnUrlParameter);
}
internal Uri SamlLoginUri()
{
var loginPageUrl = _identityServerRoutes.LoginUrl
?? throw new InvalidOperationException("No login url configured");
var returnUrlParameter = _identityServerRoutes.LoginReturnUrlParameter
?? throw new InvalidOperationException("No Login return url configured");
return BuildRedirectUrl(loginPageUrl, returnUrlParameter);
}
internal Uri SamlLogoutUri(string logoutId)
{
var logoutPageUrl = _identityServerRoutes.LogoutUrl ?? throw new InvalidOperationException("No logout url configured");
var logoutIdParameter = _identityServerRoutes.LogoutIdParameter ?? throw new InvalidOperationException("No logout id parameter configured");
logoutPageUrl = logoutPageUrl.AddQueryString(logoutIdParameter, logoutId);
return new Uri(logoutPageUrl, logoutPageUrl.IsLocalUrl() ? UriKind.Relative : UriKind.Absolute);
}
internal Uri SamlSignInCallBackUri()
{
var signInCallBackUrl = _samlRoutes.Route + _samlRoutes.SignInCallbackPath;
return new Uri(signInCallBackUrl, UriKind.Relative);
}
internal Uri SamlLogoutCallBackUri()
{
var logoutCallbackUri = _samlRoutes.Route + _samlRoutes.SingleLogoutCallbackPath;
return new Uri(logoutCallbackUri, UriKind.Relative);
}
private Uri BuildRedirectUrl(string redirectUrl, string returnUrlParameter)
{
var returnUrl = BuildReturnUrl();
var uriKind = UriKind.Relative;
if (!redirectUrl.IsLocalUrl())
{
// The login page is hosted externally. So, the return url needs to be absolute.
// Since the return url is hosted by us, we can make absolute from the server url.
returnUrl = urls.GetAbsoluteUrl(returnUrl);
uriKind = UriKind.Absolute;
}
var queryString = new QueryString();
queryString = queryString.Add(returnUrlParameter, returnUrl);
return new Uri(redirectUrl + queryString, uriKind);
}
private string BuildReturnUrl() => _samlRoutes.Route + _samlRoutes.SignInCallbackPath;
}

View file

@ -0,0 +1,194 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Xml;
using System.Xml.Linq;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
/// <summary>
/// Provides secure XML parsing with hardened settings to prevent common XML attacks.
/// </summary>
/// <remarks>
/// This class protects against:
/// - XXE (XML External Entity) attacks
/// - DTD (Document Type Definition) attacks
/// - Billion laughs attack (entity expansion)
/// - Resource exhaustion attacks
/// </remarks>
internal static class SecureXmlParser
{
/// <summary>
/// Maximum allowed size for SAML messages (1MB).
/// </summary>
internal const int MaxMessageSize = 1048576; // 1MB
/// <summary>
/// Secure XML reader settings configured to prevent common XML attacks.
/// </summary>
private static readonly XmlReaderSettings SecureSettings = new()
{
// Prohibit DTD processing to prevent DTD-based attacks
DtdProcessing = DtdProcessing.Prohibit,
// Disable external entity resolution to prevent XXE attacks
XmlResolver = null,
// Prevent entity expansion attacks (billion laughs)
MaxCharactersFromEntities = 0,
// Limit document size to prevent resource exhaustion
MaxCharactersInDocument = MaxMessageSize,
// Ignore comments to prevent comment injection attacks
IgnoreComments = true,
// Ignore processing instructions to reduce attack surface
IgnoreProcessingInstructions = true,
// Validate well-formed XML
ConformanceLevel = ConformanceLevel.Document
};
internal static XElement LoadXElement(Stream input)
{
try
{
var streamReader = new StreamReader(input);
using var xmlReader = XmlReader.Create(streamReader, SecureSettings);
return XElement.Load(xmlReader);
}
catch (XmlException ex)
{
throw new XmlException(
"Failed to parse XML document with secure settings. " +
"The document may contain prohibited constructs (DTD, external entities) or be malformed.",
ex);
}
}
/// <summary>
/// Loads an XElement from a string with secure settings.
/// </summary>
/// <param name="xml">The XML string to parse</param>
/// <returns>A parsed XElement</returns>
/// <exception cref="ArgumentNullException">Thrown when xml is null or empty</exception>
/// <exception cref="XmlException">Thrown when XML is malformed or violates security constraints</exception>
internal static XElement LoadXElement(string xml)
{
if (string.IsNullOrEmpty(xml))
{
throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty");
}
// Check size before parsing
if (xml.Length > MaxMessageSize)
{
throw new XmlException(
$"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " +
$"Actual size: {xml.Length} bytes.");
}
try
{
using var stringReader = new StringReader(xml);
using var xmlReader = XmlReader.Create(stringReader, SecureSettings);
return XElement.Load(xmlReader);
}
catch (XmlException ex)
{
throw new XmlException(
"Failed to parse XML document with secure settings. " +
"The document may contain prohibited constructs (DTD, external entities) or be malformed.",
ex);
}
}
internal static XDocument LoadXDocument(Stream input)
{
try
{
using var xmlReader = XmlReader.Create(input, SecureSettings);
return XDocument.Load(xmlReader);
}
catch (XmlException ex)
{
throw new XmlException(
"Failed to parse XML document with secure settings. " +
"The document may contain prohibited constructs (DTD, external entities) or be malformed.",
ex);
}
}
/// <summary>
/// Loads an XDocument from a string with secure settings.
/// </summary>
/// <param name="xml">The XML string to parse</param>
/// <returns>A parsed XDocument</returns>
/// <exception cref="ArgumentNullException">Thrown when xml is null or empty</exception>
/// <exception cref="XmlException">Thrown when XML is malformed or violates security constraints</exception>
internal static XDocument LoadXDocument(string xml)
{
if (string.IsNullOrEmpty(xml))
{
throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty");
}
// Check size before parsing
if (xml.Length > MaxMessageSize)
{
throw new XmlException(
$"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " +
$"Actual size: {xml.Length} bytes.");
}
try
{
using var stringReader = new StringReader(xml);
using var xmlReader = XmlReader.Create(stringReader, SecureSettings);
return XDocument.Load(xmlReader);
}
catch (XmlException ex)
{
throw new XmlException(
"Failed to parse XML document with secure settings. " +
"The document may contain prohibited constructs (DTD, external entities) or be malformed.",
ex);
}
}
internal static XmlDocument LoadXmlDocument(string xml)
{
if (string.IsNullOrEmpty(xml))
{
throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty");
}
if (xml.Length > MaxMessageSize)
{
throw new XmlException(
$"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " +
$"Actual size: {xml.Length} bytes.");
}
try
{
using var stringReader = new StringReader(xml);
using var xmlReader = XmlReader.Create(stringReader, SecureSettings);
var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
doc.Load(xmlReader);
return doc;
}
catch (XmlException ex)
{
throw new XmlException(
"Failed to parse XML document with secure settings. " +
"The document may contain prohibited constructs (DTD, external entities) or be malformed.",
ex);
}
}
}

View file

@ -0,0 +1,181 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography.Xml;
using System.Xml;
using System.Xml.Linq;
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
internal static class XmlSignatureHelper
{
internal static string SignResponse(XElement responseElement, X509Certificate2 certificate)
{
var xmlDoc = ConvertToXmlDocument(responseElement);
var docElement = xmlDoc.DocumentElement;
if (docElement?.LocalName != "Response")
{
throw new ArgumentException("XML must contain a Response element");
}
SignElement(xmlDoc, docElement, certificate);
return xmlDoc.OuterXml;
}
internal static string SignProtocolElement(XElement protocolElement, X509Certificate2 certificate)
{
var xmlDoc = ConvertToXmlDocument(protocolElement);
var docElement = xmlDoc.DocumentElement;
if (docElement == null)
{
throw new ArgumentException("XML must contain a root element");
}
SignElement(xmlDoc, docElement, certificate);
return xmlDoc.OuterXml;
}
internal static string SignAssertionInResponse(XElement responseElement, X509Certificate2 certificate)
{
var xmlDoc = ConvertToXmlDocument(responseElement);
// Find the Assertion element
var nsmgr = new XmlNamespaceManager(xmlDoc.NameTable);
nsmgr.AddNamespace("saml", SamlConstants.Namespaces.Assertion);
nsmgr.AddNamespace("samlp", SamlConstants.Namespaces.Protocol);
var assertionNode = xmlDoc.SelectSingleNode("//saml:Assertion", nsmgr);
if (assertionNode is not XmlElement assertionElement)
{
throw new ArgumentException("Response must contain an Assertion element");
}
SignElement(xmlDoc, assertionElement, certificate);
return xmlDoc.OuterXml;
}
internal static string SignBoth(XElement responseElement, X509Certificate2 certificate)
{
// First sign the assertion
var xmlAfterAssertionSigned = SignAssertionInResponse(responseElement, certificate);
// Convert back to XElement and then sign the response
var xmlDoc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
xmlDoc.LoadXml(xmlAfterAssertionSigned);
var docElement = xmlDoc.DocumentElement;
if (docElement?.LocalName != "Response")
{
throw new ArgumentException("XML must contain a Response element");
}
SignElement(xmlDoc, docElement, certificate);
return xmlDoc.OuterXml;
}
private static void SignElement(
XmlDocument xmlDoc,
XmlElement elementToSign,
X509Certificate2 certificate)
{
// Validate element has ID attribute (required for SAML signing)
var idAttribute = elementToSign.GetAttribute("ID");
if (string.IsNullOrEmpty(idAttribute))
{
throw new ArgumentException("Element to sign must have an ID attribute");
}
// Get private key
var privateKey = certificate.GetRSAPrivateKey();
if (privateKey == null)
{
throw new CryptographicException("Cannot get private key from certificate");
}
// Create a custom SignedXml that knows how to resolve ID attributes
var signedXml = new SignedXml(xmlDoc)
{
SigningKey = privateKey
};
// Set canonicalization method for SignedInfo
signedXml.SignedInfo!.CanonicalizationMethod = SignedXml.XmlDsigExcC14NTransformUrl;
signedXml.SignedInfo.SignatureMethod = SignedXml.XmlDsigRSASHA256Url;
// Create reference to the element (using its ID)
var reference = new Reference($"#{idAttribute}")
{
DigestMethod = SignedXml.XmlDsigSHA256Url
};
// Add transforms
reference.AddTransform(new XmlDsigEnvelopedSignatureTransform());
reference.AddTransform(new XmlDsigExcC14NTransform());
signedXml.AddReference(reference);
// Add certificate to KeyInfo
var keyInfo = new KeyInfo();
keyInfo.AddClause(new KeyInfoX509Data(certificate));
signedXml.KeyInfo = keyInfo;
// Compute signature
signedXml.ComputeSignature();
// Get signature element
var signatureElement = signedXml.GetXml();
// Insert signature after Issuer element (per SAML spec)
InsertSignatureAfterIssuer(elementToSign, signatureElement);
}
/// <summary>
/// Inserts signature element after Issuer element (SAML requirement)
/// </summary>
private static void InsertSignatureAfterIssuer(
XmlElement parentElement,
XmlElement signatureElement)
{
// Find Issuer element
var issuerElement = parentElement.SelectSingleNode("*[local-name()='Issuer']");
if (issuerElement != null && issuerElement.NextSibling != null)
{
// Insert after Issuer
parentElement.InsertAfter(signatureElement, issuerElement);
}
else
{
// No Issuer or no next sibling - insert as first child
if (parentElement.FirstChild != null)
{
parentElement.InsertBefore(signatureElement, parentElement.FirstChild);
}
else
{
parentElement.AppendChild(signatureElement);
}
}
}
/// <summary>
/// Converts XElement to XmlDocument, preserving namespace prefixes
/// </summary>
private static XmlDocument ConvertToXmlDocument(XElement element)
{
var xmlDoc = new XmlDocument
{
PreserveWhitespace = true, // Important for signatures
XmlResolver = null // Disable external entity resolution (XXE protection)
};
using var reader = element.CreateReader();
xmlDoc.Load(reader);
return xmlDoc;
}
}

View file

@ -0,0 +1,20 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml;
/// <summary>
/// Represents the usage type of a SAML key descriptor.
/// </summary>
internal enum KeyUse
{
/// <summary>
/// Key used for signing.
/// </summary>
Signing,
/// <summary>
/// Key used for encryption.
/// </summary>
Encryption
}

View file

@ -0,0 +1,192 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Models;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
namespace Duende.IdentityServer.Internal.Saml;
internal static class SamlLogParameters
{
internal const string RedirectUrl = "redirectUrl";
internal const string EntityId = "entityId";
internal const string SamlSigningBehavior = "samlSigningBehavior";
internal const string ErrorMessage = "errorMessage";
internal const string SecurityKey = "securityKey";
internal const string RequestedAuthnContextRequirementsWereMet = "requestedAuthnContextRequirementsWereMet";
internal const string ClaimCount = "claimCount";
internal const string AttributeCount = "attributeCount";
internal const string MessageType = "messageType";
}
internal static partial class Log
{
[LoggerMessage(
EventName = nameof(Redirecting),
Message = $"Redirecting to {{{SamlLogParameters.RedirectUrl}}}"
)]
internal static partial void Redirecting(this ILogger logger, LogLevel level, Uri redirectUrl);
[LoggerMessage(
EventName = nameof(StartSamlSigninRequest),
Message = $"Starting Saml Signin request"
)]
internal static partial void StartSamlSigninRequest(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(StartSamlSigninCallbackRequest),
Message = $"Starting Saml Signin Callback request"
)]
internal static partial void StartSamlSigninCallbackRequest(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(SamlInteractionPassiveAndForced),
Message = $"AuthN request asks for both passive and forced. This is not supported, so returning 'nopassive'"
)]
internal static partial void SamlInteractionPassiveAndForced(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(SamlInteractionForced),
Message = $"AuthN request asks for forced. User is already authenticated, so signing out user and triggering new login."
)]
internal static partial void SamlInteractionForced(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(SamlInteractionAlreadyAuthenticated),
Message = $"AuthN request asked for Passive. User is already authenticated, so triggering callback."
)]
internal static partial void SamlInteractionAlreadyAuthenticated(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(SamlInteractionNoPassive),
Message = $"AuthN request asks for passive. User is not authenticated, so returning error 'NoPassive'"
)]
internal static partial void SamlInteractionNoPassive(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(SamlInteractionConsent),
Message = $"ServiceProvider is configured to require consent. The AuthN request indicates that consent hasn't already been provided, so triggering consent screen."
)]
internal static partial void SamlInteractionConsent(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(SamlInteractionLogin),
Message = $"AuthN request asks for login. User is not authenticated, so triggering login."
)]
internal static partial void SamlInteractionLogin(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(SigningDisabledForServiceProvider),
Message = $"Signing disabled for SP {{{SamlLogParameters.EntityId}}}")]
internal static partial void SigningDisabledForServiceProvider(this ILogger logger, LogLevel level, string entityId);
[LoggerMessage(
EventName = nameof(SigningSamlResponse),
Message = $"Signing SAML message for SP {{{SamlLogParameters.EntityId}}} with signing behavior {{{SamlLogParameters.SamlSigningBehavior}}}")]
internal static partial void SigningSamlResponse(this ILogger logger, LogLevel level, string entityId, SamlSigningBehavior samlSigningBehavior);
[LoggerMessage(
EventName = nameof(SuccessfullySignedSamlResponse),
Message = $"Successfully signed SAML message for SP {{{SamlLogParameters.EntityId}}}")]
internal static partial void SuccessfullySignedSamlResponse(this ILogger logger, LogLevel level, string entityId);
[LoggerMessage(
EventName = nameof(FailedToSignSamlResponse),
Level = LogLevel.Error,
Message = $"Failed to sign SAML Response for SP {{{SamlLogParameters.EntityId}}}: {{{SamlLogParameters.ErrorMessage}}}")]
internal static partial void FailedToSignSamlResponse(this ILogger logger, Exception ex, string entityId, string errorMessage);
[LoggerMessage(
EventName = nameof(SigningCredentialIsNotX509Certificate),
Message = $"Signing credential is not an X509 certificate (Key: {{{SamlLogParameters.SecurityKey}}}). SAML signing requires X509 certificates with private keys.")]
internal static partial void SigningCredentialIsNotX509Certificate(this ILogger logger, LogLevel level, SecurityKey securityKey);
[LoggerMessage(
EventName = nameof(StateNotFound),
Message = "SAML signin state not found for state ID {StateId}")]
internal static partial void StateNotFound(this ILogger logger, LogLevel level, StateId stateId);
[LoggerMessage(
EventName = nameof(ServiceProviderNotFound),
Message = $"Service Provider {{{SamlLogParameters.EntityId}}} not found")]
internal static partial void ServiceProviderNotFound(this ILogger logger, LogLevel level, string entityId);
[LoggerMessage(
EventName = nameof(NoSamlAuthenticationStateFound),
Message = "Cannot load SAML authentication state.")]
internal static partial void NoSamlAuthenticationStateFound(this ILogger logger, LogLevel level);
[LoggerMessage(
EventName = nameof(AuthenticationStateLoaded),
Message = $"SAML authentication request context loaded for SP {{{SamlLogParameters.EntityId}}}")]
internal static partial void AuthenticationStateLoaded(this ILogger logger, LogLevel level, string entityId);
[LoggerMessage(
EventName = nameof(RequestedAuthnContextRequirementsWereMetUpdatedInState),
Message = $"Stored requestedAuthnContextRequirementsWereMet for SAML request: {{{SamlLogParameters.RequestedAuthnContextRequirementsWereMet}}}")]
internal static partial void RequestedAuthnContextRequirementsWereMetUpdatedInState(this ILogger logger,
LogLevel level, bool requestedAuthnContextRequirementsWereMet);
[LoggerMessage(
EventName = nameof(StartIdpInitiatedRequest),
Message = "Starting IdP-initiated SAML request for SP '{serviceProviderEntityId}'")]
internal static partial void StartIdpInitiatedRequest(this ILogger logger, LogLevel level, string serviceProviderEntityId);
[LoggerMessage(
EventName = nameof(IdpInitiatedRequestFailed),
Message = "IdP-initiated SAML request failed: {ErrorMessage}")]
internal static partial void IdpInitiatedRequestFailed(this ILogger logger, LogLevel level, string errorMessage);
[LoggerMessage(
EventName = nameof(IdpInitiatedRequestSuccess),
Message = "IdP-initiated SAML request succeeded, redirecting to {RedirectUrl}")]
internal static partial void IdpInitiatedRequestSuccess(this ILogger logger, LogLevel level, Uri redirectUrl);
[LoggerMessage(
EventName = nameof(RetrievedClaimsFromProfileService),
Message = $"Retrieved {{{SamlLogParameters.ClaimCount}}} claims from profile service")]
internal static partial void RetrievedClaimsFromProfileService(this ILogger logger, LogLevel level, int claimCount);
[LoggerMessage(
EventName = nameof(UsingCustomClaimMapper),
Message = $"Using custom claim mapper for SP {{{SamlLogParameters.EntityId}}}")]
internal static partial void UsingCustomClaimMapper(this ILogger logger, LogLevel level, string entityId);
[LoggerMessage(
EventName = nameof(MappedClaimsToAttributes),
Message = $"Mapped {{{SamlLogParameters.ClaimCount}}} claims to {{{SamlLogParameters.AttributeCount}}} SAML attributes for SP {{{SamlLogParameters.EntityId}}}")]
internal static partial void MappedClaimsToAttributes(this ILogger logger, LogLevel level, int claimCount, int attributeCount, string entityId);
[LoggerMessage(
EventName = nameof(SigningSamlProtocolMessage),
Message = $"Signing SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}")]
internal static partial void SigningSamlProtocolMessage(this ILogger logger, LogLevel level, string entityId, string messageType);
[LoggerMessage(
EventName = nameof(SuccessfullySignedSamlProtocolMessage),
Message = $"Successfully signed SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}")]
internal static partial void SuccessfullySignedSamlProtocolMessage(this ILogger logger, LogLevel level, string entityId, string messageType);
[LoggerMessage(
EventName = nameof(FailedToSignSamlProtocolMessage),
Level = LogLevel.Error,
Message = $"Failed to sign SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}: {{{SamlLogParameters.ErrorMessage}}}")]
internal static partial void FailedToSignSamlProtocolMessage(this ILogger logger, Exception ex, string entityId, string messageType, string errorMessage);
[LoggerMessage(
EventName = nameof(SamlSigninSuccess),
Message = $"SAML signin request processed successfully, redirecting to {{{SamlLogParameters.RedirectUrl}}}")]
internal static partial void SamlSigninSuccess(this ILogger logger, LogLevel level, Uri redirectUrl);
[LoggerMessage(
EventName = nameof(SamlSigninValidationError),
Message = $"SAML signin validation error: {{{SamlLogParameters.ErrorMessage}}}")]
internal static partial void SamlSigninValidationError(this ILogger logger, LogLevel level, string errorMessage);
[LoggerMessage(
EventName = nameof(SamlSigninProtocolError),
Message = $"SAML signin protocol error: {{statusCode}} - {{{SamlLogParameters.ErrorMessage}}}")]
internal static partial void SamlSigninProtocolError(this ILogger logger, LogLevel level, string statusCode, string errorMessage);
}

View file

@ -0,0 +1,116 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Globalization;
using System.Xml.Linq;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.Metadata.Models;
namespace Duende.IdentityServer.Internal.Saml.Metadata;
/// <summary>
/// Serializes SAML metadata EntityDescriptor to XML.
/// </summary>
internal static class EntityDescriptorSerializer
{
private static readonly XNamespace MdNamespace = SamlConstants.Namespaces.Metadata;
private static readonly XNamespace DsNamespace = SamlConstants.Namespaces.XmlSignature;
/// <summary>
/// Serializes an EntityDescriptor to SAML metadata XML string.
/// </summary>
/// <param name="descriptor">The entity descriptor to serialize.</param>
/// <returns>XML string representing the SAML metadata.</returns>
internal static XDocument SerializeToXml(EntityDescriptor descriptor)
{
ArgumentNullException.ThrowIfNull(descriptor);
var root = new XElement(MdNamespace + SamlConstants.MetadataElements.EntityDescriptor,
new XAttribute(SamlConstants.MetadataAttributes.EntityId, descriptor.EntityId));
// Add validUntil if specified
if (descriptor.ValidUntil.HasValue)
{
root.Add(new XAttribute(SamlConstants.MetadataAttributes.ValidUntil,
descriptor.ValidUntil.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)));
}
// Add IDPSSODescriptor if present
if (descriptor.IdpSsoDescriptor != null)
{
root.Add(SerializeIdpSsoDescriptor(descriptor.IdpSsoDescriptor));
}
return new XDocument(
new XDeclaration("1.0", "UTF-8", null),
root);
}
private static XElement SerializeIdpSsoDescriptor(IdpSsoDescriptor descriptor)
{
var element = new XElement(MdNamespace + SamlConstants.MetadataElements.IdpSsoDescriptor,
new XAttribute(SamlConstants.MetadataAttributes.ProtocolSupportEnumeration,
descriptor.ProtocolSupportEnumeration));
// Add WantAuthnRequestsSigned if true
if (descriptor.WantAuthnRequestsSigned)
{
element.Add(new XAttribute(SamlConstants.MetadataAttributes.WantAuthnRequestsSigned, "true"));
}
// Add KeyDescriptors
foreach (var keyDescriptor in descriptor.KeyDescriptors)
{
element.Add(SerializeKeyDescriptor(keyDescriptor));
}
// Add NameIDFormats
foreach (var nameIdFormat in descriptor.NameIdFormats)
{
element.Add(new XElement(MdNamespace + SamlConstants.MetadataElements.NameIdFormat, nameIdFormat));
}
// Add SingleSignOnServices
foreach (var ssoService in descriptor.SingleSignOnServices)
{
element.Add(SerializeSingleSignOnService(ssoService));
}
// Add SingleLogoutServices
foreach (var sloService in descriptor.SingleLogoutServices)
{
element.Add(SerializeSingleLogoutService(sloService));
}
return element;
}
private static XElement SerializeKeyDescriptor(KeyDescriptor descriptor)
{
var element = new XElement(MdNamespace + SamlConstants.MetadataElements.KeyDescriptor);
// Add use attribute if specified
if (descriptor.Use.HasValue)
{
element.Add(new XAttribute(SamlConstants.MetadataAttributes.Use, SamlConstants.MetadataAttributes.ToString(descriptor.Use.Value)));
}
// Add KeyInfo with X509Data
var keyInfo = new XElement(DsNamespace + SamlConstants.MetadataElements.KeyInfo,
new XElement(DsNamespace + SamlConstants.MetadataElements.X509Data,
new XElement(DsNamespace + SamlConstants.MetadataElements.X509Certificate,
descriptor.X509Certificate)));
element.Add(keyInfo);
return element;
}
private static XElement SerializeSingleSignOnService(SingleSignOnService service) => new(MdNamespace + SamlConstants.MetadataElements.SingleSignOnService,
new XAttribute(SamlConstants.MetadataAttributes.Binding, service.Binding.ToUrn()),
new XAttribute(SamlConstants.MetadataAttributes.Location, service.Location));
private static XElement SerializeSingleLogoutService(SingleLogoutService service) => new(MdNamespace + SamlConstants.MetadataElements.SingleLogoutService,
new XAttribute(SamlConstants.MetadataAttributes.Binding, service.Binding.ToUrn()),
new XAttribute(SamlConstants.MetadataAttributes.Location, service.Location));
}

View file

@ -0,0 +1,29 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
/// <summary>
/// Represents a SAML entity descriptor that describes a SAML entity (IdP or SP).
/// </summary>
internal record EntityDescriptor
{
/// <summary>
/// Gets or sets the entity ID (typically the IdP issuer URI).
/// This uniquely identifies the SAML entity.
/// </summary>
internal required string EntityId { get; set; }
/// <summary>
/// Gets or sets the IdP SSO descriptor.
/// Contains the Identity Provider's SSO configuration and capabilities.
/// </summary>
internal IdpSsoDescriptor? IdpSsoDescriptor { get; set; }
/// <summary>
/// Gets or sets the validity period end time (optional).
/// If set, indicates when this metadata expires.
/// </summary>
internal DateTime? ValidUntil { get; set; }
}

View file

@ -0,0 +1,50 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Collections.ObjectModel;
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
/// <summary>
/// Describes a SAML Identity Provider's SSO capabilities.
/// This element contains all the information needed for a Service Provider
/// to interact with the Identity Provider.
/// </summary>
internal record IdpSsoDescriptor
{
/// <summary>
/// Gets or sets the protocol support enumeration.
/// Typically "urn:oasis:names:tc:SAML:2.0:protocol".
/// Indicates which SAML protocols this IdP supports.
/// </summary>
internal required string ProtocolSupportEnumeration { get; set; }
/// <summary>
/// Gets or sets whether the IdP requires authentication requests to be signed.
/// </summary>
internal bool WantAuthnRequestsSigned { get; set; }
/// <summary>
/// Gets or sets the signing certificates.
/// Contains the public keys used to verify signatures from this IdP.
/// </summary>
internal Collection<KeyDescriptor> KeyDescriptors { get; init; } = [];
/// <summary>
/// Gets or sets the supported NameID formats.
/// Indicates which name identifier formats this IdP can provide.
/// </summary>
internal Collection<string> NameIdFormats { get; init; } = [];
/// <summary>
/// Gets or sets the SingleSignOnService endpoints.
/// Defines where and how Service Providers can initiate SSO.
/// </summary>
internal Collection<SingleSignOnService> SingleSignOnServices { get; init; } = [];
/// <summary>
/// Gets or sets the SingleLogoutService endpoints.
/// Defines where and how Service Providers can send logout requests.
/// </summary>
internal Collection<SingleLogoutService> SingleLogoutServices { get; init; } = [];
}

View file

@ -0,0 +1,23 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
/// <summary>
/// Describes a cryptographic key used by a SAML entity.
/// Contains certificate information for signature verification or encryption.
/// </summary>
internal record KeyDescriptor
{
/// <summary>
/// Gets or sets the key usage (signing, encryption, or null for both).
/// When null, the key can be used for both signing and encryption.
/// </summary>
internal KeyUse? Use { get; set; }
/// <summary>
/// Gets or sets the X.509 certificate in base64 encoding (without BEGIN/END markers).
/// This is the public key used to verify signatures or encrypt data.
/// </summary>
internal required string X509Certificate { get; set; }
}

View file

@ -0,0 +1,26 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
/// <summary>
/// Describes a SAML SingleLogoutService endpoint.
/// Specifies where and how a Service Provider can send logout requests.
/// </summary>
internal record SingleLogoutService
{
/// <summary>
/// Gets or sets the binding (HTTP-Redirect, HTTP-POST, etc.).
/// Indicates the protocol binding to use for this endpoint.
/// </summary>
internal required SamlBinding Binding { get; set; }
/// <summary>
/// Gets or sets the location URI.
/// The endpoint URI where logout requests should be sent.
/// </summary>
internal required Uri Location { get; set; }
}

View file

@ -0,0 +1,26 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
/// <summary>
/// Describes a SAML SingleSignOnService endpoint.
/// Specifies where and how a Service Provider can send authentication requests.
/// </summary>
internal record SingleSignOnService
{
/// <summary>
/// Gets or sets the binding (HTTP-Redirect, HTTP-POST, etc.).
/// Indicates the protocol binding to use for this endpoint.
/// </summary>
internal required SamlBinding Binding { get; set; }
/// <summary>
/// Gets or sets the location URI.
/// The endpoint URI where authentication requests should be sent.
/// </summary>
internal required Uri Location { get; set; }
}

View file

@ -0,0 +1,124 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Xml.Linq;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Endpoints.Results;
using Duende.IdentityServer.Hosting;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.Metadata.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml.Metadata;
internal class SamlMetaDataEndpoint(
TimeProvider timeProvider,
IOptions<SamlOptions> samlOptions,
IIssuerNameService issuerNameService,
IServerUrls urls,
ISamlSigningService samlSigningService) : IEndpointHandler
{
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
{
using var activity = Tracing.BasicActivitySource.StartActivity("SamlMetaDataEndpoint");
if (!HttpMethods.IsGet(context.Request.Method))
{
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
}
var options = samlOptions.Value;
var issuerUri = await issuerNameService.GetCurrentAsync(context.RequestAborted);
var baseUrl = urls.BaseUrl;
var certificateBase64 = await samlSigningService.GetSigningCertificateBase64Async(context.RequestAborted);
var singleSignOnService = BuildServiceUrl(baseUrl, options.UserInteraction.Route, options.UserInteraction.SignInPath);
var singleLogoutService = BuildServiceUrl(baseUrl, options.UserInteraction.Route, options.UserInteraction.SingleLogoutPath);
var descriptor = new EntityDescriptor
{
EntityId = issuerUri,
ValidUntil = options.MetadataValidityDuration != null
? timeProvider.GetUtcNow().Add(options.MetadataValidityDuration.Value).UtcDateTime
: null,
IdpSsoDescriptor = new IdpSsoDescriptor
{
ProtocolSupportEnumeration = SamlConstants.Namespaces.Protocol,
WantAuthnRequestsSigned = options.WantAuthnRequestsSigned,
KeyDescriptors =
[
new KeyDescriptor
{
Use = KeyUse.Signing,
X509Certificate = certificateBase64
}
],
NameIdFormats = options.SupportedNameIdFormats,
SingleSignOnServices =
[
new SingleSignOnService
{
Binding = SamlBinding.HttpPost,
Location = singleSignOnService
},
new SingleSignOnService
{
Binding = SamlBinding.HttpRedirect,
Location = singleSignOnService
}
],
SingleLogoutServices =
[
new SingleLogoutService
{
Binding = SamlBinding.HttpPost,
Location = singleLogoutService
},
new SingleLogoutService
{
Binding = SamlBinding.HttpRedirect,
Location = singleLogoutService
}
]
}
};
return new SamlMetadataResult(descriptor);
}
private static Uri BuildServiceUrl(string baseUrl, string route, string path)
{
var builder = new UriBuilder(baseUrl);
// Preserve existing base path and append new segments
var segments = new[] { builder.Path, route, path }
.Select(s => s.Trim('/'))
.Where(s => !string.IsNullOrWhiteSpace(s));
var combinedPath = string.Join('/', segments);
// UriBuilder.Path automatically adds leading slash
builder.Path = string.IsNullOrEmpty(combinedPath) ? "/" : combinedPath;
return builder.Uri;
}
}
/// <summary>
/// Endpoint result that writes SAML metadata XML to the response.
/// </summary>
internal class SamlMetadataResult(EntityDescriptor descriptor) : IEndpointResult
{
public async Task ExecuteAsync(HttpContext context)
{
context.Response.StatusCode = 200;
context.Response.ContentType = SamlConstants.ContentTypes.Metadata;
var descriptorXml = EntityDescriptorSerializer.SerializeToXml(descriptor);
await descriptorXml.SaveAsync(context.Response.Body, SaveOptions.DisableFormatting, context.RequestAborted);
}
}

View file

@ -0,0 +1,15 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
namespace Duende.IdentityServer.Internal.Saml;
internal class NopSamlLogoutNotificationService : ISamlLogoutNotificationService
{
public Task<IEnumerable<ISamlFrontChannelLogout>> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context, Ct ct) =>
Task.FromResult(Enumerable.Empty<ISamlFrontChannelLogout>());
}

View file

@ -0,0 +1,129 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Security.Claims;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml;
internal class SamlClaimsService(
IProfileService profileService,
ILogger<SamlClaimsService> logger,
IOptions<SamlOptions> options,
ISamlClaimsMapper? customMapper = null)
{
private async Task<IEnumerable<Claim>> GetClaimsAsync(ClaimsPrincipal user, SamlServiceProvider serviceProvider, Ct ct)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(serviceProvider);
var requestedClaimTypes = user.Claims.Select(c => c.Type).Distinct();
// Use IdentityServer's IProfileService to get claims
var context = new ProfileDataRequestContext
{
Subject = user,
Client = new Client
{
ClientId = serviceProvider.EntityId
},
RequestedClaimTypes = requestedClaimTypes,
Caller = "SAML"
};
await profileService.GetProfileDataAsync(context, ct);
var claims = context.IssuedClaims;
logger.RetrievedClaimsFromProfileService(LogLevel.Debug, claims.Count);
return claims;
}
internal async Task<IEnumerable<SamlAttribute>> GetMappedAttributesAsync(
ClaimsPrincipal user,
SamlServiceProvider serviceProvider,
Ct ct)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(serviceProvider);
var claims = await GetClaimsAsync(user, serviceProvider, ct);
if (customMapper != null)
{
logger.UsingCustomClaimMapper(LogLevel.Debug, serviceProvider.EntityId);
var claimsMappingContext = new SamlClaimsMappingContext { UserClaims = claims, ServiceProvider = serviceProvider };
return await customMapper.MapClaimsAsync(claimsMappingContext);
}
return MapClaimsToAttributes(claims, serviceProvider);
}
private List<SamlAttribute> MapClaimsToAttributes(
IEnumerable<Claim> claims,
SamlServiceProvider serviceProvider)
{
var samlOptions = options.Value;
var attributes = new List<SamlAttribute>();
var claimsList = claims.ToList();
foreach (var claim in claimsList)
{
// Determine attribute name: SP mapping > Global mapping > null (exclude)
var attributeName = GetAttributeName(claim.Type, serviceProvider, samlOptions);
// Skip claims that aren't mapped
if (attributeName == null)
{
continue;
}
// Check if attribute already exists (for multi-valued attributes)
var existingAttr = attributes.FirstOrDefault(a => a.Name == attributeName);
if (existingAttr != null)
{
existingAttr.Values.Add(claim.Value);
}
else
{
attributes.Add(new SamlAttribute
{
Name = attributeName,
NameFormat = samlOptions.DefaultAttributeNameFormat,
FriendlyName = attributeName,
Values = [claim.Value]
});
}
}
logger.MappedClaimsToAttributes(LogLevel.Debug, claimsList.Count, attributes.Count, serviceProvider.EntityId);
return attributes;
}
private static string? GetAttributeName(
string claimType,
SamlServiceProvider serviceProvider,
SamlOptions options)
{
if (serviceProvider.ClaimMappings.TryGetValue(claimType, out var spMapping))
{
return spMapping;
}
if (options.DefaultClaimMappings.TryGetValue(claimType, out var globalMapping))
{
return globalMapping;
}
return null;
}
}

View file

@ -0,0 +1,117 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml;
internal static class SamlConstants
{
internal class Urls
{
public const string SamlRoute = "/saml";
public const string Metadata = "/metadata";
public const string SignIn = "/signin";
public const string SigninCallback = "/signin_callback";
public const string IdpInitiated = "/idp-initiated";
public const string Signout = "/signout";
public const string SingleLogout = "/logout";
public const string SingleLogoutCallback = "/logout_callback";
}
internal class RequestProperties
{
public const string SAMLRequest = "SAMLRequest";
public const string SAMLResponse = "SAMLResponse";
public const string RelayState = "RelayState";
public const string Signature = "Signature";
public const string SigAlg = "SigAlg";
}
internal class ContentTypes
{
/// <summary>
/// https://www.iana.org/assignments/media-types/application/samlmetadata+xml
/// </summary>
public const string Metadata = "application/samlmetadata+xml";
}
internal static class Namespaces
{
public const string Assertion = "urn:oasis:names:tc:SAML:2.0:assertion";
public const string Protocol = "urn:oasis:names:tc:SAML:2.0:protocol";
public const string Metadata = "urn:oasis:names:tc:SAML:2.0:metadata";
public const string XmlSignature = "http://www.w3.org/2000/09/xmldsig#";
public const string XmlEncryption = "http://www.w3.org/2001/04/xmlenc#";
}
internal static class NameIdentifierFormats
{
public const string EmailAddress = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
public const string Persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent";
public const string Transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient";
public const string Unspecified = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified";
}
internal static class Bindings
{
public const string HttpRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
public const string HttpPost = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
}
internal static class MetadataElements
{
public const string EntityDescriptor = "EntityDescriptor";
public const string IdpSsoDescriptor = "IDPSSODescriptor";
public const string KeyDescriptor = "KeyDescriptor";
public const string KeyInfo = "KeyInfo";
public const string X509Data = "X509Data";
public const string X509Certificate = "X509Certificate";
public const string NameIdFormat = "NameIDFormat";
public const string SingleSignOnService = "SingleSignOnService";
public const string SingleLogoutService = "SingleLogoutService";
}
internal static class AuthenticationRequestAttributes
{
public const string RootElementName = "AuthnRequest";
}
internal static class MetadataAttributes
{
public const string EntityId = "entityID";
public const string ValidUntil = "validUntil";
public const string ProtocolSupportEnumeration = "protocolSupportEnumeration";
public const string WantAuthnRequestsSigned = "WantAuthnRequestsSigned";
public const string Use = "use";
public const string Binding = "Binding";
public const string Location = "Location";
/// <summary>
/// Converts a KeyUse enum value to its string representation.
/// </summary>
internal static string ToString(KeyUse keyUse) => keyUse switch
{
KeyUse.Signing => "signing",
KeyUse.Encryption => "encryption",
_ => throw new ArgumentOutOfRangeException(nameof(keyUse), keyUse, "Unknown key use")
};
}
public static class AttributeNameFormats
{
/// <summary>
/// Attribute name is interpreted as a URI reference (most common for OID format)
/// </summary>
public const string Uri = "urn:oasis:names:tc:SAML:2.0:attrname-format:uri";
}
public static class ClaimTypes
{
public const string AuthnContextClassRef = "saml:acr";
}
internal static class LogoutReasons
{
public const string User = "urn:oasis:names:tc:SAML:2.0:logout:user";
public const string Admin = "urn:oasis:names:tc:SAML:2.0:logout:admin";
public const string GlobalTimeout = "urn:oasis:names:tc:SAML:2.0:logout:global-timeout";
}
}

View file

@ -0,0 +1,192 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Security.Claims;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleSignin;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Services;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml;
internal class SamlResponseBuilder(
IServerUrls serverUrls,
IIssuerNameService issuerNameService,
TimeProvider timeProvider,
IOptions<SamlOptions> samlOptions,
SamlClaimsService samlClaimsService,
SamlNameIdGenerator nameIdGenerator
)
{
internal SamlErrorResponse BuildErrorResponse(SamlServiceProvider serviceProvider, SamlSigninRequest request,
SamlError error)
{
// Use the ACS URL from the request if present and valid, otherwise fall back to SP config
var acsUrl = request.AuthNRequest.AssertionConsumerServiceUrl
?? (request.AuthNRequest.AssertionConsumerServiceIndex != null
? serviceProvider.AssertionConsumerServiceUrls.ElementAtOrDefault(request.AuthNRequest
.AssertionConsumerServiceIndex.Value)
: null)
?? serviceProvider.AssertionConsumerServiceUrls.First();
return new SamlErrorResponse
{
ServiceProvider = serviceProvider,
Binding = serviceProvider.AssertionConsumerServiceBinding,
StatusCode = error.StatusCode,
SubStatusCode = error.SubStatusCode,
Message = error.Message,
AssertionConsumerServiceUrl = acsUrl,
Issuer = serverUrls.Origin, // Todo: not sure if this is a valid issuer
InResponseTo = request.AuthNRequest.Id,
RelayState = request.RelayState
};
}
private static Conditions CreateConditions(
SamlServiceProvider samlServiceProvider,
DateTime issueInstant,
TimeSpan defaultRequestMaxAge,
TimeSpan defaultAllowedClockSkew)
{
var lifetime = samlServiceProvider.RequestMaxAge ?? defaultRequestMaxAge;
var clockSkew = samlServiceProvider.ClockSkew ?? defaultAllowedClockSkew;
return new Conditions
{
NotBefore = issueInstant.Subtract(clockSkew),
NotOnOrAfter = issueInstant.Add(lifetime),
AudienceRestrictions = [samlServiceProvider.EntityId]
};
}
private static AuthnStatement CreateAuthnStatement(ClaimsPrincipal user, DateTime issueInstant, string sessionIndex)
{
// Determine AuthnContext based on request and user claims
var authnContextClassRef = GetAuthnContextClassRef(user);
return new AuthnStatement
{
AuthnInstant = issueInstant,
SessionIndex = sessionIndex,
AuthnContext = new AuthnContext { AuthnContextClassRef = authnContextClassRef }
};
}
private static string GetAuthnContextClassRef(ClaimsPrincipal user)
{
var contextClaim = user.FindFirst(SamlConstants.ClaimTypes.AuthnContextClassRef);
if (contextClaim == null || string.IsNullOrWhiteSpace(contextClaim.Value))
{
return "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified";
}
return contextClaim.Value.Trim();
}
private static string GetEmailNameId(ClaimsPrincipal user)
{
// Try to get email claim
var email = user.FindFirst("email")?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value;
return !string.IsNullOrEmpty(email) ? email : user.GetSubjectId();
}
private static Subject CreateSubject(
SamlAuthenticationState samlAuthenticationState,
NameIdentifier nameId,
SamlServiceProvider serviceProvider,
AuthNRequest? request,
TimeSpan defaultRequestMaxAge,
DateTime issueInstant)
{
var lifetime = serviceProvider.RequestMaxAge ?? defaultRequestMaxAge;
var notOnOrAfter = issueInstant.Add(lifetime);
return new Subject
{
NameId = nameId,
SubjectConfirmations =
[
new()
{
Method = "urn:oasis:names:tc:SAML:2.0:cm:bearer",
Data = new SubjectConfirmationData
{
NotOnOrAfter = notOnOrAfter,
Recipient = samlAuthenticationState.AssertionConsumerServiceUrl,
InResponseTo = request?.Id // Null for IdP-initiated
}
}
]
};
}
internal async Task<SamlResponse> BuildSuccessResponseAsync(
ClaimsPrincipal user,
SamlServiceProvider samlServiceProvider,
SamlAuthenticationState samlAuthenticationState,
string sessionIndex,
Ct ct)
{
var now = timeProvider.GetUtcNow().DateTime;
var options = samlOptions.Value;
var nameId = nameIdGenerator.GenerateNameIdentifier(user, samlServiceProvider, samlAuthenticationState.Request);
var attributes = await samlClaimsService.GetMappedAttributesAsync(user, samlServiceProvider, ct);
var acsUrl = GetAcsUrl(samlAuthenticationState.Request, samlServiceProvider);
var issuer = await issuerNameService.GetCurrentAsync(ct);
return new SamlResponse
{
ServiceProvider = samlServiceProvider,
InResponseTo = samlAuthenticationState.Request?.Id,
Destination = acsUrl,
IssueInstant = now,
Issuer = issuer,
Status = new Status
{
StatusCode = SamlStatusCodes.Success,
NestedStatusCode = samlAuthenticationState.Request?.RequestedAuthnContext != null && !samlAuthenticationState.RequestedAuthnContextRequirementsWereMet ? SamlStatusCodes.NoAuthnContext : null,
},
Assertion = new Assertion
{
IssueInstant = now,
Issuer = issuer,
Subject = CreateSubject(samlAuthenticationState, nameId, samlServiceProvider,
samlAuthenticationState.Request, options.DefaultRequestMaxAge,
now),
Conditions = CreateConditions(
samlServiceProvider, now,
options.DefaultRequestMaxAge,
options.DefaultClockSkew),
AuthnStatements = [CreateAuthnStatement(user, now, sessionIndex)],
AttributeStatements = [new AttributeStatement { Attributes = attributes.ToList() }]
},
RelayState = samlAuthenticationState.RelayState
};
}
private static Uri GetAcsUrl(AuthNRequest? request, SamlServiceProvider samlServiceProvider)
{
if (request?.AssertionConsumerServiceUrl != null)
{
return request.AssertionConsumerServiceUrl;
}
if (request?.AssertionConsumerServiceIndex != null)
{
return samlServiceProvider.AssertionConsumerServiceUrls.ElementAt(request.AssertionConsumerServiceIndex.Value);
}
return samlServiceProvider.AssertionConsumerServiceUrls.FirstOrDefault()
?? throw new InvalidOperationException("No ACS Url defined for service provider " + samlServiceProvider.EntityId);
}
}

View file

@ -0,0 +1,268 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal static class SingleLogoutLogParameters
{
public const string RequestId = "requestId";
public const string Issuer = "issuer";
public const string SessionIndex = "sessionIndex";
public const string Message = "message";
public const string SpName = "spName";
public const string StatusCode = "statusCode";
public const string NotOnOrAfter = "notOnOrAfter";
public const string ExpectedSessionIndex = "expectedSessionIndex";
public const string ReceivedSessionIndex = "receivedSessionIndex";
public const string EntityId = "entityId";
public const string Version = "version";
public const string IssueInstant = "issueInstant";
public const string Destination = "destination";
public const string ExpectedDestination = "expectedDestination";
public const string Count = "count";
}
internal static partial class Log
{
[LoggerMessage(
EventName = nameof(ParsedLogoutRequest),
Message = $"Parsed LogoutRequest. ID: {{{SingleLogoutLogParameters.RequestId}}}, Issuer: {{{SingleLogoutLogParameters.Issuer}}}, SessionIndex: {{{SingleLogoutLogParameters.SessionIndex}}}"
)]
internal static partial void ParsedLogoutRequest(this ILogger logger, LogLevel logLevel, string requestId, string issuer, string sessionIndex);
[LoggerMessage(
EventName = nameof(FailedToParseLogoutRequest),
Level = LogLevel.Error,
Message = $"Failed to parse LogoutRequest: {{{SingleLogoutLogParameters.Message}}}")]
internal static partial void FailedToParseLogoutRequest(this ILogger logger, Exception exception, string message);
[LoggerMessage(
EventName = nameof(UnexpectedErrorParsingLogoutRequest),
Level = LogLevel.Error,
Message = "Unexpected error parsing LogoutRequest")]
internal static partial void UnexpectedErrorParsingLogoutRequest(this ILogger logger, Exception exception);
[LoggerMessage(
EventName = nameof(ReceivedLogoutRequest),
Message = $"Received SAML LogoutRequest from {{{SingleLogoutLogParameters.Issuer}}}. RequestId: {{{SingleLogoutLogParameters.RequestId}}}, SessionIndex: {{{SingleLogoutLogParameters.SessionIndex}}}")]
internal static partial void ReceivedLogoutRequest(this ILogger logger, LogLevel logLevel, string issuer, string requestId, string sessionIndex);
[LoggerMessage(
EventName = nameof(SuccessfullyProcessedLogoutRequest),
Message = $"Logout request {{{SingleLogoutLogParameters.RequestId}}} with session index {{{SingleLogoutLogParameters.SessionIndex}}} processed successfully")]
internal static partial void SuccessfullyProcessedLogoutRequest(this ILogger logger, LogLevel logLevel, string requestId, string sessionIndex);
[LoggerMessage(
EventName = nameof(SamlLogoutValidationError),
Message = $"SAML logout validation error: {{{SingleLogoutLogParameters.Message}}}")]
internal static partial void SamlLogoutValidationError(this ILogger logger, LogLevel logLevel, string message);
[LoggerMessage(
EventName = nameof(SamlLogoutProtocolError),
Message = $"SAML logout protocol error: {{{SingleLogoutLogParameters.StatusCode}}} - {{{SingleLogoutLogParameters.Message}}}")]
internal static partial void SamlLogoutProtocolError(this ILogger logger, LogLevel logLevel, string statusCode, string message);
[LoggerMessage(
EventName = nameof(SamlLogoutRequestFromUnknownOrDisabledServiceProvider),
Message = $"LogoutRequest from unknown or disabled SP: {{{SingleLogoutLogParameters.Issuer}}}")]
internal static partial void SamlLogoutRequestFromUnknownOrDisabledServiceProvider(this ILogger logger, LogLevel logLevel, string issuer);
[LoggerMessage(
EventName = nameof(ProcessingSamlLogoutRequest),
Message = $"Processing LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} from SP: {{{SingleLogoutLogParameters.SpName}}} ({{{SingleLogoutLogParameters.Issuer}}})")]
internal static partial void ProcessingSamlLogoutRequest(this ILogger logger, LogLevel logLevel, string requestId, string spName, string issuer);
[LoggerMessage(
EventName = nameof(SamlLogoutRequestReceivedButNoActiveUserSession),
Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} received from {{{SingleLogoutLogParameters.Issuer}}} but no active user session found")]
internal static partial void SamlLogoutRequestReceivedButNoActiveUserSession(this ILogger logger, LogLevel logLevel, string requestId, string issuer);
[LoggerMessage(
EventName = nameof(SamlLogoutRequestReceivedWithWrongSessionIndex),
Message = $"SessionIndex mismatch. Request: {{{SingleLogoutLogParameters.RequestId}}}, SessionIndex: {{{SingleLogoutLogParameters.SessionIndex}}}")]
internal static partial void SamlLogoutRequestReceivedWithWrongSessionIndex(this ILogger logger, LogLevel logLevel, string requestId, string sessionIndex);
[LoggerMessage(
EventName = nameof(SamlLogoutRedirectToLogoutPage),
Message = $"Redirecting SAML logout to host logout page {{{SingleLogoutLogParameters.Issuer}}}")]
internal static partial void SamlLogoutRedirectToLogoutPage(this ILogger logger, LogLevel logLevel, string issuer);
[LoggerMessage(
EventName = nameof(SamlLogoutNoCertificatesForSignatureValidation),
Message = $"SP {{{SingleLogoutLogParameters.EntityId}}} has no signing certificates configured. LogoutRequest requires signature authentication")]
internal static partial void SamlLogoutNoCertificatesForSignatureValidation(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(SamlLogoutRequestExpired),
Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} expired. NotOnOrAfter: {{{SingleLogoutLogParameters.NotOnOrAfter}}}")]
internal static partial void SamlLogoutRequestExpired(this ILogger logger, LogLevel logLevel, string requestId, DateTime notOnOrAfter);
[LoggerMessage(
EventName = nameof(SamlLogoutSignatureValidationFailed),
Message = $"LogoutRequest signature validation failed for SP {{{SingleLogoutLogParameters.EntityId}}}: {{{SingleLogoutLogParameters.Message}}}")]
internal static partial void SamlLogoutSignatureValidationFailed(this ILogger logger, LogLevel logLevel, string entityId, string message);
[LoggerMessage(
EventName = nameof(SamlLogoutSignatureValidationSucceeded),
Message = "LogoutRequest signature validated successfully")]
internal static partial void SamlLogoutSignatureValidationSucceeded(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(SamlLogoutNoSessionFoundForServiceProvider),
Message = $"No session with session index {{{SingleLogoutLogParameters.SessionIndex}}} found for SP {{{SingleLogoutLogParameters.Issuer}}}")]
internal static partial void SamlLogoutNoSessionFoundForServiceProvider(this ILogger logger, LogLevel logLevel, string sessionIndex, string issuer);
[LoggerMessage(
EventName = nameof(SamlLogoutSessionIndexMisMatch),
Message = $"SessionIndex mismatch. Expected: {{{SingleLogoutLogParameters.ExpectedSessionIndex}}}, Received: {{{SingleLogoutLogParameters.ReceivedSessionIndex}}}")]
internal static partial void SamlLogoutSessionIndexMisMatch(this ILogger logger, LogLevel logLevel, string expectedSessionIndex, string receivedSessionIndex);
[LoggerMessage(
EventName = nameof(SamlLogoutNoSingleLogoutServiceUrl),
Message = $"SP {{{SingleLogoutLogParameters.EntityId}}} has no SingleLogoutServiceUrl configured. Cannot send LogoutResponse")]
internal static partial void SamlLogoutNoSingleLogoutServiceUrl(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(SamlLogoutUnsupportedVersion),
Message = $"LogoutRequest has unsupported SAML version: {{{SingleLogoutLogParameters.Version}}}. Only 2.0 is supported")]
internal static partial void SamlLogoutUnsupportedVersion(this ILogger logger, LogLevel logLevel, string version);
[LoggerMessage(
EventName = nameof(SamlLogoutRequestIssueInstantInFuture),
Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} has IssueInstant in the future: {{{SingleLogoutLogParameters.IssueInstant}}}")]
internal static partial void SamlLogoutRequestIssueInstantInFuture(this ILogger logger, LogLevel logLevel, string requestId, DateTime issueInstant);
[LoggerMessage(
EventName = nameof(SamlLogoutRequestIssueInstantTooOld),
Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} has IssueInstant too old (expired): {{{SingleLogoutLogParameters.IssueInstant}}}")]
internal static partial void SamlLogoutRequestIssueInstantTooOld(this ILogger logger, LogLevel logLevel, string requestId, DateTime issueInstant);
[LoggerMessage(
EventName = nameof(SamlLogoutRequestInvalidDestination),
Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} has invalid Destination. Received: {{{SingleLogoutLogParameters.Destination}}}, Expected: {{{SingleLogoutLogParameters.ExpectedDestination}}}")]
internal static partial void SamlLogoutRequestInvalidDestination(this ILogger logger, LogLevel logLevel, string requestId, Uri destination, string expectedDestination);
[LoggerMessage(
EventName = nameof(ProcessingSamlLogoutCallback),
Message = "Processing SAML logout callback")]
internal static partial void ProcessingSamlLogoutCallback(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(MissingLogoutId),
Message = "Missing logoutId parameter in callback request")]
internal static partial void MissingLogoutId(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(InvalidLogoutId),
Message = "Invalid logoutId in callback request: {logoutId}")]
internal static partial void InvalidLogoutId(this ILogger logger, LogLevel logLevel, string logoutId);
[LoggerMessage(
EventName = nameof(NotSamlInitiatedLogout),
Message = "Logout callback was not for a SAML logout")]
internal static partial void NotSamlInitiatedLogout(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(ServiceProviderNotFound),
Message = $"Service Provider not found for EntityId: {{{SingleLogoutLogParameters.EntityId}}}")]
internal static partial void ServiceProviderNotFound(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(ReturningLogoutResponseToSp),
Message = $"Returning LogoutResponse to Service Provider: {{{SingleLogoutLogParameters.EntityId}}}")]
internal static partial void ReturningLogoutResponseToSp(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(NoSamlServiceProvidersToNotifyForLogout),
Message = "No SAML Service Providers to notify for logout")]
internal static partial void NoSamlServiceProvidersToNotifyForLogout(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(SkippingLogoutUrlGenerationForUnknownOrDisabledServiceProvider),
Message = $"Skipping SAML logout for disabled or unknown SP: {{{SingleLogoutLogParameters.EntityId}}}")]
internal static partial void SkippingLogoutUrlGenerationForUnknownOrDisabledServiceProvider(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(SkippingLogoutUrlGenerationForServiceProviderWithNoSingleLogout),
Message = $"Skipping SAML logout for SP without any SingleLogoutServiceUrl: {{{SingleLogoutLogParameters.EntityId}}}")]
internal static partial void SkippingLogoutUrlGenerationForServiceProviderWithNoSingleLogout(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(NoSessionDataFoundForLogoutUrlGenerationForServiceProvider),
Message = $"No session data found for SP: {{{SingleLogoutLogParameters.EntityId}}}")]
internal static partial void NoSessionDataFoundForLogoutUrlGenerationForServiceProvider(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(FailedToGenerateLogoutUrlForServiceProvider),
Level = LogLevel.Error,
Message = $"Failed to build SAML logout URL for SP: {{{SingleLogoutLogParameters.EntityId}}}")]
internal static partial void FailedToGenerateLogoutUrlForServiceProvider(this ILogger logger, Exception ex, string entityId);
[LoggerMessage(
EventName = nameof(GeneratedSamlFrontChannelLogoutUrls),
Message = $"Generated {{{SingleLogoutLogParameters.Count}}} SAML front-channel logout URLs")]
internal static partial void GeneratedSamlFrontChannelLogoutUrls(this ILogger logger, LogLevel logLevel, int count);
[LoggerMessage(
EventName = nameof(NoSamlFrontChannelLogoutUrlsGenerated),
Message = "No SAML front-channel logout URLs generated")]
internal static partial void NoSamlFrontChannelLogoutUrlsGenerated(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(NoLogoutMessageFound),
Message = $"No logout message found for logoutId: {{logoutId}}")]
internal static partial void NoLogoutMessageFound(this ILogger logger, LogLevel logLevel, string logoutId);
[LoggerMessage(
EventName = nameof(LogoutMessageMissingSamlEntityId),
Message = "Logout message does not contain SAML SP entity ID")]
internal static partial void LogoutMessageMissingSamlEntityId(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(BuildingLogoutResponseForSp),
Message = $"Building SAML logout response for SP: {{{SingleLogoutLogParameters.EntityId}}}")]
internal static partial void BuildingLogoutResponseForSp(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(ServiceProviderDisabled),
Message = $"Service Provider is disabled: {{{SingleLogoutLogParameters.EntityId}}}")]
internal static partial void ServiceProviderDisabled(this ILogger logger, LogLevel logLevel, string entityId);
[LoggerMessage(
EventName = nameof(LogoutMessageMissingRequestId),
Message = "Logout message does not contain SAML logout request ID")]
internal static partial void LogoutMessageMissingRequestId(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(SuccessfullyBuiltLogoutResponse),
Message = $"Successfully built SAML logout response for SP: {{{SingleLogoutLogParameters.EntityId}}}, InResponseTo: {{{SingleLogoutLogParameters.RequestId}}}")]
internal static partial void SuccessfullyBuiltLogoutResponse(this ILogger logger, LogLevel logLevel, string entityId, string requestId);
[LoggerMessage(
EventName = nameof(InvalidHttpMethodForLogoutCallback),
Message = "Invalid HTTP method for SAML logout callback endpoint")]
internal static partial void InvalidHttpMethodForLogoutCallback(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(ProcessingSamlLogoutCallbackRequest),
Message = "Processing SAML logout callback request")]
internal static partial void ProcessingSamlLogoutCallbackRequest(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(MissingLogoutIdParameter),
Message = "Missing logoutId parameter in SAML logout callback")]
internal static partial void MissingLogoutIdParameter(this ILogger logger, LogLevel logLevel);
[LoggerMessage(
EventName = nameof(ErrorProcessingLogoutCallback),
Message = $"Error processing SAML logout callback: {{{SingleLogoutLogParameters.Message}}}")]
internal static partial void ErrorProcessingLogoutCallback(this ILogger logger, LogLevel logLevel, string message);
[LoggerMessage(
EventName = nameof(SuccessfullyProcessedLogoutCallback),
Message = "Successfully processed SAML logout callback")]
internal static partial void SuccessfullyProcessedLogoutCallback(this ILogger logger, LogLevel logLevel);
}

View file

@ -0,0 +1,130 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Globalization;
using System.Xml;
using System.Xml.Linq;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Saml.Models;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
/// <summary>
/// Parses SAML LogoutRequest messages.
/// </summary>
internal class LogoutRequestParser(ILogger<LogoutRequestParser> logger) : SamlProtocolMessageParser
{
/// <summary>
/// Parses a LogoutRequest from XML.
/// </summary>
internal LogoutRequest Parse(XDocument doc)
{
try
{
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
var root = doc.Root;
if (root?.Name != protocolNs + LogoutRequest.ElementNames.RootElement)
{
throw new FormatException($"Root element is not LogoutRequest. Found: {root?.Name}");
}
var request = new LogoutRequest
{
Id = GetRequiredAttribute(root, LogoutRequest.AttributeNames.Id),
Version = GetRequiredAttribute(root, LogoutRequest.AttributeNames.Version),
IssueInstant = ParseDateTime(root, LogoutRequest.AttributeNames.IssueInstant),
Destination = GetOptionalAttribute(root, LogoutRequest.AttributeNames.Destination) is { } dest ? new Uri(dest) : null,
Issuer = ParseIssuerValue(root, assertionNs, "LogoutRequest"),
NameId = ParseNameIdAsIdentifier(root, assertionNs),
SessionIndex = ParseSessionIndex(root, protocolNs),
Reason = ParseReason(GetOptionalAttribute(root, LogoutRequest.AttributeNames.Reason)),
};
var notOnOrAfterAttr = root.Attribute(LogoutRequest.AttributeNames.NotOnOrAfter)?.Value;
if (!string.IsNullOrEmpty(notOnOrAfterAttr))
{
request.NotOnOrAfter = DateTime.Parse(notOnOrAfterAttr, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal);
}
logger.ParsedLogoutRequest(LogLevel.Debug, request.Id, request.Issuer, request.SessionIndex);
return request;
}
catch (XmlException ex)
{
logger.FailedToParseLogoutRequest(ex, ex.Message);
throw;
}
catch (Exception ex)
{
logger.UnexpectedErrorParsingLogoutRequest(ex);
throw;
}
}
private static NameIdentifier ParseNameIdAsIdentifier(XElement root, XNamespace assertionNs)
{
var nameIdElement = root.Element(assertionNs + LogoutRequest.ElementNames.NameID);
if (nameIdElement == null)
{
throw new InvalidOperationException("NameID element is required in LogoutRequest");
}
var nameId = nameIdElement.Value?.Trim();
if (string.IsNullOrEmpty(nameId))
{
throw new InvalidOperationException("NameID element cannot be empty");
}
var format = nameIdElement.Attribute(NameIdPolicy.AttributeNames.Format)?.Value;
var nameQualifier = nameIdElement.Attribute("NameQualifier")?.Value;
var spNameQualifierAttr = nameIdElement.Attribute(NameIdPolicy.AttributeNames.SPNameQualifier);
string? spNameQualifier = null;
if (spNameQualifierAttr != null && !string.IsNullOrWhiteSpace(spNameQualifierAttr.Value))
{
spNameQualifier = spNameQualifierAttr.Value;
}
return new NameIdentifier
{
Value = nameId,
Format = format,
NameQualifier = nameQualifier,
SPNameQualifier = spNameQualifier
};
}
private static string ParseSessionIndex(XElement root, XNamespace protocolNs)
{
var sessionIndexElement = root.Element(protocolNs + LogoutRequest.ElementNames.SessionIndex);
if (sessionIndexElement == null)
{
throw new InvalidOperationException("SessionIndex element is required in LogoutRequest");
}
var sessionIndex = sessionIndexElement.Value.Trim();
if (string.IsNullOrEmpty(sessionIndex))
{
throw new InvalidOperationException("SessionIndex element cannot be empty");
}
return sessionIndex;
}
private static LogoutReason? ParseReason(string? reasonUrn) => reasonUrn switch
{
SamlConstants.LogoutReasons.User => LogoutReason.User,
SamlConstants.LogoutReasons.Admin => LogoutReason.Admin,
SamlConstants.LogoutReasons.GlobalTimeout => LogoutReason.GlobalTimeout,
_ => null
};
}

View file

@ -0,0 +1,68 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Services;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal class LogoutResponseBuilder(
IIssuerNameService issuerNameService,
TimeProvider timeProvider)
{
internal async Task<LogoutResponse> BuildSuccessResponseAsync(
string logoutRequestId,
SamlServiceProvider serviceProvider,
string? relayState,
Ct ct)
{
var issuer = await issuerNameService.GetCurrentAsync(ct);
var destination = serviceProvider.SingleLogoutServiceUrl ?? throw new InvalidOperationException("No SingleLogout service url configured");
return new LogoutResponse
{
Id = SamlIds.NewResponseId(),
IssueInstant = timeProvider.GetUtcNow().UtcDateTime,
Destination = destination.Location,
Issuer = issuer,
InResponseTo = logoutRequestId,
Status = new Status
{
StatusCode = SamlStatusCodes.Success
},
ServiceProvider = serviceProvider,
RelayState = relayState
};
}
internal async Task<LogoutResponse> BuildErrorResponseAsync(
SamlLogoutRequest request,
SamlServiceProvider serviceProvider,
SamlError error,
Ct ct)
{
var issuer = await issuerNameService.GetCurrentAsync(ct);
var destination = serviceProvider.SingleLogoutServiceUrl ?? throw new InvalidOperationException("No SingleLogout service url configured");
return new LogoutResponse
{
Id = SamlIds.NewResponseId(),
IssueInstant = timeProvider.GetUtcNow().UtcDateTime,
Destination = destination.Location,
Issuer = issuer,
InResponseTo = request.LogoutRequest.Id,
Status = new Status
{
StatusCode = error.StatusCode,
StatusMessage = error.Message,
NestedStatusCode = error.SubStatusCode
},
ServiceProvider = serviceProvider,
RelayState = request.RelayState
};
}
}

View file

@ -0,0 +1,101 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Saml.Models;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
/// <summary>
/// Represents a SAML 2.0 LogoutRequest message.
/// </summary>
internal record LogoutRequest : ISamlRequest
{
public static string MessageName => "SAML logout request";
/// <summary>
/// Gets or sets the unique identifier for this request.
/// </summary>
public required string Id { get; set; }
/// <summary>
/// Gets or sets the SAML version. Must be "2.0".
/// </summary>
public string Version { get; set; } = SamlVersions.V2;
/// <summary>
/// Gets or sets the time instant of issue in UTC.
/// </summary>
public required DateTime IssueInstant { get; set; }
/// <summary>
/// Gets or sets the URI of the destination endpoint where this request is sent.
/// </summary>
public Uri? Destination { get; set; }
/// <summary>
/// Gets or sets the entity identifier of the issuer (sender) of this request.
/// </summary>
public required string Issuer { get; set; }
/// <summary>
/// Gets or sets the NameID identifying the principal that is being logged out.
/// </summary>
public required NameIdentifier NameId { get; set; }
/// <summary>
/// Gets or sets the SessionIndex identifying the session to be terminated.
/// </summary>
public required string SessionIndex { get; set; }
/// <summary>
/// Gets or sets the reason for the logout (optional).
/// </summary>
public LogoutReason? Reason { get; set; }
/// <summary>
/// Gets or sets the NotOnOrAfter time limit for the logout operation.
/// </summary>
public DateTime? NotOnOrAfter { get; set; }
internal static class AttributeNames
{
public const string Id = "ID";
public const string Version = "Version";
public const string IssueInstant = "IssueInstant";
public const string Reason = "Reason";
public const string NotOnOrAfter = "NotOnOrAfter";
public const string Destination = "Destination";
}
internal static class ElementNames
{
public const string RootElement = "LogoutRequest";
public const string Issuer = "Issuer";
public const string NameID = "NameID";
public const string SessionIndex = "SessionIndex";
}
}
/// <summary>
/// Represents the reason for logout in a LogoutRequest.
/// </summary>
internal enum LogoutReason
{
/// <summary>
/// User initiated the logout.
/// </summary>
User,
/// <summary>
/// Administrator initiated the logout.
/// </summary>
Admin,
/// <summary>
/// Logout due to global timeout.
/// </summary>
GlobalTimeout
}

View file

@ -0,0 +1,132 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Globalization;
using System.Text;
using System.Xml.Linq;
using Duende.IdentityServer.Endpoints.Results;
using Duende.IdentityServer.Hosting;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
using Microsoft.AspNetCore.Http;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
/// <summary>
/// Represents a SAML 2.0 LogoutResponse message.
/// </summary>
internal class LogoutResponse : EndpointResult<LogoutResponse>
{
/// <summary>
/// Gets or sets the unique identifier for this response.
/// </summary>
public required string Id { get; set; }
/// <summary>
/// Gets or sets the SAML version. Must be "2.0".
/// </summary>
public string Version { get; set; } = SamlVersions.V2;
/// <summary>
/// Gets or sets the time instant of issue in UTC.
/// </summary>
public required DateTime IssueInstant { get; set; }
/// <summary>
/// Gets or sets the URI of the destination endpoint where this response is sent.
/// </summary>
public required Uri Destination { get; set; }
/// <summary>
/// Gets or sets the entity identifier of the issuer (sender) of this response.
/// </summary>
public required string Issuer { get; set; }
/// <summary>
/// Gets or sets the ID of the LogoutRequest to which this is a response.
/// </summary>
public required string InResponseTo { get; set; }
/// <summary>
/// Gets or sets the status of the logout operation.
/// </summary>
public required Status Status { get; set; }
/// <summary>
/// Gets or sets the service provider configuration for this response.
/// </summary>
public required SamlServiceProvider ServiceProvider { get; set; }
/// <summary>
/// Gets or sets the optional RelayState parameter to return to the SP.
/// </summary>
public string? RelayState { get; set; }
internal static class ElementNames
{
public const string RootElement = "LogoutResponse";
}
internal class ResponseWriter(ISamlResultSerializer<LogoutResponse> serializer, SamlProtocolMessageSigner samlProtocolMessageSigner) : IHttpResponseWriter<LogoutResponse>
{
public async Task WriteHttpResponse(LogoutResponse result, HttpContext httpContext)
{
var responseXml = serializer.Serialize(result);
var signedResponseXml = await samlProtocolMessageSigner.SignProtocolMessage(responseXml, result.ServiceProvider, httpContext.RequestAborted);
var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedResponseXml));
var html = HttpResponseBindings.GenerateAutoPostForm(SamlConstants.RequestProperties.SAMLResponse, encodedResponse, result.Destination, result.RelayState);
httpContext.Response.ContentType = "text/html";
httpContext.Response.Headers.CacheControl = "no-cache, no-store";
httpContext.Response.Headers.Pragma = "no-cache";
await httpContext.Response.WriteAsync(html);
}
}
internal class Serializer : ISamlResultSerializer<LogoutResponse>
{
public XElement Serialize(LogoutResponse toSerialize)
{
var issueInstant = toSerialize.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
// Build Status element
var statusCodeElement = new XElement(protocolNs + "StatusCode",
new XAttribute("Value", toSerialize.Status.StatusCode.ToString()));
if (!string.IsNullOrEmpty(toSerialize.Status.NestedStatusCode))
{
statusCodeElement.Add(
new XElement(protocolNs + "StatusCode",
new XAttribute("Value", toSerialize.Status.NestedStatusCode)));
}
var statusElement = new XElement(protocolNs + "Status", statusCodeElement);
if (!string.IsNullOrEmpty(toSerialize.Status.StatusMessage))
{
statusElement.Add(new XElement(protocolNs + "StatusMessage", toSerialize.Status.StatusMessage));
}
// Build LogoutResponse element
var responseElement = new XElement(protocolNs + ElementNames.RootElement,
new XAttribute("ID", toSerialize.Id),
new XAttribute("Version", toSerialize.Version),
new XAttribute("IssueInstant", issueInstant),
new XAttribute("Destination", toSerialize.Destination),
new XAttribute("InResponseTo", toSerialize.InResponseTo),
new XElement(XNamespace.Get(SamlConstants.Namespaces.Assertion) + "Issuer", toSerialize.Issuer),
statusElement);
return responseElement;
}
}
}

View file

@ -0,0 +1,52 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Xml.Linq;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
/// <summary>
/// Represents a SAML logout request with binding information.
/// </summary>
internal record SamlLogoutRequest : SamlRequestBase<LogoutRequest>
{
public static async ValueTask<SamlLogoutRequest?> BindAsync(HttpContext context)
{
var extractor = context.RequestServices.GetRequiredService<SamlLogoutRequestExtractor>();
return await extractor.ExtractAsync(context);
}
public LogoutRequest LogoutRequest => Request;
}
internal class SamlLogoutRequestExtractor : SamlRequestExtractor<LogoutRequest, SamlLogoutRequest>
{
private readonly LogoutRequestParser _parser;
public SamlLogoutRequestExtractor(LogoutRequestParser parser) => _parser = parser;
protected override LogoutRequest ParseRequest(XDocument xmlDocument) => _parser.Parse(xmlDocument);
protected override SamlLogoutRequest CreateResult(
LogoutRequest parsedRequest,
XDocument requestXml,
SamlBinding binding,
string? relayState,
string? signature = null,
string? signatureAlgorithm = null,
string? encodedSamlRequest = null) => new SamlLogoutRequest
{
Request = parsedRequest,
RequestXml = requestXml,
Binding = binding,
RelayState = relayState,
Signature = signature,
SignatureAlgorithm = signatureAlgorithm,
EncodedSamlRequest = encodedSamlRequest
};
}

View file

@ -0,0 +1,142 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Xml.Linq;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
using LogoutRequest = Duende.IdentityServer.Internal.Saml.SingleLogout.Models.LogoutRequest;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal class SamlFrontChannelLogoutRequestBuilder(
TimeProvider timeProvider,
SamlProtocolMessageSigner samlProtocolMessageSigner)
{
internal async Task<ISamlFrontChannelLogout> BuildLogoutRequestAsync(
SamlServiceProvider serviceProvider,
string nameId,
string? nameIdFormat,
string sessionIndex,
string issuer,
Ct ct)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
if (serviceProvider.SingleLogoutServiceUrl == null)
{
throw new InvalidOperationException(
$"Service Provider '{serviceProvider.EntityId}' has no SingleLogoutServiceUrl configured");
}
var logoutRequest = new LogoutRequest
{
Id = SamlIds.NewRequestId(),
IssueInstant = timeProvider.GetUtcNow().UtcDateTime,
Destination = serviceProvider.SingleLogoutServiceUrl.Location,
Issuer = issuer,
NameId = new NameIdentifier { Value = nameId, Format = nameIdFormat },
SessionIndex = sessionIndex
};
var requestXml = SerializeLogoutRequest(logoutRequest);
return serviceProvider.SingleLogoutServiceUrl.Binding switch
{
SamlBinding.HttpRedirect => await BuildRedirectLogoutRequest(serviceProvider.SingleLogoutServiceUrl.Location, requestXml, ct),
SamlBinding.HttpPost => await BuildHttpPostLogoutRequest(serviceProvider, requestXml, ct),
_ => throw new InvalidOperationException(
$"Binding '{serviceProvider.SingleLogoutServiceUrl.Binding}' is not supported")
};
}
private static XElement SerializeLogoutRequest(LogoutRequest logoutRequest)
{
var issueInstant =
logoutRequest.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
var requestElement = new XElement(protocolNs + LogoutRequest.ElementNames.RootElement,
new XAttribute("ID", logoutRequest.Id),
new XAttribute("Version", logoutRequest.Version),
new XAttribute("IssueInstant", issueInstant),
new XAttribute("Destination", logoutRequest.Destination!),
new XElement(assertionNs + LogoutRequest.ElementNames.Issuer, logoutRequest.Issuer));
var nameIdElement = new XElement(assertionNs + LogoutRequest.ElementNames.NameID, logoutRequest.NameId.Value);
if (!string.IsNullOrEmpty(logoutRequest.NameId.Format))
{
nameIdElement.Add(new XAttribute("Format", logoutRequest.NameId.Format));
}
requestElement.Add(nameIdElement);
requestElement.Add(new XElement(protocolNs + LogoutRequest.ElementNames.SessionIndex,
logoutRequest.SessionIndex));
if (logoutRequest.Reason.HasValue)
{
var reasonValue = logoutRequest.Reason.Value switch
{
LogoutReason.User => "urn:oasis:names:tc:SAML:2.0:logout:user",
LogoutReason.Admin => "urn:oasis:names:tc:SAML:2.0:logout:admin",
LogoutReason.GlobalTimeout => "urn:oasis:names:tc:SAML:2.0:logout:global-timeout",
_ => null
};
if (reasonValue != null)
{
requestElement.Add(new XAttribute("Reason", reasonValue));
}
}
if (logoutRequest.NotOnOrAfter.HasValue)
{
var notOnOrAfter =
logoutRequest.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
requestElement.Add(new XAttribute("NotOnOrAfter", notOnOrAfter));
}
return requestElement;
}
private async Task<ISamlFrontChannelLogout> BuildRedirectLogoutRequest(Uri singleLogoutServiceUri, XElement requestXml, Ct ct)
{
var encodedRequest = DeflateAndEncode(requestXml.ToString());
var queryString = $"?SAMLRequest={Uri.EscapeDataString(encodedRequest)}";
var signedQueryString = await samlProtocolMessageSigner.SignQueryString(queryString, ct);
return new SamlHttpRedirectFrontChannelLogout(singleLogoutServiceUri, signedQueryString);
}
private static string DeflateAndEncode(string xml)
{
var bytes = Encoding.UTF8.GetBytes(xml);
using var output = new MemoryStream();
using (var deflateStream = new DeflateStream(output, CompressionLevel.Optimal))
{
deflateStream.Write(bytes, 0, bytes.Length);
}
return Convert.ToBase64String(output.ToArray());
}
private async Task<ISamlFrontChannelLogout> BuildHttpPostLogoutRequest(SamlServiceProvider serviceProvider, XElement requestXml, Ct ct)
{
var signedRequestXml = await samlProtocolMessageSigner.SignProtocolMessage(requestXml, serviceProvider, ct);
var encodedXml = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedRequestXml));
return new SamlHttpPostFrontChannelLogout(serviceProvider.SingleLogoutServiceUrl!.Location, encodedXml, null);
}
}

View file

@ -0,0 +1,19 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal class SamlHttpPostFrontChannelLogout(Uri frontChannelLogoutUri, string logoutRequest, string? relayState) : ISamlFrontChannelLogout
{
public SamlBinding SamlBinding => SamlBinding.HttpPost;
public Uri Destination => frontChannelLogoutUri;
public string EncodedContent => logoutRequest;
public string? RelayState => relayState;
}

View file

@ -0,0 +1,19 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal class SamlHttpRedirectFrontChannelLogout(Uri frontChannelLogoutUri, string encodedContent) : ISamlFrontChannelLogout
{
public SamlBinding SamlBinding => SamlBinding.HttpRedirect;
public Uri Destination => frontChannelLogoutUri;
public string EncodedContent => encodedContent;
public string? RelayState { get; }
}

View file

@ -0,0 +1,79 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
/// <summary>
/// Processes SAML Single Logout callback requests after user logout completes.
/// </summary>
internal class SamlLogoutCallbackProcessor(
IMessageStore<LogoutMessage> logoutMessageStore,
ISamlServiceProviderStore serviceProviderStore,
LogoutResponseBuilder logoutResponseBuilder,
ILogger<SamlLogoutCallbackProcessor> logger)
{
internal async Task<Result<LogoutResponse, SamlLogoutCallbackError>> ProcessAsync(string logoutId, Ct ct = default)
{
var logoutMessage = await logoutMessageStore.ReadAsync(logoutId, ct);
if (logoutMessage?.Data == null)
{
logger.NoLogoutMessageFound(LogLevel.Warning, logoutId);
return new SamlLogoutCallbackError("No logout message found");
}
var data = logoutMessage.Data;
if (data.SamlServiceProviderEntityId == null)
{
logger.LogoutMessageMissingSamlEntityId(LogLevel.Warning);
return new SamlLogoutCallbackError("Logout message does not contain SAML SP entity ID");
}
logger.BuildingLogoutResponseForSp(LogLevel.Debug, data.SamlServiceProviderEntityId);
var sp = await serviceProviderStore.FindByEntityIdAsync(data.SamlServiceProviderEntityId, ct);
if (sp == null)
{
logger.ServiceProviderNotFound(LogLevel.Error, data.SamlServiceProviderEntityId);
return new SamlLogoutCallbackError($"Service Provider not found: {data.SamlServiceProviderEntityId}");
}
if (!sp.Enabled)
{
logger.ServiceProviderDisabled(LogLevel.Error, sp.EntityId);
return new SamlLogoutCallbackError($"Service Provider is disabled: {sp.EntityId}");
}
if (sp.SingleLogoutServiceUrl == null)
{
logger.SamlLogoutNoSingleLogoutServiceUrl(LogLevel.Error, sp.EntityId);
return new SamlLogoutCallbackError($"Service Provider has no SingleLogoutServiceUrl configured: {sp.EntityId}");
}
if (string.IsNullOrWhiteSpace(data.SamlLogoutRequestId))
{
logger.LogoutMessageMissingRequestId(LogLevel.Error);
return new SamlLogoutCallbackError("Logout message does not contain SAML logout request ID");
}
var response = await logoutResponseBuilder.BuildSuccessResponseAsync(
data.SamlLogoutRequestId,
sp,
data.SamlRelayState,
ct);
logger.SuccessfullyBuiltLogoutResponse(LogLevel.Information, data.SamlServiceProviderEntityId, data.SamlLogoutRequestId);
return response;
}
}
/// <summary>
/// Represents an error during SAML logout callback processing.
/// </summary>
internal record SamlLogoutCallbackError(string Message);

View file

@ -0,0 +1,78 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal class SamlLogoutNotificationService(
IIssuerNameService issuerNameService,
ISamlServiceProviderStore serviceProviderStore,
SamlFrontChannelLogoutRequestBuilder frontChannelLogoutRequestBuilder,
ILogger<SamlLogoutNotificationService> logger) : ISamlLogoutNotificationService
{
public async Task<IEnumerable<ISamlFrontChannelLogout>> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context, Ct ct)
{
using var activity = Tracing.ServiceActivitySource.StartActivity("LogoutNotificationService.GetSamlFrontChannelLogoutUrls");
var logoutUrls = new List<ISamlFrontChannelLogout>();
if (!context.SamlSessions.Any())
{
logger.NoSamlServiceProvidersToNotifyForLogout(LogLevel.Debug);
return logoutUrls;
}
var issuer = await issuerNameService.GetCurrentAsync(ct);
foreach (var sessionData in context.SamlSessions ?? [])
{
var sp = await serviceProviderStore.FindByEntityIdAsync(sessionData.EntityId, ct);
if (sp?.Enabled != true)
{
logger.SkippingLogoutUrlGenerationForUnknownOrDisabledServiceProvider(LogLevel.Debug, sessionData.EntityId);
continue;
}
if (sp.SingleLogoutServiceUrl == null)
{
logger.SkippingLogoutUrlGenerationForServiceProviderWithNoSingleLogout(LogLevel.Debug, sessionData.EntityId);
continue;
}
try
{
var logoutUrl = await frontChannelLogoutRequestBuilder.BuildLogoutRequestAsync(
sp,
sessionData.NameId,
sessionData.NameIdFormat,
sessionData.SessionIndex,
issuer,
ct);
logoutUrls.Add(logoutUrl);
}
#pragma warning disable CA1031 // Do not catch general exception types: one failure should not stop the whole process
catch (Exception ex)
#pragma warning restore CA1031
{
logger.FailedToGenerateLogoutUrlForServiceProvider(ex, sessionData.EntityId);
}
}
if (logoutUrls.Count > 0)
{
logger.GeneratedSamlFrontChannelLogoutUrls(LogLevel.Debug, logoutUrls.Count);
}
else
{
logger.NoSamlFrontChannelLogoutUrlsGenerated(LogLevel.Debug);
}
return logoutUrls;
}
}

View file

@ -0,0 +1,175 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Security.Claims;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using LogoutRequest = Duende.IdentityServer.Internal.Saml.SingleLogout.Models.LogoutRequest;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal class SamlLogoutRequestProcessor : SamlRequestProcessorBase<LogoutRequest, SamlLogoutRequest, SamlLogoutSuccess>
{
private readonly IUserSession _userSession;
private readonly LogoutResponseBuilder _logoutResponseBuilder;
private readonly IMessageStore<LogoutMessage> _logoutMessageStore;
private readonly TimeProvider _timeProvider;
private readonly SamlUrlBuilder _urlBuilder;
public SamlLogoutRequestProcessor(
ISamlServiceProviderStore serviceProviderStore,
IUserSession userSession,
SamlRequestSignatureValidator<SamlLogoutRequest, LogoutRequest> signatureValidator,
LogoutResponseBuilder logoutResponseBuilder,
IServerUrls serverUrls,
IOptions<SamlOptions> options,
IMessageStore<LogoutMessage> logoutMessageStore,
TimeProvider timeProvider,
SamlUrlBuilder urlBuilder,
SamlRequestValidator requestValidator,
ILogger<SamlLogoutRequestProcessor> logger)
: base(
serviceProviderStore,
options,
requestValidator,
signatureValidator,
logger,
serverUrls.GetAbsoluteUrl(options.Value.UserInteraction.Route + options.Value.UserInteraction.SingleLogoutPath))
{
_userSession = userSession;
_logoutResponseBuilder = logoutResponseBuilder;
_logoutMessageStore = logoutMessageStore;
_timeProvider = timeProvider;
_urlBuilder = urlBuilder;
}
protected override async Task<Result<SamlLogoutSuccess, SamlRequestError<SamlLogoutRequest>>> ProcessValidatedRequestAsync(
SamlServiceProvider sp,
SamlLogoutRequest request,
Ct ct = default)
{
var logoutRequest = request.LogoutRequest;
if (sp.SingleLogoutServiceUrl == null)
{
Logger.SamlLogoutNoSingleLogoutServiceUrl(LogLevel.Error, sp.EntityId);
return new SamlRequestError<SamlLogoutRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"Service Provider '{sp.EntityId}' has no SingleLogoutServiceUrl configured"
};
}
Logger.ProcessingSamlLogoutRequest(LogLevel.Debug, logoutRequest.Id, sp.DisplayName, logoutRequest.Issuer);
var user = await _userSession.GetUserAsync(ct);
if (user == null)
{
Logger.SamlLogoutRequestReceivedButNoActiveUserSession(LogLevel.Debug, logoutRequest.Id, logoutRequest.Issuer);
var noUserAuthenticatedResponse = await _logoutResponseBuilder.BuildSuccessResponseAsync(logoutRequest.Id, sp, request.RelayState, ct);
// there is no user to log out, return success
return SamlLogoutSuccess.CreateResponse(noUserAuthenticatedResponse);
}
var sessionMatch = await ValidateSessionIndexAsync(sp, logoutRequest.SessionIndex, ct);
if (!sessionMatch)
{
Logger.SamlLogoutRequestReceivedWithWrongSessionIndex(LogLevel.Warning, logoutRequest.Id, logoutRequest.SessionIndex);
var noSessionIndexResponse = await _logoutResponseBuilder.BuildSuccessResponseAsync(logoutRequest.Id, sp, request.RelayState, ct);
// there is no session to terminate, return success
return SamlLogoutSuccess.CreateResponse(noSessionIndexResponse);
}
Logger.SamlLogoutRedirectToLogoutPage(LogLevel.Information, logoutRequest.Issuer);
var logoutId = await StoreLogoutMessageAsync(user, sp, request, ct);
var logoutUri = _urlBuilder.SamlLogoutUri(logoutId);
return SamlLogoutSuccess.CreateRedirect(logoutUri);
}
protected override bool RequireSignature(SamlServiceProvider sp) =>
// SAML 2.0 spec requires LogoutRequest to be signed
true;
protected override SamlRequestError<SamlLogoutRequest>? ValidateMessageSpecific(SamlServiceProvider sp, SamlLogoutRequest request)
{
var logoutRequest = request.LogoutRequest;
// Validate NotOnOrAfter if present
if (logoutRequest.NotOnOrAfter.HasValue)
{
var now = _timeProvider.GetUtcNow();
var clockSkew = sp.ClockSkew ?? SamlOptions.DefaultClockSkew;
if (now.Subtract(clockSkew) > logoutRequest.NotOnOrAfter.Value)
{
Logger.SamlLogoutRequestExpired(LogLevel.Warning, logoutRequest.Id, logoutRequest.NotOnOrAfter.Value);
return new SamlRequestError<SamlLogoutRequest>
{
Type = SamlRequestErrorType.Protocol,
ProtocolError = new SamlProtocolError<SamlLogoutRequest>(sp, request, new SamlError
{
StatusCode = SamlStatusCodes.Requester,
Message = "Logout request expired (NotOnOrAfter is in the past)"
})
};
}
}
return null;
}
private async Task<bool> ValidateSessionIndexAsync(SamlServiceProvider sp, string sessionIndex, Ct ct)
{
var samlSessions = await _userSession.GetSamlSessionListAsync(ct);
var spSession = samlSessions.FirstOrDefault(s => s.EntityId == sp.EntityId);
if (spSession == null)
{
Logger.SamlLogoutNoSessionFoundForServiceProvider(LogLevel.Debug, sessionIndex, sp.EntityId);
return false;
}
if (spSession.SessionIndex != sessionIndex)
{
Logger.SamlLogoutSessionIndexMisMatch(LogLevel.Debug, spSession.SessionIndex, sessionIndex);
return false;
}
return true;
}
private async Task<string> StoreLogoutMessageAsync(ClaimsPrincipal user, SamlServiceProvider serviceProvider, SamlLogoutRequest logoutRequest, Ct ct)
{
var samlSessions = await _userSession.GetSamlSessionListAsync(ct);
var oidcClientIds = await _userSession.GetClientListAsync(ct);
var logoutMessage = new LogoutMessage
{
SubjectId = user.GetSubjectId(),
SessionId = await _userSession.GetSessionIdAsync(ct),
ClientIds = oidcClientIds,
SamlServiceProviderEntityId = serviceProvider.EntityId,
SamlSessions = samlSessions,
SamlLogoutRequestId = logoutRequest.LogoutRequest.Id,
SamlRelayState = logoutRequest.RelayState,
PostLogoutRedirectUri = _urlBuilder.SamlLogoutCallBackUri().ToString()
};
var msg = new Message<LogoutMessage>(logoutMessage, _timeProvider.GetUtcNow().UtcDateTime);
return await _logoutMessageStore.WriteAsync(msg, ct);
}
}

View file

@ -0,0 +1,20 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Hosting;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal record SamlLogoutSuccess
{
private SamlLogoutSuccess(IEndpointResult result) => Result = result;
public IEndpointResult Result { get; private set; }
public static SamlLogoutSuccess CreateResponse(LogoutResponse logoutResponse) =>
new(logoutResponse);
public static SamlLogoutSuccess CreateRedirect(Uri redirectUri) => new(new RedirectResult(redirectUri));
}

View file

@ -0,0 +1,50 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Net;
using Duende.IdentityServer.Endpoints.Results;
using Duende.IdentityServer.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
/// <summary>
/// Endpoint for completing SAML Single Logout and sending the LogoutResponse back to the initiating Service Provider.
/// This is called after the user completes logout and all front-channel logout notifications have been sent.
/// </summary>
internal class SamlSingleLogoutCallbackEndpoint(
SamlLogoutCallbackProcessor processor,
ILogger<SamlSingleLogoutCallbackEndpoint> logger) : IEndpointHandler
{
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
{
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSingleLogoutCallbackEndpoint");
if (!HttpMethods.IsGet(context.Request.Method))
{
return new StatusCodeResult(HttpStatusCode.MethodNotAllowed);
}
logger.ProcessingSamlLogoutCallbackRequest(LogLevel.Debug);
var logoutId = context.Request.Query["logoutId"].ToString();
if (string.IsNullOrWhiteSpace(logoutId))
{
logger.MissingLogoutIdParameter(LogLevel.Warning);
return new StatusCodeResult(HttpStatusCode.BadRequest);
}
var result = await processor.ProcessAsync(logoutId, context.RequestAborted);
if (!result.Success)
{
logger.ErrorProcessingLogoutCallback(LogLevel.Error, result.Error.Message);
return new StatusCodeResult(HttpStatusCode.BadRequest);
}
logger.SuccessfullyProcessedLogoutCallback(LogLevel.Information);
return result.Value;
}
}

View file

@ -0,0 +1,77 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Endpoints.Results;
using Duende.IdentityServer.Hosting;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
internal class SamlSingleLogoutEndpoint(
SamlLogoutRequestExtractor extractor,
SamlLogoutRequestProcessor processor,
LogoutResponseBuilder responseBuilder,
ILogger<SamlSingleLogoutEndpoint> logger) : IEndpointHandler
{
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
{
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSingleLogoutEndpoint");
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsPost(context.Request.Method))
{
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
}
// Extract the SAML logout request from query string (GET/Redirect) or form (POST)
var logoutRequest = await extractor.ExtractAsync(context);
return await ProcessLogoutRequest(logoutRequest, context.RequestAborted);
}
internal async Task<IEndpointResult> ProcessLogoutRequest(SamlLogoutRequest logoutRequest, Ct ct = default)
{
logger.ReceivedLogoutRequest(LogLevel.Debug, logoutRequest.LogoutRequest.Issuer, logoutRequest.LogoutRequest.Id, logoutRequest.LogoutRequest.SessionIndex);
var result = await processor.ProcessAsync(logoutRequest, ct);
if (!result.Success)
{
var error = result.Error;
return error.Type switch
{
SamlRequestErrorType.Validation => HandleValidationError(error),
SamlRequestErrorType.Protocol => await HandleProtocolError(error, ct),
_ => throw new InvalidOperationException($"Unexpected error type: {error.Type}")
};
}
var success = result.Value;
logger.SuccessfullyProcessedLogoutRequest(LogLevel.Information, logoutRequest.LogoutRequest.Id, logoutRequest.LogoutRequest.SessionIndex);
return success.Result;
}
private ValidationProblemResult HandleValidationError(SamlRequestError<SamlLogoutRequest> error)
{
logger.SamlLogoutValidationError(LogLevel.Information, error.ValidationMessage!);
return new ValidationProblemResult(error.ValidationMessage!);
}
private async Task<LogoutResponse> HandleProtocolError(SamlRequestError<SamlLogoutRequest> error, Ct ct)
{
var protocolError = error.ProtocolError!;
logger.SamlLogoutProtocolError(LogLevel.Information,
protocolError.Error.StatusCode,
protocolError.Error.Message);
return await responseBuilder.BuildErrorResponseAsync(
protocolError.Request,
protocolError.ServiceProvider,
protocolError.Error,
ct);
}
}

View file

@ -0,0 +1,140 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Globalization;
using System.Xml;
using System.Xml.Linq;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Saml.Models;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class AuthNRequestParser : SamlProtocolMessageParser
{
private readonly ILogger<AuthNRequestParser> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AuthNRequestParser"/> class.
/// </summary>
public AuthNRequestParser(ILogger<AuthNRequestParser> logger) => _logger = logger;
internal AuthNRequest Parse(XDocument doc)
{
try
{
var ns = XNamespace.Get(SamlConstants.Namespaces.Protocol);
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
var root = doc.Root;
if (root?.Name != ns + SamlConstants.AuthenticationRequestAttributes.RootElementName)
{
throw new FormatException(
$"Root element is not AuthnRequest. Found: {root?.Name}");
}
var request = new AuthNRequest
{
Id = GetRequiredAttribute(root, AuthNRequest.AttributeNames.Id),
Version = GetRequiredAttribute(root, AuthNRequest.AttributeNames.Version),
IssueInstant = ParseDateTime(root, AuthNRequest.AttributeNames.IssueInstant),
Destination = GetOptionalAttribute(root, AuthNRequest.AttributeNames.Destination) is { } dest ? new Uri(dest) : null,
Consent = GetOptionalAttribute(root, AuthNRequest.AttributeNames.Consent),
Issuer = ParseIssuerValue(root, assertionNs, "AuthnRequest"),
ForceAuthn = ParseBooleanAttribute(root, AuthNRequest.AttributeNames.ForceAuthn, false),
IsPassive = ParseBooleanAttribute(root, AuthNRequest.AttributeNames.IsPassive, false),
AssertionConsumerServiceUrl =
GetOptionalAttribute(root, AuthNRequest.AttributeNames.AssertionConsumerServiceUrl) is { } acsUrl ? new Uri(acsUrl) : null,
AssertionConsumerServiceIndex =
ParseIntegerAttribute(root, AuthNRequest.AttributeNames.AssertionConsumerServiceIndex),
ProtocolBinding =
SamlBindingExtensions.FromUrnOrDefault(GetOptionalAttribute(root, AuthNRequest.AttributeNames.ProtocolBinding))
};
// Parse optional elements
// request.Subject = ParseSubject(root, assertionNs);
request.NameIdPolicy = ParseNameIdPolicy(root, ns);
// request.Conditions = ParseConditions(root, assertionNs);
request.RequestedAuthnContext = ParseRequestedAuthnContext(root, ns);
// request.Scoping = ParseScoping(root, ns);
_logger.ParsedAuthenticationRequest(request.Id, request.Issuer);
return request;
}
catch (XmlException ex)
{
_logger.FailedToParseAuthNRequest(ex, ex.Message);
throw;
}
catch (Exception ex)
{
_logger.UnexpectedErrorParsingAuthNRequest(ex);
throw;
}
}
private static NameIdPolicy? ParseNameIdPolicy(XElement root, XNamespace ns)
{
var nameIdPolicyElement = root.Element(ns + AuthNRequest.ElementNames.NameIdPolicy);
if (nameIdPolicyElement == null)
{
return null;
}
var format = GetOptionalAttribute(nameIdPolicyElement, NameIdPolicy.AttributeNames.Format);
var spNameQualifier = GetOptionalAttribute(nameIdPolicyElement, NameIdPolicy.AttributeNames.SPNameQualifier);
// If element exists but all attributes are null/default, still return object
// to indicate element was present (SP may want default behavior explicitly)
return new NameIdPolicy
{
Format = string.IsNullOrWhiteSpace(format) ? null : format.Trim(),
SPNameQualifier = string.IsNullOrWhiteSpace(spNameQualifier) ? null : spNameQualifier.Trim()
};
}
private static int? ParseIntegerAttribute(XElement element, string attributeName)
{
var value = GetOptionalAttribute(element, attributeName);
if (string.IsNullOrEmpty(value))
{
return null;
}
return int.Parse(value, CultureInfo.InvariantCulture);
}
private static RequestedAuthnContext? ParseRequestedAuthnContext(XElement root, XNamespace ns)
{
var requestedAuthnContextElement = root.Element(ns + AuthNRequest.ElementNames.RequestedAuthnContext);
if (requestedAuthnContextElement == null)
{
return null;
}
// Parse Comparison attribute (defaults to "exact" per spec)
var comparisonAttr = requestedAuthnContextElement.Attribute(RequestedAuthnContext.AttributeNames.Comparison)?.Value;
var comparison = AuthnContextComparisonExtensions.Parse(comparisonAttr);
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
var classRefs = requestedAuthnContextElement
.Elements(assertionNs + RequestedAuthnContext.ElementNames.AuthnContextClassRef)
.Select(e => e.Value?.Trim())
.Where(v => !string.IsNullOrEmpty(v))
.Select(v => v!)
.ToList();
if (classRefs.Count == 0)
{
throw new InvalidOperationException("No AuthnContextClassRef element found in requestedAuthnContext");
}
return new RequestedAuthnContext
{
AuthnContextClassRefs = classRefs.AsReadOnly(),
Comparison = comparison
};
}
}

View file

@ -0,0 +1,86 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class DefaultSamlSigninInteractionResponseGenerator(
IUserSession userSession,
ILogger<DefaultSamlSigninInteractionResponseGenerator> logger,
IHttpContextAccessor httpContextAccessor)
: ISamlSigninInteractionResponseGenerator
{
public async Task<SamlInteractionResponse> ProcessInteractionAsync(SamlServiceProvider sp, AuthNRequest request, Ct ct = default)
{
var signedInUser = await userSession.GetUserAsync(ct);
if (signedInUser != null)
{
if (request.IsPassive && request.ForceAuthn)
{
// Below is quite ambiguous in the spec. IsPassive means no user interaction. But ForceAuthn means we must re-authenticate.
// For now, we have no way to re-authenticate the user without user interaction.
// From the spec:
//ForceAuthn[Optional]
//A Boolean value.If "true", the identity provider MUST authenticate the presenter directly rather than
//rely on a previous security context. If a value is not provided, the default is "false".However, if both
// ForceAuthn and IsPassive are "true", the identity provider MUST NOT freshly authenticate the
//presenter unless the constraints of IsPassive can be met.
logger.SamlInteractionPassiveAndForced(LogLevel.Debug);
return SamlInteractionResponse.CreateError(SamlStatusCodes.NoPassive, "The user is not currently logged in");
}
if (request.ForceAuthn)
{
logger.SamlInteractionForced(LogLevel.Debug);
ArgumentNullException.ThrowIfNull(httpContextAccessor.HttpContext, nameof(httpContextAccessor.HttpContext));
await httpContextAccessor.HttpContext.SignOutAsync();
return SamlInteractionResponse.Create(SamlInteractionResponseType.Login);
}
logger.SamlInteractionAlreadyAuthenticated(LogLevel.Debug);
return SamlInteractionResponse.Create(SamlInteractionResponseType.AlreadyAuthenticated);
}
if (request.IsPassive)
{
logger.SamlInteractionNoPassive(LogLevel.Debug);
return SamlInteractionResponse.CreateError(SamlStatusCodes.NoPassive, "The user is not currently logged in and passive login was requested.");
}
// Todo: The AuthN request may contain hints on account creation 3.4.1.1 Element <NameIDPolicy>: AllowCreate
// Consent is a weird one.
// There is no way for SAML for an SP to mandate that a consent screen should be shown.
if (sp.RequireConsent && !IsConsentAcquired(request.Consent))
{
logger.SamlInteractionConsent(LogLevel.Debug);
return SamlInteractionResponse.Create(SamlInteractionResponseType.Consent);
}
logger.SamlInteractionLogin(LogLevel.Debug);
return SamlInteractionResponse.Create(SamlInteractionResponseType.Login);
}
/// <summary>
/// Determines whether consent has been acquired based on the SAML consent URN value.
/// See SAML 2.0 Core spec section 8.4.
/// </summary>
private static bool IsConsentAcquired(string? consent) => consent is
"urn:oasis:names:tc:SAML:2.0:consent:obtained" or
"urn:oasis:names:tc:SAML:2.0:consent:prior" or
"urn:oasis:names:tc:SAML:2.0:consent:current-implicit" or
"urn:oasis:names:tc:SAML:2.0:consent:current-explicit";
}

View file

@ -0,0 +1,54 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Text.Json;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Microsoft.Extensions.Caching.Distributed;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class DistributedCacheSamlSigninStateStore(IDistributedCache cache) : ISamlSigninStateStore
{
private const string KeyPrefix = "saml-signin-state:";
private static readonly DistributedCacheEntryOptions CacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
};
public async Task<StateId> StoreSigninRequestStateAsync(SamlAuthenticationState state, Ct ct = default)
{
var stateId = StateId.NewId();
var key = GetKey(stateId);
var json = JsonSerializer.Serialize(state);
await cache.SetStringAsync(key, json, CacheOptions, ct);
return stateId;
}
public async Task<SamlAuthenticationState?> RetrieveSigninRequestStateAsync(StateId stateId, Ct ct = default)
{
var key = GetKey(stateId);
var json = await cache.GetStringAsync(key, ct);
if (json == null)
{
return null;
}
await cache.RemoveAsync(key, ct);
return JsonSerializer.Deserialize<SamlAuthenticationState>(json);
}
public async Task UpdateSigninRequestStateAsync(StateId stateId, SamlAuthenticationState state, Ct ct = default)
{
var key = GetKey(stateId);
var json = JsonSerializer.Serialize(state);
await cache.SetStringAsync(key, json, CacheOptions, ct);
}
private static string GetKey(StateId stateId) => $"{KeyPrefix}{stateId.Value}";
}

View file

@ -0,0 +1,14 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal interface ISamlSigninStateStore
{
Task<StateId> StoreSigninRequestStateAsync(SamlAuthenticationState request, Ct ct = default);
Task<SamlAuthenticationState?> RetrieveSigninRequestStateAsync(StateId stateId, Ct ct = default);
Task UpdateSigninRequestStateAsync(StateId stateId, SamlAuthenticationState state, Ct ct = default);
}

View file

@ -0,0 +1,48 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal static class SingleSignInLogParameters
{
public const string Message = "Message";
public const string RequestId = "Id";
public const string Issuer = "Issuer";
public const string Format = "Format";
public const string SPNameQualifier = "SPNameQualifier";
public const string Source = "Source";
}
internal static partial class Log
{
[LoggerMessage(LogLevel.Error,
Message = $"Failed to parse AuthnRequest XML: {{{SingleSignInLogParameters.Message}}}")]
internal static partial void FailedToParseAuthNRequest(this ILogger logger, Exception ex, string message);
[LoggerMessage(LogLevel.Error,
Message = "Unexpected error parsing AuthnRequest")]
internal static partial void UnexpectedErrorParsingAuthNRequest(this ILogger logger, Exception ex);
[LoggerMessage(LogLevel.Debug,
Message =
$"Parsed AuthnRequest {{{SingleSignInLogParameters.RequestId}}} from {{{SingleSignInLogParameters.Issuer}}}")]
internal static partial void ParsedAuthenticationRequest(this ILogger logger, string id, string issuer);
[LoggerMessage(
EventName = nameof(NameIdPolicyParsed),
Message = $"Parsed NameIDPolicy: Format='{{{SingleSignInLogParameters.Format}}}', SPNameQualifier='{{{SingleSignInLogParameters.SPNameQualifier}}}'")]
internal static partial void NameIdPolicyParsed(this ILogger logger, LogLevel level, string? format, string? spNameQualifier);
[LoggerMessage(
EventName = nameof(RequestedNameIdFormatNotSupported),
Message = $"Requested NameID format '{{{SingleSignInLogParameters.Format}}}' is not supported, returning InvalidNameIDPolicy error")]
internal static partial void RequestedNameIdFormatNotSupported(this ILogger logger, LogLevel level, string format);
[LoggerMessage(
EventName = nameof(UsingNameIdFormat),
Message = $"Using NameID format '{{{SingleSignInLogParameters.Format}}}'")]
internal static partial void UsingNameIdFormat(this ILogger logger, LogLevel level, string format);
}

View file

@ -0,0 +1,63 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Duende.IdentityServer.Saml.Models;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
internal record Assertion
{
// ev: This can also be a UUIDV7
/// <summary>
/// Unique identifier for this assertion
/// Must start with a _ character and be unique
///
/// According to SAML 2.0 Core Specification (Section 1.3.4):
///- ID attributes must be of type xs:ID
///- xs:ID must conform to the NCName production (Non-Colonized Name) from the XML Namespaces specification
///- NCName cannot start with a digit, colon, or certain other characters
/// </summary>
public string Id { get; } = SamlIds.NewAssertionId();
/// <summary>
/// SAML version (must be "2.0")
/// </summary>
public string Version { get; } = SamlVersions.V2;
/// <summary>
/// Time instant of issuance
/// </summary>
public required DateTime IssueInstant { get; set; }
/// <summary>
/// Identifies the entity that issued the assertion
/// </summary>
public required string Issuer { get; set; }
/// <summary>
/// The subject of the assertion
/// </summary>
public Subject? Subject { get; set; }
/// <summary>
/// Conditions under which the assertion is valid
/// </summary>
public Conditions? Conditions { get; set; }
/// <summary>
/// Authentication statements
/// </summary>
public List<AuthnStatement> AuthnStatements { get; set; } = [];
/// <summary>
/// Attribute statements
/// </summary>
public List<AttributeStatement> AttributeStatements { get; set; } = [];
/// <summary>
/// Authorization decision statements
/// </summary>
public List<AuthzDecisionStatement> AuthzDecisionStatements { get; set; } = [];
}

View file

@ -0,0 +1,17 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Saml.Models;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents a SAML 2.0 AttributeStatement element
/// </summary>
internal record AttributeStatement
{
/// <summary>
/// Attributes in this statement
/// </summary>
public List<SamlAttribute> Attributes { get; set; } = [];
}

View file

@ -0,0 +1,16 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents a SAML 2.0 AuthnContext element
/// </summary>
internal record AuthnContext
{
/// <summary>
/// Authentication context class reference (URI)
/// </summary>
public string? AuthnContextClassRef { get; set; }
}

View file

@ -0,0 +1,31 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents a SAML 2.0 AuthnStatement element
/// </summary>
internal record AuthnStatement
{
/// <summary>
/// Time at which the authentication took place
/// </summary>
public required DateTime AuthnInstant { get; set; }
/// <summary>
/// Session index for the authenticated session
/// </summary>
public string? SessionIndex { get; set; }
/// <summary>
/// Time instant at which the session expires
/// </summary>
public DateTime? SessionNotOnOrAfter { get; set; }
/// <summary>
/// Authentication context
/// </summary>
public AuthnContext? AuthnContext { get; set; }
}

View file

@ -0,0 +1,26 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents a SAML 2.0 AuthzDecisionStatement element (Section 2.7.4)
/// </summary>
internal record AuthzDecisionStatement
{
/// <summary>
/// URI reference identifying the resource to which access authorization is sought
/// </summary>
public required string Resource { get; set; }
/// <summary>
/// The decision rendered by the SAML authority with respect to the specified resource
/// </summary>
public DecisionType Decision { get; set; }
/// <summary>
/// A set of assertions that the SAML authority relied on in making the decision (optional)
/// </summary>
public Evidence? Evidence { get; set; }
}

View file

@ -0,0 +1,27 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Collections.ObjectModel;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents SAML 2.0 Conditions element
/// </summary>
internal record Conditions
{
/// <summary>
/// Time instant before which the assertion is invalid
/// </summary>
public DateTime? NotBefore { get; set; }
/// <summary>
/// Time instant at which the assertion expires
/// </summary>
public DateTime? NotOnOrAfter { get; set; }
/// <summary>
/// Audience restrictions for the assertion
/// </summary>
public ReadOnlyCollection<string> AudienceRestrictions { get; init; } = new List<string>().AsReadOnly();
}

View file

@ -0,0 +1,25 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents the decision rendered by the SAML authority
/// </summary>
internal enum DecisionType
{
/// <summary>
/// The specified action is permitted
/// </summary>
Permit,
/// <summary>
/// The specified action is denied
/// </summary>
Deny,
/// <summary>
/// The SAML authority cannot determine whether the action is permitted or denied
/// </summary>
Indeterminate
}

View file

@ -0,0 +1,25 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents evidence supporting the authorization decision (optional)
/// </summary>
internal record Evidence
{
/// <summary>
/// URI references to assertions
/// </summary>
public List<string> AssertionIDRefs { get; set; } = [];
/// <summary>
/// URI references to assertions
/// </summary>
public List<string> AssertionURIRefs { get; set; } = [];
/// <summary>
/// Embedded assertions
/// </summary>
public List<Assertion> Assertions { get; set; } = [];
}

Some files were not shown because too many files have changed in this diff Show more