Initial import of SAML code

This commit is contained in:
Brett Hazen 2026-02-18 16:04:18 -06:00
parent e556d5b314
commit 72df8704fc
142 changed files with 8919 additions and 29 deletions

View file

@ -16,6 +16,13 @@ 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.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.Licensing;
using Duende.IdentityServer.Licensing.V2;
using Duende.IdentityServer.Licensing.V2.Diagnostics;
@ -23,6 +30,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;
@ -127,6 +135,14 @@ public static class IdentityServerBuilderExtensionsCore
builder.AddEndpoint<TokenEndpoint>(EndpointNames.Token, ProtocolRoutePaths.Token.EnsureLeadingSlash());
builder.AddEndpoint<UserInfoEndpoint>(EndpointNames.UserInfo, ProtocolRoutePaths.UserInfo.EnsureLeadingSlash());
// 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());
builder.AddHttpWriter<AuthorizeInteractionPageResult, AuthorizeInteractionPageHttpWriter>();
builder.AddHttpWriter<AuthorizeResult, AuthorizeHttpWriter>();
builder.AddHttpWriter<BackchannelAuthenticationResult, BackchannelAuthenticationHttpWriter>();
@ -150,6 +166,67 @@ public static class IdentityServerBuilderExtensionsCore
return builder;
}
/// <summary>
/// Adds SAML 2.0 protocol services.
/// </summary>
/// <param name="builder">The builder.</param>
/// <returns></returns>
public static IIdentityServerBuilder AddSamlServices(this IIdentityServerBuilder builder)
{
// 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>();
builder.Services.TryAddScoped<ISamlLogoutNotificationService, SamlLogoutNotificationService>();
builder.Services.TryAddScoped<ISamlInteractionService, DefaultSamlInteractionService>();
// State management (Singleton)
builder.Services.TryAddSingleton<SamlSigninStateIdCookie>();
builder.Services.TryAddSingleton<ISamlSigninStateStore, DistributedCacheSamlSigninStateStore>();
// Default no-op service provider store (can be overridden by user)
builder.Services.TryAddTransient<ISamlServiceProviderStore, InMemorySamlServiceProviderStore>();
return builder;
}
/// <summary>
/// Adds an endpoint.
/// </summary>

View file

