diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs index d595bc7fd..fc67fe4be 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -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(EndpointNames.Token, ProtocolRoutePaths.Token.EnsureLeadingSlash()); builder.AddEndpoint(EndpointNames.UserInfo, ProtocolRoutePaths.UserInfo.EnsureLeadingSlash()); + // SAML 2.0 endpoints + builder.AddEndpoint(EndpointNames.SamlMetadata, ProtocolRoutePaths.SamlMetadata.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlSignin, ProtocolRoutePaths.SamlSignin.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlSigninCallback, ProtocolRoutePaths.SamlSigninCallback.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlIdpInitiated, ProtocolRoutePaths.SamlIdpInitiated.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlLogout, ProtocolRoutePaths.SamlLogout.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlLogoutCallback, ProtocolRoutePaths.SamlLogoutCallback.EnsureLeadingSlash()); + builder.AddHttpWriter(); builder.AddHttpWriter(); builder.AddHttpWriter(); @@ -150,6 +166,67 @@ public static class IdentityServerBuilderExtensionsCore return builder; } + /// + /// Adds SAML 2.0 protocol services. + /// + /// The builder. + /// + public static IIdentityServerBuilder AddSamlServices(this IIdentityServerBuilder builder) + { + // Serializers (Transient) + builder.Services.AddTransient, SamlErrorResponseXmlSerializer>(); + builder.Services.AddTransient, SamlResponse.Serializer>(); + builder.Services.AddTransient, LogoutResponse.Serializer>(); + + // HTTP response writers + builder.AddHttpWriter(); + builder.AddHttpWriter(); + builder.AddHttpWriter(); + + // Processors (Scoped) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Builders (Scoped) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Parsers / Extractors (Scoped) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Infrastructure (Scoped) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.TryAddScoped(typeof(SamlRequestSignatureValidator<,>)); + + // Interface → Implementation (TryAddScoped for extensibility) + builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); + + // State management (Singleton) + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + + // Default no-op service provider store (can be overridden by user) + builder.Services.TryAddTransient(); + + return builder; + } + /// /// Adds an endpoint. /// diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/IdentityServerServiceCollectionExtensions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/IdentityServerServiceCollectionExtensions.cs index dc9945034..45d90705b 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/IdentityServerServiceCollectionExtensions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/IdentityServerServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ public static class IdentityServerServiceCollectionExtensions .AddCookieAuthentication() .AddCoreServices() .AddDefaultEndpoints() + .AddSamlServices() .AddPluggableServices() .AddKeyManagement() .AddDynamicProvidersCore() diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/EndpointOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/EndpointOptions.cs index 8e07588a5..595de2bb5 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/EndpointOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/EndpointOptions.cs @@ -111,4 +111,52 @@ public class EndpointsOptions /// true if the OAuth 2.0 discovery metadata is enabled; otherwise, false. /// public bool EnableOAuth2MetadataEndpoint { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SAML metadata endpoint is enabled. + /// + /// + /// true if the SAML metadata endpoint is enabled; otherwise, false. + /// + public bool EnableSamlMetadataEndpoint { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SAML sign-in (SSO) endpoint is enabled. + /// + /// + /// true if the SAML sign-in endpoint is enabled; otherwise, false. + /// + public bool EnableSamlSigninEndpoint { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SAML sign-in callback endpoint is enabled. + /// + /// + /// true if the SAML sign-in callback endpoint is enabled; otherwise, false. + /// + public bool EnableSamlSigninCallbackEndpoint { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SAML IdP-initiated SSO endpoint is enabled. + /// + /// + /// true if the SAML IdP-initiated endpoint is enabled; otherwise, false. + /// + public bool EnableSamlIdpInitiatedEndpoint { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SAML Single Logout (SLO) endpoint is enabled. + /// + /// + /// true if the SAML logout endpoint is enabled; otherwise, false. + /// + public bool EnableSamlLogoutEndpoint { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SAML Single Logout callback endpoint is enabled. + /// + /// + /// true if the SAML logout callback endpoint is enabled; otherwise, false. + /// + public bool EnableSamlLogoutCallbackEndpoint { get; set; } = true; } diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 93763f48f..5d5961455 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -293,4 +293,9 @@ public class IdentityServerOptions /// Options that control the diagnostic data that is logged by IdentityServer. /// public DiagnosticOptions Diagnostics { get; set; } = new DiagnosticOptions(); + + /// + /// Gets or sets the SAML 2.0 Identity Provider options. + /// + public SamlOptions Saml { get; set; } = new SamlOptions(); } diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/SamlOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/SamlOptions.cs new file mode 100644 index 000000000..a3842537d --- /dev/null +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/SamlOptions.cs @@ -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; + +/// +/// Options for SAML 2.0 Identity Provider functionality. +/// +public class SamlOptions +{ + /// + /// Gets or sets the metadata validity duration (optional). + /// If set, metadata will include a validUntil attribute. + /// Defaults to 7 days. + /// + public TimeSpan? MetadataValidityDuration { get; set; } = TimeSpan.FromDays(7); + + /// + /// Gets or sets whether the IdP requires signed AuthnRequests. + /// Defaults to false. + /// + public bool WantAuthnRequestsSigned { get; set; } + + /// + /// 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) + /// + public string DefaultAttributeNameFormat { get; set; } + = SamlConstants.AttributeNameFormats.Uri; + + /// + /// 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. + /// + public string DefaultPersistentNameIdentifierClaimType { get; set; } = ClaimTypes.NameIdentifier; + + /// + /// 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. + /// + public ReadOnlyDictionary DefaultClaimMappings { get; init; } = + new(new Dictionary + { + ["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", + }); + + /// + /// Gets or sets the supported NameID formats. + /// Defaults to EmailAddress, Persistent, Transient, and Unspecified. + /// + public Collection SupportedNameIdFormats { get; init; } = + [ + SamlConstants.NameIdentifierFormats.EmailAddress, + SamlConstants.NameIdentifierFormats.Persistent, + SamlConstants.NameIdentifierFormats.Transient, + SamlConstants.NameIdentifierFormats.Unspecified + ]; + + /// + /// Gets or sets the default clock skew tolerance for SAML message validation. + /// Defaults to 5 minutes. + /// + public TimeSpan DefaultClockSkew { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the default maximum age for SAML authentication requests. + /// Defaults to 5 minutes. + /// + public TimeSpan DefaultRequestMaxAge { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the default signing behavior for SAML messages. + /// Defaults to . + /// + public SamlSigningBehavior DefaultSigningBehavior { get; set; } = SamlSigningBehavior.SignAssertion; + + /// + /// 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). + /// + public int MaxRelayStateLength { get; set; } = 80; + + /// + /// Gets or sets the user interaction options for SAML endpoints. + /// + public SamlUserInteractionOptions UserInteraction { get; set; } = new(); +} + +/// +/// Options for SAML user interaction endpoint paths. +/// +public class SamlUserInteractionOptions +{ + /// + /// Gets or sets the base route for all SAML endpoints. + /// Default: "/saml". + /// + public string Route { get; set; } = SamlConstants.Urls.SamlRoute; + + /// + /// Gets or sets the path for the SAML metadata endpoint. + /// Default: "/metadata". + /// + public string Metadata { get; set; } = SamlConstants.Urls.Metadata; + + /// + /// Gets or sets the path for the SAML sign-in endpoint. + /// Default: "/signin". + /// + public string SignInPath { get; set; } = SamlConstants.Urls.SignIn; + + /// + /// Gets or sets the path for the SAML sign-in callback endpoint. + /// Default: "/signin_callback". + /// + public string SignInCallbackPath { get; set; } = SamlConstants.Urls.SigninCallback; + + /// + /// Gets or sets the path for the IdP-initiated SSO endpoint. + /// Default: "/idp-initiated". + /// + public string IdpInitiatedPath { get; set; } = SamlConstants.Urls.IdpInitiated; + + /// + /// Gets or sets the path for the SAML single logout endpoint. + /// Default: "/logout". + /// + public string SingleLogoutPath { get; set; } = SamlConstants.Urls.SingleLogout; + + /// + /// Gets or sets the path for the SAML single logout callback endpoint. + /// Default: "/logout_callback". + /// + public string SingleLogoutCallbackPath { get; set; } = SamlConstants.Urls.SingleLogoutCallback; +} diff --git a/identity-server/src/IdentityServer/Endpoints/Results/EndSessionCallbackResult.cs b/identity-server/src/IdentityServer/Endpoints/Results/EndSessionCallbackResult.cs index 18ab5395b..f272b2e6b 100644 --- a/identity-server/src/IdentityServer/Endpoints/Results/EndSessionCallbackResult.cs +++ b/identity-server/src/IdentityServer/Endpoints/Results/EndSessionCallbackResult.cs @@ -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 internal class EndSessionCallbackHttpWriter : IHttpResponseWriter { - public EndSessionCallbackHttpWriter(IdentityServerOptions options) => _options = options; + public EndSessionCallbackHttpWriter(IdentityServerOptions options, ILogger logger) + { + _options = options; + _logger = logger; + } - private IdentityServerOptions _options; + private readonly IdentityServerOptions _options; + private readonly ILogger _logger; public async Task WriteHttpResponse(EndSessionCallbackResult result, HttpContext context) { @@ -59,25 +68,36 @@ internal class EndSessionCallbackHttpWriter : IHttpResponseWriter 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(""); @@ -91,6 +111,28 @@ internal class EndSessionCallbackHttpWriter : IHttpResponseWriter"); + break; + case SamlBinding.HttpRedirect: + sb.Append(CultureInfo.InvariantCulture, $""); + break; + default: + _logger.LogDebug("Unknown SAML Binding: {SamlBinding}", samlFrontChannelLogout.SamlBinding); + break; + } + + sb.AppendLine(); + } + } + return sb.ToString(); } } diff --git a/identity-server/src/IdentityServer/Extensions/AuthenticationPropertiesExtensions.cs b/identity-server/src/IdentityServer/Extensions/AuthenticationPropertiesExtensions.cs index 1cd60d896..3328df2b1 100644 --- a/identity-server/src/IdentityServer/Extensions/AuthenticationPropertiesExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/AuthenticationPropertiesExtensions.cs @@ -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"; /// /// Gets the user's session identifier. @@ -86,7 +88,6 @@ public static class AuthenticationPropertiesExtensions } } - private static IEnumerable DecodeList(string value) { if (value.IsPresent()) @@ -111,4 +112,100 @@ public static class AuthenticationPropertiesExtensions return null; } + + /// + /// Gets the list of SAML SP sessions from the authentication properties. + /// + /// + /// + /// + /// 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. + /// + public static IEnumerable GetSamlSessionList(this AuthenticationProperties properties) + { + if (properties?.Items.TryGetValue(SamlSessionListKey, out var value) == true && value != null) + { + return DecodeSamlSessionList(value); + } + + return []; + } + + /// + /// Sets the list of SAML SP sessions in the authentication properties. + /// + /// + /// + public static void SetSamlSessionList(this AuthenticationProperties properties, IEnumerable sessions) + { + var value = EncodeSamlSessionList(sessions); + if (value == null) + { + properties.Items.Remove(SamlSessionListKey); + } + else + { + properties.Items[SamlSessionListKey] = value; + } + } + + /// + /// 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. + /// + /// + /// + 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); + } + + /// + /// Removes a SAML session from the authentication properties by EntityId. + /// + /// + /// + 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(json) ?? []; + } + + return []; + } + + private static string EncodeSamlSessionList(IEnumerable list) + { + if (list != null && list.Any()) + { + var json = ObjectSerializer.ToString(list); + var bytes = Encoding.UTF8.GetBytes(json); + return Base64Url.EncodeToString(bytes); + } + + return null; + } } diff --git a/identity-server/src/IdentityServer/Extensions/EndpointOptionsExtensions.cs b/identity-server/src/IdentityServer/Extensions/EndpointOptionsExtensions.cs index 25b617c23..fd98b0601 100644 --- a/identity-server/src/IdentityServer/Extensions/EndpointOptionsExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/EndpointOptionsExtensions.cs @@ -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 }; } diff --git a/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs b/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs index 235feb016..c7ff019b7 100644 --- a/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs @@ -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 AnySamlServiceProviderHasFrontChannelLogout(IEnumerable entityIds) + { + var serviceProviderStore = context.RequestServices.GetRequiredService(); + foreach (var entityId in entityIds) + { + var sp = await serviceProviderStore.FindByEntityIdAsync(entityId); + if (sp?.Enabled == true && sp.SingleLogoutServiceUrl != null) + { + return true; + } + } + + return false; + } } } diff --git a/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs b/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs index 29b4d42dd..c0dbe181a 100644 --- a/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs @@ -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")) diff --git a/identity-server/src/IdentityServer/IdentityServerConstants.cs b/identity-server/src/IdentityServer/IdentityServerConstants.cs index 8bf5edc4b..129cbfb77 100644 --- a/identity-server/src/IdentityServer/IdentityServerConstants.cs +++ b/identity-server/src/IdentityServer/IdentityServerConstants.cs @@ -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. /// public const string CheckSessionScript = "sha256-jyguj/c+mxOUX7TJrFnIkEQlj4jinO1nejo8qnuF1jc="; + + /// + /// The hash of the inline script used for SAML auto-post form submissions. + /// + 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, diff --git a/identity-server/src/IdentityServer/Internal/Saml/DefaultSamlInteractionService.cs b/identity-server/src/IdentityServer/Internal/Saml/DefaultSamlInteractionService.cs new file mode 100644 index 000000000..5e03fae38 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/DefaultSamlInteractionService.cs @@ -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 logger) + : ISamlInteractionService +{ + public async Task 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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/HttpResponseBindings.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/HttpResponseBindings.cs new file mode 100644 index 000000000..fb855d085 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/HttpResponseBindings.cs @@ -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 + : $@""; + + var cspMetaTag = includeCsp + ? $@"" + : string.Empty; + + return $@" + + + + {cspMetaTag} + SAML Response + + + +
+ + {relayStateField} + +
+ + +"; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlRequest.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlRequest.cs new file mode 100644 index 000000000..d198a5c1e --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlRequest.cs @@ -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; + +/// +/// Interface for SAML requests that have common validation fields +/// +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; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlResultSerializer.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlResultSerializer.cs new file mode 100644 index 000000000..3c0fb0c0e --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlResultSerializer.cs @@ -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 +{ + XElement Serialize(T toSerialize); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlSigningService.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlSigningService.cs new file mode 100644 index 000000000..6c6359326 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlSigningService.cs @@ -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; + +/// +/// Service for obtaining signing credentials for SAML operations. +/// +internal interface ISamlSigningService +{ + /// + /// Gets the X509 certificate used for signing SAML messages. + /// + /// The signing certificate with private key. + /// + /// 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. + /// + Task GetSigningCertificateAsync(); + + /// + /// Gets the X509 certificate as a base64-encoded string for inclusion in SAML metadata. + /// + /// Base64-encoded certificate bytes. + /// + /// Thrown when no signing credential is available or when the credential is not an X509 certificate. + /// + Task GetSigningCertificateBase64Async(); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/LimitedReadStream.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/LimitedReadStream.cs new file mode 100644 index 000000000..96036c032 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/LimitedReadStream.cs @@ -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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/RedirectResult.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/RedirectResult.cs new file mode 100644 index 000000000..25b0e36dc --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/RedirectResult.cs @@ -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>(); + 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[] errors) : IEndpointResult +{ + public async Task ExecuteAsync(HttpContext context) => + await Results.ValidationProblem(new Dictionary(errors), title).ExecuteAsync(context); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/Result.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/Result.cs new file mode 100644 index 000000000..8bc0ccafd --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/Result.cs @@ -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 +{ + [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 FromValue(TValue value) => + new() + { + Success = true, + Value = value + }; + + public static Result FromError(TFailure error) => + new() + { + Success = false, + Error = error + }; + + public static implicit operator Result(TFailure value) => + FromError(value); + public static implicit operator Result(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) +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.Logging.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.Logging.cs new file mode 100644 index 000000000..22af147ea --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.Logging.cs @@ -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); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.cs new file mode 100644 index 000000000..2582bc019 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.cs @@ -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 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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlBindingExtensions.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlBindingExtensions.cs new file mode 100644 index 000000000..09899ec5a --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlBindingExtensions.cs @@ -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") + }; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponse.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponse.cs new file mode 100644 index 000000000..95092e8a3 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponse.cs @@ -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; + +/// +/// Represents a SAML error response that will be sent to the Service Provider. +/// +internal class SamlErrorResponse : EndpointResult +{ + /// + /// Gets the SAML binding to use for sending the response (HTTP-POST or HTTP-Redirect). + /// + public required SamlBinding Binding { get; init; } + + /// + /// Gets the SAML status code for the error. + /// + public required SamlStatusCode StatusCode { get; init; } + + /// + /// Gets the human-readable error message. + /// + public required string Message { get; init; } + + /// + /// Gets the Assertion Consumer Service URL to send the response to. + /// + public required Uri AssertionConsumerServiceUrl { get; init; } + + /// + /// Gets the IdP issuer URI. + /// + public required string Issuer { get; init; } + + /// + /// Gets the request ID this response is replying to (InResponseTo), or null for IdP-initiated. + /// + public string? InResponseTo { get; init; } + + /// + /// Gets the RelayState to preserve across the response. + /// + public string? RelayState { get; init; } + + /// + /// Gets an optional secondary status code for more specific error information. + /// + public SamlStatusCode? SubStatusCode { get; init; } + + /// + /// Gets or sets the Service Provider where the response will be sent. + /// + public required SamlServiceProvider ServiceProvider { get; init; } + + internal class ResponseWriter(ISamlResultSerializer serializer) + : IHttpResponseWriter + { + 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); + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs new file mode 100644 index 000000000..686a17f70 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs @@ -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 +{ + 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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageParser.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageParser.cs new file mode 100644 index 000000000..d5806d726 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageParser.cs @@ -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; + +/// +/// Base class for SAML protocol message parsers. +/// Provides common XML parsing and validation utilities. +/// +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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageSigner.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageSigner.cs new file mode 100644 index 000000000..d7ed97c90 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageSigner.cs @@ -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 logger) +{ + internal async Task 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 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))}"; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestBase.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestBase.cs new file mode 100644 index 000000000..c62ce9ef9 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestBase.cs @@ -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; + +/// +/// Base record for SAML request wrappers that contain both the parsed request +/// and HTTP binding metadata. +/// +/// The type of the parsed SAML request +internal abstract record SamlRequestBase 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; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestError.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestError.cs new file mode 100644 index 000000000..3df7f2a07 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestError.cs @@ -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 +{ + internal SamlRequestErrorType Type { get; init; } + internal string? ValidationMessage { get; init; } + internal SamlProtocolError? ProtocolError { get; init; } +} + +internal record SamlProtocolError( + SamlServiceProvider ServiceProvider, + TRequest Request, + SamlError Error); diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestExtractor.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestExtractor.cs new file mode 100644 index 000000000..c31223de0 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestExtractor.cs @@ -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; + +/// +/// Base class for extracting and parsing SAML protocol messages from HTTP requests. +/// Handles common logic for both HTTP-Redirect and HTTP-POST bindings. +/// +/// The type of the parsed SAML request (e.g., AuthNRequest, LogoutRequest) +/// The type of the result containing the parsed request and metadata +internal abstract class SamlRequestExtractor + where TRequest : ISamlRequest + where TResult : SamlRequestBase +{ + 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 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 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); + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestProcessorBase.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestProcessorBase.cs new file mode 100644 index 000000000..827355d52 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestProcessorBase.cs @@ -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( + ISamlServiceProviderStore serviceProviderStore, + IOptions options, + SamlRequestValidator requestValidator, + SamlRequestSignatureValidator signatureValidator, + ILogger logger, + string expectedDestination) + where TMessage : ISamlRequest + where TRequest : SamlRequestBase +{ + protected readonly ISamlServiceProviderStore ServiceProviderStore = serviceProviderStore; + protected readonly SamlOptions SamlOptions = options.Value; + protected readonly SamlRequestValidator RequestValidator = requestValidator; + protected readonly SamlRequestSignatureValidator SignatureValidator = signatureValidator; + protected readonly ILogger Logger = logger; + protected readonly string ExpectedDestination = expectedDestination; + + internal async Task>> 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 + { + 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? 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 + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(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? ValidateSignature(SamlServiceProvider sp, TRequest request) + { + var requireSignature = RequireSignature(sp); + + if (!requireSignature) + { + return null; + } + + if (sp.SigningCertificates == null || sp.SigningCertificates.Count == 0) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{sp.EntityId}' has no signing certificates configured and has sent a {TMessage.MessageName} which requires signature validation" + }; + } + + Result 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 + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, request, new SamlError + { + StatusCode = SamlStatusCode.Requester, + Message = $"Unsupported binding for signature validation: {request.Binding}" + }) + }; + } + + if (!validationResult.Success) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, request, validationResult.Error) + }; + } + + return null; + } + protected abstract SamlRequestError? ValidateMessageSpecific(SamlServiceProvider sp, TRequest request); + protected abstract Task>> ProcessValidatedRequestAsync(SamlServiceProvider sp, TRequest request, CancellationToken ct = default); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestSignatureValidator.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestSignatureValidator.cs new file mode 100644 index 000000000..0732f433c --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestSignatureValidator.cs @@ -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; + +/// +/// Validates signatures on SAML request messages for both HTTP-Redirect and HTTP-POST bindings. +/// +internal class SamlRequestSignatureValidator(TimeProvider timeProvider) + where TRequest : SamlRequestBase + where TSamlRequest : ISamlRequest +{ + private static readonly HashSet SupportedAlgorithms = + [ + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" + ]; + + /// + /// Validates signature on HTTP-Redirect binding request. + /// + internal Result ValidateRedirectBindingSignature( + TRequest request, + SamlServiceProvider serviceProvider) + { + var signature = request.Signature; + var sigAlg = request.SignatureAlgorithm; + + if (string.IsNullOrEmpty(signature)) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = "Missing signature parameter" }); + } + + if (string.IsNullOrEmpty(sigAlg)) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = "Missing signature algorithm parameter" }); + } + + if (!SupportedAlgorithms.Contains(sigAlg)) + { + return Result.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); + } + + /// + /// Validates signature on HTTP-POST binding request. + /// + internal Result 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.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.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.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().FirstOrDefault(); + if (reference == null) + { + return false; + } + + var referencedId = reference.Uri?.TrimStart('#'); + return referencedId == expectedId; + } + + private Result ValidateWithCertificates( + SamlServiceProvider serviceProvider, + Func validateSignature) + { + var validCertificates = serviceProvider.SigningCertificates?.Where(cert => ValidateCertificate(cert).Success).ToList(); + if (validCertificates == null || validCertificates.Count == 0) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCode.Responder, Message = "No valid certificates configured for service provider" }); + } + + foreach (var cert in validCertificates) + { + if (validateSignature(cert)) + { + return Result.FromValue(true); + } + } + + return Result.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = "Invalid signature" }); + } + + private Result ValidateCertificate(X509Certificate2 certificate) + { + var now = timeProvider.GetUtcNow(); + + if (certificate.NotBefore > now.UtcDateTime) + { + return Result.FromError($"Certificate is not yet valid (NotBefore: {certificate.NotBefore:u})"); + } + + if (certificate.NotAfter < now.UtcDateTime) + { + return Result.FromError($"Certificate has expired (NotAfter: {certificate.NotAfter:u})"); + } + + return Result.FromValue(true); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestValidator.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestValidator.cs new file mode 100644 index 000000000..9508ee880 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestValidator.cs @@ -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; + +/// +/// Helper class for common SAML request validation logic +/// +internal class SamlRequestValidator(TimeProvider timeProvider, IOptions options) +{ + private readonly SamlOptions _samlOptions = options.Value; + + /// + /// Validates version, issue instant, and destination for a SAML request + /// + 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; + } +} + +/// +/// Represents a SAML validation error +/// +internal class SamlValidationError +{ + internal required string Message { get; init; } + internal SamlStatusCode StatusCode { get; init; } = SamlStatusCode.Requester; + internal SamlStatusCode? SubStatusCode { get; init; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlResponseSigner.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlResponseSigner.cs new file mode 100644 index 000000000..6029f1e24 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlResponseSigner.cs @@ -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, + ILogger logger) +{ + internal async Task 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; + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlSigningService.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlSigningService.cs new file mode 100644 index 000000000..ab184eede --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlSigningService.cs @@ -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; + +/// +/// Default implementation of . +/// +internal class SamlSigningService( + IKeyMaterialService keyMaterialService, + ILogger logger) : ISamlSigningService +{ + /// + public async Task 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; + } + + /// + public async Task 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 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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlUrlBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlUrlBuilder.cs new file mode 100644 index 000000000..9c17de3c7 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlUrlBuilder.cs @@ -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, + IOptions 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; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs new file mode 100644 index 000000000..1baaa713f --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs @@ -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; + +/// +/// Provides secure XML parsing with hardened settings to prevent common XML attacks. +/// +/// +/// This class protects against: +/// - XXE (XML External Entity) attacks +/// - DTD (Document Type Definition) attacks +/// - Billion laughs attack (entity expansion) +/// - Resource exhaustion attacks +/// +internal static class SecureXmlParser +{ + /// + /// Maximum allowed size for SAML messages (1MB). + /// + internal const int MaxMessageSize = 1048576; // 1MB + + /// + /// Secure XML reader settings configured to prevent common XML attacks. + /// + 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); + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/XmlSignatureHelper.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/XmlSignatureHelper.cs new file mode 100644 index 000000000..9d174f0c5 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/XmlSignatureHelper.cs @@ -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); + } + + /// + /// Inserts signature element after Issuer element (SAML requirement) + /// + 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); + } + } + } + + /// + /// Converts XElement to XmlDocument, preserving namespace prefixes + /// + 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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs b/identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs new file mode 100644 index 000000000..a1572633a --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs @@ -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; + +/// +/// Represents the usage type of a SAML key descriptor. +/// +internal enum KeyUse +{ + /// + /// Key used for signing. + /// + Signing, + + /// + /// Key used for encryption. + /// + Encryption +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Log.cs b/identity-server/src/IdentityServer/Internal/Saml/Log.cs new file mode 100644 index 000000000..f1517057c --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Log.cs @@ -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); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/EntityDescriptorSerializer.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/EntityDescriptorSerializer.cs new file mode 100644 index 000000000..a97b10b3c --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/EntityDescriptorSerializer.cs @@ -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; + +/// +/// Serializes SAML metadata EntityDescriptor to XML. +/// +internal static class EntityDescriptorSerializer +{ + private static readonly XNamespace MdNamespace = SamlConstants.Namespaces.Metadata; + private static readonly XNamespace DsNamespace = SamlConstants.Namespaces.XmlSignature; + + /// + /// Serializes an EntityDescriptor to SAML metadata XML string. + /// + /// The entity descriptor to serialize. + /// XML string representing the SAML metadata. + 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)); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/EntityDescriptor.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/EntityDescriptor.cs new file mode 100644 index 000000000..1480a75af --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/EntityDescriptor.cs @@ -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; + +/// +/// Represents a SAML entity descriptor that describes a SAML entity (IdP or SP). +/// +internal record EntityDescriptor +{ + /// + /// Gets or sets the entity ID (typically the IdP issuer URI). + /// This uniquely identifies the SAML entity. + /// + internal required string EntityId { get; set; } + + /// + /// Gets or sets the IdP SSO descriptor. + /// Contains the Identity Provider's SSO configuration and capabilities. + /// + internal IdpSsoDescriptor? IdpSsoDescriptor { get; set; } + + /// + /// Gets or sets the validity period end time (optional). + /// If set, indicates when this metadata expires. + /// + internal DateTime? ValidUntil { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/IdpSsoDescriptor.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/IdpSsoDescriptor.cs new file mode 100644 index 000000000..1554b9892 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/IdpSsoDescriptor.cs @@ -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; + +/// +/// 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. +/// +internal record IdpSsoDescriptor +{ + /// + /// Gets or sets the protocol support enumeration. + /// Typically "urn:oasis:names:tc:SAML:2.0:protocol". + /// Indicates which SAML protocols this IdP supports. + /// + internal required string ProtocolSupportEnumeration { get; set; } + + /// + /// Gets or sets whether the IdP requires authentication requests to be signed. + /// + internal bool WantAuthnRequestsSigned { get; set; } + + /// + /// Gets or sets the signing certificates. + /// Contains the public keys used to verify signatures from this IdP. + /// + internal Collection KeyDescriptors { get; init; } = []; + + /// + /// Gets or sets the supported NameID formats. + /// Indicates which name identifier formats this IdP can provide. + /// + internal Collection NameIdFormats { get; init; } = []; + + /// + /// Gets or sets the SingleSignOnService endpoints. + /// Defines where and how Service Providers can initiate SSO. + /// + internal Collection SingleSignOnServices { get; init; } = []; + + /// + /// Gets or sets the SingleLogoutService endpoints. + /// Defines where and how Service Providers can send logout requests. + /// + internal Collection SingleLogoutServices { get; init; } = []; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/KeyDescriptor.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/KeyDescriptor.cs new file mode 100644 index 000000000..32a2164ce --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/KeyDescriptor.cs @@ -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; + +/// +/// Describes a cryptographic key used by a SAML entity. +/// Contains certificate information for signature verification or encryption. +/// +internal record KeyDescriptor +{ + /// + /// Gets or sets the key usage (signing, encryption, or null for both). + /// When null, the key can be used for both signing and encryption. + /// + internal KeyUse? Use { get; set; } + + /// + /// 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. + /// + internal required string X509Certificate { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleLogoutService.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleLogoutService.cs new file mode 100644 index 000000000..54a5e6692 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleLogoutService.cs @@ -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; + +/// +/// Describes a SAML SingleLogoutService endpoint. +/// Specifies where and how a Service Provider can send logout requests. +/// +internal record SingleLogoutService +{ + /// + /// Gets or sets the binding (HTTP-Redirect, HTTP-POST, etc.). + /// Indicates the protocol binding to use for this endpoint. + /// + internal required SamlBinding Binding { get; set; } + + /// + /// Gets or sets the location URI. + /// The endpoint URI where logout requests should be sent. + /// + internal required Uri Location { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleSignOnService.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleSignOnService.cs new file mode 100644 index 000000000..fa8d4c463 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleSignOnService.cs @@ -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; + +/// +/// Describes a SAML SingleSignOnService endpoint. +/// Specifies where and how a Service Provider can send authentication requests. +/// +internal record SingleSignOnService +{ + /// + /// Gets or sets the binding (HTTP-Redirect, HTTP-POST, etc.). + /// Indicates the protocol binding to use for this endpoint. + /// + internal required SamlBinding Binding { get; set; } + + /// + /// Gets or sets the location URI. + /// The endpoint URI where authentication requests should be sent. + /// + internal required Uri Location { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/SamlMetaDataEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/SamlMetaDataEndpoint.cs new file mode 100644 index 000000000..97efdbc65 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/SamlMetaDataEndpoint.cs @@ -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, + IIssuerNameService issuerNameService, + IServerUrls urls, + ISamlSigningService samlSigningService) : IEndpointHandler +{ + public async Task 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; + } +} + +/// +/// Endpoint result that writes SAML metadata XML to the response. +/// +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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SamlClaimService.cs b/identity-server/src/IdentityServer/Internal/Saml/SamlClaimService.cs new file mode 100644 index 000000000..3d86cdaad --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SamlClaimService.cs @@ -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 logger, + IOptions options, + ISamlClaimsMapper? customMapper = null) +{ + private async Task> 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> 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 MapClaimsToAttributes( + IEnumerable claims, + SamlServiceProvider serviceProvider) + { + var samlOptions = options.Value; + var attributes = new List(); + 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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SamlConstants.cs b/identity-server/src/IdentityServer/Internal/Saml/SamlConstants.cs new file mode 100644 index 000000000..f34fd2d0f --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SamlConstants.cs @@ -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 + { + /// + /// https://www.iana.org/assignments/media-types/application/samlmetadata+xml + /// + 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"; + + /// + /// Converts a KeyUse enum value to its string representation. + /// + 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 + { + /// + /// Attribute name is interpreted as a URI reference (most common for OID format) + /// + 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"; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SamlMessageName.cs b/identity-server/src/IdentityServer/Internal/Saml/SamlMessageName.cs new file mode 100644 index 000000000..4b6478d6d --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SamlMessageName.cs @@ -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; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs new file mode 100644 index 000000000..8daded114 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs @@ -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, + 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 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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Log.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Log.cs new file mode 100644 index 000000000..973d44438 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Log.cs @@ -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); +} + diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutRequestParser.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutRequestParser.cs new file mode 100644 index 000000000..75b7de7cd --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutRequestParser.cs @@ -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; + +/// +/// Parses SAML LogoutRequest messages. +/// +internal class LogoutRequestParser(ILogger logger) : SamlProtocolMessageParser +{ + /// + /// Parses a LogoutRequest from XML. + /// + 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 + }; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutResponseBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutResponseBuilder.cs new file mode 100644 index 000000000..0f9147274 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutResponseBuilder.cs @@ -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 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 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 + }; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutRequest.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutRequest.cs new file mode 100644 index 000000000..33101777e --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutRequest.cs @@ -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; + +/// +/// Represents a SAML 2.0 LogoutRequest message. +/// +internal record LogoutRequest : ISamlRequest +{ + public static string MessageName => "SAML logout request"; + + /// + /// Gets or sets the unique identifier for this request. + /// + public required RequestId Id { get; set; } + + /// + /// Gets or sets the SAML version. Must be "2.0". + /// + public SamlVersion Version { get; set; } + + /// + /// Gets or sets the time instant of issue in UTC. + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Gets or sets the URI of the destination endpoint where this request is sent. + /// + public Uri? Destination { get; set; } + + /// + /// Gets or sets the entity identifier of the issuer (sender) of this request. + /// + public required string Issuer { get; set; } + + /// + /// Gets or sets the NameID identifying the principal that is being logged out. + /// + public required NameIdentifier NameId { get; set; } + + /// + /// Gets or sets the SessionIndex identifying the session to be terminated. + /// + public required string SessionIndex { get; set; } + + /// + /// Gets or sets the reason for the logout (optional). + /// + public LogoutReason? Reason { get; set; } + + /// + /// Gets or sets the NotOnOrAfter time limit for the logout operation. + /// + 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"; + } +} + +/// +/// Represents the reason for logout in a LogoutRequest. +/// +internal enum LogoutReason +{ + /// + /// User initiated the logout. + /// + User, + + /// + /// Administrator initiated the logout. + /// + Admin, + + /// + /// Logout due to global timeout. + /// + GlobalTimeout +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutResponse.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutResponse.cs new file mode 100644 index 000000000..70c1980da --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutResponse.cs @@ -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; + +/// +/// Represents a SAML 2.0 LogoutResponse message. +/// +internal class LogoutResponse : EndpointResult +{ + /// + /// Gets or sets the unique identifier for this response. + /// + public required ResponseId Id { get; set; } + + /// + /// Gets or sets the SAML version. Must be "2.0". + /// + public SamlVersion Version { get; set; } = SamlVersion.V2; + + /// + /// Gets or sets the time instant of issue in UTC. + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Gets or sets the URI of the destination endpoint where this response is sent. + /// + public required Uri Destination { get; set; } + + /// + /// Gets or sets the entity identifier of the issuer (sender) of this response. + /// + public required string Issuer { get; set; } + + /// + /// Gets or sets the ID of the LogoutRequest to which this is a response. + /// + public required string InResponseTo { get; set; } + + /// + /// Gets or sets the status of the logout operation. + /// + public required Status Status { get; set; } + + /// + /// Gets or sets the service provider configuration for this response. + /// + public required SamlServiceProvider ServiceProvider { get; set; } + + /// + /// Gets or sets the optional RelayState parameter to return to the SP. + /// + public string? RelayState { get; set; } + + internal static class ElementNames + { + public const string RootElement = "LogoutResponse"; + } + + internal class ResponseWriter(ISamlResultSerializer serializer, SamlProtocolMessageSigner samlProtocolMessageSigner) : IHttpResponseWriter + { + 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 + { + 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; + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/SamlLogoutRequest.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/SamlLogoutRequest.cs new file mode 100644 index 000000000..432d73ec1 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/SamlLogoutRequest.cs @@ -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; + +/// +/// Represents a SAML logout request with binding information. +/// +internal record SamlLogoutRequest : SamlRequestBase +{ + public static async ValueTask BindAsync(HttpContext context) + { + var extractor = context.RequestServices.GetRequiredService(); + return await extractor.ExtractAsync(context); + } + + public LogoutRequest LogoutRequest => Request; +} + +internal class SamlLogoutRequestExtractor : SamlRequestExtractor +{ + 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 + }; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlFrontChannelLogoutRequestBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlFrontChannelLogoutRequestBuilder.cs new file mode 100644 index 000000000..c7d545729 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlFrontChannelLogoutRequestBuilder.cs @@ -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 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 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 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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpPostFrontChannelLogout.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpPostFrontChannelLogout.cs new file mode 100644 index 000000000..2f231d358 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpPostFrontChannelLogout.cs @@ -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; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpRedirectFrontChannelLogout.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpRedirectFrontChannelLogout.cs new file mode 100644 index 000000000..06d36bee0 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpRedirectFrontChannelLogout.cs @@ -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; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutCallbackProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutCallbackProcessor.cs new file mode 100644 index 000000000..f692d21d4 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutCallbackProcessor.cs @@ -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; + +/// +/// Processes SAML Single Logout callback requests after user logout completes. +/// +internal class SamlLogoutCallbackProcessor( + IMessageStore logoutMessageStore, + ISamlServiceProviderStore serviceProviderStore, + LogoutResponseBuilder logoutResponseBuilder, + ILogger logger) +{ + internal async Task> 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; + } +} + +/// +/// Represents an error during SAML logout callback processing. +/// +internal record SamlLogoutCallbackError(string Message); diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs new file mode 100644 index 000000000..498395f43 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs @@ -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 logger) : ISamlLogoutNotificationService +{ + public async Task> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context) + { + using var activity = Tracing.ServiceActivitySource.StartActivity("LogoutNotificationService.GetSamlFrontChannelLogoutUrls"); + + var logoutUrls = new List(); + + 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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutRequestProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutRequestProcessor.cs new file mode 100644 index 000000000..62d0c4b4d --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutRequestProcessor.cs @@ -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 +{ + private readonly IUserSession _userSession; + private readonly LogoutResponseBuilder _logoutResponseBuilder; + private readonly IMessageStore _logoutMessageStore; + private readonly TimeProvider _timeProvider; + private readonly SamlUrlBuilder _urlBuilder; + + public SamlLogoutRequestProcessor( + ISamlServiceProviderStore serviceProviderStore, + IUserSession userSession, + SamlRequestSignatureValidator signatureValidator, + LogoutResponseBuilder logoutResponseBuilder, + IServerUrls serverUrls, + IOptions options, + IMessageStore logoutMessageStore, + TimeProvider timeProvider, + SamlUrlBuilder urlBuilder, + SamlRequestValidator requestValidator, + ILogger 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>> ProcessValidatedRequestAsync( + SamlServiceProvider sp, + SamlLogoutRequest request, + CancellationToken ct) + { + var logoutRequest = request.LogoutRequest; + + if (sp.SingleLogoutServiceUrl == null) + { + Logger.SamlLogoutNoSingleLogoutServiceUrl(LogLevel.Error, sp.EntityId); + return new SamlRequestError + { + 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? 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 + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, request, new SamlError + { + StatusCode = SamlStatusCode.Requester, + Message = "Logout request expired (NotOnOrAfter is in the past)" + }) + }; + } + } + + return null; + } + + private async Task 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 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, _timeProvider.GetUtcNow().UtcDateTime); + + return await _logoutMessageStore.WriteAsync(msg); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutResults.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutResults.cs new file mode 100644 index 000000000..87910cc79 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutResults.cs @@ -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)); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs new file mode 100644 index 000000000..4007ff691 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs @@ -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; + +/// +/// 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. +/// +internal class SamlSingleLogoutCallbackEndpoint( + SamlLogoutCallbackProcessor processor, + ILogger logger) : IEndpointHandler +{ + public async Task 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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutEndpoint.cs new file mode 100644 index 000000000..14cbcb91b --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutEndpoint.cs @@ -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 logger) : IEndpointHandler +{ + public async Task 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 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 error) + { + logger.SamlLogoutValidationError(LogLevel.Information, error.ValidationMessage!); + return new ValidationProblemResult(error.ValidationMessage!); + } + + private async Task HandleProtocolError(SamlRequestError 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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/AuthNRequestParser.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/AuthNRequestParser.cs new file mode 100644 index 000000000..33aefc0f9 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/AuthNRequestParser.cs @@ -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 _logger; + + /// + /// Initializes a new instance of the class. + /// + public AuthNRequestParser(ILogger 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 + }; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DefaultSamlSigninInteractionResponseGenerator.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DefaultSamlSigninInteractionResponseGenerator.cs new file mode 100644 index 000000000..e2408d0ff --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DefaultSamlSigninInteractionResponseGenerator.cs @@ -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 logger, + IHttpContextAccessor httpContextAccessor) + : ISamlSigninInteractionResponseGenerator +{ + public async Task 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 : 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); + } + + /// + /// Determines whether consent has been acquired based on the SAML consent URN value. + /// See SAML 2.0 Core spec section 8.4. + /// + 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"; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DistributedCacheSamlSigninStateStore.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DistributedCacheSamlSigninStateStore.cs new file mode 100644 index 000000000..4e8661467 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DistributedCacheSamlSigninStateStore.cs @@ -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 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 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(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}"; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/ISamlSigninStateStore.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/ISamlSigninStateStore.cs new file mode 100644 index 000000000..99edb0c6b --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/ISamlSigninStateStore.cs @@ -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 StoreSigninRequestStateAsync(SamlAuthenticationState request, CancellationToken ct = default); + Task RetrieveSigninRequestStateAsync(StateId stateId, CancellationToken ct = default); + Task UpdateSigninRequestStateAsync(StateId stateId, SamlAuthenticationState state, CancellationToken ct = default); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Log.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Log.cs new file mode 100644 index 000000000..3ff6288f8 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Log.cs @@ -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); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Assertion.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Assertion.cs new file mode 100644 index 000000000..3aa880bda --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Assertion.cs @@ -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 + + /// + /// 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 + /// + public AssertionId Id { get; } = AssertionId.NewId(); + + /// + /// SAML version (must be "2.0") + /// + public SamlVersion Version { get; } = SamlVersion.V2; + + /// + /// Time instant of issuance + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Identifies the entity that issued the assertion + /// + public required string Issuer { get; set; } + + /// + /// The subject of the assertion + /// + public Subject? Subject { get; set; } + + /// + /// Conditions under which the assertion is valid + /// + public Conditions? Conditions { get; set; } + + /// + /// Authentication statements + /// + public List AuthnStatements { get; set; } = []; + + /// + /// Attribute statements + /// + public List AttributeStatements { get; set; } = []; + + /// + /// Authorization decision statements + /// + public List AuthzDecisionStatements { get; set; } = []; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AssertionId.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AssertionId.cs new file mode 100644 index 000000000..5eacde529 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AssertionId.cs @@ -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; + +/// +/// 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 +/// +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; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AttributeStatement.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AttributeStatement.cs new file mode 100644 index 000000000..e99743bd4 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AttributeStatement.cs @@ -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; + +/// +/// Represents a SAML 2.0 AttributeStatement element +/// +internal record AttributeStatement +{ + /// + /// Attributes in this statement + /// + public List Attributes { get; set; } = []; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnContext.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnContext.cs new file mode 100644 index 000000000..0fb592368 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnContext.cs @@ -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; + +/// +/// Represents a SAML 2.0 AuthnContext element +/// +internal record AuthnContext +{ + /// + /// Authentication context class reference (URI) + /// + public string? AuthnContextClassRef { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnStatement.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnStatement.cs new file mode 100644 index 000000000..ed5c72847 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnStatement.cs @@ -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; + +/// +/// Represents a SAML 2.0 AuthnStatement element +/// +internal record AuthnStatement +{ + /// + /// Time at which the authentication took place + /// + public required DateTime AuthnInstant { get; set; } + + /// + /// Session index for the authenticated session + /// + public string? SessionIndex { get; set; } + + /// + /// Time instant at which the session expires + /// + public DateTime? SessionNotOnOrAfter { get; set; } + + /// + /// Authentication context + /// + public AuthnContext? AuthnContext { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthzDecisionStatement.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthzDecisionStatement.cs new file mode 100644 index 000000000..421dd3728 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthzDecisionStatement.cs @@ -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; + +/// +/// Represents a SAML 2.0 AuthzDecisionStatement element (Section 2.7.4) +/// +internal record AuthzDecisionStatement +{ + /// + /// URI reference identifying the resource to which access authorization is sought + /// + public required string Resource { get; set; } + + /// + /// The decision rendered by the SAML authority with respect to the specified resource + /// + public DecisionType Decision { get; set; } + + /// + /// A set of assertions that the SAML authority relied on in making the decision (optional) + /// + public Evidence? Evidence { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Conditions.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Conditions.cs new file mode 100644 index 000000000..7c0b468a3 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Conditions.cs @@ -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; + +/// +/// Represents SAML 2.0 Conditions element +/// +internal record Conditions +{ + /// + /// Time instant before which the assertion is invalid + /// + public DateTime? NotBefore { get; set; } + + /// + /// Time instant at which the assertion expires + /// + public DateTime? NotOnOrAfter { get; set; } + + /// + /// Audience restrictions for the assertion + /// + public ReadOnlyCollection AudienceRestrictions { get; init; } = new List().AsReadOnly(); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/DecisionType.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/DecisionType.cs new file mode 100644 index 000000000..0cfefc5d7 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/DecisionType.cs @@ -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; + +/// +/// Represents the decision rendered by the SAML authority +/// +internal enum DecisionType +{ + /// + /// The specified action is permitted + /// + Permit, + + /// + /// The specified action is denied + /// + Deny, + + /// + /// The SAML authority cannot determine whether the action is permitted or denied + /// + Indeterminate +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Evidence.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Evidence.cs new file mode 100644 index 000000000..dcdaccd29 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Evidence.cs @@ -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; + +/// +/// Represents evidence supporting the authorization decision (optional) +/// +internal record Evidence +{ + /// + /// URI references to assertions + /// + public List AssertionIDRefs { get; set; } = []; + + /// + /// URI references to assertions + /// + public List AssertionURIRefs { get; set; } = []; + + /// + /// Embedded assertions + /// + public List Assertions { get; set; } = []; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/NameIdentifier.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/NameIdentifier.cs new file mode 100644 index 000000000..7f30395da --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/NameIdentifier.cs @@ -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; + +/// +/// Represents a SAML 2.0 NameID element +/// +internal record NameIdentifier +{ + /// + /// The name identifier value + /// + public required string Value { get; set; } + + /// + /// The format of the name identifier (URI) + /// + public string? Format { get; set; } + + /// + /// The NameQualifier attribute + /// + public string? NameQualifier { get; set; } + + /// + /// The SPNameQualifier attribute + /// + public string? SPNameQualifier { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/RequestId.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/RequestId.cs new file mode 100644 index 000000000..0629261bc --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/RequestId.cs @@ -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; + +/// +/// 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 +/// +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; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/ResponseId.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/ResponseId.cs new file mode 100644 index 000000000..c560e22d5 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/ResponseId.cs @@ -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; + +/// +/// 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 +/// +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; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlAuthenticationState.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlAuthenticationState.cs new file mode 100644 index 000000000..b9ab3e37d --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlAuthenticationState.cs @@ -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; + +/// +/// Represents the stored context for a SAML authentication flow. +/// +internal record SamlAuthenticationState +{ + /// + /// Gets or sets the original AuthnRequest. + /// Will be null for IdP-initiated SSO flows. + /// + public AuthNRequest? Request { get; set; } + + public required string ServiceProviderEntityId { get; init; } + + /// + /// Gets or sets the RelayState parameter from the original request. + /// For IdP-initiated SSO, this typically contains the target URL at the SP. + /// + public string? RelayState { get; set; } + + /// + /// 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. + /// + public bool IsIdpInitiated { get; set; } + + /// + /// Gets or sets the timestamp when this context was created. + /// + public DateTimeOffset CreatedUtc { get; set; } + + public required Uri AssertionConsumerServiceUrl { get; set; } + + /// + /// Gets or sets whether the RequestedAuthnContext in the request were met. + /// + public bool RequestedAuthnContextRequirementsWereMet { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlResponse.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlResponse.cs new file mode 100644 index 000000000..6c7050c50 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlResponse.cs @@ -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 +{ + /// + /// Gets or sets the unique identifier for this response. + /// + public ResponseId Id { get; } = ResponseId.New(); + + /// + /// Gets or sets the SAML version. Must be "2.0". + /// + public SamlVersion Version { get; } = SamlVersion.V2; + + /// + /// Gets or sets the time instant of issue in UTC. + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Gets or sets the URI of the destination endpoint where this response is sent. + /// This is the SP's Assertion Consumer Service (ACS) URL. + /// + public required Uri Destination { get; set; } + + /// + /// Gets or sets the entity identifier of the Identity Provider sending this response. + /// + public required string Issuer { get; set; } + + /// + /// Gets or sets the ID of the request to which this is a response. + /// Will be null for IdP-initiated SSO (unsolicited responses). + /// + public string? InResponseTo { get; set; } + + /// + /// Gets or sets the status of this response. + /// + public required Status Status { get; set; } + + /// + /// Gets or sets the assertion included in this response. + /// Will be null for error responses. + /// + public Assertion? Assertion { get; set; } + + /// + /// Gets or sets the relay state included in this response. + /// + public string? RelayState { get; set; } + + /// + /// Gets or sets the Service Provider where the response will be sent. + /// + public required SamlServiceProvider ServiceProvider { get; init; } + + internal class ResponseWriter( + ISamlResultSerializer serializer, + SamlResponseSigner samlResponseSigner, + SamlAssertionEncryptor samlAssertionEncryptor) : IHttpResponseWriter + { + 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 + { + 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; + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlSigninRequest.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlSigninRequest.cs new file mode 100644 index 000000000..96d62e3fd --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlSigninRequest.cs @@ -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; + +/// +/// Represents a saml signin request, either as a Redirect Binding or a Post Binding. +/// +internal record SamlSigninRequest : SamlRequestBase +{ + public static async ValueTask BindAsync(HttpContext context) + { + var extractor = context.RequestServices.GetRequiredService(); + return await extractor.ExtractAsync(context); + } + + public AuthNRequest AuthNRequest => Request; +} + +internal class SamlSigninRequestExtractor(AuthNRequestParser parser) + : SamlRequestExtractor +{ + 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 + }; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SessionId.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SessionId.cs new file mode 100644 index 000000000..8249c800f --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SessionId.cs @@ -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(); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/StateId.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/StateId.cs new file mode 100644 index 000000000..3b8e4480e --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/StateId.cs @@ -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(); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Status.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Status.cs new file mode 100644 index 000000000..ceb101888 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Status.cs @@ -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; + +/// +/// Represents the status of a SAML Response. +/// +internal record Status +{ + /// + /// Gets or sets the status code indicating the success or failure of the request. + /// + public required SamlStatusCode StatusCode { get; set; } + + /// + /// Gets or sets an optional human-readable message providing additional information about the status. + /// + public string? StatusMessage { get; set; } + + /// + /// Gets or sets an optional nested status code for more detailed error information. + /// + public string? NestedStatusCode { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Subject.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Subject.cs new file mode 100644 index 000000000..25681092a --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Subject.cs @@ -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; + +/// +/// Represents a SAML 2.0 Subject element +/// +internal record Subject +{ + /// + /// The name identifier of the subject + /// + public NameIdentifier? NameId { get; set; } + + /// + /// Subject confirmation data + /// + public ReadOnlyCollection SubjectConfirmations { get; init; } = new List().AsReadOnly(); +} + +/// +/// Represents a SAML 2.0 SubjectConfirmation element +/// +internal record SubjectConfirmation +{ + /// + /// The method used to confirm the subject (URI) + /// + public required string Method { get; set; } + + /// + /// Subject confirmation data + /// + public SubjectConfirmationData? Data { get; set; } +} + +/// +/// Represents SAML 2.0 SubjectConfirmationData element +/// +internal record SubjectConfirmationData +{ + /// + /// Time instant before which the subject cannot be confirmed + /// + public DateTime? NotBefore { get; set; } + + /// + /// Time instant at which the subject can no longer be confirmed + /// + public DateTime? NotOnOrAfter { get; set; } + + /// + /// URI of a recipient entity + /// + public Uri? Recipient { get; set; } + + /// + /// ID of a SAML request to which this is a response + /// + public string? InResponseTo { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedEndpoint.cs new file mode 100644 index 000000000..912ece558 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedEndpoint.cs @@ -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 logger) : IEndpointHandler +{ + public async Task 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 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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedRequestProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedRequestProcessor.cs new file mode 100644 index 000000000..0bc27c0c3 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedRequestProcessor.cs @@ -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 options) +{ + private readonly SamlOptions _samlOptions = options.Value; + + internal async Task>> ProcessAsync( + string spEntityId, + string? relayState, + CancellationToken ct) + { + var sp = await serviceProviderStore.FindByEntityIdAsync(spEntityId); + if (sp == null) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{spEntityId}' is not registered" + }; + } + + if (!sp.Enabled) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{spEntityId}' is disabled" + }; + } + + if (!sp.AllowIdpInitiated) + { + return new SamlRequestError + { + 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 + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"RelayState exceeds maximum length of {_samlOptions.MaxRelayStateLength} bytes" + }; + } + } + + var acsUrl = sp.AssertionConsumerServiceUrls.FirstOrDefault(); + if (acsUrl == null) + { + return new SamlRequestError + { + 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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlNameIdGenerator.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlNameIdGenerator.cs new file mode 100644 index 000000000..6f66b889b --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlNameIdGenerator.cs @@ -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, ILogger 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; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackEndpoint.cs new file mode 100644 index 000000000..e30ce1d64 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackEndpoint.cs @@ -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 logger) : IEndpointHandler +{ + public async Task 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 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}") + }; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackRequestProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackRequestProcessor.cs new file mode 100644 index 000000000..8e0d29ae1 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackRequestProcessor.cs @@ -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>> ProcessAsync(CancellationToken ct) + { + if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId)) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = "No state id could be found." + }; + } + + var authenticationState = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct); + if (authenticationState == null) + { + return new SamlRequestError + { + 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 + { + 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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninEndpoint.cs new file mode 100644 index 000000000..2ec9d728e --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninEndpoint.cs @@ -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 logger, + SamlResponseBuilder responseBuilder) : IEndpointHandler +{ + public async Task 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 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 error) + { + logger.SamlSigninValidationError(LogLevel.Information, error.ValidationMessage!); + return new ValidationProblemResult(error.ValidationMessage!); + } + + private SamlErrorResponse HandleProtocolError(SamlRequestError error) + { + var protocolError = error.ProtocolError!; + logger.SamlSigninProtocolError( + LogLevel.Information, + protocolError.Error.StatusCode, + protocolError.Error.Message); + + return responseBuilder.BuildErrorResponse( + protocolError.ServiceProvider, + protocolError.Request, + protocolError.Error); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninRequestProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninRequestProcessor.cs new file mode 100644 index 000000000..db09bc5bc --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninRequestProcessor.cs @@ -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 options, + IServerUrls serverUrls, + SamlSigninStateIdCookie stateIdCookie, + SamlRequestSignatureValidator signatureValidator, + SamlRequestValidator requestValidator, + ILogger logger) + : SamlRequestProcessorBase(serviceProviderStore, + options, + requestValidator, + signatureValidator, + logger, + serverUrls.GetAbsoluteUrl(options.Value.UserInteraction.Route + options.Value.UserInteraction.SignInPath)) +{ + protected override async Task>> 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 + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(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> GetAcsUrl(SamlServiceProvider serviceProvider, + AuthNRequest authNRequest) + { + if (authNRequest.AssertionConsumerServiceUrl != null) + { + if (!serviceProvider.AssertionConsumerServiceUrls.Contains(authNRequest.AssertionConsumerServiceUrl)) + { + return new SamlRequestError + { + 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 + { + 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 + { + 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? 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 + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(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); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninResults.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninResults.cs new file mode 100644 index 000000000..c246165d4 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninResults.cs @@ -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 +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs new file mode 100644 index 000000000..4d3757cf9 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs @@ -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 + }); +} diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/EndpointUsageDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/EndpointUsageDiagnosticEntry.cs index 9ccc5e428..065fecc63 100644 --- a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/EndpointUsageDiagnosticEntry.cs +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/EndpointUsageDiagnosticEntry.cs @@ -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; diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/RegisteredImplementationsDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/RegisteredImplementationsDiagnosticEntry.cs index e14422df9..6205aaa14 100644 --- a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/RegisteredImplementationsDiagnosticEntry.cs +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/RegisteredImplementationsDiagnosticEntry.cs @@ -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), []), diff --git a/identity-server/src/IdentityServer/Models/Contexts/LogoutNotificationContext.cs b/identity-server/src/IdentityServer/Models/Contexts/LogoutNotificationContext.cs index cd733328e..d2cfacee4 100644 --- a/identity-server/src/IdentityServer/Models/Contexts/LogoutNotificationContext.cs +++ b/identity-server/src/IdentityServer/Models/Contexts/LogoutNotificationContext.cs @@ -4,6 +4,8 @@ #nullable enable +using Duende.IdentityServer.Saml.Models; + namespace Duende.IdentityServer.Models; /// @@ -31,6 +33,13 @@ public class LogoutNotificationContext /// public IEnumerable ClientIds { get; set; } = default!; + /// + /// The SAML Service Provider sessions that the user has authenticated to. + /// Contains full session data including NameId, SessionIndex, and NameIdFormat + /// required to construct logout requests. + /// + public IEnumerable SamlSessions { get; set; } = []; + /// /// Indicates why the user's session ended, if known. /// diff --git a/identity-server/src/IdentityServer/Models/Messages/LogoutRequest.cs b/identity-server/src/IdentityServer/Models/Messages/LogoutRequest.cs index 119cd8a32..b65e06b32 100644 --- a/identity-server/src/IdentityServer/Models/Messages/LogoutRequest.cs +++ b/identity-server/src/IdentityServer/Models/Messages/LogoutRequest.cs @@ -7,6 +7,7 @@ using System.Collections.Specialized; using Duende.IdentityModel; using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Validation; namespace Duende.IdentityServer.Models; @@ -47,6 +48,7 @@ public class LogoutMessage SubjectId = request.Subject?.GetSubjectId(); SessionId = request.SessionId; ClientIds = request.ClientIds; + SamlSessions = request.SamlSessions; UiLocales = request.UiLocales; if (request.PostLogOutUri != null) @@ -90,6 +92,30 @@ public class LogoutMessage /// public IEnumerable? ClientIds { get; set; } + /// + /// Gets or sets the EntityId of the SAML Service Provider that initiated logout. + /// Null if this is not a SAML-initiated logout. + /// + public string? SamlServiceProviderEntityId { get; set; } + + /// + /// Gets or sets the ID of the SAML LogoutRequest being responded to. + /// Null if this is not a SAML-initiated logout. + /// + public string? SamlLogoutRequestId { get; set; } + + /// + /// Gets or sets the SAML RelayState parameter to return to the SP. + /// Null if this is not a SAML-initiated logout or no RelayState was provided. + /// + public string? SamlRelayState { get; set; } + + /// + /// SAML Service Provider sessions for the user at logout time. + /// Contains full session data required for logout notifications. + /// + public IEnumerable? SamlSessions { get; set; } + /// /// The UI locales. /// @@ -103,7 +129,10 @@ public class LogoutMessage /// /// Flag to indicate if the payload contains useful information or not to avoid serialization. /// - internal bool ContainsPayload => ClientId.IsPresent() || ClientIds?.Any() == true; + internal bool ContainsPayload => ClientId.IsPresent() + || ClientIds?.Any() == true + || SamlServiceProviderEntityId.IsPresent() + || SamlSessions?.Any() == true; } /// @@ -127,6 +156,10 @@ public class LogoutRequest SessionId = message.SessionId; ClientIds = message.ClientIds; UiLocales = message.UiLocales; + SamlServiceProviderEntityId = message.SamlServiceProviderEntityId; + SamlLogoutRequestId = message.SamlLogoutRequestId; + SamlRelayState = message.SamlRelayState; + SamlSessions = message.SamlSessions; Parameters = message.Parameters.FromFullDictionary(); } @@ -163,6 +196,30 @@ public class LogoutRequest /// public IEnumerable? ClientIds { get; set; } + /// + /// Gets or sets the EntityId of the SAML Service Provider that initiated logout. + /// Null if this is not a SAML-initiated logout. + /// + public string? SamlServiceProviderEntityId { get; set; } + + /// + /// Gets or sets the ID of the SAML LogoutRequest being responded to. + /// Null if this is not a SAML-initiated logout. + /// + public string? SamlLogoutRequestId { get; set; } + + /// + /// Gets or sets the SAML RelayState parameter to return to the SP. + /// Null if this is not a SAML-initiated logout or no RelayState was provided. + /// + public string? SamlRelayState { get; set; } + + /// + /// SAML Service Provider sessions for the user at logout time. + /// Contains full session data required for logout notifications. + /// + public IEnumerable? SamlSessions { get; set; } + /// /// The UI locales. /// diff --git a/identity-server/src/IdentityServer/Saml/ISamlClaimsMapper.cs b/identity-server/src/IdentityServer/Saml/ISamlClaimsMapper.cs new file mode 100644 index 000000000..a6c4749ee --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlClaimsMapper.cs @@ -0,0 +1,23 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Saml; + +/// +/// Service for customizing how claims are mapped to SAML attributes. +/// If registered, this service completely replaces the default mapping logic. +/// +public interface ISamlClaimsMapper +{ + /// + /// Maps claims to SAML attributes. + /// + /// This method is called when a custom mapper is registered and completely + /// replaces the default mapping behavior (global + service provider mappings). + /// + /// Context with information about the authentication request for which claims need to be mapped + /// The mapped SAML attributes + Task> MapClaimsAsync(SamlClaimsMappingContext claimsMappingContext); +} diff --git a/identity-server/src/IdentityServer/Saml/ISamlFrontChannelLogout.cs b/identity-server/src/IdentityServer/Saml/ISamlFrontChannelLogout.cs new file mode 100644 index 000000000..603b1dca6 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlFrontChannelLogout.cs @@ -0,0 +1,18 @@ +// 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.Saml; + +public interface ISamlFrontChannelLogout +{ + SamlBinding SamlBinding { get; } + + Uri Destination { get; } + + string EncodedContent { get; } + + string? RelayState { get; } +} diff --git a/identity-server/src/IdentityServer/Saml/ISamlInteractionService.cs b/identity-server/src/IdentityServer/Saml/ISamlInteractionService.cs new file mode 100644 index 000000000..83a0f4df9 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlInteractionService.cs @@ -0,0 +1,30 @@ +// 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.Saml; + +/// +/// Provide services to be used by the user interface to communicate with IdentityServer for SAML flows. +/// +public interface ISamlInteractionService +{ + /// + /// Gets the SAML authentication request context from the current request's state cookie. + /// Returns null if no SAML authentication is in progress. + /// + Task GetAuthenticationRequestContextAsync(CancellationToken ct); + + /// + /// Stores whether the user met the requirements of the RequestedAuthnContext in the + /// AuthNRequest. If the value is set to false, the generated response will include a second-level + /// status code of urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext per section 3.3.2.2.1 of the + /// core spec. + /// + /// Whether the requirements of the RequestedAuthnContext were met. + /// Cancellation token + /// + Task StoreRequestedAuthnContextResultAsync(bool requestedAuthnContextRequirementsWereMet, CancellationToken ct); +} diff --git a/identity-server/src/IdentityServer/Saml/ISamlLogoutNotificationService.cs b/identity-server/src/IdentityServer/Saml/ISamlLogoutNotificationService.cs new file mode 100644 index 000000000..236ca8551 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlLogoutNotificationService.cs @@ -0,0 +1,11 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Saml; + +public interface ISamlLogoutNotificationService +{ + Task> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context); +} diff --git a/identity-server/src/IdentityServer/Saml/ISamlSigninInteractionResponseGenerator.cs b/identity-server/src/IdentityServer/Saml/ISamlSigninInteractionResponseGenerator.cs new file mode 100644 index 000000000..2c1740b5e --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlSigninInteractionResponseGenerator.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Saml; + +public interface ISamlSigninInteractionResponseGenerator +{ + Task ProcessInteractionAsync(SamlServiceProvider sp, AuthNRequest request, CancellationToken ct); +} diff --git a/identity-server/src/IdentityServer/Saml/Models/AuthNRequest.cs b/identity-server/src/IdentityServer/Saml/Models/AuthNRequest.cs new file mode 100644 index 000000000..f30d1ab9d --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/AuthNRequest.cs @@ -0,0 +1,116 @@ +// 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.Models; + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents a SAML 2.0 AuthnRequest message sent by a Service Provider to request authentication. +/// +public record AuthNRequest : ISamlRequest +{ + public static string MessageName => "SAML signin request"; + + /// + /// Gets or sets the unique identifier for this request. + /// Must be unique across all requests from this SP. + /// + public required string Id { get; set; } + + /// + /// Gets or sets the SAML version. Must be "2.0". + /// + public required SamlVersion Version { get; set; } + + /// + /// Gets or sets the time instant of issue in UTC. + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Gets or sets the URI reference indicating the destination to which this request is directed. + /// Should match the IdP's SSO endpoint URL. + /// + public Uri? Destination { get; set; } + + /// + /// Gets or sets the consent obtained from the principal for sending this request. + /// + public string? Consent { get; set; } + + /// + /// Gets or sets the entity identifier of the Service Provider making this request. + /// This is the SP's entity ID from its metadata. + /// + public required string Issuer { get; set; } + + /// + /// Gets or sets a value indicating whether the IdP must freshly obtain the authentication (not from cache). + /// If true, the IdP must reauthenticate the user even if a session exists. + /// Default: false + /// + public bool ForceAuthn { get; set; } + + /// + /// Gets or sets a value indicating whether the IdP should not actively interact with the user. + /// If true, the IdP should not show UI to the user (authentication must be passive). + /// Default: false + /// + public bool IsPassive { get; set; } + + /// + /// Gets or sets the URL of the ACS endpoint where the response should be sent (optional). + /// If specified, overrides the default ACS URL from SP metadata. + /// + public Uri? AssertionConsumerServiceUrl { get; set; } + + /// + /// Gets or sets the index of the ACS endpoint where the response should be sent (optional). + /// References an indexed ACS endpoint in the SP's metadata. + /// + public int? AssertionConsumerServiceIndex { get; set; } + + /// + /// Gets or sets the SAML protocol binding to use for the response (optional). + /// Example: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + /// + public SamlBinding? ProtocolBinding { get; set; } + + /// + /// Gets or sets the requested authentication context constraints. + /// Specifies requirements/preferences for the authentication context the IdP should use. + /// Optional - if null, no specific context is required. + /// + public RequestedAuthnContext? RequestedAuthnContext { get; set; } + + /// + /// Gets or sets the requested NameID policy constraints from the SP. + /// Specifies the format and characteristics of the name identifier to return. + /// Optional - if null, no specific policy is requested. + /// + public NameIdPolicy? NameIdPolicy { get; set; } + + internal static class AttributeNames + { + public const string Id = "ID"; + public const string Version = "Version"; + public const string IssueInstant = "IssueInstant"; + public const string Destination = "Destination"; + public const string Consent = "Consent"; + public const string Issuer = "Issuer"; + public const string ForceAuthn = "ForceAuthn"; + public const string IsPassive = "IsPassive"; + public const string AssertionConsumerServiceUrl = "AssertionConsumerServiceURL"; + public const string AssertionConsumerServiceIndex = "AssertionConsumerServiceIndex"; + public const string ProtocolBinding = "ProtocolBinding"; + } + + internal static class ElementNames + { + public const string RequestedAuthnContext = "RequestedAuthnContext"; + public const string NameIdPolicy = "NameIDPolicy"; + } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/AuthnContextComparison.cs b/identity-server/src/IdentityServer/Saml/Models/AuthnContextComparison.cs new file mode 100644 index 000000000..88c7c1a2d --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/AuthnContextComparison.cs @@ -0,0 +1,66 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Specifies the comparison method to apply to requested authentication contexts. +/// SAML 2.0 Core Section 3.3.2.2.1 +/// +public enum AuthnContextComparison +{ + /// + /// The authentication context must match exactly one of the requested contexts. + /// + Exact, + + /// + /// The authentication context must be at least as strong as one of the requested contexts. + /// + Minimum, + + /// + /// The authentication context must be no stronger than one of the requested contexts. + /// + Maximum, + + /// + /// The authentication context must be stronger than all requested contexts. + /// + Better +} + +/// +/// Extension methods for AuthnContextComparison enum +/// +public static class AuthnContextComparisonExtensions +{ + /// + /// Parses a string value into an AuthnContextComparison enum. + /// Defaults to Exact if value is null, empty, or invalid. + /// + public static AuthnContextComparison Parse(string? value) => + value?.ToUpperInvariant() switch + { + "EXACT" => AuthnContextComparison.Exact, + "MINIMUM" => AuthnContextComparison.Minimum, + "MAXIMUM" => AuthnContextComparison.Maximum, + "BETTER" => AuthnContextComparison.Better, + null => AuthnContextComparison.Exact, // Default per SAML spec + _ => throw new ArgumentException($"Unknown {nameof(AuthnContextComparison)}: {value}") + }; + + /// + /// Converts an AuthnContextComparison enum to its XML attribute value. + /// + public static string ToAttributeValue(this AuthnContextComparison comparison) => + comparison switch + { + AuthnContextComparison.Exact => "exact", + AuthnContextComparison.Minimum => "minimum", + AuthnContextComparison.Maximum => "maximum", + AuthnContextComparison.Better => "better", + _ => "exact" + }; +} diff --git a/identity-server/src/IdentityServer/Saml/Models/EndpointType.cs b/identity-server/src/IdentityServer/Saml/Models/EndpointType.cs new file mode 100644 index 000000000..f9c65b9de --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/EndpointType.cs @@ -0,0 +1,13 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Saml.Models; + +public record EndpointType +{ + public required Uri Location { get; init; } + + public required SamlBinding Binding { get; init; } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/IdpInitiatedResult.cs b/identity-server/src/IdentityServer/Saml/Models/IdpInitiatedResult.cs new file mode 100644 index 000000000..3d06fa5e3 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/IdpInitiatedResult.cs @@ -0,0 +1,40 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Result of initiating an IdP-initiated SAML SSO flow. +/// +public class IdpInitiatedResult +{ + /// + /// Gets whether the initiation was successful. + /// + public bool Success { get; init; } + + /// + /// Gets the URL to redirect to (login or callback endpoint). + /// Only set when Success is true. + /// + public Uri? RedirectUrl { get; init; } + + /// + /// Gets the validation error message. + /// Only set when Success is false. + /// + public string? ErrorMessage { get; init; } + + /// + /// Creates a successful result with a redirect URL. + /// + public static IdpInitiatedResult Succeed(Uri redirectUrl) => + new() { Success = true, RedirectUrl = redirectUrl }; + + /// + /// Creates a failed result with an error message. + /// + public static IdpInitiatedResult Fail(string errorMessage) => + new() { Success = false, ErrorMessage = errorMessage }; +} diff --git a/identity-server/src/IdentityServer/Saml/Models/NameIdPolicy.cs b/identity-server/src/IdentityServer/Saml/Models/NameIdPolicy.cs new file mode 100644 index 000000000..cfba2ac97 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/NameIdPolicy.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents the NameIDPolicy element from a SAML AuthnRequest. +/// Specifies constraints on the name identifier to be returned. +/// SAML 2.0 Core Section 3.4.1.1 +/// +public record NameIdPolicy +{ + /// + /// Gets the requested name identifier format. + /// Example: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + /// If null, no specific format is requested. + /// + public string? Format { get; init; } + + /// + /// Gets the SPNameQualifier to use in the returned NameID. + /// Typically the SP's entity ID, but SP can request a different value. + /// If null, IdP should use SP's entity ID. + /// + public string? SPNameQualifier { get; init; } + + internal static class AttributeNames + { + public const string Format = "Format"; + public const string SPNameQualifier = "SPNameQualifier"; + } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/RequestedAuthnContext.cs b/identity-server/src/IdentityServer/Saml/Models/RequestedAuthnContext.cs new file mode 100644 index 000000000..ff2fe5bd9 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/RequestedAuthnContext.cs @@ -0,0 +1,34 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents the RequestedAuthnContext element from a SAML AuthnRequest. +/// Specifies requirements or preferences for the authentication context the IdP should use. +/// SAML 2.0 Core Section 3.3.2.2.1 +/// +public record RequestedAuthnContext +{ + /// + /// Gets the authentication context class references requested by the SP. + /// URIs identifying authentication context classes (e.g., urn:oasis:names:tc:SAML:2.0:ac:classes:Password). + /// + public required IReadOnlyCollection AuthnContextClassRefs { get; init; } + + /// + /// Gets the comparison method to apply to the requested contexts. + /// Default: Exact + /// + public AuthnContextComparison Comparison { get; init; } = AuthnContextComparison.Exact; + + internal static class ElementNames + { + public const string AuthnContextClassRef = "AuthnContextClassRef"; + } + + internal static class AttributeNames + { + public const string Comparison = "Comparison"; + } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlAttribute.cs b/identity-server/src/IdentityServer/Saml/Models/SamlAttribute.cs new file mode 100644 index 000000000..65663d737 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlAttribute.cs @@ -0,0 +1,36 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents a SAML 2.0 Attribute element +/// +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix - SamlAttribute is the standard SAML term +public record SamlAttribute +#pragma warning restore CA1711 +{ + /// + /// Attribute name (e.g., "urn:oid:0.9.2342.19200300.100.1.1" or "email") + /// + public required string Name { get; set; } + + /// + /// Attribute name format URI (e.g., "urn:oasis:names:tc:SAML:2.0:attrname-format:uri") + /// + public string? NameFormat { get; set; } + + /// + /// Human-readable friendly name for the attribute (e.g., "uid", "email"). + /// Optional but recommended for debugging and some SP compatibility. + /// + public string? FriendlyName { get; set; } + + /// + /// Attribute values (can be multi-valued) + /// +#pragma warning disable CA2227, CA1002 // Collection properties should be read only and use Collection - List is by design for mutability and performance + public List Values { get; set; } = []; +#pragma warning restore CA2227, CA1002 +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlAuthenticationRequest.cs b/identity-server/src/IdentityServer/Saml/Models/SamlAuthenticationRequest.cs new file mode 100644 index 000000000..01b8c6354 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlAuthenticationRequest.cs @@ -0,0 +1,42 @@ +// 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.Saml.Models; + +/// +/// Represents contextual information about a SAML authentication request. +/// +public class SamlAuthenticationRequest +{ + /// + /// Gets or sets the Service Provider making the authentication request. + /// + public required SamlServiceProvider ServiceProvider { get; set; } + + /// + /// Gets or sets the original SAML AuthnRequest. + /// Will be null for IdP-initiated SSO flows. + /// + public AuthNRequest? AuthNRequest { get; set; } + + /// + /// Gets the requested authentication context from the AuthNRequest. + /// This is a convenience property that accesses AuthNRequest.RequestedAuthnContext. + /// + public RequestedAuthnContext? RequestedAuthnContext => AuthNRequest?.RequestedAuthnContext; + + /// + /// Gets or sets the RelayState parameter to be echoed back to the Service Provider. + /// For IdP-initiated SSO, this typically contains the target URL at the SP. + /// + public string? RelayState { get; set; } + + /// + /// 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. + /// + public bool IsIdpInitiated { get; set; } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlClaimsMappingContext.cs b/identity-server/src/IdentityServer/Saml/Models/SamlClaimsMappingContext.cs new file mode 100644 index 000000000..7f6674216 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlClaimsMappingContext.cs @@ -0,0 +1,21 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Saml.Models; + +public record SamlClaimsMappingContext +{ + /// + /// The claims issued for the current user to be mapped to SAML Attributes + /// for inclusion in the SAMLResponse. + /// + public IEnumerable UserClaims { get; init; } = []; + + /// + /// The Service Provider which initiated the Authn request. + /// + public required SamlServiceProvider ServiceProvider { get; init; } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlError.cs b/identity-server/src/IdentityServer/Saml/Models/SamlError.cs new file mode 100644 index 000000000..5c04c52bb --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlError.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +public record SamlError +{ + public required string StatusCode { get; init; } + public string? SubStatusCode { get; init; } + public required string Message { get; init; } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponse.cs b/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponse.cs new file mode 100644 index 000000000..bc12d73de --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponse.cs @@ -0,0 +1,40 @@ +// 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.Saml.Models; + +public record SamlInteractionResponse +{ + [MemberNotNullWhen(true, nameof(Error))] + public bool IsError => ResultType == SamlInteractionResponseType.Error; + + public SamlInteractionResponseType ResultType { get; init; } + + public SamlError? Error { get; init; } + + public static SamlInteractionResponse CreateError(string statusCode, string errorDescription) => new SamlInteractionResponse() + { + ResultType = SamlInteractionResponseType.Error, + Error = new SamlError + { + StatusCode = statusCode, + Message = errorDescription + } + }; + + public static SamlInteractionResponse Create(SamlInteractionResponseType type) + { + if (type == SamlInteractionResponseType.Error) + { + throw new InvalidOperationException("Cannot create error interaction response without error details"); + } + + return new SamlInteractionResponse() + { + ResultType = type + }; + } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponseType.cs b/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponseType.cs new file mode 100644 index 000000000..c2e1588e2 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponseType.cs @@ -0,0 +1,13 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Saml.Models; + +public enum SamlInteractionResponseType +{ + Login, + AlreadyAuthenticated, + CreateAccount, + Consent, + Error +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlSpSessionData.cs b/identity-server/src/IdentityServer/Saml/Models/SamlSpSessionData.cs new file mode 100644 index 000000000..831b86501 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlSpSessionData.cs @@ -0,0 +1,64 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents SAML SP session data stored in the user's authentication session. +/// +/// +/// +/// IMPORTANT: For production deployments with multiple SAML service providers, +/// server-side sessions SHOULD be enabled to avoid cookie size limitations. +/// Configure with: builder.AddServerSideSessions() +/// +/// +/// Without server-side sessions, session data is stored in the authentication cookie. +/// Practical limits are approximately: +/// - 5-8 SAML SPs with 5 OIDC clients +/// - 3-5 SAML SPs with 10+ OIDC clients +/// Browser cookie size limit is ~4KB; exceeding this causes cookie chunking and performance degradation. +/// +/// +/// With server-side sessions enabled, there is no practical limit on the number of SAML sessions. +/// +/// +public class SamlSpSessionData +{ + /// + /// Gets or sets the SAML Service Provider's EntityId. + /// + public string EntityId { get; set; } = default!; + + /// + /// Gets or sets the SAML SessionIndex value for this SP session. + /// This value is unique per SP and is included in the SAML AuthnStatement. + /// + public string SessionIndex { get; set; } = default!; + + /// + /// Gets or sets the NameID value sent to the SP. + /// + public string NameId { get; set; } = default!; + + /// + /// Gets or sets the NameID Format used for this SP. + /// + public string? NameIdFormat { get; set; } + + /// + /// Determines whether the specified object is equal to the current object. + /// Two SamlSpSessionData instances are considered equal if they have the same EntityId and SessionIndex, + /// as these uniquely identify a SAML session at a specific Service Provider. + /// + public override bool Equals(object? obj) => obj is SamlSpSessionData other && + EntityId == other.EntityId && + SessionIndex == other.SessionIndex; + + /// + /// Returns a hash code for this instance based on EntityId and SessionIndex. + /// + public override int GetHashCode() => HashCode.Combine(EntityId, SessionIndex); +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlStatusCode.cs b/identity-server/src/IdentityServer/Saml/Models/SamlStatusCode.cs new file mode 100644 index 000000000..41d7e9493 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlStatusCode.cs @@ -0,0 +1,31 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents a SAML 2.0 status code as defined in the SAML 2.0 Core specification. +/// +public readonly record struct SamlStatusCode(string Value) +{ + public static readonly SamlStatusCode Success = new("urn:oasis:names:tc:SAML:2.0:status:Success"); + public static readonly SamlStatusCode Requester = new("urn:oasis:names:tc:SAML:2.0:status:Requester"); + public static readonly SamlStatusCode Responder = new("urn:oasis:names:tc:SAML:2.0:status:Responder"); + public static readonly SamlStatusCode VersionMismatch = new("urn:oasis:names:tc:SAML:2.0:status:VersionMismatch"); + public static readonly SamlStatusCode NoAuthnContext = new("urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext"); + public static readonly SamlStatusCode AuthnFailed = new("urn:oasis:names:tc:SAML:2.0:status:AuthnFailed"); + public static readonly SamlStatusCode InvalidNameIdPolicy = new("urn:oasis:names:tc:SAML:2.0:status:InvalidNameIDPolicy"); + public static readonly SamlStatusCode RequestDenied = new("urn:oasis:names:tc:SAML:2.0:status:RequestDenied"); + public static readonly SamlStatusCode UnknownPrincipal = new("urn:oasis:names:tc:SAML:2.0:status:UnknownPrincipal"); + public static readonly SamlStatusCode UnsupportedBinding = new("urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding"); + public static readonly SamlStatusCode NoPassive = new("urn:oasis:names:tc:SAML:2.0:status:NoPassive"); + + /// + public override string ToString() => Value; + + public static implicit operator string(SamlStatusCode statusCode) => statusCode.Value; + + public static implicit operator SamlStatusCode(string value) => new(value); + + public SamlStatusCode ToSamlStatusCode() => Value; +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlVersion.cs b/identity-server/src/IdentityServer/Saml/Models/SamlVersion.cs new file mode 100644 index 000000000..0aaf448f9 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlVersion.cs @@ -0,0 +1,19 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents a SAML version string. +/// +public readonly record struct SamlVersion(string Value) +{ + public static readonly SamlVersion V2 = new("2.0"); + + /// + public override string ToString() => Value; + + public static implicit operator SamlVersion(string value) => new(value); + + public SamlVersion ToSamlVersion() => Value; +} diff --git a/identity-server/src/IdentityServer/Services/Default/DefaultIdentityServerInteractionService.cs b/identity-server/src/IdentityServer/Services/Default/DefaultIdentityServerInteractionService.cs index ccea3bdc2..ded94b020 100644 --- a/identity-server/src/IdentityServer/Services/Default/DefaultIdentityServerInteractionService.cs +++ b/identity-server/src/IdentityServer/Services/Default/DefaultIdentityServerInteractionService.cs @@ -82,14 +82,16 @@ internal class DefaultIdentityServerInteractionService : IIdentityServerInteract if (user != null) { var clientIds = await _userSession.GetClientListAsync(ct); - if (clientIds.Any()) + var samlSessions = await _userSession.GetSamlSessionListAsync(); + if (clientIds.Any() || samlSessions.Any()) { var sid = await _userSession.GetSessionIdAsync(ct); var msg = new Message(new LogoutMessage { SubjectId = user.GetSubjectId(), SessionId = sid, - ClientIds = clientIds + ClientIds = clientIds, + SamlSessions = samlSessions }, _timeProvider.GetUtcNow().UtcDateTime); var id = await _logoutMessageStore.WriteAsync(msg, ct); return id; diff --git a/identity-server/src/IdentityServer/Services/Default/DefaultUserSession.cs b/identity-server/src/IdentityServer/Services/Default/DefaultUserSession.cs index e57e1b81a..48cd6ac79 100644 --- a/identity-server/src/IdentityServer/Services/Default/DefaultUserSession.cs +++ b/identity-server/src/IdentityServer/Services/Default/DefaultUserSession.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using Duende.IdentityModel; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Saml.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -223,7 +224,7 @@ public class DefaultUserSession : IUserSession /// /// Ensures the session identifier cookie is synchronized with the current /// session identifier. If there is no sid, the cookie is removed. If there - /// is a sid, and the session identifier cookie is missing, it is issued. + /// is a sid, and the session identifier cookie is missing, it is issued. /// /// The cancellation token. /// @@ -361,4 +362,50 @@ public class DefaultUserSession : IUserSession var scheme = await HttpContext.GetCookieAuthenticationSchemeAsync(); await HttpContext.SignInAsync(scheme, Principal, Properties); } + + /// + public virtual async Task AddSamlSessionAsync(SamlSpSessionData session) + { + ArgumentNullException.ThrowIfNull(session); + + await AuthenticateAsync(); + if (Properties != null) + { + Properties.AddSamlSession(session); + await UpdateSessionCookie(); + } + } + + /// + public virtual async Task> GetSamlSessionListAsync() + { + await AuthenticateAsync(); + + if (Properties != null) + { + try + { + return Properties.GetSamlSessionList(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting SAML session list"); + } + } + + return Array.Empty(); + } + + /// + public virtual async Task RemoveSamlSessionAsync(string entityId) + { + ArgumentNullException.ThrowIfNull(entityId); + + await AuthenticateAsync(); + if (Properties != null) + { + Properties.RemoveSamlSession(entityId); + await UpdateSessionCookie(); + } + } } diff --git a/identity-server/src/IdentityServer/Services/IUserSession.cs b/identity-server/src/IdentityServer/Services/IUserSession.cs index ad88e4877..e201f0b23 100644 --- a/identity-server/src/IdentityServer/Services/IUserSession.cs +++ b/identity-server/src/IdentityServer/Services/IUserSession.cs @@ -5,6 +5,7 @@ #nullable enable using System.Security.Claims; +using Duende.IdentityServer.Saml.Models; using Microsoft.AspNetCore.Authentication; namespace Duende.IdentityServer.Services; @@ -62,4 +63,26 @@ public interface IUserSession /// The cancellation token. /// Task> GetClientListAsync(Ct ct); + + /// + /// Adds a SAML SP session to the user's session. + /// + /// The SAML session data. + /// + /// Session data is stored in AuthenticationProperties. For deployments with many SAML service providers, + /// server-side sessions should be enabled to avoid cookie size limitations. + /// See for details. + /// + Task AddSamlSessionAsync(SamlSpSessionData session); + + /// + /// Gets the list of SAML SP sessions for the user's session. + /// + Task> GetSamlSessionListAsync(); + + /// + /// Removes a SAML SP session by EntityId. + /// + /// The SP's entity ID. + Task RemoveSamlSessionAsync(string entityId); } diff --git a/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs b/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs new file mode 100644 index 000000000..8803ee3a2 --- /dev/null +++ b/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs @@ -0,0 +1,46 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Stores; + +/// +/// In-memory SAML Service Provider store. +/// +public class InMemorySamlServiceProviderStore : ISamlServiceProviderStore +{ + private readonly IEnumerable _serviceProviders; + + /// + /// Initializes a new instance of the class. + /// + /// The service providers. + public InMemorySamlServiceProviderStore(IEnumerable serviceProviders) + { + if (serviceProviders.HasDuplicates(m => m.EntityId)) + { + throw new ArgumentException("Service providers must not contain duplicate entity IDs"); + } + _serviceProviders = serviceProviders; + } + + /// + /// Finds a SAML Service Provider by its entity identifier. + /// + /// The entity identifier of the Service Provider. + /// The Service Provider, or null if not found. + public Task FindByEntityIdAsync(string entityId) + { + using var activity = Tracing.StoreActivitySource.StartActivity("InMemorySamlServiceProviderStore.FindByEntityId"); + activity?.SetTag(Tracing.Properties.SamlEntityId, entityId); + + var query = + from sp in _serviceProviders + where sp.EntityId == entityId + select sp; + + return Task.FromResult(query.SingleOrDefault()); + } +} diff --git a/identity-server/src/IdentityServer/Validation/Default/EndSessionRequestValidator.cs b/identity-server/src/IdentityServer/Validation/Default/EndSessionRequestValidator.cs index 6b8e10e06..b21e13389 100644 --- a/identity-server/src/IdentityServer/Validation/Default/EndSessionRequestValidator.cs +++ b/identity-server/src/IdentityServer/Validation/Default/EndSessionRequestValidator.cs @@ -9,6 +9,7 @@ using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Logging.Models; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; using Duende.IdentityServer.Services; using Duende.IdentityServer.Stores; using Microsoft.Extensions.Logging; @@ -50,6 +51,11 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator /// public ILogoutNotificationService LogoutNotificationService { get; } + /// + /// The SAML logout notification service. + /// + protected ISamlLogoutNotificationService SamlLogoutNotificationService { get; } + /// /// The end session message store. /// @@ -63,6 +69,7 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator /// /// /// + /// /// /// public EndSessionRequestValidator( @@ -71,6 +78,7 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator IRedirectUriValidator uriValidator, IUserSession userSession, ILogoutNotificationService logoutNotificationService, + ISamlLogoutNotificationService samlLogoutNotificationService, IMessageStore endSessionMessageStore, ILogger logger) { @@ -79,6 +87,7 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator UriValidator = uriValidator; UserSession = userSession; LogoutNotificationService = logoutNotificationService; + SamlLogoutNotificationService = samlLogoutNotificationService; EndSessionMessageStore = endSessionMessageStore; Logger = logger; } @@ -140,6 +149,9 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator validatedRequest.Subject = subject; validatedRequest.SessionId = await UserSession.GetSessionIdAsync(ct); validatedRequest.ClientIds = await UserSession.GetClientListAsync(ct); + + var samlSessions = await UserSession.GetSamlSessionListAsync(); + validatedRequest.SamlSessions = samlSessions; } var redirectUri = parameters.Get(OidcConstants.EndSessionRequest.PostLogoutRedirectUri); @@ -170,6 +182,9 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator validatedRequest.Subject = subject; validatedRequest.SessionId = await UserSession.GetSessionIdAsync(ct); validatedRequest.ClientIds = await UserSession.GetClientListAsync(ct); + + var samlSessions = await UserSession.GetSamlSessionListAsync(); + validatedRequest.SamlSessions = samlSessions; } LogSuccess(validatedRequest); @@ -231,10 +246,13 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator var endSessionId = parameters[Constants.UIConstants.DefaultRoutePathParams.EndSessionCallback]; var endSessionMessage = await EndSessionMessageStore.ReadAsync(endSessionId, ct); - if (endSessionMessage?.Data?.ClientIds?.Any() == true) + if (endSessionMessage?.Data?.ClientIds?.Any() == true || endSessionMessage?.Data?.SamlSessions?.Any() == true) { result.IsError = false; result.FrontChannelLogoutUrls = await LogoutNotificationService.GetFrontChannelLogoutNotificationsUrlsAsync(endSessionMessage.Data, ct); + + var samlFrontChannelLogouts = await SamlLogoutNotificationService.GetSamlFrontChannelLogoutsAsync(endSessionMessage.Data); + result.SamlFrontChannelLogouts = samlFrontChannelLogouts; } else { diff --git a/identity-server/src/IdentityServer/Validation/Models/EndSessionCallbackValidationResult.cs b/identity-server/src/IdentityServer/Validation/Models/EndSessionCallbackValidationResult.cs index 4c72b6ca6..fad61ed62 100644 --- a/identity-server/src/IdentityServer/Validation/Models/EndSessionCallbackValidationResult.cs +++ b/identity-server/src/IdentityServer/Validation/Models/EndSessionCallbackValidationResult.cs @@ -4,6 +4,8 @@ #nullable enable +using Duende.IdentityServer.Saml; + namespace Duende.IdentityServer.Validation; /// @@ -16,4 +18,9 @@ public class EndSessionCallbackValidationResult : ValidationResult /// Gets the client front-channel logout urls. /// public IEnumerable? FrontChannelLogoutUrls { get; set; } + + /// + /// Gets or sets the SAML front-channel logout requests. + /// + public IEnumerable SamlFrontChannelLogouts { get; set; } = []; } diff --git a/identity-server/src/IdentityServer/Validation/Models/ValidatedEndSessionRequest.cs b/identity-server/src/IdentityServer/Validation/Models/ValidatedEndSessionRequest.cs index 99e7ab0d0..9bfa8521a 100644 --- a/identity-server/src/IdentityServer/Validation/Models/ValidatedEndSessionRequest.cs +++ b/identity-server/src/IdentityServer/Validation/Models/ValidatedEndSessionRequest.cs @@ -2,6 +2,8 @@ // See LICENSE in the project root for license information. +using Duende.IdentityServer.Saml.Models; + namespace Duende.IdentityServer.Validation; /// @@ -45,4 +47,10 @@ public class ValidatedEndSessionRequest : ValidatedRequest /// Ids of clients known to have an authentication session for user at end session time /// public IEnumerable ClientIds { get; set; } + + /// + /// SAML Service Provider sessions for the user at end session time. + /// Contains full session data including EntityIds, NameIds, and SessionIndexes required for logout notifications. + /// + public IEnumerable SamlSessions { get; set; } = []; } diff --git a/identity-server/src/Shared/Telemetry/Tracing.cs b/identity-server/src/Shared/Telemetry/Tracing.cs index 41e9ca6ae..eb9c295b3 100644 --- a/identity-server/src/Shared/Telemetry/Tracing.cs +++ b/identity-server/src/Shared/Telemetry/Tracing.cs @@ -109,5 +109,6 @@ public static class Tracing public const string ScopeNames = "scope_names"; public const string ApiResourceNames = "api_resource_names"; + public const string SamlEntityId = "saml_entity_id"; } } diff --git a/identity-server/src/Storage/Models/SamlBinding.cs b/identity-server/src/Storage/Models/SamlBinding.cs new file mode 100644 index 000000000..4492d28b7 --- /dev/null +++ b/identity-server/src/Storage/Models/SamlBinding.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Models; + +/// +/// Represents the available SAML protocol bindings for message transport. +/// +public enum SamlBinding +{ + /// + /// HTTP-Redirect binding. + /// + HttpRedirect, + + /// + /// HTTP-POST binding. + /// + HttpPost +} diff --git a/identity-server/src/Storage/Models/SamlEndpointType.cs b/identity-server/src/Storage/Models/SamlEndpointType.cs new file mode 100644 index 000000000..47a16cbe2 --- /dev/null +++ b/identity-server/src/Storage/Models/SamlEndpointType.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Models; + +/// +/// Represents a SAML endpoint with location and binding. +/// +public class SamlEndpointType +{ + /// + /// Gets or sets the URL of the endpoint. + /// + public Uri Location { get; set; } = default!; + + /// + /// Gets or sets the SAML binding used by the endpoint. + /// + public SamlBinding Binding { get; set; } +} diff --git a/identity-server/src/Storage/Models/SamlServiceProvider.cs b/identity-server/src/Storage/Models/SamlServiceProvider.cs new file mode 100644 index 000000000..c49d1ded1 --- /dev/null +++ b/identity-server/src/Storage/Models/SamlServiceProvider.cs @@ -0,0 +1,127 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Security.Cryptography.X509Certificates; + +namespace Duende.IdentityServer.Models; + +/// +/// Models a SAML 2.0 Service Provider configuration. +/// +public class SamlServiceProvider +{ + /// + /// Gets or sets the entity identifier for the Service Provider. + /// This is typically a URI that uniquely identifies the SP. + /// + public string EntityId { get; set; } = default!; + + /// + /// Gets or sets the display name for the Service Provider. + /// Used for logging and consent screens. + /// + public string DisplayName { get; set; } = default!; + + /// + /// Gets or sets the description of the Service Provider. + /// + public string? Description { get; set; } + + /// + /// Gets or sets whether this Service Provider is enabled. + /// Defaults to true. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the clock skew tolerance for validating SAML messages. + /// If null, the global default from SamlOptions.DefaultClockSkew is used. + /// + public TimeSpan? ClockSkew { get; set; } + + /// + /// Gets or sets the maximum age for SAML authentication requests. + /// If null, the global default from SamlOptions.DefaultRequestMaxAge is used. + /// + public TimeSpan? RequestMaxAge { get; set; } + + /// + /// Gets or sets the Assertion Consumer Service (ACS) URLs where SAML responses can be sent. + /// At least one URL is required. + /// + public ICollection AssertionConsumerServiceUrls { get; set; } = new HashSet(); + + /// + /// Gets or sets the SAML binding used for the Assertion Consumer Service. + /// + public SamlBinding AssertionConsumerServiceBinding { get; set; } + + /// + /// Gets or sets the Single Logout Service endpoint where LogoutRequest and LogoutResponse messages should be sent. + /// This is the endpoint at the SP that handles SAML Single Logout protocol messages. + /// + public SamlEndpointType? SingleLogoutServiceUrl { get; set; } + + /// + /// Gets or sets whether the SP's AuthnRequests must be signed. + /// + public bool RequireSignedAuthnRequests { get; set; } + + /// + /// Gets or sets the X.509 certificates used by the SP to sign messages. + /// + public ICollection? SigningCertificates { get; set; } + + /// + /// Gets or sets the X.509 certificates used to encrypt SAML assertions for the SP. + /// + public ICollection? EncryptionCertificates { get; set; } + + /// + /// Gets or sets whether SAML assertions should be encrypted for this SP. + /// + public bool EncryptAssertions { get; set; } + + /// + /// Gets or sets whether consent is required for this SP. + /// + public bool RequireConsent { get; set; } + + /// + /// Gets or sets whether IdP-initiated SSO is allowed for this service provider. + /// When false, IdP-initiated SSO requests will be rejected. + /// Defaults to false (secure by default). + /// + public bool AllowIdpInitiated { get; set; } + + /// + /// Service provider-specific mappings from claim types to SAML attribute names. + /// These mappings override the global DefaultClaimMappings for this service provider. + /// + /// Key: claim type (e.g., "department") + /// Value: SAML attribute name (e.g., "businessUnit") + /// + /// If empty, only global mappings are used. + /// + public IDictionary ClaimMappings { get; set; } = new Dictionary(); + + /// + /// Gets or sets the default NameID format for this SP. + /// If null, the unspecified format is used. + /// + public string? DefaultNameIdFormat { get; set; } = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"; + + /// + /// Gets or sets the claim type used to resolve a persistent name identifier for this SP. + /// Overrides SamlOptions.DefaultPersistentNameIdentifierClaimType. + /// + public string? DefaultPersistentNameIdentifierClaimType { get; set; } + + /// + /// Gets or sets the signing behavior for SAML messages sent to this SP. + /// If null, the global default from SamlOptions.DefaultSigningBehavior is used. + /// + public SamlSigningBehavior? SigningBehavior { get; set; } +} diff --git a/identity-server/src/Storage/Models/SamlSigningBehavior.cs b/identity-server/src/Storage/Models/SamlSigningBehavior.cs new file mode 100644 index 000000000..fff904584 --- /dev/null +++ b/identity-server/src/Storage/Models/SamlSigningBehavior.cs @@ -0,0 +1,38 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Models; + +/// +/// Specifies the signing behavior for SAML messages and assertions. +/// +public enum SamlSigningBehavior +{ + /// + /// Do not sign the SAML Response or Assertion. + /// Only use for testing or non-production scenarios. + /// + DoNotSign = 0, + + /// + /// Sign only the Response element. + /// The signature wraps the entire response including the assertion. + /// + SignResponse = 1, + + /// + /// Sign only the Assertion element (within the Response). + /// This is the most common and recommended strategy. + /// Works with all SAML 2.0 compliant Service Providers. + /// + SignAssertion = 2, + + /// + /// Sign both the Response and the Assertion. + /// Provides maximum security but increases message size. + /// Use for high-security environments. + /// + SignBoth = 3 +} diff --git a/identity-server/src/Storage/Stores/ISamlServiceProviderStore.cs b/identity-server/src/Storage/Stores/ISamlServiceProviderStore.cs new file mode 100644 index 000000000..8eae4d467 --- /dev/null +++ b/identity-server/src/Storage/Stores/ISamlServiceProviderStore.cs @@ -0,0 +1,21 @@ +// 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.Stores; + +/// +/// Interface for retrieval of SAML Service Provider configuration. +/// +public interface ISamlServiceProviderStore +{ + /// + /// Finds a SAML Service Provider by its entity identifier. + /// + /// The entity identifier of the Service Provider. + /// The Service Provider, or null if not found. + Task FindByEntityIdAsync(string entityId); +} diff --git a/identity-server/test/IdentityServer.UnitTests/Common/MockSamlLogoutNotificationService.cs b/identity-server/test/IdentityServer.UnitTests/Common/MockSamlLogoutNotificationService.cs new file mode 100644 index 000000000..90d3bc86c --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Common/MockSamlLogoutNotificationService.cs @@ -0,0 +1,19 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; + +namespace UnitTests.Common; + +public class MockSamlLogoutNotificationService : ISamlLogoutNotificationService +{ + public bool GetSamlFrontChannelLogoutsAsyncCalled { get; set; } + public List SamlFrontChannelLogouts { get; set; } = []; + + public Task> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context) + { + GetSamlFrontChannelLogoutsAsyncCalled = true; + return Task.FromResult(SamlFrontChannelLogouts.AsEnumerable()); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Common/MockUserSession.cs b/identity-server/test/IdentityServer.UnitTests/Common/MockUserSession.cs index c66c01bcd..a9872da50 100644 --- a/identity-server/test/IdentityServer.UnitTests/Common/MockUserSession.cs +++ b/identity-server/test/IdentityServer.UnitTests/Common/MockUserSession.cs @@ -3,6 +3,7 @@ using System.Security.Claims; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; @@ -11,6 +12,7 @@ namespace UnitTests.Common; public class MockUserSession : IUserSession { public List Clients = new List(); + public List SamlSessions = new List(); public bool EnsureSessionIdCookieWasCalled { get; set; } public bool RemoveSessionIdCookieWasCalled { get; set; } @@ -52,4 +54,19 @@ public class MockUserSession : IUserSession Clients.Add(clientId); return Task.CompletedTask; } + + public Task AddSamlSessionAsync(SamlSpSessionData session) + { + SamlSessions.RemoveAll(s => s.EntityId == session.EntityId); + SamlSessions.Add(session); + return Task.CompletedTask; + } + + public Task> GetSamlSessionListAsync() => Task.FromResult>(SamlSessions); + + public Task RemoveSamlSessionAsync(string entityId) + { + SamlSessions.RemoveAll(s => s.EntityId == entityId); + return Task.CompletedTask; + } } diff --git a/identity-server/test/IdentityServer.UnitTests/Endpoints/EndSession/EndSessionCallbackResultTests.cs b/identity-server/test/IdentityServer.UnitTests/Endpoints/EndSession/EndSessionCallbackResultTests.cs index 45d31502c..0345b9e02 100644 --- a/identity-server/test/IdentityServer.UnitTests/Endpoints/EndSession/EndSessionCallbackResultTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Endpoints/EndSession/EndSessionCallbackResultTests.cs @@ -2,10 +2,16 @@ // See LICENSE in the project root for license information. +using System.Net; +using System.Text.RegularExpressions; +using Duende.IdentityModel; +using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Internal.Saml.SingleLogout; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Testing; namespace UnitTests.Endpoints.EndSession; @@ -24,20 +30,59 @@ public class EndSessionCallbackResultTests IsError = false, }; _options = new IdentityServerOptions(); - _subject = new EndSessionCallbackHttpWriter(_options); + _subject = new EndSessionCallbackHttpWriter(_options, new FakeLogger()); } [Fact] public async Task default_options_should_emit_frame_src_csp_headers() { _validationResult.FrontChannelLogoutUrls = new[] { "http://foo" }; + _validationResult.SamlFrontChannelLogouts = [new SamlHttpRedirectFrontChannelLogout(new Uri("http://bar"), string.Empty)]; var ctx = new DefaultHttpContext(); ctx.Request.Method = "GET"; await _subject.WriteHttpResponse(new EndSessionCallbackResult(_validationResult), ctx); - ctx.Response.Headers.ContentSecurityPolicy.First().ShouldContain("frame-src http://foo"); + ctx.Response.Headers.ContentSecurityPolicy.First().ShouldContain("frame-src http://foo http://bar"); + } + + [Fact] + public async Task default_options_should_emit_script_src_hash_for_saml_iframe_auto_post() + { + _validationResult.FrontChannelLogoutUrls = new[] { "http://foo" }; + _validationResult.SamlFrontChannelLogouts = [new SamlHttpPostFrontChannelLogout(new Uri("http://bar"), string.Empty, null)]; + + var ctx = new DefaultHttpContext(); + ctx.Request.Method = "GET"; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_validationResult), ctx); + + ctx.Response.Headers.ContentSecurityPolicy.First().ShouldContain($"script-src '{IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript}'"); + } + + [Fact] + public async Task csp_hash_should_match_inline_script() + { + _validationResult.SamlFrontChannelLogouts = [new SamlHttpPostFrontChannelLogout(new Uri("http://foo"), string.Empty, null)]; + + var ctx = new DefaultHttpContext(); + ctx.Request.Method = "GET"; + ctx.Response.Body = new MemoryStream(); + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_validationResult), ctx); + + ctx.Response.StatusCode.ShouldBe(200); + ctx.Response.ContentType.ShouldStartWith("text/html"); + ctx.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(ctx.Response.Body); + var html = await rdr.ReadToEndAsync(); + + var match = Regex.Match(html, "<script>(.*?)</script>", RegexOptions.Singleline | RegexOptions.IgnoreCase); + match.Success.ShouldBeTrue(); + + var scriptSha256 = "sha256-" + WebUtility.HtmlDecode(match.Groups[1].Value).ToSha256(); + scriptSha256.ShouldBe(IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript); } [Fact] @@ -45,6 +90,7 @@ public class EndSessionCallbackResultTests { _options.Authentication.RequireCspFrameSrcForSignout = false; _validationResult.FrontChannelLogoutUrls = new[] { "http://foo" }; + _validationResult.SamlFrontChannelLogouts = [new SamlHttpRedirectFrontChannelLogout(new Uri("http://bar"), string.Empty)]; var ctx = new DefaultHttpContext(); ctx.Request.Method = "GET"; diff --git a/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/EndSessionCallbackResultTests.cs b/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/EndSessionCallbackResultTests.cs index 20dbad1e5..c8dee230f 100644 --- a/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/EndSessionCallbackResultTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/EndSessionCallbackResultTests.cs @@ -8,8 +8,10 @@ using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Endpoints.Results; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Testing; using UnitTests.Common; namespace UnitTests.Endpoints.Results; @@ -29,7 +31,7 @@ public class EndSessionCallbackResultTests _context.Request.Host = new HostString("server"); _context.Response.Body = new MemoryStream(); - _subject = new EndSessionCallbackHttpWriter(_options); + _subject = new EndSessionCallbackHttpWriter(_options, new FakeLogger()); } [Fact] @@ -113,4 +115,144 @@ public class EndSessionCallbackResultTests _context.Response.Headers.ContentSecurityPolicy.First().ShouldContain($"style-src '{IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle}'"); _context.Response.Headers["X-Content-Security-Policy"].ShouldBeEmpty(); } + + [Fact] + public async Task saml_http_redirect_logout_should_render_iframe() + { + _result.IsError = false; + _result.SamlFrontChannelLogouts = + [ + new MockSamlFrontChannelLogout + { + SamlBinding = SamlBinding.HttpRedirect, + Destination = new Uri("https://sp.example.com/slo"), + EncodedContent = "SAMLRequest=abc123&SigAlg=xyz&Signature=sig", + RelayState = null + } + ]; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_result), _context); + + _context.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(_context.Response.Body); + var html = await rdr.ReadToEndAsync(); + + html.ShouldContain(""); + } + + [Fact] + public async Task saml_http_post_logout_should_render_iframe_with_srcdoc() + { + _result.IsError = false; + _result.SamlFrontChannelLogouts = + [ + new MockSamlFrontChannelLogout + { + SamlBinding = SamlBinding.HttpPost, + Destination = new Uri("https://sp.example.com/slo"), + EncodedContent = "base64encodedlogoutrequest", + RelayState = "state123" + } + ]; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_result), _context); + + _context.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(_context.Response.Body); + var html = await rdr.ReadToEndAsync(); + + html.ShouldContain(""); + html.ShouldContain(""); + } + + [Fact] + public async Task multiple_saml_logouts_should_render_multiple_iframes() + { + _result.IsError = false; + _result.SamlFrontChannelLogouts = + [ + new MockSamlFrontChannelLogout + { + SamlBinding = SamlBinding.HttpRedirect, + Destination = new Uri("https://sp1.example.com/slo"), + EncodedContent = "SAMLRequest=sp1", + RelayState = null + }, + new MockSamlFrontChannelLogout + { + SamlBinding = SamlBinding.HttpPost, + Destination = new Uri("https://sp2.example.com/slo"), + EncodedContent = "base64sp2", + RelayState = null + } + ]; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_result), _context); + + _context.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(_context.Response.Body); + var html = await rdr.ReadToEndAsync(); + + html.ShouldContain("https://sp1.example.com/slo"); + html.ShouldContain("https://sp2.example.com/slo"); + } + + [Fact] + public async Task saml_logout_with_unknown_binding_should_be_skipped() + { + _result.IsError = false; + _result.SamlFrontChannelLogouts = + [ + new MockSamlFrontChannelLogout + { + SamlBinding = (SamlBinding)999, // Unknown binding + Destination = new Uri("https://sp.example.com/slo"), + EncodedContent = "content", + RelayState = null + } + ]; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_result), _context); + + _context.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(_context.Response.Body); + var html = await rdr.ReadToEndAsync(); + + html.ShouldNotContain("https://sp.example.com/slo"); + } + + private class MockSamlFrontChannelLogout : ISamlFrontChannelLogout + { + public required SamlBinding SamlBinding { get; init; } + public required Uri Destination { get; init; } + public required string EncodedContent { get; init; } + public required string RelayState { get; init; } + } } diff --git a/identity-server/test/IdentityServer.UnitTests/Extensions/EndpointOptionsExtensionsTests.cs b/identity-server/test/IdentityServer.UnitTests/Extensions/EndpointOptionsExtensionsTests.cs index 73a59f8e7..3217b451a 100644 --- a/identity-server/test/IdentityServer.UnitTests/Extensions/EndpointOptionsExtensionsTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Extensions/EndpointOptionsExtensionsTests.cs @@ -132,5 +132,71 @@ public class EndpointOptionsExtensionsTests actual.ShouldBe(expectedIsEndpointEnabled); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlMetadataEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlMetadataEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlMetadata)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlSigninEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlSigninEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlSignin)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlSigninCallbackEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlSigninCallbackEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlSigninCallback)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlIdpInitiatedEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlIdpInitiatedEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlIdpInitiated)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlLogoutEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlLogoutEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlLogout)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlLogoutCallbackEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlLogoutCallbackEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlLogoutCallback)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + private Endpoint CreateTestEndpoint(string name) => new Endpoint(name, "", null); } diff --git a/identity-server/test/IdentityServer.UnitTests/Extensions/HttpContextExtensionsTests.cs b/identity-server/test/IdentityServer.UnitTests/Extensions/HttpContextExtensionsTests.cs index 4b2c4a1fb..65f1a8401 100644 --- a/identity-server/test/IdentityServer.UnitTests/Extensions/HttpContextExtensionsTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Extensions/HttpContextExtensionsTests.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using Duende.IdentityModel; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Services; using Duende.IdentityServer.Stores; using Microsoft.AspNetCore.Http; @@ -204,11 +205,211 @@ public class HttpContextExtensionsTests result.ShouldBeNull(); } - private DefaultHttpContext CreateContextWithUserSession(string? subjectId, params Client[] clients) + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_without_logout_message_returns_null_if_no_saml_service_providers_have_front_channel_logout() + { + var sp = CreateSamlServiceProvider("https://sp.example.com"); + sp.SingleLogoutServiceUrl = null; + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp]); + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_without_logout_message_returns_url_if_saml_service_provider_has_front_channel_logout() + { + var sp = CreateSamlServiceProvider("https://sp.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp]); + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(); + + result.ShouldNotBeNull(); + result.ShouldContain("/connect/endsession/callback?endSessionId="); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_without_logout_message_returns_url_if_both_oidc_and_saml_have_front_channel_logout() + { + var client = new Client + { + ClientId = "oidc_client", + AllowedGrantTypes = GrantTypes.ClientCredentials, + RequireClientSecret = false, + AllowedScopes = { "api1" }, + FrontChannelLogoutUri = "http://oidc-client/logout" + }; + var sp = CreateSamlServiceProvider("https://sp.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [client], [sp]); + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(); + + result.ShouldNotBeNull(); + result.ShouldContain("/connect/endsession/callback?endSessionId="); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_with_logout_message_includes_saml_service_providers() + { + var sp = CreateSamlServiceProvider("https://sp.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp]); + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldNotBeNull(); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_with_logout_message_returns_null_if_saml_service_provider_disabled() + { + var sp = CreateSamlServiceProvider("https://sp.example.com"); + sp.Enabled = false; + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp]); + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_with_logout_message_merges_current_user_saml_sessions() + { + var sp1 = CreateSamlServiceProvider("https://sp1.example.com"); + var sp2 = CreateSamlServiceProvider("https://sp2.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp1, sp2]); + + // Logout message only has sp1, but current user has session with sp2 + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldNotBeNull(); + // Both sp1 and sp2 should be included since current user matches logout message subject + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_with_logout_message_combines_didc_and_saml() + { + var client = new Client + { + ClientId = "oidc_client", + AllowedGrantTypes = GrantTypes.ClientCredentials, + RequireClientSecret = false, + AllowedScopes = { "api1" }, + FrontChannelLogoutUri = "http://oidc-client/logout" + }; + var sp = CreateSamlServiceProvider("https://sp.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [client], [sp]); + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = ["oidc_client"], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldNotBeNull(); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsyncWithEmptyLogoutMessageReturnsNull() + { + var context = CreateContextWithUserSessionAndSaml("Test", [], []); + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = [], + SamlSessions = [] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldBeNull(); + } + + private static SamlServiceProvider CreateSamlServiceProvider(string entityId) => new SamlServiceProvider + { + EntityId = entityId, + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri($"{entityId}/acs")], + SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpRedirect, + Location = new Uri($"{entityId}/slo") + }, + Enabled = true + }; + + private DefaultHttpContext CreateContextWithUserSessionAndSaml(string? subjectId, Client[] clients, SamlServiceProvider[] serviceProviders) { var userSession = new MockUserSession { Clients = clients.Select(client => client.ClientId).ToList(), + SamlSessions = serviceProviders + .Where(sp => sp.Enabled) + .Select(sp => new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = $"session-{sp.EntityId}" + }).ToList() }; if (subjectId != null) @@ -223,6 +424,35 @@ public class HttpContextExtensionsTests services.AddSingleton(new FakeTimeProvider()); services.AddSingleton, MockMessageStore>(); services.AddSingleton(new MockServerUrls()); + services.AddSingleton(new InMemorySamlServiceProviderStore(serviceProviders.ToArray())); + + return new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + } + + private DefaultHttpContext CreateContextWithUserSession(string? subjectId, params Client[] clients) + { + var userSession = new MockUserSession + { + Clients = clients.Select(client => client.ClientId).ToList(), + }; + + if (subjectId != null) + { + userSession.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, subjectId)])); + } + + var clientStore = new InMemoryClientStore(clients); + var serviceProviderStore = new InMemorySamlServiceProviderStore([]); + var services = new ServiceCollection(); + services.AddSingleton(userSession); + services.AddSingleton(clientStore); + services.AddSingleton(new FakeTimeProvider()); + services.AddSingleton(serviceProviderStore); + services.AddSingleton, MockMessageStore>(); + services.AddSingleton(new MockServerUrls()); return new DefaultHttpContext { diff --git a/identity-server/test/IdentityServer.UnitTests/Validation/EndSessionRequestValidation/EndSessionRequestValidatorTests.cs b/identity-server/test/IdentityServer.UnitTests/Validation/EndSessionRequestValidation/EndSessionRequestValidatorTests.cs index 1383f27ba..5a51b8d7f 100644 --- a/identity-server/test/IdentityServer.UnitTests/Validation/EndSessionRequestValidation/EndSessionRequestValidatorTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Validation/EndSessionRequestValidation/EndSessionRequestValidatorTests.cs @@ -8,6 +8,8 @@ using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Validation; using UnitTests.Common; @@ -22,6 +24,7 @@ public class EndSessionRequestValidatorTests private StubRedirectUriValidator _stubRedirectUriValidator = new StubRedirectUriValidator(); private MockUserSession _userSession = new MockUserSession(); private MockLogoutNotificationService _mockLogoutNotificationService = new MockLogoutNotificationService(); + private MockSamlLogoutNotificationService _mockSamlLogoutNotificationService = new MockSamlLogoutNotificationService(); private MockMessageStore _mockEndSessionMessageStore = new MockMessageStore(); private ClaimsPrincipal _user; @@ -37,6 +40,7 @@ public class EndSessionRequestValidatorTests _stubRedirectUriValidator, _userSession, _mockLogoutNotificationService, + _mockSamlLogoutNotificationService, _mockEndSessionMessageStore, TestLogger.Create()); } @@ -178,4 +182,286 @@ public class EndSessionRequestValidatorTests result.IsError.ShouldBeFalse(); result.ValidatedRequest.Raw.ShouldBeSameAs(parameters); } + + [Fact] + public async Task successful_request_with_saml_sessions_should_populate_saml_sessions() + { + _userSession.User = _user; + _userSession.SamlSessions = + [ + new() { EntityId = "https://sp1.example.com", SessionIndex = "idx1", NameId = "user1" }, + new() { EntityId = "https://sp2.example.com", SessionIndex = "idx2", NameId = "user1" } + ]; + + var parameters = new NameValueCollection(); + + var result = await _subject.ValidateAsync(parameters, _user); + + result.IsError.ShouldBeFalse(); + result.ValidatedRequest.SamlSessions.ShouldNotBeNull(); + result.ValidatedRequest.SamlSessions.Count().ShouldBe(2); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp2.example.com"); + } + + [Fact] + public async Task successful_request_without_saml_sessions_should_have_empty_saml_sessions() + { + _userSession.User = _user; + _userSession.SamlSessions = []; + + var parameters = new NameValueCollection(); + + var result = await _subject.ValidateAsync(parameters, _user); + + result.IsError.ShouldBeFalse(); + result.ValidatedRequest.SamlSessions.ShouldNotBeNull(); + result.ValidatedRequest.SamlSessions.ShouldBeEmpty(); + } + + [Fact] + public async Task successful_request_with_both_oidc_and_saml_sessions_should_populate_both() + { + _userSession.User = _user; + _userSession.Clients = ["client1", "client2"]; + _userSession.SamlSessions = + [ + new() { EntityId = "https://sp1.example.com", SessionIndex = "idx1", NameId = "user1" }, + new() { EntityId = "https://sp2.example.com", SessionIndex = "idx2", NameId = "user1" } + ]; + + var parameters = new NameValueCollection(); + + var result = await _subject.ValidateAsync(parameters, _user); + + result.IsError.ShouldBeFalse(); + + // OIDC clients + result.ValidatedRequest.ClientIds.ShouldNotBeNull(); + result.ValidatedRequest.ClientIds.Count().ShouldBe(2); + result.ValidatedRequest.ClientIds.ShouldContain("client1"); + result.ValidatedRequest.ClientIds.ShouldContain("client2"); + + // SAML SPs + result.ValidatedRequest.SamlSessions.ShouldNotBeNull(); + result.ValidatedRequest.SamlSessions.Count().ShouldBe(2); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp2.example.com"); + } + + [Fact] + public async Task successful_request_with_id_token_hint_should_collect_saml_sessions() + { + _stubTokenValidator.IdentityTokenValidationResult = new TokenValidationResult() + { + IsError = false, + Claims = [new Claim("sub", _user.GetSubjectId())], + Client = new Client() { ClientId = "client" } + }; + _userSession.User = _user; + _userSession.SamlSessions = + [ + new() { EntityId = "https://sp1.example.com", SessionIndex = "idx1", NameId = "user1" } + ]; + + var parameters = new NameValueCollection(); + parameters.Add("id_token_hint", "id_token"); + + var result = await _subject.ValidateAsync(parameters, _user); + + result.IsError.ShouldBeFalse(); + result.ValidatedRequest.SamlSessions.ShouldNotBeNull(); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + } + + [Fact] + public async Task validate_callback_async_with_only_saml_service_providers_return_success() + { + var context = new LogoutNotificationContext + { + SubjectId = "test", + SessionId = "session123", + ClientIds = [], + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + var samlLogout = new MockSamlFrontChannelLogout(); + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(samlLogout); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + var result = await _subject.ValidateCallbackAsync(parameters); + + result.IsError.ShouldBeFalse(); + result.SamlFrontChannelLogouts.ShouldNotBeNull(); + result.SamlFrontChannelLogouts.ShouldHaveSingleItem(); + _mockSamlLogoutNotificationService.GetSamlFrontChannelLogoutsAsyncCalled.ShouldBeTrue(); + } + + [Fact] + public async Task validate_callback_async_with_both_oidc_and_saml_returns_both() + { + var context = new LogoutNotificationContext + { + SubjectId = "test", + SessionId = "session123", + ClientIds = ["client1"], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + _mockLogoutNotificationService.FrontChannelLogoutNotificationsUrls.Add("http://client1.com/logout"); + var samlLogout = new MockSamlFrontChannelLogout(); + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(samlLogout); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + var result = await _subject.ValidateCallbackAsync(parameters); + + result.IsError.ShouldBeFalse(); + result.FrontChannelLogoutUrls.ShouldHaveSingleItem(); + result.SamlFrontChannelLogouts.ShouldHaveSingleItem(); + } + + [Fact] + public async Task validate_callback_async_with_only_saml_empty_list_returns_error() + { + var context = new LogoutNotificationContext + { + SubjectId = "test", + SessionId = "session123", + ClientIds = [], + SamlSessions = [] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + var result = await _subject.ValidateCallbackAsync(parameters); + + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task validate_callback_async_with_saml_passes_context_to_saml_notification_service() + { + var context = new LogoutNotificationContext + { + SubjectId = "test_user", + SessionId = "session123", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + }, + new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session2" + } + ] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(new MockSamlFrontChannelLogout()); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + await _subject.ValidateCallbackAsync(parameters); + + _mockSamlLogoutNotificationService.GetSamlFrontChannelLogoutsAsyncCalled.ShouldBeTrue(); + } + + [Fact] + public async Task validate_callback_async_with_multiple_saml_service_providers_returns_all() + { + var context = new LogoutNotificationContext + { + SubjectId = "test", + SessionId = "session123", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + }, + new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session2" + }, + new SamlSpSessionData + { + EntityId = "https://sp3.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session3" + } + ] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(new MockSamlFrontChannelLogout()); + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(new MockSamlFrontChannelLogout()); + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(new MockSamlFrontChannelLogout()); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + var result = await _subject.ValidateCallbackAsync(parameters); + + result.IsError.ShouldBeFalse(); + result.SamlFrontChannelLogouts.Count().ShouldBe(3); + } + + private class MockSamlFrontChannelLogout : ISamlFrontChannelLogout + { + public SamlBinding SamlBinding => SamlBinding.HttpRedirect; + public Uri Destination => new Uri("https://sp.example.com/slo"); + public string EncodedContent => "encoded"; + public string RelayState => null; + } }