@ -37,6 +37,7 @@ public static class IdentityServerServiceCollectionExtensions
.AddCookieAuthentication()
.AddCoreServices()
.AddDefaultEndpoints()
.AddSamlServices()
.AddPluggableServices()
.AddKeyManagement()
.AddDynamicProvidersCore()

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; } = true;
/// <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; } = true;
/// <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; } = true;
/// <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; } = true;
/// <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; } = true;
/// <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; } = true;
}

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(SamlMessageName.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();
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))
{
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();
var samlEntityIds = samlSessions.Select(s => s.EntityId);
if ((clientIds.Any() && await AnyClientHasFrontChannelLogout(clientIds)) ||
(samlEntityIds.Any() && await AnySamlServiceProviderHasFrontChannelLogout(samlEntityIds)))
{
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)
{
var serviceProviderStore = context.RequestServices.GetRequiredService<ISamlServiceProviderStore>();
foreach (var entityId in entityIds)
{
var sp = await serviceProviderStore.FindByEntityIdAsync(entityId);
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 + "/sso";
public const string SamlSigninCallback = SamlSignin + "/callback";
public const string SamlIdpInitiated = SamlPathPrefix + "/idp-initiated";
public const string SamlLogout = SamlPathPrefix + "/slo";
public const string SamlLogoutCallback = SamlLogout + "/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(CancellationToken ct)
{
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);
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, CancellationToken ct)
{
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,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(SamlMessageName 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}"" 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,19 @@
// 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.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 SamlVersion 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,31 @@
// 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>
/// <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();
/// <summary>
/// Gets the X509 certificate as a base64-encoded string for inclusion in SAML metadata.
/// </summary>
/// <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();
}

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,101 @@
// 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 Duende.IdentityServer.Saml.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 SamlStatusCode 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 SamlStatusCode? 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(SamlMessageName.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 = ResponseId.New().ToString();
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.ToString()));
// Add sub-status code if provided
if (result.SubStatusCode.HasValue)
{
statusCodeElement.Add(
new XElement(protocolNs + "StatusCode",
new XAttribute("Value", result.SubStatusCode.Value.ToString())));
}
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)
{
ArgumentNullException.ThrowIfNull(messageElement);
ArgumentNullException.ThrowIfNull(serviceProvider);
var certificate = await samlSigningService.GetSigningCertificateAsync();
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)
{
var certificate = await samlSigningService.GetSigningCertificateAsync();
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,155 @@
// 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();
// 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();
// 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, CancellationToken ct = default)
{
var sp = await ServiceProviderStore.FindByEntityIdAsync(request.Request.Issuer);
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 = SamlStatusCode.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, CancellationToken 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 = SamlStatusCode.Requester, Message = "Missing signature parameter" });
}
if (string.IsNullOrEmpty(sigAlg))
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = "Missing signature algorithm parameter" });
}
if (!SupportedAlgorithms.Contains(sigAlg))
{
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.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 = SamlStatusCode.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 = SamlStatusCode.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 = SamlStatusCode.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 = SamlStatusCode.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 = SamlStatusCode.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(
SamlVersion version,
DateTime issueInstant,
Uri? destination,
SamlServiceProvider serviceProvider,
string expectedDestination)
{
// Version validation
if (version != SamlVersion.V2)
{
return new SamlValidationError
{
Message = "Only Version 2.0 is supported",
StatusCode = SamlStatusCode.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 = SamlStatusCode.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 = SamlStatusCode.Requester,
Message = "Request has expired (IssueInstant too old)"
};
}
// Destination validation
if (destination != null)
{
if (!destination.ToString().Equals(expectedDestination, StringComparison.OrdinalIgnoreCase))
{
return new SamlValidationError
{
StatusCode = SamlStatusCode.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 SamlStatusCode StatusCode { get; init; } = SamlStatusCode.Requester;
internal SamlStatusCode? 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)
{
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();
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()
{
var credential = await GetSigningCredentialsAsync();
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()
{
var credential = await GetSigningCredentialsAsync();
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()
{
var credential = await keyMaterialService.GetSigningCredentialsAsync();
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,101 @@
// 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 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);
}
}
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();
var baseUrl = urls.BaseUrl;
var certificateBase64 = await samlSigningService.GetSigningCertificateBase64Async();
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,128 @@
// 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)
{
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.ToString()
},
RequestedClaimTypes = requestedClaimTypes,
Caller = "SAML"
};
await profileService.GetProfileDataAsync(context);
var claims = context.IssuedClaims;
logger.RetrievedClaimsFromProfileService(LogLevel.Debug, claims.Count);
return claims;
}
internal async Task<IEnumerable<SamlAttribute>> GetMappedAttributesAsync(
ClaimsPrincipal user,
SamlServiceProvider serviceProvider)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(serviceProvider);
var claims = await GetClaimsAsync(user, serviceProvider);
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,15 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml;
internal readonly record struct SamlMessageName(string Value)
{
public static readonly SamlMessageName SamlResponse = new("SAMLResponse");
public static readonly SamlMessageName SamlRequest = new("SAMLRequest");
public static implicit operator SamlMessageName(string value) => new(value);
public override string ToString() => Value;
}

View file

@ -0,0 +1,191 @@
// 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)
{
var now = timeProvider.GetUtcNow().DateTime;
var options = samlOptions.Value;
var nameId = nameIdGenerator.GenerateNameIdentifier(user, samlServiceProvider, samlAuthenticationState.Request);
var attributes = await samlClaimsService.GetMappedAttributesAsync(user, samlServiceProvider);
var acsUrl = GetAcsUrl(samlAuthenticationState.Request, samlServiceProvider);
var issuer = await issuerNameService.GetCurrentAsync();
return new SamlResponse
{
ServiceProvider = samlServiceProvider,
InResponseTo = samlAuthenticationState.Request?.Id,
Destination = acsUrl,
IssueInstant = now,
Issuer = issuer,
Status = new Status
{
StatusCode = SamlStatusCode.Success,
NestedStatusCode = samlAuthenticationState.Request?.RequestedAuthnContext != null && !samlAuthenticationState.RequestedAuthnContextRequirementsWereMet ? (string)SamlStatusCode.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,270 @@
// 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.Saml.Models;
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, RequestId 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, RequestId 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, RequestId 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, RequestId 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, RequestId 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, RequestId 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, RequestId 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, SamlVersion 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, RequestId 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, RequestId 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, RequestId 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(
RequestId logoutRequestId,
SamlServiceProvider serviceProvider,
string? relayState)
{
var issuer = await issuerNameService.GetCurrentAsync();
var destination = serviceProvider.SingleLogoutServiceUrl ?? throw new InvalidOperationException("No SingleLogout service url configured");
return new LogoutResponse
{
Id = ResponseId.New(),
Version = SamlVersion.V2,
IssueInstant = timeProvider.GetUtcNow().UtcDateTime,
Destination = destination.Location,
Issuer = issuer,
InResponseTo = logoutRequestId.ToString(),
Status = new Status
{
StatusCode = SamlStatusCode.Success
},
ServiceProvider = serviceProvider,
RelayState = relayState
};
}
internal async Task<LogoutResponse> BuildErrorResponseAsync(
SamlLogoutRequest request,
SamlServiceProvider serviceProvider,
SamlError error)
{
var issuer = await issuerNameService.GetCurrentAsync();
var destination = serviceProvider.SingleLogoutServiceUrl ?? throw new InvalidOperationException("No SingleLogout service url configured");
return new LogoutResponse
{
Id = ResponseId.New(),
Version = SamlVersion.V2,
IssueInstant = timeProvider.GetUtcNow().UtcDateTime,
Destination = destination.Location,
Issuer = issuer,
InResponseTo = request.LogoutRequest.Id.ToString(),
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 RequestId Id { get; set; }
/// <summary>
/// Gets or sets the SAML version. Must be "2.0".
/// </summary>
public SamlVersion Version { get; set; }
/// <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 ResponseId Id { get; set; }
/// <summary>
/// Gets or sets the SAML version. Must be "2.0".
/// </summary>
public SamlVersion Version { get; set; } = SamlVersion.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);
var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedResponseXml));
var html = HttpResponseBindings.GenerateAutoPostForm(SamlMessageName.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.Value),
new XAttribute("Version", toSerialize.Version.ToString()),
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,143 @@
// 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 Duende.IdentityServer.Saml.Models;
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)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
if (serviceProvider.SingleLogoutServiceUrl == null)
{
throw new InvalidOperationException(
$"Service Provider '{serviceProvider.EntityId}' has no SingleLogoutServiceUrl configured");
}
var logoutRequest = new LogoutRequest
{
Id = RequestId.New(),
Version = SamlVersion.V2,
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),
SamlBinding.HttpPost => await BuildHttpPostLogoutRequest(serviceProvider, requestXml),
_ => 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.Value),
new XAttribute("Version", logoutRequest.Version.ToString()),
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)
{
var encodedRequest = DeflateAndEncode(requestXml.ToString());
var queryString = $"?SAMLRequest={Uri.EscapeDataString(encodedRequest)}";
var signedQueryString = await samlProtocolMessageSigner.SignQueryString(queryString);
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)
{
var signedRequestXml = await samlProtocolMessageSigner.SignProtocolMessage(requestXml, serviceProvider);
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.Internal.Saml.SingleSignin.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, CancellationToken ct = default)
{
var logoutMessage = await logoutMessageStore.ReadAsync(logoutId);
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);
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(
new RequestId(data.SamlLogoutRequestId),
sp,
data.SamlRelayState);
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,77 @@
// 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)
{
using var activity = Tracing.ServiceActivitySource.StartActivity("LogoutNotificationService.GetSamlFrontChannelLogoutUrls");
var logoutUrls = new List<ISamlFrontChannelLogout>();
if (context.SamlSessions?.Any() == true)
{
logger.NoSamlServiceProvidersToNotifyForLogout(LogLevel.Debug);
return logoutUrls;
}
var issuer = await issuerNameService.GetCurrentAsync();
foreach (var sessionData in context.SamlSessions ?? [])
{
var sp = await serviceProviderStore.FindByEntityIdAsync(sessionData.EntityId);
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);
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,
CancellationToken ct)
{
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();
if (user == null)
{
Logger.SamlLogoutRequestReceivedButNoActiveUserSession(LogLevel.Debug, logoutRequest.Id, logoutRequest.Issuer);
var noUserAuthenticatedResponse = await _logoutResponseBuilder.BuildSuccessResponseAsync(logoutRequest.Id, sp, request.RelayState);
// there is no user to log out, return success
return SamlLogoutSuccess.CreateResponse(noUserAuthenticatedResponse);
}
var sessionMatch = await ValidateSessionIndexAsync(sp, logoutRequest.SessionIndex);
if (!sessionMatch)
{
Logger.SamlLogoutRequestReceivedWithWrongSessionIndex(LogLevel.Warning, logoutRequest.Id, logoutRequest.SessionIndex);
var noSessionIndexResponse = await _logoutResponseBuilder.BuildSuccessResponseAsync(logoutRequest.Id, sp, request.RelayState);
// 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);
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 = SamlStatusCode.Requester,
Message = "Logout request expired (NotOnOrAfter is in the past)"
})
};
}
}
return null;
}
private async Task<bool> ValidateSessionIndexAsync(SamlServiceProvider sp, string sessionIndex)
{
var samlSessions = await _userSession.GetSamlSessionListAsync();
var spSession = samlSessions.FirstOrDefault(s => s.EntityId == sp.EntityId.ToString());
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)
{
var samlSessions = await _userSession.GetSamlSessionListAsync();
var oidcClientIds = await _userSession.GetClientListAsync();
var logoutMessage = new LogoutMessage
{
SubjectId = user.GetSubjectId(),
SessionId = await _userSession.GetSessionIdAsync(),
ClientIds = oidcClientIds,
SamlServiceProviderEntityId = serviceProvider.EntityId,
SamlSessions = samlSessions,
SamlLogoutRequestId = logoutRequest.LogoutRequest.Id.Value,
SamlRelayState = logoutRequest.RelayState,
PostLogoutRedirectUri = _urlBuilder.SamlLogoutCallBackUri().ToString()
};
var msg = new Message<LogoutMessage>(logoutMessage, _timeProvider.GetUtcNow().UtcDateTime);
return await _logoutMessageStore.WriteAsync(msg);
}
}

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,45 @@
// 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");
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,76 @@
// 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, CancellationToken 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),
_ => 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)
{
var protocolError = error.ProtocolError!;
logger.SamlLogoutProtocolError(LogLevel.Information,
protocolError.Error.StatusCode,
protocolError.Error.Message);
return await responseBuilder.BuildErrorResponseAsync(
protocolError.Request,
protocolError.ServiceProvider,
protocolError.Error);
}
}

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,87 @@
// 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;
using SamlStatusCode = Duende.IdentityServer.Saml.Models.SamlStatusCode;
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, CancellationToken ct)
{
var signedInUser = await userSession.GetUserAsync();
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(SamlStatusCode.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(SamlStatusCode.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, CancellationToken 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, CancellationToken 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, CancellationToken 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, CancellationToken ct = default);
Task<SamlAuthenticationState?> RetrieveSigninRequestStateAsync(StateId stateId, CancellationToken ct = default);
Task UpdateSigninRequestStateAsync(StateId stateId, SamlAuthenticationState state, CancellationToken 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 AssertionId Id { get; } = AssertionId.NewId();
/// <summary>
/// SAML version (must be "2.0")
/// </summary>
public SamlVersion Version { get; } = SamlVersion.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,22 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <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>
internal readonly record struct AssertionId(string Value)
{
public static AssertionId NewId() => new("_" + Guid.NewGuid().ToString("N"));
public static implicit operator AssertionId(string value) => new(value);
public override string ToString() => Value;
}

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; } = [];
}

View file

@ -0,0 +1,32 @@
// 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 NameID element
/// </summary>
internal record NameIdentifier
{
/// <summary>
/// The name identifier value
/// </summary>
public required string Value { get; set; }
/// <summary>
/// The format of the name identifier (URI)
/// </summary>
public string? Format { get; set; }
/// <summary>
/// The NameQualifier attribute
/// </summary>
public string? NameQualifier { get; set; }
/// <summary>
/// The SPNameQualifier attribute
/// </summary>
public string? SPNameQualifier { get; set; }
}

View file

@ -0,0 +1,22 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <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>
internal readonly record struct RequestId(string Value)
{
public static RequestId New() => new("_" + Guid.NewGuid().ToString("N"));
public static implicit operator RequestId(string value) => new(value);
public override string ToString() => Value;
}

View file

@ -0,0 +1,22 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <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>
internal readonly record struct ResponseId(string Value)
{
public static ResponseId New() => new("_" + Guid.NewGuid().ToString("N"));
public static implicit operator ResponseId(string value) => new(value);
public override string ToString() => Value;
}

View file

@ -0,0 +1,45 @@
// 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;
/// <summary>
/// Represents the stored context for a SAML authentication flow.
/// </summary>
internal record SamlAuthenticationState
{
/// <summary>
/// Gets or sets the original AuthnRequest.
/// Will be null for IdP-initiated SSO flows.
/// </summary>
public AuthNRequest? Request { get; set; }
public required string ServiceProviderEntityId { get; init; }
/// <summary>
/// Gets or sets the RelayState parameter from the original request.
/// For IdP-initiated SSO, this typically contains the target URL at the SP.
/// </summary>
public string? RelayState { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this is an IdP-initiated SSO flow.
/// If true, there was no AuthnRequest and the response will be unsolicited.
/// </summary>
public bool IsIdpInitiated { get; set; }
/// <summary>
/// Gets or sets the timestamp when this context was created.
/// </summary>
public DateTimeOffset CreatedUtc { get; set; }
public required Uri AssertionConsumerServiceUrl { get; set; }
/// <summary>
/// Gets or sets whether the RequestedAuthnContext in the request were met.
/// </summary>
public bool RequestedAuthnContextRequirementsWereMet { get; set; }
}

View file

@ -0,0 +1,337 @@
// 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.Models;
using Duende.IdentityServer.Saml.Models;
using Microsoft.AspNetCore.Http;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
internal class SamlResponse : EndpointResult<SamlResponse>
{
/// <summary>
/// Gets or sets the unique identifier for this response.
/// </summary>
public ResponseId Id { get; } = ResponseId.New();
/// <summary>
/// Gets or sets the SAML version. Must be "2.0".
/// </summary>
public SamlVersion Version { get; } = SamlVersion.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.
/// This is the SP's Assertion Consumer Service (ACS) URL.
/// </summary>
public required Uri Destination { get; set; }
/// <summary>
/// Gets or sets the entity identifier of the Identity Provider sending this response.
/// </summary>
public required string Issuer { get; set; }
/// <summary>
/// Gets or sets the ID of the request to which this is a response.
/// Will be null for IdP-initiated SSO (unsolicited responses).
/// </summary>
public string? InResponseTo { get; set; }
/// <summary>
/// Gets or sets the status of this response.
/// </summary>
public required Status Status { get; set; }
/// <summary>
/// Gets or sets the assertion included in this response.
/// Will be null for error responses.
/// </summary>
public Assertion? Assertion { get; set; }
/// <summary>
/// Gets or sets the relay state included in this response.
/// </summary>
public string? RelayState { get; set; }
/// <summary>
/// Gets or sets the Service Provider where the response will be sent.
/// </summary>
public required SamlServiceProvider ServiceProvider { get; init; }
internal class ResponseWriter(
ISamlResultSerializer<SamlResponse> serializer,
SamlResponseSigner samlResponseSigner,
SamlAssertionEncryptor samlAssertionEncryptor) : IHttpResponseWriter<SamlResponse>
{
public async Task WriteHttpResponse(SamlResponse result, HttpContext httpContext)
{
var responseXml = serializer.Serialize(result);
var signedResponseXml = await samlResponseSigner.SignResponse(responseXml, result.ServiceProvider);
if (result.ServiceProvider.EncryptAssertions)
{
signedResponseXml = samlAssertionEncryptor.EncryptAssertion(signedResponseXml, result.ServiceProvider);
}
var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedResponseXml));
var html = HttpResponseBindings.GenerateAutoPostForm(SamlMessageName.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<SamlResponse>
{
public XElement Serialize(SamlResponse toSerialize)
{
var issueInstant = toSerialize.IssueInstant.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", 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 Response element
var responseElement = new XElement(protocolNs + "Response",
new XAttribute("ID", toSerialize.Id.ToString()),
new XAttribute("Version", toSerialize.Version.ToString()),
new XAttribute("IssueInstant", issueInstant),
new XAttribute("Destination", toSerialize.Destination.ToString()),
new XElement(assertionNs + "Issuer", toSerialize.Issuer.ToString()),
statusElement);
if (toSerialize.InResponseTo != null)
{
responseElement.Add(new XAttribute("InResponseTo", toSerialize.InResponseTo));
}
// Add Assertion if present
if (toSerialize.Assertion != null)
{
responseElement.Add(GenerateAssertionElement(toSerialize.Assertion, assertionNs, protocolNs));
}
return responseElement;
}
private static XElement GenerateAssertionElement(Assertion assertion, XNamespace assertionNs, XNamespace protocolNs)
{
var assertionElement = new XElement(assertionNs + "Assertion",
new XAttribute("ID", assertion.Id.ToString()),
new XAttribute("Version", assertion.Version.ToString()),
new XAttribute("IssueInstant", assertion.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)),
new XElement(assertionNs + "Issuer", assertion.Issuer.ToString()));
// Add Subject
if (assertion.Subject != null)
{
assertionElement.Add(GenerateSubjectElement(assertion.Subject, assertionNs));
}
// Add Conditions
if (assertion.Conditions != null)
{
assertionElement.Add(GenerateConditionsElement(assertion.Conditions, assertionNs));
}
// Add AuthnStatements
foreach (var authnStatement in assertion.AuthnStatements)
{
assertionElement.Add(GenerateAuthnStatementElement(authnStatement, assertionNs));
}
// Add AttributeStatements
foreach (var attributeStatement in assertion.AttributeStatements)
{
assertionElement.Add(GenerateAttributeStatementElement(attributeStatement, assertionNs));
}
return assertionElement;
}
private static XElement GenerateSubjectElement(Subject subject, XNamespace assertionNs)
{
var subjectElement = new XElement(assertionNs + "Subject");
if (subject.NameId != null)
{
var nameIdElement = new XElement(assertionNs + "NameID", subject.NameId.Value);
if (!string.IsNullOrEmpty(subject.NameId.Format))
{
nameIdElement.Add(new XAttribute("Format", subject.NameId.Format));
}
if (!string.IsNullOrEmpty(subject.NameId.NameQualifier))
{
nameIdElement.Add(new XAttribute("NameQualifier", subject.NameId.NameQualifier));
}
if (subject.NameId.SPNameQualifier != null)
{
nameIdElement.Add(new XAttribute("SPNameQualifier", subject.NameId.SPNameQualifier));
}
subjectElement.Add(nameIdElement);
}
foreach (var confirmation in subject.SubjectConfirmations)
{
var confirmationElement = new XElement(assertionNs + "SubjectConfirmation",
new XAttribute("Method", confirmation.Method));
if (confirmation.Data != null)
{
var dataElement = new XElement(assertionNs + "SubjectConfirmationData");
if (confirmation.Data.NotBefore.HasValue)
{
dataElement.Add(new XAttribute("NotBefore",
confirmation.Data.NotBefore.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
}
if (confirmation.Data.NotOnOrAfter.HasValue)
{
dataElement.Add(new XAttribute("NotOnOrAfter",
confirmation.Data.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
}
if (confirmation.Data.Recipient != null)
{
dataElement.Add(new XAttribute("Recipient", confirmation.Data.Recipient.ToString()));
}
if (confirmation.Data.InResponseTo != null)
{
dataElement.Add(new XAttribute("InResponseTo", confirmation.Data.InResponseTo));
}
confirmationElement.Add(dataElement);
}
subjectElement.Add(confirmationElement);
}
return subjectElement;
}
private static XElement GenerateConditionsElement(Conditions conditions, XNamespace assertionNs)
{
var conditionsElement = new XElement(assertionNs + "Conditions");
if (conditions.NotBefore.HasValue)
{
conditionsElement.Add(new XAttribute("NotBefore",
conditions.NotBefore.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
}
if (conditions.NotOnOrAfter.HasValue)
{
conditionsElement.Add(new XAttribute("NotOnOrAfter",
conditions.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
}
if (conditions.AudienceRestrictions.Count > 0)
{
var audienceRestrictionElement = new XElement(assertionNs + "AudienceRestriction");
foreach (var audience in conditions.AudienceRestrictions)
{
audienceRestrictionElement.Add(new XElement(assertionNs + "Audience", audience));
}
conditionsElement.Add(audienceRestrictionElement);
}
return conditionsElement;
}
private static XElement GenerateAuthnStatementElement(AuthnStatement authnStatement, XNamespace assertionNs)
{
var authnStatementElement = new XElement(assertionNs + "AuthnStatement",
new XAttribute("AuthnInstant", authnStatement.AuthnInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
if (!string.IsNullOrEmpty(authnStatement.SessionIndex))
{
authnStatementElement.Add(new XAttribute("SessionIndex", authnStatement.SessionIndex));
}
if (authnStatement.SessionNotOnOrAfter.HasValue)
{
authnStatementElement.Add(new XAttribute("SessionNotOnOrAfter",
authnStatement.SessionNotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
}
if (authnStatement.AuthnContext != null && !string.IsNullOrEmpty(authnStatement.AuthnContext.AuthnContextClassRef))
{
var authnContextElement = new XElement(assertionNs + "AuthnContext",
new XElement(assertionNs + "AuthnContextClassRef", authnStatement.AuthnContext.AuthnContextClassRef));
authnStatementElement.Add(authnContextElement);
}
return authnStatementElement;
}
private static XElement GenerateAttributeStatementElement(AttributeStatement attributeStatement, XNamespace assertionNs)
{
var attributeStatementElement = new XElement(assertionNs + "AttributeStatement");
foreach (var attribute in attributeStatement.Attributes)
{
var attributeElement = new XElement(assertionNs + "Attribute",
new XAttribute("Name", attribute.Name));
if (!string.IsNullOrEmpty(attribute.NameFormat))
{
attributeElement.Add(new XAttribute("NameFormat", attribute.NameFormat));
}
if (!string.IsNullOrEmpty(attribute.FriendlyName))
{
attributeElement.Add(new XAttribute("FriendlyName", attribute.FriendlyName));
}
foreach (var value in attribute.Values)
{
attributeElement.Add(new XElement(assertionNs + "AttributeValue", value));
}
attributeStatementElement.Add(attributeElement);
}
return attributeStatementElement;
}
}
}

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.Xml.Linq;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents a saml signin request, either as a Redirect Binding or a Post Binding.
/// </summary>
internal record SamlSigninRequest : SamlRequestBase<AuthNRequest>
{
public static async ValueTask<SamlSigninRequest?> BindAsync(HttpContext context)
{
var extractor = context.RequestServices.GetRequiredService<SamlSigninRequestExtractor>();
return await extractor.ExtractAsync(context);
}
public AuthNRequest AuthNRequest => Request;
}
internal class SamlSigninRequestExtractor(AuthNRequestParser parser)
: SamlRequestExtractor<AuthNRequest, SamlSigninRequest>
{
protected override AuthNRequest ParseRequest(XDocument xmlDocument) => parser.Parse(xmlDocument);
protected override SamlSigninRequest CreateResult(
AuthNRequest parsedRequest,
XDocument requestXml,
SamlBinding binding,
string? relayState,
string? signature = null,
string? signatureAlgorithm = null,
string? encodedSamlRequest = null) => new()
{
Request = parsedRequest,
RequestXml = requestXml,
Binding = binding,
RelayState = relayState,
Signature = signature,
SignatureAlgorithm = signatureAlgorithm,
EncodedSamlRequest = encodedSamlRequest
};
}

View file

@ -0,0 +1,11 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
internal readonly record struct SessionId(Guid Value)
{
public static SessionId NewId() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}

View file

@ -0,0 +1,11 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
internal readonly record struct StateId(Guid Value)
{
public static StateId NewId() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}

View file

@ -0,0 +1,28 @@
// 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;
/// <summary>
/// Represents the status of a SAML Response.
/// </summary>
internal record Status
{
/// <summary>
/// Gets or sets the status code indicating the success or failure of the request.
/// </summary>
public required SamlStatusCode StatusCode { get; set; }
/// <summary>
/// Gets or sets an optional human-readable message providing additional information about the status.
/// </summary>
public string? StatusMessage { get; set; }
/// <summary>
/// Gets or sets an optional nested status code for more detailed error information.
/// </summary>
public string? NestedStatusCode { get; set; }
}

View file

@ -0,0 +1,66 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Collections.ObjectModel;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
/// <summary>
/// Represents a SAML 2.0 Subject element
/// </summary>
internal record Subject
{
/// <summary>
/// The name identifier of the subject
/// </summary>
public NameIdentifier? NameId { get; set; }
/// <summary>
/// Subject confirmation data
/// </summary>
public ReadOnlyCollection<SubjectConfirmation> SubjectConfirmations { get; init; } = new List<SubjectConfirmation>().AsReadOnly();
}
/// <summary>
/// Represents a SAML 2.0 SubjectConfirmation element
/// </summary>
internal record SubjectConfirmation
{
/// <summary>
/// The method used to confirm the subject (URI)
/// </summary>
public required string Method { get; set; }
/// <summary>
/// Subject confirmation data
/// </summary>
public SubjectConfirmationData? Data { get; set; }
}
/// <summary>
/// Represents SAML 2.0 SubjectConfirmationData element
/// </summary>
internal record SubjectConfirmationData
{
/// <summary>
/// Time instant before which the subject cannot be confirmed
/// </summary>
public DateTime? NotBefore { get; set; }
/// <summary>
/// Time instant at which the subject can no longer be confirmed
/// </summary>
public DateTime? NotOnOrAfter { get; set; }
/// <summary>
/// URI of a recipient entity
/// </summary>
public Uri? Recipient { get; set; }
/// <summary>
/// ID of a SAML request to which this is a response
/// </summary>
public string? InResponseTo { get; set; }
}

View file

@ -0,0 +1,60 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class SamlIdpInitiatedEndpoint(
SamlIdpInitiatedRequestProcessor requestProcessor,
ILogger<SamlIdpInitiatedEndpoint> logger) : IEndpointHandler
{
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
{
using var activity = Tracing.BasicActivitySource.StartActivity("SamlIdpInitiatedEndpoint");
if (!HttpMethods.IsGet(context.Request.Method))
{
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
}
var spEntityId = context.Request.Query["spEntityId"].ToString();
var relayState = context.Request.Query["relayState"].ToString();
if (string.IsNullOrWhiteSpace(spEntityId))
{
return new ValidationProblemResult("Missing required 'spEntityId' query parameter");
}
return await ProcessInternalAsync(
spEntityId,
string.IsNullOrEmpty(relayState) ? null : relayState,
context.RequestAborted);
}
internal async Task<IEndpointResult> ProcessInternalAsync(
string spEntityId,
string? relayState,
CancellationToken ct)
{
logger.StartIdpInitiatedRequest(LogLevel.Debug, spEntityId);
var result = await requestProcessor.ProcessAsync(spEntityId, relayState, ct);
if (!result.Success)
{
var error = result.Error;
logger.IdpInitiatedRequestFailed(LogLevel.Information, error.ValidationMessage!);
return new ValidationProblemResult(error.ValidationMessage!);
}
var success = result.Value;
logger.IdpInitiatedRequestSuccess(LogLevel.Debug, success.RedirectUri);
return new RedirectResult(success.RedirectUri);
}
}

View file

@ -0,0 +1,117 @@
// 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.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class SamlIdpInitiatedRequestProcessor(
ISamlServiceProviderStore serviceProviderStore,
ISamlSigninStateStore stateStore,
SamlUrlBuilder samlUrlBuilder,
SamlSigninStateIdCookie stateIdCookie,
IHttpContextAccessor httpContextAccessor,
IOptions<SamlOptions> options)
{
private readonly SamlOptions _samlOptions = options.Value;
internal async Task<Result<SamlSigninSuccess, SamlRequestError<SamlSigninRequest>>> ProcessAsync(
string spEntityId,
string? relayState,
CancellationToken ct)
{
var sp = await serviceProviderStore.FindByEntityIdAsync(spEntityId);
if (sp == null)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"Service Provider '{spEntityId}' is not registered"
};
}
if (!sp.Enabled)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"Service Provider '{spEntityId}' is disabled"
};
}
if (!sp.AllowIdpInitiated)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"Service Provider '{spEntityId}' does not allow IdP-initiated SSO"
};
}
if (relayState != null)
{
var relayStateBytes = System.Text.Encoding.UTF8.GetByteCount(relayState);
if (relayStateBytes > _samlOptions.MaxRelayStateLength)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"RelayState exceeds maximum length of {_samlOptions.MaxRelayStateLength} bytes"
};
}
}
var acsUrl = sp.AssertionConsumerServiceUrls.FirstOrDefault();
if (acsUrl == null)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"Service Provider '{spEntityId}' has no AssertionConsumerServiceUrls configured"
};
}
string? relayStateParam = null;
if (!string.IsNullOrEmpty(relayState))
{
relayStateParam = relayState;
}
var state = new SamlAuthenticationState
{
Request = null, // No AuthNRequest for IdP-initiated
RelayState = relayStateParam,
ServiceProviderEntityId = sp.EntityId,
AssertionConsumerServiceUrl = acsUrl,
IsIdpInitiated = true,
CreatedUtc = DateTimeOffset.UtcNow
};
var storedStateId = await stateStore.StoreSigninRequestStateAsync(state, ct);
stateIdCookie.StoreSamlSigninStateId(storedStateId);
// Determine redirect based on authentication status
var httpContext = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("No HttpContext available");
var isAuthenticated = httpContext.User.Identity?.IsAuthenticated ?? false;
Uri redirectUrl;
if (isAuthenticated)
{
redirectUrl = samlUrlBuilder.SamlSignInCallBackUri();
}
else
{
redirectUrl = samlUrlBuilder.SamlLoginUri();
}
return SamlSigninSuccess.CreateRedirectSuccess(redirectUrl);
}
}

View file

@ -0,0 +1,80 @@
// 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.SingleSignin.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class SamlNameIdGenerator(IOptions<SamlOptions> samlOptions, ILogger<SamlNameIdGenerator> logger)
{
private readonly SamlOptions _samlOptions = samlOptions.Value;
internal NameIdentifier GenerateNameIdentifier(
ClaimsPrincipal user,
SamlServiceProvider samlServiceProvider,
AuthNRequest? request)
{
// Format selection priority: Request > SP Default > IdP Default
var format = request?.NameIdPolicy?.Format
?? samlServiceProvider.DefaultNameIdFormat
?? SamlConstants.NameIdentifierFormats.Unspecified;
logger.UsingNameIdFormat(LogLevel.Debug, format);
var value = format switch
{
SamlConstants.NameIdentifierFormats.EmailAddress => GetEmailNameId(user),
SamlConstants.NameIdentifierFormats.Persistent => GetPersistentNameId(samlServiceProvider, user),
SamlConstants.NameIdentifierFormats.Transient => Guid.NewGuid().ToString(),
_ => user.GetSubjectId()
};
var nameId = new NameIdentifier
{
Value = value,
Format = format,
};
if (format == SamlConstants.NameIdentifierFormats.Persistent)
{
nameId.SPNameQualifier = samlServiceProvider.EntityId;
}
return nameId;
}
private static string GetEmailNameId(ClaimsPrincipal user)
{
// Try to get email claim
var email = user.FindFirst("email")?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(email))
{
throw new InvalidOperationException("Could not find email address for authenticated user");
}
return email;
}
private string GetPersistentNameId(SamlServiceProvider samlServiceProvider, ClaimsPrincipal user)
{
var persistentIdClaimType = samlServiceProvider.DefaultPersistentNameIdentifierClaimType ??
_samlOptions.DefaultPersistentNameIdentifierClaimType;
var persistentIdentifier = user.FindFirst(persistentIdClaimType);
if (persistentIdentifier == null || string.IsNullOrEmpty(persistentIdentifier.Value))
{
throw new InvalidOperationException("Could not find persistent identifier for authenticated user");
}
return persistentIdentifier.Value;
}
}

View file

@ -0,0 +1,58 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class SamlSigninCallbackEndpoint(SamlResponseBuilder responseBuilder, SamlSigninCallbackRequestProcessor samlSigninCallbackRequestProcessor, ILogger<SamlSigninEndpoint> logger) : IEndpointHandler
{
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
{
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSigninCallbackEndpoint");
if (!HttpMethods.IsGet(context.Request.Method))
{
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
}
return await Process(context.RequestAborted);
}
internal async Task<IEndpointResult> Process(CancellationToken ct)
{
logger.StartSamlSigninCallbackRequest(LogLevel.Debug);
var result = await samlSigninCallbackRequestProcessor.ProcessAsync(ct);
if (!result.Success)
{
var error = result.Error;
return error.Type switch
{
SamlRequestErrorType.Validation =>
new ValidationProblemResult(error.ValidationMessage!),
SamlRequestErrorType.Protocol =>
responseBuilder.BuildErrorResponse(
error.ProtocolError!.ServiceProvider,
error.ProtocolError.Request,
error.ProtocolError.Error),
_ => throw new InvalidOperationException($"Unexpected error type: {error.Type}")
};
}
return result.Value.SuccessType switch
{
SamlSigninSuccessType.Redirect => new RedirectResult(result.Value.RedirectUri),
SamlSigninSuccessType.Response => result.Value.SamlResponse,
_ => throw new InvalidOperationException($"Unexpected success type: {result.Value.SuccessType}")
};
}
}

View file

@ -0,0 +1,105 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Internal.Saml.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class SamlSigninCallbackRequestProcessor(
SamlSigninStateIdCookie stateIdCookie,
IUserSession userSession,
ISamlServiceProviderStore serviceProviderStore,
ISamlSigninStateStore stateStore,
SamlUrlBuilder samlUrlBuilder,
SamlResponseBuilder responseBuilder)
{
internal async Task<Result<SamlSigninSuccess, SamlRequestError<SamlSigninRequest>>> ProcessAsync(CancellationToken ct)
{
if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId))
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = "No state id could be found."
};
}
var authenticationState = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct);
if (authenticationState == null)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage = $"The request {stateId} could not be found."
};
}
var user = await userSession.GetUserAsync();
if (user == null || !user.IsAuthenticated())
{
var loginUri = samlUrlBuilder.SamlLoginUri();
return SamlSigninSuccess.CreateRedirectSuccess(loginUri);
}
var samlServiceProvider =
await serviceProviderStore.FindByEntityIdAsync(authenticationState.ServiceProviderEntityId);
if (samlServiceProvider is not { Enabled: true })
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage =
$"Service Provider '{authenticationState.ServiceProviderEntityId}' is not registered or is disabled"
};
}
// Check if this SP already has a session - if so, reuse the SessionIndex
var existingSessions = await userSession.GetSamlSessionListAsync();
var existingSession = existingSessions.FirstOrDefault(s => s.EntityId == samlServiceProvider.EntityId);
string sessionIndex;
if (existingSession != null)
{
// Reuse existing SessionIndex (e.g., for step-up authentication)
sessionIndex = existingSession.SessionIndex;
}
else
{
// Generate new SessionIndex for this SP
sessionIndex = Guid.NewGuid().ToString("N");
}
var samlResponse = await responseBuilder.BuildSuccessResponseAsync(user, samlServiceProvider, authenticationState, sessionIndex);
if (string.IsNullOrEmpty(samlResponse.Assertion?.Subject?.NameId?.Value))
{
throw new InvalidOperationException("SAML success response created without a NameId value");
}
if (string.IsNullOrEmpty(samlResponse.Assertion?.Subject?.NameId?.Format))
{
throw new InvalidOperationException("SAML success response created without a NameId format");
}
// Track the SAML SP session for logout coordination
var sessionData = new SamlSpSessionData
{
EntityId = samlServiceProvider.EntityId,
SessionIndex = sessionIndex,
NameId = samlResponse.Assertion.Subject.NameId.Value,
NameIdFormat = samlResponse.Assertion.Subject.NameId.Format
};
await userSession.AddSamlSessionAsync(sessionData);
stateIdCookie.ClearAuthenticationState();
return SamlSigninSuccess.CreateResponseSuccess(samlResponse);
}
}

View file

@ -0,0 +1,78 @@
// 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.SingleSignin.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class SamlSigninEndpoint(
SamlSigninRequestExtractor extractor,
SamlSigninRequestProcessor signinRequestProcessor,
ILogger<SamlSigninEndpoint> logger,
SamlResponseBuilder responseBuilder) : IEndpointHandler
{
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
{
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSigninEndpoint");
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsPost(context.Request.Method))
{
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
}
// Extract the SAML request from query string (GET/Redirect) or form (POST)
var signinRequest = await extractor.ExtractAsync(context);
return await ProcessSpInitiatedSignin(signinRequest, context.RequestAborted);
}
internal async Task<IEndpointResult> ProcessSpInitiatedSignin(
SamlSigninRequest signinRequest,
CancellationToken ct)
{
logger.StartSamlSigninRequest(LogLevel.Debug);
var result = await signinRequestProcessor.ProcessAsync(signinRequest, ct);
if (!result.Success)
{
var error = result.Error;
return error.Type switch
{
SamlRequestErrorType.Validation => HandleValidationError(error),
SamlRequestErrorType.Protocol => HandleProtocolError(error),
_ => throw new InvalidOperationException($"Unexpected error type: {error.Type}")
};
}
var success = result.Value;
logger.SamlSigninSuccess(LogLevel.Debug, success.RedirectUri);
return new RedirectResult(success.RedirectUri);
}
private ValidationProblemResult HandleValidationError(SamlRequestError<SamlSigninRequest> error)
{
logger.SamlSigninValidationError(LogLevel.Information, error.ValidationMessage!);
return new ValidationProblemResult(error.ValidationMessage!);
}
private SamlErrorResponse HandleProtocolError(SamlRequestError<SamlSigninRequest> error)
{
var protocolError = error.ProtocolError!;
logger.SamlSigninProtocolError(
LogLevel.Information,
protocolError.Error.StatusCode,
protocolError.Error.Message);
return responseBuilder.BuildErrorResponse(
protocolError.ServiceProvider,
protocolError.Request,
protocolError.Error);
}
}

View file

@ -0,0 +1,198 @@
// 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.Infrastructure;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Saml.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SamlStatusCode = Duende.IdentityServer.Saml.Models.SamlStatusCode;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class SamlSigninRequestProcessor(
ISamlServiceProviderStore serviceProviderStore,
ISamlSigninInteractionResponseGenerator interactionResponseGenerator,
ISamlSigninStateStore stateStore,
SamlUrlBuilder samlUrlBuilder,
TimeProvider timeProvider,
IOptions<SamlOptions> options,
IServerUrls serverUrls,
SamlSigninStateIdCookie stateIdCookie,
SamlRequestSignatureValidator<SamlSigninRequest, AuthNRequest> signatureValidator,
SamlRequestValidator requestValidator,
ILogger<SamlSigninRequestProcessor> logger)
: SamlRequestProcessorBase<AuthNRequest, SamlSigninRequest, SamlSigninSuccess>(serviceProviderStore,
options,
requestValidator,
signatureValidator,
logger,
serverUrls.GetAbsoluteUrl(options.Value.UserInteraction.Route + options.Value.UserInteraction.SignInPath))
{
protected override async Task<Result<SamlSigninSuccess, SamlRequestError<SamlSigninRequest>>> ProcessValidatedRequestAsync(
SamlServiceProvider sp,
SamlSigninRequest signinRequest,
CancellationToken ct)
{
var authNRequest = signinRequest.AuthNRequest;
var getAcsUrlResult = GetAcsUrl(sp, authNRequest);
if (!getAcsUrlResult.Success)
{
return getAcsUrlResult.Error;
}
var result = await interactionResponseGenerator.ProcessInteractionAsync(sp, authNRequest, ct);
if (result.IsError)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Protocol,
ProtocolError = new SamlProtocolError<SamlSigninRequest>(sp, signinRequest, result.Error!)
};
}
var assertionConsumerServiceUrl = getAcsUrlResult.Value;
switch (result.ResultType)
{
case SamlInteractionResponseType.Login:
{
await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct);
var redirectUri = samlUrlBuilder.SamlLoginUri();
return SamlSigninSuccess.CreateRedirectSuccess(redirectUri);
}
case SamlInteractionResponseType.AlreadyAuthenticated:
{
await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct);
var samlCallBackUri = samlUrlBuilder.SamlSignInCallBackUri();
return SamlSigninSuccess.CreateRedirectSuccess(samlCallBackUri);
}
case SamlInteractionResponseType.Consent:
{
await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct);
var samlConsentUri = samlUrlBuilder.SamlConsentUri();
return SamlSigninSuccess.CreateRedirectSuccess(samlConsentUri);
}
case SamlInteractionResponseType.CreateAccount:
throw new NotImplementedException("Create account isn't implemented yet");
default:
throw new InvalidOperationException("Unexpected result type: " + result.ResultType);
}
}
private static Result<Uri, SamlRequestError<SamlSigninRequest>> GetAcsUrl(SamlServiceProvider serviceProvider,
AuthNRequest authNRequest)
{
if (authNRequest.AssertionConsumerServiceUrl != null)
{
if (!serviceProvider.AssertionConsumerServiceUrls.Contains(authNRequest.AssertionConsumerServiceUrl))
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage =
$"AssertionConsumerServiceUrl '{authNRequest.AssertionConsumerServiceUrl}' is not valid"
};
}
return authNRequest.AssertionConsumerServiceUrl;
}
if (authNRequest.AssertionConsumerServiceIndex != null)
{
if (authNRequest.AssertionConsumerServiceIndex.Value < 0 ||
authNRequest.AssertionConsumerServiceIndex.Value >= serviceProvider.AssertionConsumerServiceUrls.Count)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage =
$"AssertionConsumerServiceIndex '{authNRequest.AssertionConsumerServiceIndex}' is not valid"
};
}
return serviceProvider.AssertionConsumerServiceUrls.ElementAt(authNRequest.AssertionConsumerServiceIndex
.Value);
}
if (serviceProvider.AssertionConsumerServiceUrls.Count == 0)
{
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Validation,
ValidationMessage =
$"The Service Provider '{serviceProvider.EntityId}' does not have any configured Assertion Consumer Service URLs"
};
}
return serviceProvider.AssertionConsumerServiceUrls.First();
}
protected override bool RequireSignature(SamlServiceProvider sp) => sp.RequireSignedAuthnRequests;
protected override SamlRequestError<SamlSigninRequest>? ValidateMessageSpecific(SamlServiceProvider sp, SamlSigninRequest signinRequest)
{
var authNRequest = signinRequest.AuthNRequest;
// AuthNRequest-specific validation (NameIdPolicy)
if (authNRequest.NameIdPolicy?.Format != null)
{
var requestedFormat = authNRequest.NameIdPolicy.Format;
var supportedFormats = SamlOptions.SupportedNameIdFormats;
if (!supportedFormats.Contains(requestedFormat))
{
Logger.RequestedNameIdFormatNotSupported(LogLevel.Debug, requestedFormat);
var samlError = new SamlError
{
StatusCode = SamlStatusCode.Responder,
SubStatusCode = SamlStatusCode.InvalidNameIdPolicy,
Message = $"Requested NameID format '{requestedFormat}' is not supported by this IdP"
};
return new SamlRequestError<SamlSigninRequest>
{
Type = SamlRequestErrorType.Protocol,
ProtocolError = new SamlProtocolError<SamlSigninRequest>(sp, signinRequest, samlError)
};
}
Logger.NameIdPolicyParsed(LogLevel.Debug, authNRequest.NameIdPolicy.Format, authNRequest.NameIdPolicy.SPNameQualifier);
}
return null;
}
private async Task StoreStateAsync(
SamlSigninRequest signinRequest,
Uri assertionConsumerServiceUrl,
AuthNRequest authNRequest,
SamlServiceProvider sp,
CancellationToken ct)
{
var state = new SamlAuthenticationState
{
Request = authNRequest,
ServiceProviderEntityId = sp.EntityId,
RelayState = signinRequest.RelayState,
IsIdpInitiated = false,
CreatedUtc = timeProvider.GetUtcNow(),
AssertionConsumerServiceUrl = assertionConsumerServiceUrl
};
var stateId = await stateStore.StoreSigninRequestStateAsync(state, ct);
stateIdCookie.StoreSamlSigninStateId(stateId);
}
}

View file

@ -0,0 +1,35 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal record SamlSigninSuccess
{
private SamlSigninSuccess(Uri redirectUri)
{
RedirectUri = redirectUri;
SuccessType = SamlSigninSuccessType.Redirect;
}
private SamlSigninSuccess(SamlResponse response)
{
SamlResponse = response;
SuccessType = SamlSigninSuccessType.Response;
}
public SamlSigninSuccessType SuccessType { get; private set; }
public Uri RedirectUri { get; private set; } = null!;
public SamlResponse SamlResponse { get; private set; } = null!;
public static SamlSigninSuccess CreateRedirectSuccess(Uri redirectUri) => new(redirectUri);
public static SamlSigninSuccess CreateResponseSuccess(SamlResponse samlResponse) => new(samlResponse);
}
internal enum SamlSigninSuccessType
{
Redirect,
Response
}

View file

@ -0,0 +1,67 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Diagnostics.CodeAnalysis;
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
using Microsoft.AspNetCore.Http;
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
internal class SamlSigninStateIdCookie(IHttpContextAccessor httpContextAccessor)
{
private const string CookieName = "__IdsSvr_SamlSigninState";
private static readonly TimeSpan CookieLifetime = TimeSpan.FromMinutes(5);
private HttpContext HttpContext => httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
internal void StoreSamlSigninStateId(StateId stateId)
{
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Secure = true,
// Note: Safari does not set the cookie on a redirect if this is set to Strict
SameSite = SameSiteMode.Lax,
IsEssential = true,
Expires = DateTimeOffset.UtcNow.Add(CookieLifetime)
};
HttpContext.Response.Cookies.Append(CookieName, stateId.Value.ToString("N"), cookieOptions);
}
internal bool TryGetSamlSigninStateId([NotNullWhen(true)] out StateId? stateId)
{
stateId = null;
if (!HttpContext.Request.Cookies.TryGetValue(CookieName, out var rawStateId) || string.IsNullOrEmpty(rawStateId))
{
return false;
}
try
{
if (!Guid.TryParse(rawStateId, out var guid))
{
return false;
}
stateId = new StateId(guid);
return true;
}
#pragma warning disable CA1031
catch (Exception)
#pragma warning restore CA1031
{
return false;
}
}
internal void ClearAuthenticationState() => HttpContext.Response.Cookies.Delete(CookieName, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
IsEssential = true
});
}

View file

@ -25,6 +25,12 @@ internal class EndpointUsageDiagnosticEntry : IDiagnosticEntry, IDisposable
private long _token;
private long _userInfo;
private long _oAuthMetadata;
private long _samlMetadata;
private long _samlSignIn;
private long _samlSignInCallback;
private long _samlIdPInitiated;
private long _samlLogout;
private long _samlLogoutCallback;
private long _other;
private readonly MeterListener _meterListener;
@ -65,6 +71,12 @@ internal class EndpointUsageDiagnosticEntry : IDiagnosticEntry, IDisposable
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.Token.EnsureLeadingSlash(), _token);
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.UserInfo.EnsureLeadingSlash(), _userInfo);
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.OAuthMetadata.EnsureLeadingSlash(), _oAuthMetadata);
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlMetadata.EnsureLeadingSlash(), _samlMetadata);
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlSignin.EnsureLeadingSlash(), _samlSignIn);
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlSigninCallback.EnsureLeadingSlash(), _samlSignInCallback);
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlIdpInitiated.EnsureLeadingSlash(), _samlIdPInitiated);
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlLogout.EnsureLeadingSlash(), _samlLogout);
writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlLogoutCallback.EnsureLeadingSlash(), _samlLogoutCallback);
writer.WriteNumber("other", _other);
writer.WriteEndObject();
@ -141,6 +153,24 @@ internal class EndpointUsageDiagnosticEntry : IDiagnosticEntry, IDisposable
case { } s when s.StartsWith(IdentityServerConstants.ProtocolRoutePaths.OAuthMetadata, StringComparison.OrdinalIgnoreCase):
Interlocked.Increment(ref _oAuthMetadata);
break;
case IdentityServerConstants.ProtocolRoutePaths.SamlMetadata:
Interlocked.Increment(ref _samlMetadata);
break;
case IdentityServerConstants.ProtocolRoutePaths.SamlSignin:
Interlocked.Increment(ref _samlSignIn);
break;
case IdentityServerConstants.ProtocolRoutePaths.SamlSigninCallback:
Interlocked.Increment(ref _samlSignInCallback);
break;
case IdentityServerConstants.ProtocolRoutePaths.SamlIdpInitiated:
Interlocked.Increment(ref _samlIdPInitiated);
break;
case IdentityServerConstants.ProtocolRoutePaths.SamlLogout:
Interlocked.Increment(ref _samlLogout);
break;
case IdentityServerConstants.ProtocolRoutePaths.SamlLogoutCallback:
Interlocked.Increment(ref _samlLogoutCallback);
break;
default:
Interlocked.Increment(ref _other);
break;

View file

@ -6,7 +6,11 @@ using Duende.IdentityServer.Events;
using Duende.IdentityServer.Hosting;
using Duende.IdentityServer.Hosting.DynamicProviders;
using Duende.IdentityServer.Internal;
using Duende.IdentityServer.Internal.Saml;
using Duende.IdentityServer.Internal.Saml.SingleLogout;
using Duende.IdentityServer.Internal.Saml.SingleSignin;
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Services.Default;
using Duende.IdentityServer.Services.KeyManagement;
@ -52,6 +56,15 @@ internal class RegisteredImplementationsDiagnosticEntry(ServiceCollectionAccesso
new(typeof(IUserInfoResponseGenerator), [typeof(UserInfoResponseGenerator)]),
]
},
{
"SAML", [
new(typeof(ISamlClaimsMapper), []),
new(typeof(ISamlFrontChannelLogout), [typeof(SamlHttpPostFrontChannelLogout), typeof(SamlHttpRedirectFrontChannelLogout)]),
new(typeof(ISamlInteractionService),[typeof(DefaultSamlInteractionService)]),
new(typeof(ISamlLogoutNotificationService), [typeof(SamlLogoutNotificationService)]),
new(typeof(ISamlSigninInteractionResponseGenerator),[typeof(DefaultSamlSigninInteractionResponseGenerator)]),
]
},
{
"Services", [
new(typeof(IAutomaticKeyManagerKeyStore), [typeof(AutomaticKeyManagerKeyStore)]),
@ -83,6 +96,7 @@ internal class RegisteredImplementationsDiagnosticEntry(ServiceCollectionAccesso
new(typeof(IRefreshTokenService), [typeof(DefaultRefreshTokenService)]),
new(typeof(IReplayCache), [typeof(DefaultReplayCache)]),
new(typeof(IReturnUrlParser), [typeof(OidcReturnUrlParser)]),
new(typeof(ISamlServiceProviderStore), [typeof(InMemorySamlServiceProviderStore)]),
new(typeof(IServerUrls), [typeof(DefaultServerUrls)]),
new(typeof(ISessionCoordinationService), [typeof(DefaultSessionCoordinationService)]),
new(typeof(ISessionManagementService), []),

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