mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 01:18:22 +00:00
Initial import of SAML code
This commit is contained in:
parent
e556d5b314
commit
72df8704fc
142 changed files with 8919 additions and 29 deletions
|
|
@ -16,6 +16,13 @@ using Duende.IdentityServer.Hosting;
|
|||
using Duende.IdentityServer.Hosting.DynamicProviders;
|
||||
using Duende.IdentityServer.Hosting.FederatedSignOut;
|
||||
using Duende.IdentityServer.Internal;
|
||||
using Duende.IdentityServer.Internal.Saml;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.Metadata;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Licensing;
|
||||
using Duende.IdentityServer.Licensing.V2;
|
||||
using Duende.IdentityServer.Licensing.V2.Diagnostics;
|
||||
|
|
@ -23,6 +30,7 @@ using Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries;
|
|||
using Duende.IdentityServer.Logging;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.ResponseHandling;
|
||||
using Duende.IdentityServer.Saml;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Services.Default;
|
||||
using Duende.IdentityServer.Services.KeyManagement;
|
||||
|
|
@ -127,6 +135,14 @@ public static class IdentityServerBuilderExtensionsCore
|
|||
builder.AddEndpoint<TokenEndpoint>(EndpointNames.Token, ProtocolRoutePaths.Token.EnsureLeadingSlash());
|
||||
builder.AddEndpoint<UserInfoEndpoint>(EndpointNames.UserInfo, ProtocolRoutePaths.UserInfo.EnsureLeadingSlash());
|
||||
|
||||
// SAML 2.0 endpoints
|
||||
builder.AddEndpoint<SamlMetaDataEndpoint>(EndpointNames.SamlMetadata, ProtocolRoutePaths.SamlMetadata.EnsureLeadingSlash());
|
||||
builder.AddEndpoint<SamlSigninEndpoint>(EndpointNames.SamlSignin, ProtocolRoutePaths.SamlSignin.EnsureLeadingSlash());
|
||||
builder.AddEndpoint<SamlSigninCallbackEndpoint>(EndpointNames.SamlSigninCallback, ProtocolRoutePaths.SamlSigninCallback.EnsureLeadingSlash());
|
||||
builder.AddEndpoint<SamlIdpInitiatedEndpoint>(EndpointNames.SamlIdpInitiated, ProtocolRoutePaths.SamlIdpInitiated.EnsureLeadingSlash());
|
||||
builder.AddEndpoint<SamlSingleLogoutEndpoint>(EndpointNames.SamlLogout, ProtocolRoutePaths.SamlLogout.EnsureLeadingSlash());
|
||||
builder.AddEndpoint<SamlSingleLogoutCallbackEndpoint>(EndpointNames.SamlLogoutCallback, ProtocolRoutePaths.SamlLogoutCallback.EnsureLeadingSlash());
|
||||
|
||||
builder.AddHttpWriter<AuthorizeInteractionPageResult, AuthorizeInteractionPageHttpWriter>();
|
||||
builder.AddHttpWriter<AuthorizeResult, AuthorizeHttpWriter>();
|
||||
builder.AddHttpWriter<BackchannelAuthenticationResult, BackchannelAuthenticationHttpWriter>();
|
||||
|
|
@ -150,6 +166,67 @@ public static class IdentityServerBuilderExtensionsCore
|
|||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds SAML 2.0 protocol services.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder.</param>
|
||||
/// <returns></returns>
|
||||
public static IIdentityServerBuilder AddSamlServices(this IIdentityServerBuilder builder)
|
||||
{
|
||||
// Serializers (Transient)
|
||||
builder.Services.AddTransient<ISamlResultSerializer<SamlErrorResponse>, SamlErrorResponseXmlSerializer>();
|
||||
builder.Services.AddTransient<ISamlResultSerializer<SamlResponse>, SamlResponse.Serializer>();
|
||||
builder.Services.AddTransient<ISamlResultSerializer<LogoutResponse>, LogoutResponse.Serializer>();
|
||||
|
||||
// HTTP response writers
|
||||
builder.AddHttpWriter<SamlErrorResponse, SamlErrorResponse.ResponseWriter>();
|
||||
builder.AddHttpWriter<SamlResponse, SamlResponse.ResponseWriter>();
|
||||
builder.AddHttpWriter<LogoutResponse, LogoutResponse.ResponseWriter>();
|
||||
|
||||
// Processors (Scoped)
|
||||
builder.Services.AddScoped<SamlSigninRequestProcessor>();
|
||||
builder.Services.AddScoped<SamlSigninCallbackRequestProcessor>();
|
||||
builder.Services.AddScoped<SamlIdpInitiatedRequestProcessor>();
|
||||
builder.Services.AddScoped<SamlLogoutRequestProcessor>();
|
||||
builder.Services.AddScoped<SamlLogoutCallbackProcessor>();
|
||||
|
||||
// Builders (Scoped)
|
||||
builder.Services.AddScoped<SamlResponseBuilder>();
|
||||
builder.Services.AddScoped<LogoutResponseBuilder>();
|
||||
builder.Services.AddScoped<SamlFrontChannelLogoutRequestBuilder>();
|
||||
|
||||
// Parsers / Extractors (Scoped)
|
||||
builder.Services.AddScoped<AuthNRequestParser>();
|
||||
builder.Services.AddScoped<LogoutRequestParser>();
|
||||
builder.Services.AddScoped<SamlSigninRequestExtractor>();
|
||||
builder.Services.AddScoped<SamlLogoutRequestExtractor>();
|
||||
|
||||
// Infrastructure (Scoped)
|
||||
builder.Services.AddScoped<SamlUrlBuilder>();
|
||||
builder.Services.AddScoped<SamlClaimsService>();
|
||||
builder.Services.AddScoped<SamlNameIdGenerator>();
|
||||
builder.Services.AddScoped<SamlResponseSigner>();
|
||||
builder.Services.AddScoped<SamlProtocolMessageSigner>();
|
||||
builder.Services.AddScoped<SamlAssertionEncryptor>();
|
||||
builder.Services.AddScoped<SamlRequestValidator>();
|
||||
builder.Services.TryAddScoped(typeof(SamlRequestSignatureValidator<,>));
|
||||
|
||||
// Interface → Implementation (TryAddScoped for extensibility)
|
||||
builder.Services.TryAddScoped<ISamlSigninInteractionResponseGenerator, DefaultSamlSigninInteractionResponseGenerator>();
|
||||
builder.Services.TryAddScoped<ISamlSigningService, SamlSigningService>();
|
||||
builder.Services.TryAddScoped<ISamlLogoutNotificationService, SamlLogoutNotificationService>();
|
||||
builder.Services.TryAddScoped<ISamlInteractionService, DefaultSamlInteractionService>();
|
||||
|
||||
// State management (Singleton)
|
||||
builder.Services.TryAddSingleton<SamlSigninStateIdCookie>();
|
||||
builder.Services.TryAddSingleton<ISamlSigninStateStore, DistributedCacheSamlSigninStateStore>();
|
||||
|
||||
// Default no-op service provider store (can be overridden by user)
|
||||
builder.Services.TryAddTransient<ISamlServiceProviderStore, InMemorySamlServiceProviderStore>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an endpoint.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ public static class IdentityServerServiceCollectionExtensions
|
|||
.AddCookieAuthentication()
|
||||
.AddCoreServices()
|
||||
.AddDefaultEndpoints()
|
||||
.AddSamlServices()
|
||||
.AddPluggableServices()
|
||||
.AddKeyManagement()
|
||||
.AddDynamicProvidersCore()
|
||||
|
|
|
|||
|
|
@ -111,4 +111,52 @@ public class EndpointsOptions
|
|||
/// <c>true</c> if the OAuth 2.0 discovery metadata is enabled; otherwise, <c>false</c>.
|
||||
/// </value>
|
||||
public bool EnableOAuth2MetadataEndpoint { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the SAML metadata endpoint is enabled.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <c>true</c> if the SAML metadata endpoint is enabled; otherwise, <c>false</c>.
|
||||
/// </value>
|
||||
public bool EnableSamlMetadataEndpoint { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the SAML sign-in (SSO) endpoint is enabled.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <c>true</c> if the SAML sign-in endpoint is enabled; otherwise, <c>false</c>.
|
||||
/// </value>
|
||||
public bool EnableSamlSigninEndpoint { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the SAML sign-in callback endpoint is enabled.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <c>true</c> if the SAML sign-in callback endpoint is enabled; otherwise, <c>false</c>.
|
||||
/// </value>
|
||||
public bool EnableSamlSigninCallbackEndpoint { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the SAML IdP-initiated SSO endpoint is enabled.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <c>true</c> if the SAML IdP-initiated endpoint is enabled; otherwise, <c>false</c>.
|
||||
/// </value>
|
||||
public bool EnableSamlIdpInitiatedEndpoint { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the SAML Single Logout (SLO) endpoint is enabled.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <c>true</c> if the SAML logout endpoint is enabled; otherwise, <c>false</c>.
|
||||
/// </value>
|
||||
public bool EnableSamlLogoutEndpoint { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the SAML Single Logout callback endpoint is enabled.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <c>true</c> if the SAML logout callback endpoint is enabled; otherwise, <c>false</c>.
|
||||
/// </value>
|
||||
public bool EnableSamlLogoutCallbackEndpoint { get; set; } = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,4 +293,9 @@ public class IdentityServerOptions
|
|||
/// Options that control the diagnostic data that is logged by IdentityServer.
|
||||
/// </summary>
|
||||
public DiagnosticOptions Diagnostics { get; set; } = new DiagnosticOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML 2.0 Identity Provider options.
|
||||
/// </summary>
|
||||
public SamlOptions Saml { get; set; } = new SamlOptions();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Claims;
|
||||
using Duende.IdentityServer.Models;
|
||||
using SamlConstants = Duende.IdentityServer.Internal.Saml.SamlConstants;
|
||||
|
||||
namespace Duende.IdentityServer.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Options for SAML 2.0 Identity Provider functionality.
|
||||
/// </summary>
|
||||
public class SamlOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the metadata validity duration (optional).
|
||||
/// If set, metadata will include a validUntil attribute.
|
||||
/// Defaults to 7 days.
|
||||
/// </summary>
|
||||
public TimeSpan? MetadataValidityDuration { get; set; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the IdP requires signed AuthnRequests.
|
||||
/// Defaults to false.
|
||||
/// </summary>
|
||||
public bool WantAuthnRequestsSigned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default attribute name format to use when SP doesn't specify.
|
||||
/// Common values:
|
||||
/// - "urn:oasis:names:tc:SAML:2.0:attrname-format:uri" (for OID format)
|
||||
/// - "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" (for simple names)
|
||||
/// Default: Uri (most common)
|
||||
/// </summary>
|
||||
public string DefaultAttributeNameFormat { get; set; }
|
||||
= SamlConstants.AttributeNameFormats.Uri;
|
||||
|
||||
/// <summary>
|
||||
/// Default claim type to use when resolving a persistent name identifier based on where
|
||||
/// the host application has populated the value. Persistent name identifiers will not be
|
||||
/// generated and are the responsibility of the host application to create.
|
||||
/// </summary>
|
||||
public string DefaultPersistentNameIdentifierClaimType { get; set; } = ClaimTypes.NameIdentifier;
|
||||
|
||||
/// <summary>
|
||||
/// Default mappings from claim types to SAML attribute names.
|
||||
/// Key: claim type (e.g., "email", "name")
|
||||
/// Value: SAML attribute name (e.g., "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")
|
||||
///
|
||||
/// Includes common OIDC to SAML attribute mappings by default.
|
||||
/// Service providers can override these mappings via SamlServiceProvider.ClaimMappings.
|
||||
///
|
||||
/// If a claim type is not in this dictionary, the claim will be excluded from the SAML assertion.
|
||||
/// </summary>
|
||||
public ReadOnlyDictionary<string, string> DefaultClaimMappings { get; init; } =
|
||||
new(new Dictionary<string, string>
|
||||
{
|
||||
["name"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
["email"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
["role"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/role",
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the supported NameID formats.
|
||||
/// Defaults to EmailAddress, Persistent, Transient, and Unspecified.
|
||||
/// </summary>
|
||||
public Collection<string> SupportedNameIdFormats { get; init; } =
|
||||
[
|
||||
SamlConstants.NameIdentifierFormats.EmailAddress,
|
||||
SamlConstants.NameIdentifierFormats.Persistent,
|
||||
SamlConstants.NameIdentifierFormats.Transient,
|
||||
SamlConstants.NameIdentifierFormats.Unspecified
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default clock skew tolerance for SAML message validation.
|
||||
/// Defaults to 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultClockSkew { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default maximum age for SAML authentication requests.
|
||||
/// Defaults to 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultRequestMaxAge { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default signing behavior for SAML messages.
|
||||
/// Defaults to <see cref="Models.SamlSigningBehavior.SignAssertion"/>.
|
||||
/// </summary>
|
||||
public SamlSigningBehavior DefaultSigningBehavior { get; set; } = SamlSigningBehavior.SignAssertion;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length of the RelayState parameter, measured in bytes of its UTF-8 encoding.
|
||||
/// SAML spec recommends 80 bytes, but can be increased for SPs that support longer values.
|
||||
/// Default: 80 (UTF-8 bytes).
|
||||
/// </summary>
|
||||
public int MaxRelayStateLength { get; set; } = 80;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user interaction options for SAML endpoints.
|
||||
/// </summary>
|
||||
public SamlUserInteractionOptions UserInteraction { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for SAML user interaction endpoint paths.
|
||||
/// </summary>
|
||||
public class SamlUserInteractionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the base route for all SAML endpoints.
|
||||
/// Default: "/saml".
|
||||
/// </summary>
|
||||
public string Route { get; set; } = SamlConstants.Urls.SamlRoute;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path for the SAML metadata endpoint.
|
||||
/// Default: "/metadata".
|
||||
/// </summary>
|
||||
public string Metadata { get; set; } = SamlConstants.Urls.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path for the SAML sign-in endpoint.
|
||||
/// Default: "/signin".
|
||||
/// </summary>
|
||||
public string SignInPath { get; set; } = SamlConstants.Urls.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path for the SAML sign-in callback endpoint.
|
||||
/// Default: "/signin_callback".
|
||||
/// </summary>
|
||||
public string SignInCallbackPath { get; set; } = SamlConstants.Urls.SigninCallback;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path for the IdP-initiated SSO endpoint.
|
||||
/// Default: "/idp-initiated".
|
||||
/// </summary>
|
||||
public string IdpInitiatedPath { get; set; } = SamlConstants.Urls.IdpInitiated;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path for the SAML single logout endpoint.
|
||||
/// Default: "/logout".
|
||||
/// </summary>
|
||||
public string SingleLogoutPath { get; set; } = SamlConstants.Urls.SingleLogout;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path for the SAML single logout callback endpoint.
|
||||
/// Default: "/logout_callback".
|
||||
/// </summary>
|
||||
public string SingleLogoutCallbackPath { get; set; } = SamlConstants.Urls.SingleLogoutCallback;
|
||||
}
|
||||
|
|
@ -9,8 +9,12 @@ using System.Text.Encodings.Web;
|
|||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Internal.Saml;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Endpoints.Results;
|
||||
|
||||
|
|
@ -34,9 +38,14 @@ public class EndSessionCallbackResult : EndpointResult<EndSessionCallbackResult>
|
|||
|
||||
internal class EndSessionCallbackHttpWriter : IHttpResponseWriter<EndSessionCallbackResult>
|
||||
{
|
||||
public EndSessionCallbackHttpWriter(IdentityServerOptions options) => _options = options;
|
||||
public EndSessionCallbackHttpWriter(IdentityServerOptions options, ILogger<EndSessionCallbackHttpWriter> logger)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IdentityServerOptions _options;
|
||||
private readonly IdentityServerOptions _options;
|
||||
private readonly ILogger<EndSessionCallbackHttpWriter> _logger;
|
||||
|
||||
public async Task WriteHttpResponse(EndSessionCallbackResult result, HttpContext context)
|
||||
{
|
||||
|
|
@ -59,25 +68,36 @@ internal class EndSessionCallbackHttpWriter : IHttpResponseWriter<EndSessionCall
|
|||
if (_options.Authentication.RequireCspFrameSrcForSignout)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var origins = result.Result.FrontChannelLogoutUrls?.Select(x => x.GetOrigin());
|
||||
if (origins != null)
|
||||
var origins = result.Result.FrontChannelLogoutUrls?.Select(x => x.GetOrigin()) ?? [];
|
||||
origins = origins.Concat(result.Result.SamlFrontChannelLogouts.Select(x => x.Destination.OriginalString));
|
||||
foreach (var origin in origins.Distinct())
|
||||
{
|
||||
foreach (var origin in origins.Distinct())
|
||||
sb.Append(origin);
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append(origin);
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append(' ');
|
||||
}
|
||||
sb.Append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
// the hash matches the embedded style element being used below
|
||||
context.Response.AddStyleCspHeaders(_options.Csp, IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle, sb.ToString());
|
||||
if (result.Result.SamlFrontChannelLogouts.Any())
|
||||
{
|
||||
// the hash matches the embedded style element being used below
|
||||
// and the SAML auto-post script hash allows the inline script in the iframe srcdoc
|
||||
context.Response.AddStyleAndScriptCspHeaders(
|
||||
_options.Csp,
|
||||
IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle,
|
||||
IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript,
|
||||
sb.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// the hash matches the embedded style element being used below
|
||||
context.Response.AddStyleCspHeaders(_options.Csp, IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle, sb.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetHtml(EndSessionCallbackResult result)
|
||||
private string GetHtml(EndSessionCallbackResult result)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("<!DOCTYPE html><html><style>iframe{{display:none;width:0;height:0;}}</style><body>");
|
||||
|
|
@ -91,6 +111,28 @@ internal class EndSessionCallbackHttpWriter : IHttpResponseWriter<EndSessionCall
|
|||
}
|
||||
}
|
||||
|
||||
if (result.Result.SamlFrontChannelLogouts.Any())
|
||||
{
|
||||
foreach (var samlFrontChannelLogout in result.Result.SamlFrontChannelLogouts)
|
||||
{
|
||||
switch (samlFrontChannelLogout.SamlBinding)
|
||||
{
|
||||
case SamlBinding.HttpPost:
|
||||
var autoPostFormContent = HttpResponseBindings.GenerateAutoPostForm(SamlMessageName.SamlRequest, samlFrontChannelLogout.EncodedContent, samlFrontChannelLogout.Destination, samlFrontChannelLogout.RelayState, includeCsp: true);
|
||||
sb.Append(CultureInfo.InvariantCulture, $"<iframe sandbox='allow-forms allow-scripts allow-same-origin' srcdoc='{HtmlEncoder.Default.Encode(autoPostFormContent)}'></iframe>");
|
||||
break;
|
||||
case SamlBinding.HttpRedirect:
|
||||
sb.Append(CultureInfo.InvariantCulture, $"<iframe loading='eager' allow='' src='{HtmlEncoder.Default.Encode($"{samlFrontChannelLogout.Destination}?{samlFrontChannelLogout.EncodedContent}")}'></iframe>");
|
||||
break;
|
||||
default:
|
||||
_logger.LogDebug("Unknown SAML Binding: {SamlBinding}", samlFrontChannelLogout.SamlBinding);
|
||||
break;
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
using System.Buffers.Text;
|
||||
using System.Text;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace Duende.IdentityServer.Extensions;
|
||||
|
|
@ -15,6 +16,7 @@ public static class AuthenticationPropertiesExtensions
|
|||
{
|
||||
internal const string SessionIdKey = "session_id";
|
||||
internal const string ClientListKey = "client_list";
|
||||
internal const string SamlSessionListKey = "saml_session_list";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user's session identifier.
|
||||
|
|
@ -86,7 +88,6 @@ public static class AuthenticationPropertiesExtensions
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private static IEnumerable<string> DecodeList(string value)
|
||||
{
|
||||
if (value.IsPresent())
|
||||
|
|
@ -111,4 +112,100 @@ public static class AuthenticationPropertiesExtensions
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of SAML SP sessions from the authentication properties.
|
||||
/// </summary>
|
||||
/// <param name="properties"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>
|
||||
/// For production deployments with many SAML service providers, enable server-side sessions
|
||||
/// to avoid cookie size limitations. Without server-side sessions, the practical limit is
|
||||
/// approximately 5-10 SAML sessions depending on the number of OIDC clients.
|
||||
/// </remarks>
|
||||
public static IEnumerable<SamlSpSessionData> GetSamlSessionList(this AuthenticationProperties properties)
|
||||
{
|
||||
if (properties?.Items.TryGetValue(SamlSessionListKey, out var value) == true && value != null)
|
||||
{
|
||||
return DecodeSamlSessionList(value);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of SAML SP sessions in the authentication properties.
|
||||
/// </summary>
|
||||
/// <param name="properties"></param>
|
||||
/// <param name="sessions"></param>
|
||||
public static void SetSamlSessionList(this AuthenticationProperties properties, IEnumerable<SamlSpSessionData> sessions)
|
||||
{
|
||||
var value = EncodeSamlSessionList(sessions);
|
||||
if (value == null)
|
||||
{
|
||||
properties.Items.Remove(SamlSessionListKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
properties.Items[SamlSessionListKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a SAML session to the authentication properties.
|
||||
/// This is an upsert operation - if a session for the same EntityId already exists, it is replaced.
|
||||
/// </summary>
|
||||
/// <param name="properties"></param>
|
||||
/// <param name="session"></param>
|
||||
public static void AddSamlSession(this AuthenticationProperties properties, SamlSpSessionData session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
var sessions = properties.GetSamlSessionList().ToList();
|
||||
|
||||
// Remove existing session for this SP if present
|
||||
sessions.RemoveAll(s => s.EntityId == session.EntityId);
|
||||
|
||||
// Add the (potentially updated) session
|
||||
sessions.Add(session);
|
||||
properties.SetSamlSessionList(sessions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a SAML session from the authentication properties by EntityId.
|
||||
/// </summary>
|
||||
/// <param name="properties"></param>
|
||||
/// <param name="entityId"></param>
|
||||
public static void RemoveSamlSession(this AuthenticationProperties properties, string entityId)
|
||||
{
|
||||
var sessions = properties.GetSamlSessionList()
|
||||
.Where(s => s.EntityId != entityId)
|
||||
.ToList();
|
||||
|
||||
properties.SetSamlSessionList(sessions);
|
||||
}
|
||||
|
||||
private static SamlSpSessionData[] DecodeSamlSessionList(string value)
|
||||
{
|
||||
if (value.IsPresent())
|
||||
{
|
||||
var bytes = Base64Url.DecodeFromChars(value);
|
||||
var json = Encoding.UTF8.GetString(bytes);
|
||||
return ObjectSerializer.FromString<SamlSpSessionData[]>(json) ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static string EncodeSamlSessionList(IEnumerable<SamlSpSessionData> list)
|
||||
{
|
||||
if (list != null && list.Any())
|
||||
{
|
||||
var json = ObjectSerializer.ToString(list);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
return Base64Url.EncodeToString(bytes);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,23 +57,28 @@ public static class HttpContextExtensions
|
|||
LogoutNotificationContext endSessionMsg = null;
|
||||
|
||||
// if we have a logout message, then that take precedence over the current user
|
||||
if (logoutMessage?.ClientIds?.Any() == true)
|
||||
if (logoutMessage?.ClientIds?.Any() == true || logoutMessage?.SamlSessions?.Any() == true)
|
||||
{
|
||||
var clientIds = logoutMessage.ClientIds;
|
||||
var clientIds = logoutMessage.ClientIds ?? [];
|
||||
var samlSessions = logoutMessage.SamlSessions?.ToList() ?? [];
|
||||
|
||||
// check if current user is same, since we might have new clients (albeit unlikely)
|
||||
if (currentSubId == logoutMessage.SubjectId)
|
||||
{
|
||||
clientIds = clientIds.Union(await userSession.GetClientListAsync(context.RequestAborted));
|
||||
var currentSamlSessions = await userSession.GetSamlSessionListAsync();
|
||||
samlSessions = samlSessions.Union(currentSamlSessions).ToList();
|
||||
}
|
||||
|
||||
if (await AnyClientHasFrontChannelLogout(logoutMessage.ClientIds))
|
||||
var samlEntityIds = samlSessions.Select(s => s.EntityId);
|
||||
if (await AnyClientHasFrontChannelLogout(logoutMessage.ClientIds) || await AnySamlServiceProviderHasFrontChannelLogout(samlEntityIds))
|
||||
{
|
||||
endSessionMsg = new LogoutNotificationContext
|
||||
{
|
||||
SubjectId = logoutMessage.SubjectId,
|
||||
SessionId = logoutMessage.SessionId,
|
||||
ClientIds = clientIds
|
||||
ClientIds = clientIds,
|
||||
SamlSessions = samlSessions
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -81,13 +86,18 @@ public static class HttpContextExtensions
|
|||
{
|
||||
// see if current user has any clients they need to signout of
|
||||
var clientIds = await userSession.GetClientListAsync(context.RequestAborted);
|
||||
if (clientIds.Any() && await AnyClientHasFrontChannelLogout(clientIds))
|
||||
var samlSessions = await userSession.GetSamlSessionListAsync();
|
||||
var samlEntityIds = samlSessions.Select(s => s.EntityId);
|
||||
|
||||
if ((clientIds.Any() && await AnyClientHasFrontChannelLogout(clientIds)) ||
|
||||
(samlEntityIds.Any() && await AnySamlServiceProviderHasFrontChannelLogout(samlEntityIds)))
|
||||
{
|
||||
endSessionMsg = new LogoutNotificationContext
|
||||
{
|
||||
SubjectId = currentSubId,
|
||||
SessionId = await userSession.GetSessionIdAsync(context.RequestAborted),
|
||||
ClientIds = clientIds
|
||||
ClientIds = clientIds,
|
||||
SamlSessions = samlSessions
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -124,5 +134,20 @@ public static class HttpContextExtensions
|
|||
|
||||
return false;
|
||||
}
|
||||
|
||||
async Task<bool> AnySamlServiceProviderHasFrontChannelLogout(IEnumerable<string> entityIds)
|
||||
{
|
||||
var serviceProviderStore = context.RequestServices.GetRequiredService<ISamlServiceProviderStore>();
|
||||
foreach (var entityId in entityIds)
|
||||
{
|
||||
var sp = await serviceProviderStore.FindByEntityIdAsync(entityId);
|
||||
if (sp?.Enabled == true && sp.SingleLogoutServiceUrl != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -218,6 +218,12 @@ public static class IdentityServerConstants
|
|||
public const string PushedAuthorization = "PushedAuthorization";
|
||||
public const string OAuthMetadata = "OAuthMetadata";
|
||||
|
||||
public const string SamlMetadata = "SamlMetadata";
|
||||
public const string SamlSignin = "SamlSignin";
|
||||
public const string SamlSigninCallback = "SamlSigninCallback";
|
||||
public const string SamlIdpInitiated = "SamlIdpInitiated";
|
||||
public const string SamlLogout = "SamlLogout";
|
||||
public const string SamlLogoutCallback = "SamlLogoutCallback";
|
||||
}
|
||||
|
||||
public static class ContentSecurityPolicyHashes
|
||||
|
|
@ -236,6 +242,11 @@ public static class IdentityServerConstants
|
|||
/// The hash of the inline script used on the check session endpoint.
|
||||
/// </summary>
|
||||
public const string CheckSessionScript = "sha256-jyguj/c+mxOUX7TJrFnIkEQlj4jinO1nejo8qnuF1jc=";
|
||||
|
||||
/// <summary>
|
||||
/// The hash of the inline script used for SAML auto-post form submissions.
|
||||
/// </summary>
|
||||
public const string SamlAutoPostScript = "sha256-x5thY6OTOhOhd8GSiineDdcCYxqXyCOfbLSHMWmHPjw=";
|
||||
}
|
||||
|
||||
public static class ProtocolRoutePaths
|
||||
|
|
@ -266,6 +277,14 @@ public static class IdentityServerConstants
|
|||
public const string MtlsIntrospection = MtlsPathPrefix + "/introspect";
|
||||
public const string MtlsDeviceAuthorization = MtlsPathPrefix + "/deviceauthorization";
|
||||
|
||||
public const string SamlPathPrefix = "saml";
|
||||
public const string SamlMetadata = SamlPathPrefix + "/metadata";
|
||||
public const string SamlSignin = SamlPathPrefix + "/sso";
|
||||
public const string SamlSigninCallback = SamlSignin + "/callback";
|
||||
public const string SamlIdpInitiated = SamlPathPrefix + "/idp-initiated";
|
||||
public const string SamlLogout = SamlPathPrefix + "/slo";
|
||||
public const string SamlLogoutCallback = SamlLogout + "/callback";
|
||||
|
||||
public static readonly string[] CorsPaths =
|
||||
{
|
||||
DiscoveryConfiguration,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
using Duende.IdentityServer.Saml;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml;
|
||||
|
||||
internal class DefaultSamlInteractionService(
|
||||
ISamlSigninStateStore stateStore,
|
||||
SamlSigninStateIdCookie stateIdCookie,
|
||||
ISamlServiceProviderStore serviceProviderStore,
|
||||
ILogger<DefaultSamlInteractionService> logger)
|
||||
: ISamlInteractionService
|
||||
{
|
||||
public async Task<SamlAuthenticationRequest?> GetAuthenticationRequestContextAsync(CancellationToken ct)
|
||||
{
|
||||
using var activity = Tracing.ServiceActivitySource.StartActivity("DefaultSamlInteractionService.GetAuthenticationRequestContext");
|
||||
|
||||
if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId))
|
||||
{
|
||||
logger.NoSamlAuthenticationStateFound(LogLevel.Warning);
|
||||
return null;
|
||||
}
|
||||
|
||||
var state = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct);
|
||||
if (state == null)
|
||||
{
|
||||
logger.StateNotFound(LogLevel.Warning, stateId.Value);
|
||||
return null;
|
||||
}
|
||||
|
||||
var sp = await serviceProviderStore.FindByEntityIdAsync(state.ServiceProviderEntityId);
|
||||
if (sp == null)
|
||||
{
|
||||
logger.ServiceProviderNotFound(LogLevel.Warning, state.ServiceProviderEntityId);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.AuthenticationStateLoaded(LogLevel.Debug, sp.EntityId);
|
||||
|
||||
return new SamlAuthenticationRequest
|
||||
{
|
||||
ServiceProvider = sp,
|
||||
AuthNRequest = state.Request,
|
||||
RelayState = state.RelayState,
|
||||
IsIdpInitiated = state.IsIdpInitiated
|
||||
};
|
||||
}
|
||||
|
||||
public async Task StoreRequestedAuthnContextResultAsync(bool requestedAuthnContextRequirementsWereMet, CancellationToken ct)
|
||||
{
|
||||
using var activity = Tracing.ServiceActivitySource.StartActivity("DefaultSamlInteractionService.StoreRequestedAuthnContextResult");
|
||||
|
||||
if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId))
|
||||
{
|
||||
logger.NoSamlAuthenticationStateFound(LogLevel.Warning);
|
||||
throw new InvalidOperationException("No active SAML authentication request found. Cannot store authentication error.");
|
||||
}
|
||||
|
||||
var state = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct);
|
||||
if (state == null)
|
||||
{
|
||||
logger.StateNotFound(LogLevel.Warning, stateId.Value);
|
||||
throw new InvalidOperationException($"SAML signin state not found for state ID {stateId.Value}");
|
||||
}
|
||||
|
||||
state.RequestedAuthnContextRequirementsWereMet = requestedAuthnContextRequirementsWereMet;
|
||||
await stateStore.UpdateSigninRequestStateAsync(stateId.Value, state, ct);
|
||||
|
||||
logger.RequestedAuthnContextRequirementsWereMetUpdatedInState(LogLevel.Debug, requestedAuthnContextRequirementsWereMet);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal static class HttpResponseBindings
|
||||
{
|
||||
internal static string GenerateAutoPostForm(SamlMessageName messageName, string encodedMessage, Uri destination, string? relayState, bool includeCsp = false)
|
||||
{
|
||||
var relayStateField = relayState == null
|
||||
? string.Empty
|
||||
: $@"<input type=""hidden"" name=""RelayState"" value=""{HtmlEncoder.Default.Encode(relayState)}"" />";
|
||||
|
||||
var cspMetaTag = includeCsp
|
||||
? $@"<meta http-equiv=""Content-Security-Policy"" content=""script-src '{IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript}'"" />"
|
||||
: string.Empty;
|
||||
|
||||
return $@"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
{cspMetaTag}
|
||||
<title>SAML Response</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<p><strong>Note:</strong> Since your browser does not support JavaScript, you must press the button below to proceed.</p>
|
||||
</noscript>
|
||||
<form method=""post"" action=""{HtmlEncoder.Default.Encode(destination.ToString())}"">
|
||||
<input type=""hidden"" name=""{messageName.Value}"" value=""{HtmlEncoder.Default.Encode(encodedMessage)}"" />
|
||||
{relayStateField}
|
||||
<noscript>
|
||||
<input type=""submit"" value=""Continue"" />
|
||||
</noscript>
|
||||
</form>
|
||||
<script>window.addEventListener('load', function () {{ document.forms[0].submit(); }});</script>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for SAML requests that have common validation fields
|
||||
/// </summary>
|
||||
internal interface ISamlRequest
|
||||
{
|
||||
internal static abstract string MessageName { get; }
|
||||
internal string Issuer { get; }
|
||||
internal SamlVersion Version { get; }
|
||||
internal DateTime IssueInstant { get; }
|
||||
internal Uri? Destination { get; }
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal interface ISamlResultSerializer<T>
|
||||
{
|
||||
XElement Serialize(T toSerialize);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Service for obtaining signing credentials for SAML operations.
|
||||
/// </summary>
|
||||
internal interface ISamlSigningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the X509 certificate used for signing SAML messages.
|
||||
/// </summary>
|
||||
/// <returns>The signing certificate with private key.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when no signing credential is available, when the credential is not an X509 certificate,
|
||||
/// or when the certificate does not have a private key.
|
||||
/// </exception>
|
||||
Task<X509Certificate2> GetSigningCertificateAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the X509 certificate as a base64-encoded string for inclusion in SAML metadata.
|
||||
/// </summary>
|
||||
/// <returns>Base64-encoded certificate bytes.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when no signing credential is available or when the credential is not an X509 certificate.
|
||||
/// </exception>
|
||||
Task<string> GetSigningCertificateBase64Async();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Net;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal class RedirectResult(Uri RedirectUri) : IEndpointResult
|
||||
{
|
||||
public Task ExecuteAsync(HttpContext httpContext)
|
||||
{
|
||||
var logger = httpContext.RequestServices.GetRequiredService<ILogger<RedirectResult>>();
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(RedirectUri);
|
||||
|
||||
logger.Redirecting(LogLevel.Trace, RedirectUri);
|
||||
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.Redirect;
|
||||
httpContext.Response.Headers.Location = RedirectUri.ToString();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal class ValidationProblemResult(string title, params KeyValuePair<string, string[]>[] errors) : IEndpointResult
|
||||
{
|
||||
public async Task ExecuteAsync(HttpContext context) =>
|
||||
await Results.ValidationProblem(new Dictionary<string, string[]>(errors), title).ExecuteAsync(context);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal record Result<TValue, TFailure>
|
||||
{
|
||||
[MemberNotNullWhen(true, nameof(Value))]
|
||||
[MemberNotNullWhen(false, nameof(Error))]
|
||||
internal bool Success { get; private init; }
|
||||
internal TValue? Value { get; private init; }
|
||||
internal TFailure? Error { get; private init; }
|
||||
|
||||
public static Result<TValue, TFailure> FromValue(TValue value) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
Value = value
|
||||
};
|
||||
|
||||
public static Result<TValue, TFailure> FromError(TFailure error) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
|
||||
public static implicit operator Result<TValue, TFailure>(TFailure value) =>
|
||||
FromError(value);
|
||||
public static implicit operator Result<TValue, TFailure>(TValue value) =>
|
||||
FromValue(value);
|
||||
|
||||
// Note: We can't have an implicit operator for TFailure when it's an interface
|
||||
// because C# won't do double conversion (ConcreteType -> Interface -> Result)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Security.Cryptography.Xml;
|
||||
using System.Xml;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal class SamlAssertionEncryptor(TimeProvider timeProvider, ILogger<SamlAssertionEncryptor> logger)
|
||||
{
|
||||
internal string EncryptAssertion(string responseXml, SamlServiceProvider serviceProvider)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(responseXml);
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
|
||||
var encryptionCertificate = serviceProvider.EncryptionCertificates?.FirstOrDefault(cert => IsCertificateValid(cert, serviceProvider.EntityId));
|
||||
if (encryptionCertificate == null)
|
||||
{
|
||||
throw new InvalidOperationException($"No valid encryption certificate found for {serviceProvider.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys.");
|
||||
}
|
||||
|
||||
var doc = SecureXmlParser.LoadXmlDocument(responseXml);
|
||||
var assertion = FindAssertion(doc);
|
||||
if (assertion == null)
|
||||
{
|
||||
throw new InvalidOperationException($"SAML Response does not contain an Assertion element for {serviceProvider.EntityId}");
|
||||
}
|
||||
|
||||
logger.EncryptingAssertion(LogLevel.Debug, serviceProvider.EntityId);
|
||||
|
||||
try
|
||||
{
|
||||
var encryptedAssertion = EncryptAssertionXml(assertion, encryptionCertificate, doc);
|
||||
|
||||
ReplaceAssertionWithEncrypted(assertion, encryptedAssertion);
|
||||
|
||||
logger.AssertionEncryptedSuccessfully(LogLevel.Debug, serviceProvider.EntityId);
|
||||
|
||||
return doc.OuterXml;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.FailedToEncryptAssertion(ex, serviceProvider.EntityId, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCertificateValid(X509Certificate2 certificate, string serviceProviderEntityId)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (certificate.NotAfter < now)
|
||||
{
|
||||
logger.CertificateExpired(LogLevel.Error, serviceProviderEntityId, certificate.NotAfter);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (certificate.NotBefore > now)
|
||||
{
|
||||
logger.CertificateNotYetValid(LogLevel.Error, serviceProviderEntityId, certificate.NotBefore);
|
||||
return false;
|
||||
}
|
||||
|
||||
using var publicKey = certificate.GetRSAPublicKey();
|
||||
if (publicKey == null)
|
||||
{
|
||||
logger.CertificateHasNoPublicRsaKey(LogLevel.Error, serviceProviderEntityId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (publicKey.KeySize < 2048)
|
||||
{
|
||||
logger.CertificateWeakKeySize(LogLevel.Error, serviceProviderEntityId, publicKey.KeySize);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.CertificateValidated(LogLevel.Debug, serviceProviderEntityId, certificate.NotAfter);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static XmlElement? FindAssertion(XmlDocument doc)
|
||||
{
|
||||
var nsManager = new XmlNamespaceManager(doc.NameTable);
|
||||
nsManager.AddNamespace("saml", SamlConstants.Namespaces.Assertion);
|
||||
|
||||
return doc.SelectSingleNode("//saml:Assertion", nsManager) as XmlElement;
|
||||
}
|
||||
|
||||
private static void ReplaceAssertionWithEncrypted(XmlElement originalAssertion, XmlElement encryptedAssertion)
|
||||
{
|
||||
var parentNode = originalAssertion.ParentNode;
|
||||
if (parentNode is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cannot replace SAML Assertion because it has no parent node in the XML document.");
|
||||
}
|
||||
|
||||
parentNode.ReplaceChild(encryptedAssertion, originalAssertion);
|
||||
}
|
||||
|
||||
private static XmlElement EncryptAssertionXml(XmlElement assertion, X509Certificate2 encryptionCertificate, XmlDocument doc)
|
||||
{
|
||||
var encryptedXml = new EncryptedXml();
|
||||
var encryptedData = encryptedXml.Encrypt(assertion, encryptionCertificate);
|
||||
|
||||
var encryptedAssertion = doc.CreateElement("saml", "EncryptedAssertion", SamlConstants.Namespaces.Assertion);
|
||||
var encryptedDataElement = doc.ImportNode(encryptedData.GetXml(), true);
|
||||
encryptedAssertion.AppendChild(encryptedDataElement);
|
||||
|
||||
return encryptedAssertion;
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML error response that will be sent to the Service Provider.
|
||||
/// </summary>
|
||||
internal class SamlErrorResponse : EndpointResult<SamlErrorResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the SAML binding to use for sending the response (HTTP-POST or HTTP-Redirect).
|
||||
/// </summary>
|
||||
public required SamlBinding Binding { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SAML status code for the error.
|
||||
/// </summary>
|
||||
public required SamlStatusCode StatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Assertion Consumer Service URL to send the response to.
|
||||
/// </summary>
|
||||
public required Uri AssertionConsumerServiceUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the IdP issuer URI.
|
||||
/// </summary>
|
||||
public required string Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request ID this response is replying to (InResponseTo), or null for IdP-initiated.
|
||||
/// </summary>
|
||||
public string? InResponseTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the RelayState to preserve across the response.
|
||||
/// </summary>
|
||||
public string? RelayState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional secondary status code for more specific error information.
|
||||
/// </summary>
|
||||
public SamlStatusCode? SubStatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Service Provider where the response will be sent.
|
||||
/// </summary>
|
||||
public required SamlServiceProvider ServiceProvider { get; init; }
|
||||
|
||||
internal class ResponseWriter(ISamlResultSerializer<SamlErrorResponse> serializer)
|
||||
: IHttpResponseWriter<SamlErrorResponse>
|
||||
{
|
||||
public async Task WriteHttpResponse(SamlErrorResponse result, HttpContext httpContext)
|
||||
{
|
||||
var responseElement = serializer.Serialize(result);
|
||||
|
||||
var doc = new XDocument(new XDeclaration("1.0", "UTF-8", null), responseElement);
|
||||
await using var stringWriter = new StringWriter();
|
||||
await using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings
|
||||
{
|
||||
OmitXmlDeclaration = false,
|
||||
Encoding = Encoding.UTF8,
|
||||
Indent = false,
|
||||
Async = true
|
||||
}))
|
||||
{
|
||||
doc.Save(xmlWriter);
|
||||
await xmlWriter.FlushAsync();
|
||||
}
|
||||
|
||||
var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(stringWriter.ToString()));
|
||||
|
||||
// Generate HTML form that auto-submits to the ACS URL
|
||||
var html = HttpResponseBindings.GenerateAutoPostForm(SamlMessageName.SamlResponse, encodedResponse, result.AssertionConsumerServiceUrl,
|
||||
result.RelayState);
|
||||
|
||||
httpContext.Response.ContentType = "text/html";
|
||||
httpContext.Response.Headers.CacheControl = "no-cache, no-store";
|
||||
httpContext.Response.Headers.Pragma = "no-cache";
|
||||
|
||||
await httpContext.Response.WriteAsync(html);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal class SamlErrorResponseXmlSerializer : ISamlResultSerializer<SamlErrorResponse>
|
||||
{
|
||||
public XElement Serialize(SamlErrorResponse result)
|
||||
{
|
||||
var responseId = ResponseId.New().ToString();
|
||||
var issueInstant = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
|
||||
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
|
||||
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
|
||||
|
||||
// Build Status element
|
||||
var statusCodeElement = new XElement(protocolNs + "StatusCode",
|
||||
new XAttribute("Value", result.StatusCode.ToString()));
|
||||
|
||||
// Add sub-status code if provided
|
||||
if (result.SubStatusCode.HasValue)
|
||||
{
|
||||
statusCodeElement.Add(
|
||||
new XElement(protocolNs + "StatusCode",
|
||||
new XAttribute("Value", result.SubStatusCode.Value.ToString())));
|
||||
}
|
||||
|
||||
var statusElement = new XElement(protocolNs + "Status",
|
||||
statusCodeElement);
|
||||
|
||||
// Add status message if provided
|
||||
if (!string.IsNullOrEmpty(result.Message))
|
||||
{
|
||||
statusElement.Add(
|
||||
new XElement(protocolNs + "StatusMessage", result.Message));
|
||||
}
|
||||
|
||||
// Build Response element
|
||||
var responseElement = new XElement(protocolNs + "Response",
|
||||
new XAttribute("ID", responseId),
|
||||
new XAttribute("Version", "2.0"),
|
||||
new XAttribute("IssueInstant", issueInstant),
|
||||
new XAttribute("Destination", result.AssertionConsumerServiceUrl.ToString()),
|
||||
new XElement(assertionNs + "Issuer", result.Issuer.ToString()),
|
||||
statusElement);
|
||||
|
||||
// Add InResponseTo if this is a response to a request
|
||||
if (result.InResponseTo != null)
|
||||
{
|
||||
responseElement.Add(new XAttribute("InResponseTo", result.InResponseTo));
|
||||
}
|
||||
|
||||
return responseElement;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for SAML protocol message parsers.
|
||||
/// Provides common XML parsing and validation utilities.
|
||||
/// </summary>
|
||||
internal abstract class SamlProtocolMessageParser
|
||||
{
|
||||
protected static string GetRequiredAttribute(XElement element, XName attributeName)
|
||||
{
|
||||
var value = element.Attribute(attributeName)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new FormatException($"Required attribute '{attributeName}' is missing or empty");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
protected static string? GetOptionalAttribute(XElement element, XName attributeName)
|
||||
{
|
||||
var value = element.Attribute(attributeName)?.Value;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
protected static DateTime ParseDateTime(XElement element, XName attributeName)
|
||||
{
|
||||
var value = GetRequiredAttribute(element, attributeName);
|
||||
if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var result))
|
||||
{
|
||||
throw new FormatException($"Invalid DateTime format for attribute '{attributeName}': {value}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected static bool ParseBooleanAttribute(XElement element, XName attributeName, bool defaultValue)
|
||||
{
|
||||
var value = GetOptionalAttribute(element, attributeName);
|
||||
if (value == null)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new FormatException($"Invalid boolean format for attribute '{attributeName}': {value}");
|
||||
}
|
||||
|
||||
protected static string ParseIssuerValue(XElement root, XNamespace assertionNs, string messageType)
|
||||
{
|
||||
var issuerElement = root.Element(assertionNs + "Issuer");
|
||||
if (issuerElement == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Issuer element is required in {messageType}");
|
||||
}
|
||||
|
||||
var issuer = issuerElement.Value?.Trim();
|
||||
if (string.IsNullOrEmpty(issuer))
|
||||
{
|
||||
throw new InvalidOperationException("Issuer element cannot be empty");
|
||||
}
|
||||
|
||||
return issuer;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal class SamlProtocolMessageSigner(
|
||||
ISamlSigningService samlSigningService,
|
||||
ILogger<SamlProtocolMessageSigner> logger)
|
||||
{
|
||||
internal async Task<string> SignProtocolMessage(XElement messageElement, SamlServiceProvider serviceProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(messageElement);
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
|
||||
var certificate = await samlSigningService.GetSigningCertificateAsync();
|
||||
|
||||
logger.SigningSamlProtocolMessage(LogLevel.Debug, serviceProvider.EntityId, messageElement.Name.LocalName);
|
||||
|
||||
try
|
||||
{
|
||||
var signedXml = XmlSignatureHelper.SignProtocolElement(messageElement, certificate);
|
||||
|
||||
logger.SuccessfullySignedSamlProtocolMessage(LogLevel.Debug, serviceProvider.EntityId, messageElement.Name.LocalName);
|
||||
|
||||
return signedXml;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.FailedToSignSamlProtocolMessage(ex, serviceProvider.EntityId, messageElement.Name.LocalName, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<string> SignQueryString(string queryString)
|
||||
{
|
||||
var certificate = await samlSigningService.GetSigningCertificateAsync();
|
||||
using var rsa = certificate.GetRSAPrivateKey();
|
||||
if (rsa == null)
|
||||
{
|
||||
throw new InvalidOperationException("RSA private key not available for signing.");
|
||||
}
|
||||
|
||||
queryString = $"{queryString}&SigAlg={Uri.EscapeDataString("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")}";
|
||||
|
||||
var bytesToSign = Encoding.UTF8.GetBytes(queryString);
|
||||
|
||||
var signature = rsa.SignData(bytesToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
|
||||
return $"{queryString}&Signature={Uri.EscapeDataString(Convert.ToBase64String(signature))}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Base record for SAML request wrappers that contain both the parsed request
|
||||
/// and HTTP binding metadata.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The type of the parsed SAML request</typeparam>
|
||||
internal abstract record SamlRequestBase<TRequest> where TRequest : ISamlRequest
|
||||
{
|
||||
public required TRequest Request { get; init; }
|
||||
|
||||
public required XDocument RequestXml { get; init; }
|
||||
|
||||
public required SamlBinding Binding { get; init; }
|
||||
|
||||
public string? RelayState { get; init; }
|
||||
|
||||
public string? Signature { get; init; }
|
||||
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
public string? EncodedSamlRequest { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal enum SamlRequestErrorType
|
||||
{
|
||||
Validation,
|
||||
Protocol
|
||||
}
|
||||
|
||||
internal class SamlRequestError<TRequest>
|
||||
{
|
||||
internal SamlRequestErrorType Type { get; init; }
|
||||
internal string? ValidationMessage { get; init; }
|
||||
internal SamlProtocolError<TRequest>? ProtocolError { get; init; }
|
||||
}
|
||||
|
||||
internal record SamlProtocolError<TRequest>(
|
||||
SamlServiceProvider ServiceProvider,
|
||||
TRequest Request,
|
||||
SamlError Error);
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.IO.Compression;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for extracting and parsing SAML protocol messages from HTTP requests.
|
||||
/// Handles common logic for both HTTP-Redirect and HTTP-POST bindings.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The type of the parsed SAML request (e.g., AuthNRequest, LogoutRequest)</typeparam>
|
||||
/// <typeparam name="TResult">The type of the result containing the parsed request and metadata</typeparam>
|
||||
internal abstract class SamlRequestExtractor<TRequest, TResult>
|
||||
where TRequest : ISamlRequest
|
||||
where TResult : SamlRequestBase<TRequest>
|
||||
{
|
||||
private const int MaxRequestSize = 1024 * 1024; // 1MB limit
|
||||
|
||||
protected abstract TRequest ParseRequest(XDocument xmlDocument);
|
||||
|
||||
protected abstract TResult CreateResult(
|
||||
TRequest parsedRequest,
|
||||
XDocument requestXml,
|
||||
SamlBinding binding,
|
||||
string? relayState,
|
||||
string? signature = null,
|
||||
string? signatureAlgorithm = null,
|
||||
string? encodedSamlRequest = null);
|
||||
|
||||
internal async ValueTask<TResult> ExtractAsync(HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
if (request.Method == HttpMethods.Get)
|
||||
{
|
||||
return ExtractRedirectRequest(request);
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethods.Post)
|
||||
{
|
||||
return await ExtractPostBindingRequest(request);
|
||||
}
|
||||
|
||||
throw new BadHttpRequestException($"Unsupported HTTP method '{request.Method}' for {TRequest.MessageName}");
|
||||
}
|
||||
|
||||
private TResult ExtractRedirectRequest(HttpRequest request)
|
||||
{
|
||||
var encodedRequest = request.Query[SamlConstants.RequestProperties.SAMLRequest].ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(encodedRequest))
|
||||
{
|
||||
throw new BadHttpRequestException(
|
||||
$"Missing '{SamlConstants.RequestProperties.SAMLRequest}' query parameter in {TRequest.MessageName}");
|
||||
}
|
||||
|
||||
var relayState = request.Query[SamlConstants.RequestProperties.RelayState].ToString();
|
||||
var signature = request.Query[SamlConstants.RequestProperties.Signature].ToString();
|
||||
var sigAlg = request.Query[SamlConstants.RequestProperties.SigAlg].ToString();
|
||||
|
||||
// HTTP-Redirect uses deflate compression
|
||||
byte[] compressedXmlBytes;
|
||||
try
|
||||
{
|
||||
compressedXmlBytes = Convert.FromBase64String(encodedRequest);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadHttpRequestException($"Invalid base64 encoding in {TRequest.MessageName}", ex);
|
||||
}
|
||||
using var compressedXmlStream = new MemoryStream(compressedXmlBytes);
|
||||
using var xmlStream = new DeflateStream(compressedXmlStream, CompressionMode.Decompress);
|
||||
using var limitedStream = new LimitedReadStream(xmlStream, MaxRequestSize);
|
||||
|
||||
var (parsedRequest, xmlDocument) = LoadRequestFromStream(limitedStream);
|
||||
|
||||
return CreateResult(
|
||||
parsedRequest,
|
||||
xmlDocument,
|
||||
SamlBinding.HttpRedirect,
|
||||
relayState,
|
||||
string.IsNullOrEmpty(signature) ? null : signature,
|
||||
string.IsNullOrEmpty(sigAlg) ? null : sigAlg,
|
||||
encodedRequest);
|
||||
}
|
||||
|
||||
private async Task<TResult> ExtractPostBindingRequest(HttpRequest request)
|
||||
{
|
||||
if (!request.HasFormContentType)
|
||||
{
|
||||
throw new BadHttpRequestException($"POST request does not have form content type for {TRequest.MessageName}");
|
||||
}
|
||||
|
||||
var form = await request.ReadFormAsync();
|
||||
var encodedRequest = form[SamlConstants.RequestProperties.SAMLRequest].ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(encodedRequest))
|
||||
{
|
||||
throw new BadHttpRequestException(
|
||||
$"Missing '{SamlConstants.RequestProperties.SAMLRequest}' form parameter in {TRequest.MessageName}");
|
||||
}
|
||||
|
||||
var relayState = form[SamlConstants.RequestProperties.RelayState].ToString();
|
||||
|
||||
// HTTP-POST has no compression
|
||||
byte[] xmlBytes;
|
||||
try
|
||||
{
|
||||
xmlBytes = Convert.FromBase64String(encodedRequest);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadHttpRequestException($"Invalid base64 encoding in {TRequest.MessageName}", ex);
|
||||
}
|
||||
using var xmlStream = new MemoryStream(xmlBytes);
|
||||
await using var limitedStream = new LimitedReadStream(xmlStream, MaxRequestSize);
|
||||
|
||||
var (parsedRequest, xmlDocument) = LoadRequestFromStream(limitedStream);
|
||||
|
||||
return CreateResult(
|
||||
parsedRequest,
|
||||
xmlDocument,
|
||||
SamlBinding.HttpPost,
|
||||
relayState);
|
||||
}
|
||||
|
||||
private (TRequest parsedRequest, XDocument xmlDocument) LoadRequestFromStream(LimitedReadStream limitedStream)
|
||||
{
|
||||
try
|
||||
{
|
||||
var xmlDocument = SecureXmlParser.LoadXDocument(limitedStream);
|
||||
var parsedRequest = ParseRequest(xmlDocument);
|
||||
|
||||
return (parsedRequest, xmlDocument);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadHttpRequestException($"Invalid SAMLRequest format in {TRequest.MessageName}", ex);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
throw new BadHttpRequestException($"Invalid SAMLRequest format in {TRequest.MessageName}", ex);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new BadHttpRequestException($"Failed to parse SAMLRequest XML in {TRequest.MessageName}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal abstract class SamlRequestProcessorBase<TMessage, TRequest, TSuccess>(
|
||||
ISamlServiceProviderStore serviceProviderStore,
|
||||
IOptions<SamlOptions> options,
|
||||
SamlRequestValidator requestValidator,
|
||||
SamlRequestSignatureValidator<TRequest, TMessage> signatureValidator,
|
||||
ILogger logger,
|
||||
string expectedDestination)
|
||||
where TMessage : ISamlRequest
|
||||
where TRequest : SamlRequestBase<TMessage>
|
||||
{
|
||||
protected readonly ISamlServiceProviderStore ServiceProviderStore = serviceProviderStore;
|
||||
protected readonly SamlOptions SamlOptions = options.Value;
|
||||
protected readonly SamlRequestValidator RequestValidator = requestValidator;
|
||||
protected readonly SamlRequestSignatureValidator<TRequest, TMessage> SignatureValidator = signatureValidator;
|
||||
protected readonly ILogger Logger = logger;
|
||||
protected readonly string ExpectedDestination = expectedDestination;
|
||||
|
||||
internal async Task<Result<TSuccess, SamlRequestError<TRequest>>> ProcessAsync(TRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var sp = await ServiceProviderStore.FindByEntityIdAsync(request.Request.Issuer);
|
||||
if (sp?.Enabled != true)
|
||||
{
|
||||
Logger.ServiceProviderNotFound(LogLevel.Warning, request.Request.Issuer);
|
||||
return new SamlRequestError<TRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"Service Provider '{request.Request.Issuer}' is not registered or is disabled"
|
||||
};
|
||||
}
|
||||
|
||||
var validationError = ValidateRequest(sp, request);
|
||||
if (validationError != null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
return await ProcessValidatedRequestAsync(sp, request, ct);
|
||||
}
|
||||
|
||||
private SamlRequestError<TRequest>? ValidateRequest(SamlServiceProvider sp, TRequest request)
|
||||
{
|
||||
// Common validation (version, issue instant, destination)
|
||||
var validationError = RequestValidator.ValidateCommonFields(
|
||||
request.Request.Version,
|
||||
request.Request.IssueInstant,
|
||||
request.Request.Destination,
|
||||
sp,
|
||||
ExpectedDestination);
|
||||
|
||||
if (validationError != null)
|
||||
{
|
||||
return new SamlRequestError<TRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Protocol,
|
||||
ProtocolError = new SamlProtocolError<TRequest>(sp, request, new SamlError
|
||||
{
|
||||
StatusCode = validationError.StatusCode,
|
||||
SubStatusCode = validationError.SubStatusCode,
|
||||
Message = validationError.Message
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// Signature validation
|
||||
var signatureError = ValidateSignature(sp, request);
|
||||
if (signatureError != null)
|
||||
{
|
||||
return signatureError;
|
||||
}
|
||||
|
||||
// Message-specific validation
|
||||
return ValidateMessageSpecific(sp, request);
|
||||
}
|
||||
|
||||
protected abstract bool RequireSignature(SamlServiceProvider sp);
|
||||
|
||||
private SamlRequestError<TRequest>? ValidateSignature(SamlServiceProvider sp, TRequest request)
|
||||
{
|
||||
var requireSignature = RequireSignature(sp);
|
||||
|
||||
if (!requireSignature)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sp.SigningCertificates == null || sp.SigningCertificates.Count == 0)
|
||||
{
|
||||
return new SamlRequestError<TRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"Service Provider '{sp.EntityId}' has no signing certificates configured and has sent a {TMessage.MessageName} which requires signature validation"
|
||||
};
|
||||
}
|
||||
|
||||
Result<bool, SamlError> validationResult;
|
||||
|
||||
if (request.Binding == SamlBinding.HttpRedirect)
|
||||
{
|
||||
validationResult = SignatureValidator.ValidateRedirectBindingSignature(request, sp);
|
||||
}
|
||||
else if (request.Binding == SamlBinding.HttpPost)
|
||||
{
|
||||
validationResult = SignatureValidator.ValidatePostBindingSignature(request, sp);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new SamlRequestError<TRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Protocol,
|
||||
ProtocolError = new SamlProtocolError<TRequest>(sp, request, new SamlError
|
||||
{
|
||||
StatusCode = SamlStatusCode.Requester,
|
||||
Message = $"Unsupported binding for signature validation: {request.Binding}"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (!validationResult.Success)
|
||||
{
|
||||
return new SamlRequestError<TRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Protocol,
|
||||
ProtocolError = new SamlProtocolError<TRequest>(sp, request, validationResult.Error)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
protected abstract SamlRequestError<TRequest>? ValidateMessageSpecific(SamlServiceProvider sp, TRequest request);
|
||||
protected abstract Task<Result<TSuccess, SamlRequestError<TRequest>>> ProcessValidatedRequestAsync(SamlServiceProvider sp, TRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Security.Cryptography.Xml;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Validates signatures on SAML request messages for both HTTP-Redirect and HTTP-POST bindings.
|
||||
/// </summary>
|
||||
internal class SamlRequestSignatureValidator<TRequest, TSamlRequest>(TimeProvider timeProvider)
|
||||
where TRequest : SamlRequestBase<TSamlRequest>
|
||||
where TSamlRequest : ISamlRequest
|
||||
{
|
||||
private static readonly HashSet<string> SupportedAlgorithms =
|
||||
[
|
||||
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
||||
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Validates signature on HTTP-Redirect binding request.
|
||||
/// </summary>
|
||||
internal Result<bool, SamlError> ValidateRedirectBindingSignature(
|
||||
TRequest request,
|
||||
SamlServiceProvider serviceProvider)
|
||||
{
|
||||
var signature = request.Signature;
|
||||
var sigAlg = request.SignatureAlgorithm;
|
||||
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
{
|
||||
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = "Missing signature parameter" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(sigAlg))
|
||||
{
|
||||
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = "Missing signature algorithm parameter" });
|
||||
}
|
||||
|
||||
if (!SupportedAlgorithms.Contains(sigAlg))
|
||||
{
|
||||
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = $"Unsupported signature algorithm: {sigAlg}" });
|
||||
}
|
||||
|
||||
// re-create the querystring part that is signed. The spec dictates the exact way this is to be done:
|
||||
// SAMLRequest=value&RelayState=value&SigAlg=value
|
||||
// The parameters must be URL-encoded
|
||||
var queryToVerify = $"SAMLRequest={Uri.EscapeDataString(request.EncodedSamlRequest!)}";
|
||||
|
||||
if (request.RelayState != null)
|
||||
{
|
||||
queryToVerify += $"&RelayState={Uri.EscapeDataString(request.RelayState)}";
|
||||
}
|
||||
|
||||
queryToVerify += $"&SigAlg={Uri.EscapeDataString(sigAlg)}";
|
||||
|
||||
var bytesToVerify = Encoding.UTF8.GetBytes(queryToVerify);
|
||||
var signatureBytes = Convert.FromBase64String(signature);
|
||||
|
||||
return ValidateWithCertificates(
|
||||
serviceProvider,
|
||||
cert => ValidateRedirectSignature(cert, bytesToVerify, signatureBytes, sigAlg));
|
||||
}
|
||||
|
||||
private static bool ValidateRedirectSignature(X509Certificate2 cert, byte[] data, byte[] signature, string sigAlg)
|
||||
{
|
||||
using var rsa = cert.GetRSAPublicKey();
|
||||
if (rsa == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hashAlgorithm = sigAlg.Contains("sha512", StringComparison.OrdinalIgnoreCase)
|
||||
? HashAlgorithmName.SHA512
|
||||
: HashAlgorithmName.SHA256;
|
||||
|
||||
return rsa.VerifyData(data, signature, hashAlgorithm, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates signature on HTTP-POST binding request.
|
||||
/// </summary>
|
||||
internal Result<bool, SamlError> ValidatePostBindingSignature(
|
||||
TRequest request,
|
||||
SamlServiceProvider serviceProvider)
|
||||
{
|
||||
var requestXml = request.RequestXml;
|
||||
|
||||
// In order to use SignedXml, we need to work with XmlDocument, not an XDocument.
|
||||
// So we convert the XDocument to string and then parse it securely into an XmlDocument.
|
||||
var xmlString = requestXml.ToString(SaveOptions.DisableFormatting);
|
||||
XmlDocument doc;
|
||||
|
||||
try
|
||||
{
|
||||
doc = SecureXmlParser.LoadXmlDocument(xmlString);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = $"Invalid XML: {ex.Message}" });
|
||||
}
|
||||
|
||||
// Find signature element
|
||||
var nsmgr = new XmlNamespaceManager(doc.NameTable);
|
||||
nsmgr.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#");
|
||||
|
||||
var signatureNode = doc.SelectSingleNode("//ds:Signature", nsmgr);
|
||||
if (signatureNode == null)
|
||||
{
|
||||
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = "Signature element not found" });
|
||||
}
|
||||
|
||||
// Get the request ID that must be signed
|
||||
var requestId = doc.DocumentElement?.GetAttribute("ID");
|
||||
if (string.IsNullOrEmpty(requestId))
|
||||
{
|
||||
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = $"{TSamlRequest.MessageName} missing ID attribute" });
|
||||
}
|
||||
|
||||
return ValidateWithCertificates(
|
||||
serviceProvider,
|
||||
cert => ValidateXmlSignature(cert, doc, signatureNode, requestId));
|
||||
}
|
||||
|
||||
private static bool ValidateXmlSignature(X509Certificate2 cert, XmlDocument doc, XmlNode signatureNode, string expectedId)
|
||||
{
|
||||
var signedXml = new SignedXml(doc);
|
||||
signedXml.LoadXml((XmlElement)signatureNode);
|
||||
|
||||
if (!signedXml.CheckSignature(cert, true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// SECURITY: Verify the signature references the request element
|
||||
var reference = signedXml.SignedInfo?.References.Cast<Reference>().FirstOrDefault();
|
||||
if (reference == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var referencedId = reference.Uri?.TrimStart('#');
|
||||
return referencedId == expectedId;
|
||||
}
|
||||
|
||||
private Result<bool, SamlError> ValidateWithCertificates(
|
||||
SamlServiceProvider serviceProvider,
|
||||
Func<X509Certificate2, bool> validateSignature)
|
||||
{
|
||||
var validCertificates = serviceProvider.SigningCertificates?.Where(cert => ValidateCertificate(cert).Success).ToList();
|
||||
if (validCertificates == null || validCertificates.Count == 0)
|
||||
{
|
||||
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Responder, Message = "No valid certificates configured for service provider" });
|
||||
}
|
||||
|
||||
foreach (var cert in validCertificates)
|
||||
{
|
||||
if (validateSignature(cert))
|
||||
{
|
||||
return Result<bool, SamlError>.FromValue(true);
|
||||
}
|
||||
}
|
||||
|
||||
return Result<bool, SamlError>.FromError(new SamlError { StatusCode = SamlStatusCode.Requester, Message = "Invalid signature" });
|
||||
}
|
||||
|
||||
private Result<bool, string> ValidateCertificate(X509Certificate2 certificate)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (certificate.NotBefore > now.UtcDateTime)
|
||||
{
|
||||
return Result<bool, string>.FromError($"Certificate is not yet valid (NotBefore: {certificate.NotBefore:u})");
|
||||
}
|
||||
|
||||
if (certificate.NotAfter < now.UtcDateTime)
|
||||
{
|
||||
return Result<bool, string>.FromError($"Certificate has expired (NotAfter: {certificate.NotAfter:u})");
|
||||
}
|
||||
|
||||
return Result<bool, string>.FromValue(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for common SAML request validation logic
|
||||
/// </summary>
|
||||
internal class SamlRequestValidator(TimeProvider timeProvider, IOptions<SamlOptions> options)
|
||||
{
|
||||
private readonly SamlOptions _samlOptions = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Validates version, issue instant, and destination for a SAML request
|
||||
/// </summary>
|
||||
internal SamlValidationError? ValidateCommonFields(
|
||||
SamlVersion version,
|
||||
DateTime issueInstant,
|
||||
Uri? destination,
|
||||
SamlServiceProvider serviceProvider,
|
||||
string expectedDestination)
|
||||
{
|
||||
// Version validation
|
||||
if (version != SamlVersion.V2)
|
||||
{
|
||||
return new SamlValidationError
|
||||
{
|
||||
Message = "Only Version 2.0 is supported",
|
||||
StatusCode = SamlStatusCode.VersionMismatch
|
||||
};
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow().UtcDateTime;
|
||||
var clockSkew = serviceProvider.ClockSkew ?? _samlOptions.DefaultClockSkew;
|
||||
|
||||
// Issue instant not in future
|
||||
if (issueInstant > now.Add(clockSkew))
|
||||
{
|
||||
return new SamlValidationError
|
||||
{
|
||||
StatusCode = SamlStatusCode.Requester,
|
||||
Message = "Request IssueInstant is in the future"
|
||||
};
|
||||
}
|
||||
|
||||
// Issue instant not too old
|
||||
var maxAge = serviceProvider.RequestMaxAge ?? _samlOptions.DefaultRequestMaxAge;
|
||||
if (issueInstant < now.Subtract(maxAge))
|
||||
{
|
||||
return new SamlValidationError
|
||||
{
|
||||
StatusCode = SamlStatusCode.Requester,
|
||||
Message = "Request has expired (IssueInstant too old)"
|
||||
};
|
||||
}
|
||||
|
||||
// Destination validation
|
||||
if (destination != null)
|
||||
{
|
||||
if (!destination.ToString().Equals(expectedDestination, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new SamlValidationError
|
||||
{
|
||||
StatusCode = SamlStatusCode.Requester,
|
||||
Message = $"Invalid destination. Expected '{expectedDestination}'"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML validation error
|
||||
/// </summary>
|
||||
internal class SamlValidationError
|
||||
{
|
||||
internal required string Message { get; init; }
|
||||
internal SamlStatusCode StatusCode { get; init; } = SamlStatusCode.Requester;
|
||||
internal SamlStatusCode? SubStatusCode { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal class SamlResponseSigner(
|
||||
ISamlSigningService samlSigningService,
|
||||
IOptions<SamlOptions> samlOptions,
|
||||
ILogger<SamlResponseSigner> logger)
|
||||
{
|
||||
internal async Task<string> SignResponse(XElement responseElement, SamlServiceProvider serviceProvider)
|
||||
{
|
||||
var signingBehavior = serviceProvider.SigningBehavior ?? samlOptions.Value.DefaultSigningBehavior;
|
||||
|
||||
if (signingBehavior == SamlSigningBehavior.DoNotSign)
|
||||
{
|
||||
logger.SigningDisabledForServiceProvider(LogLevel.Debug, serviceProvider.EntityId);
|
||||
return responseElement.ToString(SaveOptions.DisableFormatting);
|
||||
}
|
||||
|
||||
var certificate = await samlSigningService.GetSigningCertificateAsync();
|
||||
|
||||
logger.SigningSamlResponse(LogLevel.Debug, serviceProvider.EntityId, signingBehavior);
|
||||
|
||||
try
|
||||
{
|
||||
var signedXml = signingBehavior switch
|
||||
{
|
||||
SamlSigningBehavior.SignResponse =>
|
||||
XmlSignatureHelper.SignResponse(responseElement, certificate),
|
||||
|
||||
SamlSigningBehavior.SignAssertion =>
|
||||
XmlSignatureHelper.SignAssertionInResponse(responseElement, certificate),
|
||||
|
||||
SamlSigningBehavior.SignBoth =>
|
||||
XmlSignatureHelper.SignBoth(responseElement, certificate),
|
||||
|
||||
_ => throw new ArgumentException($"Unknown signing behavior: {signingBehavior}")
|
||||
};
|
||||
|
||||
logger.SuccessfullySignedSamlResponse(LogLevel.Debug, serviceProvider.EntityId);
|
||||
|
||||
return signedXml;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.FailedToSignSamlResponse(ex, serviceProvider.EntityId, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISamlSigningService"/>.
|
||||
/// </summary>
|
||||
internal class SamlSigningService(
|
||||
IKeyMaterialService keyMaterialService,
|
||||
ILogger<SamlSigningService> logger) : ISamlSigningService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public async Task<X509Certificate2> GetSigningCertificateAsync()
|
||||
{
|
||||
var credential = await GetSigningCredentialsAsync();
|
||||
if (!TryExtractCertificateFromCredential(credential, out var certificate))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Signing credential must be an X509 certificate with private key.");
|
||||
}
|
||||
|
||||
if (!certificate.HasPrivateKey)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Signing certificate must have a private key.");
|
||||
}
|
||||
|
||||
return certificate;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> GetSigningCertificateBase64Async()
|
||||
{
|
||||
var credential = await GetSigningCredentialsAsync();
|
||||
if (TryExtractCertificateFromCredential(credential, out var certificate))
|
||||
{
|
||||
var certBytes = certificate.Export(X509ContentType.Cert);
|
||||
return Convert.ToBase64String(certBytes);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Signing credential key is not an X509SecurityKey and cannot be used to extract an X509 certificate for SAML metadata.");
|
||||
}
|
||||
|
||||
private async Task<SigningCredentials> GetSigningCredentialsAsync()
|
||||
{
|
||||
var credential = await keyMaterialService.GetSigningCredentialsAsync();
|
||||
return credential ?? throw new InvalidOperationException("No signing credential available. Configure a signing certificate.");
|
||||
}
|
||||
|
||||
private bool TryExtractCertificateFromCredential(SigningCredentials credential, [NotNullWhen(returnValue: true)] out X509Certificate2? certificate)
|
||||
{
|
||||
certificate = null;
|
||||
if (credential.Key is X509SecurityKey x509Key)
|
||||
{
|
||||
certificate = x509Key.Certificate;
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.SigningCredentialIsNotX509Certificate(LogLevel.Warning, credential.Key);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal class SamlUrlBuilder(IServerUrls urls,
|
||||
IOptions<IdentityServerOptions> identityServerOptions,
|
||||
IOptions<SamlOptions> samlOptions)
|
||||
{
|
||||
private readonly SamlUserInteractionOptions _samlRoutes = samlOptions.Value.UserInteraction;
|
||||
private readonly UserInteractionOptions _identityServerRoutes = identityServerOptions.Value.UserInteraction;
|
||||
|
||||
internal Uri SamlConsentUri()
|
||||
{
|
||||
var consentUrl = _identityServerRoutes.ConsentUrl
|
||||
?? throw new InvalidOperationException("No consent url configured");
|
||||
|
||||
var returnUrlParameter = _identityServerRoutes.ConsentReturnUrlParameter
|
||||
?? throw new InvalidOperationException("No Consent return url configured");
|
||||
|
||||
|
||||
return BuildRedirectUrl(consentUrl, returnUrlParameter);
|
||||
}
|
||||
|
||||
internal Uri SamlLoginUri()
|
||||
{
|
||||
var loginPageUrl = _identityServerRoutes.LoginUrl
|
||||
?? throw new InvalidOperationException("No login url configured");
|
||||
var returnUrlParameter = _identityServerRoutes.LoginReturnUrlParameter
|
||||
?? throw new InvalidOperationException("No Login return url configured");
|
||||
|
||||
|
||||
return BuildRedirectUrl(loginPageUrl, returnUrlParameter);
|
||||
}
|
||||
|
||||
internal Uri SamlLogoutUri(string logoutId)
|
||||
{
|
||||
var logoutPageUrl = _identityServerRoutes.LogoutUrl ?? throw new InvalidOperationException("No logout url configured");
|
||||
var logoutIdParameter = _identityServerRoutes.LogoutIdParameter ?? throw new InvalidOperationException("No logout id parameter configured");
|
||||
|
||||
logoutPageUrl = logoutPageUrl.AddQueryString(logoutIdParameter, logoutId);
|
||||
|
||||
return new Uri(logoutPageUrl, logoutPageUrl.IsLocalUrl() ? UriKind.Relative : UriKind.Absolute);
|
||||
}
|
||||
|
||||
internal Uri SamlSignInCallBackUri()
|
||||
{
|
||||
var signInCallBackUrl = _samlRoutes.Route + _samlRoutes.SignInCallbackPath;
|
||||
|
||||
return new Uri(signInCallBackUrl, UriKind.Relative);
|
||||
}
|
||||
|
||||
internal Uri SamlLogoutCallBackUri()
|
||||
{
|
||||
var logoutCallbackUri = _samlRoutes.Route + _samlRoutes.SingleLogoutCallbackPath;
|
||||
|
||||
return new Uri(logoutCallbackUri, UriKind.Relative);
|
||||
}
|
||||
|
||||
private Uri BuildRedirectUrl(string redirectUrl, string returnUrlParameter)
|
||||
{
|
||||
var returnUrl = BuildReturnUrl();
|
||||
|
||||
var uriKind = UriKind.Relative;
|
||||
if (!redirectUrl.IsLocalUrl())
|
||||
{
|
||||
// The login page is hosted externally. So, the return url needs to be absolute.
|
||||
// Since the return url is hosted by us, we can make absolute from the server url.
|
||||
returnUrl = urls.GetAbsoluteUrl(returnUrl);
|
||||
uriKind = UriKind.Absolute;
|
||||
}
|
||||
|
||||
var queryString = new QueryString();
|
||||
queryString = queryString.Add(returnUrlParameter, returnUrl);
|
||||
|
||||
return new Uri(redirectUrl + queryString, uriKind);
|
||||
}
|
||||
|
||||
private string BuildReturnUrl() => _samlRoutes.Route + _samlRoutes.SignInCallbackPath;
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Provides secure XML parsing with hardened settings to prevent common XML attacks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class protects against:
|
||||
/// - XXE (XML External Entity) attacks
|
||||
/// - DTD (Document Type Definition) attacks
|
||||
/// - Billion laughs attack (entity expansion)
|
||||
/// - Resource exhaustion attacks
|
||||
/// </remarks>
|
||||
internal static class SecureXmlParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum allowed size for SAML messages (1MB).
|
||||
/// </summary>
|
||||
internal const int MaxMessageSize = 1048576; // 1MB
|
||||
|
||||
/// <summary>
|
||||
/// Secure XML reader settings configured to prevent common XML attacks.
|
||||
/// </summary>
|
||||
private static readonly XmlReaderSettings SecureSettings = new()
|
||||
{
|
||||
// Prohibit DTD processing to prevent DTD-based attacks
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
|
||||
// Disable external entity resolution to prevent XXE attacks
|
||||
XmlResolver = null,
|
||||
|
||||
// Prevent entity expansion attacks (billion laughs)
|
||||
MaxCharactersFromEntities = 0,
|
||||
|
||||
// Limit document size to prevent resource exhaustion
|
||||
MaxCharactersInDocument = MaxMessageSize,
|
||||
|
||||
// Ignore comments to prevent comment injection attacks
|
||||
IgnoreComments = true,
|
||||
|
||||
// Ignore processing instructions to reduce attack surface
|
||||
IgnoreProcessingInstructions = true,
|
||||
|
||||
// Validate well-formed XML
|
||||
ConformanceLevel = ConformanceLevel.Document
|
||||
};
|
||||
|
||||
internal static XDocument LoadXDocument(Stream input)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var xmlReader = XmlReader.Create(input, SecureSettings);
|
||||
return XDocument.Load(xmlReader);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new XmlException(
|
||||
"Failed to parse XML document with secure settings. " +
|
||||
"The document may contain prohibited constructs (DTD, external entities) or be malformed.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal static XmlDocument LoadXmlDocument(string xml)
|
||||
{
|
||||
if (string.IsNullOrEmpty(xml))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty");
|
||||
}
|
||||
|
||||
if (xml.Length > MaxMessageSize)
|
||||
{
|
||||
throw new XmlException(
|
||||
$"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " +
|
||||
$"Actual size: {xml.Length} bytes.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stringReader = new StringReader(xml);
|
||||
using var xmlReader = XmlReader.Create(stringReader, SecureSettings);
|
||||
|
||||
var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
|
||||
doc.Load(xmlReader);
|
||||
|
||||
return doc;
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new XmlException(
|
||||
"Failed to parse XML document with secure settings. " +
|
||||
"The document may contain prohibited constructs (DTD, external entities) or be malformed.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Security.Cryptography.Xml;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
|
||||
internal static class XmlSignatureHelper
|
||||
{
|
||||
internal static string SignResponse(XElement responseElement, X509Certificate2 certificate)
|
||||
{
|
||||
var xmlDoc = ConvertToXmlDocument(responseElement);
|
||||
|
||||
var docElement = xmlDoc.DocumentElement;
|
||||
if (docElement?.LocalName != "Response")
|
||||
{
|
||||
throw new ArgumentException("XML must contain a Response element");
|
||||
}
|
||||
|
||||
SignElement(xmlDoc, docElement, certificate);
|
||||
return xmlDoc.OuterXml;
|
||||
}
|
||||
|
||||
internal static string SignProtocolElement(XElement protocolElement, X509Certificate2 certificate)
|
||||
{
|
||||
var xmlDoc = ConvertToXmlDocument(protocolElement);
|
||||
|
||||
var docElement = xmlDoc.DocumentElement;
|
||||
if (docElement == null)
|
||||
{
|
||||
throw new ArgumentException("XML must contain a root element");
|
||||
}
|
||||
|
||||
SignElement(xmlDoc, docElement, certificate);
|
||||
return xmlDoc.OuterXml;
|
||||
}
|
||||
|
||||
internal static string SignAssertionInResponse(XElement responseElement, X509Certificate2 certificate)
|
||||
{
|
||||
var xmlDoc = ConvertToXmlDocument(responseElement);
|
||||
|
||||
// Find the Assertion element
|
||||
var nsmgr = new XmlNamespaceManager(xmlDoc.NameTable);
|
||||
nsmgr.AddNamespace("saml", SamlConstants.Namespaces.Assertion);
|
||||
nsmgr.AddNamespace("samlp", SamlConstants.Namespaces.Protocol);
|
||||
|
||||
var assertionNode = xmlDoc.SelectSingleNode("//saml:Assertion", nsmgr);
|
||||
if (assertionNode is not XmlElement assertionElement)
|
||||
{
|
||||
throw new ArgumentException("Response must contain an Assertion element");
|
||||
}
|
||||
|
||||
SignElement(xmlDoc, assertionElement, certificate);
|
||||
return xmlDoc.OuterXml;
|
||||
}
|
||||
|
||||
internal static string SignBoth(XElement responseElement, X509Certificate2 certificate)
|
||||
{
|
||||
// First sign the assertion
|
||||
var xmlAfterAssertionSigned = SignAssertionInResponse(responseElement, certificate);
|
||||
|
||||
// Convert back to XElement and then sign the response
|
||||
var xmlDoc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
|
||||
xmlDoc.LoadXml(xmlAfterAssertionSigned);
|
||||
|
||||
var docElement = xmlDoc.DocumentElement;
|
||||
if (docElement?.LocalName != "Response")
|
||||
{
|
||||
throw new ArgumentException("XML must contain a Response element");
|
||||
}
|
||||
|
||||
SignElement(xmlDoc, docElement, certificate);
|
||||
return xmlDoc.OuterXml;
|
||||
}
|
||||
|
||||
private static void SignElement(
|
||||
XmlDocument xmlDoc,
|
||||
XmlElement elementToSign,
|
||||
X509Certificate2 certificate)
|
||||
{
|
||||
// Validate element has ID attribute (required for SAML signing)
|
||||
var idAttribute = elementToSign.GetAttribute("ID");
|
||||
if (string.IsNullOrEmpty(idAttribute))
|
||||
{
|
||||
throw new ArgumentException("Element to sign must have an ID attribute");
|
||||
}
|
||||
|
||||
// Get private key
|
||||
var privateKey = certificate.GetRSAPrivateKey();
|
||||
if (privateKey == null)
|
||||
{
|
||||
throw new CryptographicException("Cannot get private key from certificate");
|
||||
}
|
||||
|
||||
// Create a custom SignedXml that knows how to resolve ID attributes
|
||||
var signedXml = new SignedXml(xmlDoc)
|
||||
{
|
||||
SigningKey = privateKey
|
||||
};
|
||||
|
||||
// Set canonicalization method for SignedInfo
|
||||
signedXml.SignedInfo!.CanonicalizationMethod = SignedXml.XmlDsigExcC14NTransformUrl;
|
||||
signedXml.SignedInfo.SignatureMethod = SignedXml.XmlDsigRSASHA256Url;
|
||||
|
||||
// Create reference to the element (using its ID)
|
||||
var reference = new Reference($"#{idAttribute}")
|
||||
{
|
||||
DigestMethod = SignedXml.XmlDsigSHA256Url
|
||||
};
|
||||
|
||||
// Add transforms
|
||||
reference.AddTransform(new XmlDsigEnvelopedSignatureTransform());
|
||||
reference.AddTransform(new XmlDsigExcC14NTransform());
|
||||
|
||||
signedXml.AddReference(reference);
|
||||
|
||||
// Add certificate to KeyInfo
|
||||
var keyInfo = new KeyInfo();
|
||||
keyInfo.AddClause(new KeyInfoX509Data(certificate));
|
||||
signedXml.KeyInfo = keyInfo;
|
||||
|
||||
// Compute signature
|
||||
signedXml.ComputeSignature();
|
||||
|
||||
// Get signature element
|
||||
var signatureElement = signedXml.GetXml();
|
||||
|
||||
// Insert signature after Issuer element (per SAML spec)
|
||||
InsertSignatureAfterIssuer(elementToSign, signatureElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts signature element after Issuer element (SAML requirement)
|
||||
/// </summary>
|
||||
private static void InsertSignatureAfterIssuer(
|
||||
XmlElement parentElement,
|
||||
XmlElement signatureElement)
|
||||
{
|
||||
// Find Issuer element
|
||||
var issuerElement = parentElement.SelectSingleNode("*[local-name()='Issuer']");
|
||||
|
||||
if (issuerElement != null && issuerElement.NextSibling != null)
|
||||
{
|
||||
// Insert after Issuer
|
||||
parentElement.InsertAfter(signatureElement, issuerElement);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No Issuer or no next sibling - insert as first child
|
||||
if (parentElement.FirstChild != null)
|
||||
{
|
||||
parentElement.InsertBefore(signatureElement, parentElement.FirstChild);
|
||||
}
|
||||
else
|
||||
{
|
||||
parentElement.AppendChild(signatureElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts XElement to XmlDocument, preserving namespace prefixes
|
||||
/// </summary>
|
||||
private static XmlDocument ConvertToXmlDocument(XElement element)
|
||||
{
|
||||
var xmlDoc = new XmlDocument
|
||||
{
|
||||
PreserveWhitespace = true, // Important for signatures
|
||||
XmlResolver = null // Disable external entity resolution (XXE protection)
|
||||
};
|
||||
|
||||
using var reader = element.CreateReader();
|
||||
xmlDoc.Load(reader);
|
||||
|
||||
return xmlDoc;
|
||||
}
|
||||
}
|
||||
20
identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs
Normal file
20
identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the usage type of a SAML key descriptor.
|
||||
/// </summary>
|
||||
internal enum KeyUse
|
||||
{
|
||||
/// <summary>
|
||||
/// Key used for signing.
|
||||
/// </summary>
|
||||
Signing,
|
||||
|
||||
/// <summary>
|
||||
/// Key used for encryption.
|
||||
/// </summary>
|
||||
Encryption
|
||||
}
|
||||
192
identity-server/src/IdentityServer/Internal/Saml/Log.cs
Normal file
192
identity-server/src/IdentityServer/Internal/Saml/Log.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml;
|
||||
|
||||
internal static class SamlLogParameters
|
||||
{
|
||||
internal const string RedirectUrl = "redirectUrl";
|
||||
internal const string EntityId = "entityId";
|
||||
internal const string SamlSigningBehavior = "samlSigningBehavior";
|
||||
internal const string ErrorMessage = "errorMessage";
|
||||
internal const string SecurityKey = "securityKey";
|
||||
internal const string RequestedAuthnContextRequirementsWereMet = "requestedAuthnContextRequirementsWereMet";
|
||||
internal const string ClaimCount = "claimCount";
|
||||
internal const string AttributeCount = "attributeCount";
|
||||
internal const string MessageType = "messageType";
|
||||
}
|
||||
|
||||
internal static partial class Log
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventName = nameof(Redirecting),
|
||||
Message = $"Redirecting to {{{SamlLogParameters.RedirectUrl}}}"
|
||||
)]
|
||||
internal static partial void Redirecting(this ILogger logger, LogLevel level, Uri redirectUrl);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(StartSamlSigninRequest),
|
||||
Message = $"Starting Saml Signin request"
|
||||
)]
|
||||
internal static partial void StartSamlSigninRequest(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(StartSamlSigninCallbackRequest),
|
||||
Message = $"Starting Saml Signin Callback request"
|
||||
)]
|
||||
internal static partial void StartSamlSigninCallbackRequest(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlInteractionPassiveAndForced),
|
||||
Message = $"AuthN request asks for both passive and forced. This is not supported, so returning 'nopassive'"
|
||||
)]
|
||||
internal static partial void SamlInteractionPassiveAndForced(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlInteractionForced),
|
||||
Message = $"AuthN request asks for forced. User is already authenticated, so signing out user and triggering new login."
|
||||
)]
|
||||
internal static partial void SamlInteractionForced(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlInteractionAlreadyAuthenticated),
|
||||
Message = $"AuthN request asked for Passive. User is already authenticated, so triggering callback."
|
||||
)]
|
||||
internal static partial void SamlInteractionAlreadyAuthenticated(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlInteractionNoPassive),
|
||||
Message = $"AuthN request asks for passive. User is not authenticated, so returning error 'NoPassive'"
|
||||
)]
|
||||
internal static partial void SamlInteractionNoPassive(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlInteractionConsent),
|
||||
Message = $"ServiceProvider is configured to require consent. The AuthN request indicates that consent hasn't already been provided, so triggering consent screen."
|
||||
)]
|
||||
internal static partial void SamlInteractionConsent(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlInteractionLogin),
|
||||
Message = $"AuthN request asks for login. User is not authenticated, so triggering login."
|
||||
)]
|
||||
internal static partial void SamlInteractionLogin(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SigningDisabledForServiceProvider),
|
||||
Message = $"Signing disabled for SP {{{SamlLogParameters.EntityId}}}")]
|
||||
internal static partial void SigningDisabledForServiceProvider(this ILogger logger, LogLevel level, string entityId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SigningSamlResponse),
|
||||
Message = $"Signing SAML message for SP {{{SamlLogParameters.EntityId}}} with signing behavior {{{SamlLogParameters.SamlSigningBehavior}}}")]
|
||||
internal static partial void SigningSamlResponse(this ILogger logger, LogLevel level, string entityId, SamlSigningBehavior samlSigningBehavior);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SuccessfullySignedSamlResponse),
|
||||
Message = $"Successfully signed SAML message for SP {{{SamlLogParameters.EntityId}}}")]
|
||||
internal static partial void SuccessfullySignedSamlResponse(this ILogger logger, LogLevel level, string entityId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(FailedToSignSamlResponse),
|
||||
Level = LogLevel.Error,
|
||||
Message = $"Failed to sign SAML Response for SP {{{SamlLogParameters.EntityId}}}: {{{SamlLogParameters.ErrorMessage}}}")]
|
||||
internal static partial void FailedToSignSamlResponse(this ILogger logger, Exception ex, string entityId, string errorMessage);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SigningCredentialIsNotX509Certificate),
|
||||
Message = $"Signing credential is not an X509 certificate (Key: {{{SamlLogParameters.SecurityKey}}}). SAML signing requires X509 certificates with private keys.")]
|
||||
internal static partial void SigningCredentialIsNotX509Certificate(this ILogger logger, LogLevel level, SecurityKey securityKey);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(StateNotFound),
|
||||
Message = "SAML signin state not found for state ID {StateId}")]
|
||||
internal static partial void StateNotFound(this ILogger logger, LogLevel level, StateId stateId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(ServiceProviderNotFound),
|
||||
Message = $"Service Provider {{{SamlLogParameters.EntityId}}} not found")]
|
||||
internal static partial void ServiceProviderNotFound(this ILogger logger, LogLevel level, string entityId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(NoSamlAuthenticationStateFound),
|
||||
Message = "Cannot load SAML authentication state.")]
|
||||
internal static partial void NoSamlAuthenticationStateFound(this ILogger logger, LogLevel level);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(AuthenticationStateLoaded),
|
||||
Message = $"SAML authentication request context loaded for SP {{{SamlLogParameters.EntityId}}}")]
|
||||
internal static partial void AuthenticationStateLoaded(this ILogger logger, LogLevel level, string entityId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(RequestedAuthnContextRequirementsWereMetUpdatedInState),
|
||||
Message = $"Stored requestedAuthnContextRequirementsWereMet for SAML request: {{{SamlLogParameters.RequestedAuthnContextRequirementsWereMet}}}")]
|
||||
internal static partial void RequestedAuthnContextRequirementsWereMetUpdatedInState(this ILogger logger,
|
||||
LogLevel level, bool requestedAuthnContextRequirementsWereMet);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(StartIdpInitiatedRequest),
|
||||
Message = "Starting IdP-initiated SAML request for SP '{serviceProviderEntityId}'")]
|
||||
internal static partial void StartIdpInitiatedRequest(this ILogger logger, LogLevel level, string serviceProviderEntityId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(IdpInitiatedRequestFailed),
|
||||
Message = "IdP-initiated SAML request failed: {ErrorMessage}")]
|
||||
internal static partial void IdpInitiatedRequestFailed(this ILogger logger, LogLevel level, string errorMessage);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(IdpInitiatedRequestSuccess),
|
||||
Message = "IdP-initiated SAML request succeeded, redirecting to {RedirectUrl}")]
|
||||
internal static partial void IdpInitiatedRequestSuccess(this ILogger logger, LogLevel level, Uri redirectUrl);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(RetrievedClaimsFromProfileService),
|
||||
Message = $"Retrieved {{{SamlLogParameters.ClaimCount}}} claims from profile service")]
|
||||
internal static partial void RetrievedClaimsFromProfileService(this ILogger logger, LogLevel level, int claimCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(UsingCustomClaimMapper),
|
||||
Message = $"Using custom claim mapper for SP {{{SamlLogParameters.EntityId}}}")]
|
||||
internal static partial void UsingCustomClaimMapper(this ILogger logger, LogLevel level, string entityId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(MappedClaimsToAttributes),
|
||||
Message = $"Mapped {{{SamlLogParameters.ClaimCount}}} claims to {{{SamlLogParameters.AttributeCount}}} SAML attributes for SP {{{SamlLogParameters.EntityId}}}")]
|
||||
internal static partial void MappedClaimsToAttributes(this ILogger logger, LogLevel level, int claimCount, int attributeCount, string entityId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SigningSamlProtocolMessage),
|
||||
Message = $"Signing SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}")]
|
||||
internal static partial void SigningSamlProtocolMessage(this ILogger logger, LogLevel level, string entityId, string messageType);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SuccessfullySignedSamlProtocolMessage),
|
||||
Message = $"Successfully signed SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}")]
|
||||
internal static partial void SuccessfullySignedSamlProtocolMessage(this ILogger logger, LogLevel level, string entityId, string messageType);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(FailedToSignSamlProtocolMessage),
|
||||
Level = LogLevel.Error,
|
||||
Message = $"Failed to sign SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}: {{{SamlLogParameters.ErrorMessage}}}")]
|
||||
internal static partial void FailedToSignSamlProtocolMessage(this ILogger logger, Exception ex, string entityId, string messageType, string errorMessage);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlSigninSuccess),
|
||||
Message = $"SAML signin request processed successfully, redirecting to {{{SamlLogParameters.RedirectUrl}}}")]
|
||||
internal static partial void SamlSigninSuccess(this ILogger logger, LogLevel level, Uri redirectUrl);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlSigninValidationError),
|
||||
Message = $"SAML signin validation error: {{{SamlLogParameters.ErrorMessage}}}")]
|
||||
internal static partial void SamlSigninValidationError(this ILogger logger, LogLevel level, string errorMessage);
|
||||
|
||||
[LoggerMessage(
|
||||
EventName = nameof(SamlSigninProtocolError),
|
||||
Message = $"SAML signin protocol error: {{statusCode}} - {{{SamlLogParameters.ErrorMessage}}}")]
|
||||
internal static partial void SamlSigninProtocolError(this ILogger logger, LogLevel level, string statusCode, string errorMessage);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.Metadata.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes SAML metadata EntityDescriptor to XML.
|
||||
/// </summary>
|
||||
internal static class EntityDescriptorSerializer
|
||||
{
|
||||
private static readonly XNamespace MdNamespace = SamlConstants.Namespaces.Metadata;
|
||||
private static readonly XNamespace DsNamespace = SamlConstants.Namespaces.XmlSignature;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an EntityDescriptor to SAML metadata XML string.
|
||||
/// </summary>
|
||||
/// <param name="descriptor">The entity descriptor to serialize.</param>
|
||||
/// <returns>XML string representing the SAML metadata.</returns>
|
||||
internal static XDocument SerializeToXml(EntityDescriptor descriptor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var root = new XElement(MdNamespace + SamlConstants.MetadataElements.EntityDescriptor,
|
||||
new XAttribute(SamlConstants.MetadataAttributes.EntityId, descriptor.EntityId));
|
||||
|
||||
// Add validUntil if specified
|
||||
if (descriptor.ValidUntil.HasValue)
|
||||
{
|
||||
root.Add(new XAttribute(SamlConstants.MetadataAttributes.ValidUntil,
|
||||
descriptor.ValidUntil.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
// Add IDPSSODescriptor if present
|
||||
if (descriptor.IdpSsoDescriptor != null)
|
||||
{
|
||||
root.Add(SerializeIdpSsoDescriptor(descriptor.IdpSsoDescriptor));
|
||||
}
|
||||
|
||||
return new XDocument(
|
||||
new XDeclaration("1.0", "UTF-8", null),
|
||||
root);
|
||||
}
|
||||
|
||||
private static XElement SerializeIdpSsoDescriptor(IdpSsoDescriptor descriptor)
|
||||
{
|
||||
var element = new XElement(MdNamespace + SamlConstants.MetadataElements.IdpSsoDescriptor,
|
||||
new XAttribute(SamlConstants.MetadataAttributes.ProtocolSupportEnumeration,
|
||||
descriptor.ProtocolSupportEnumeration));
|
||||
|
||||
// Add WantAuthnRequestsSigned if true
|
||||
if (descriptor.WantAuthnRequestsSigned)
|
||||
{
|
||||
element.Add(new XAttribute(SamlConstants.MetadataAttributes.WantAuthnRequestsSigned, "true"));
|
||||
}
|
||||
|
||||
// Add KeyDescriptors
|
||||
foreach (var keyDescriptor in descriptor.KeyDescriptors)
|
||||
{
|
||||
element.Add(SerializeKeyDescriptor(keyDescriptor));
|
||||
}
|
||||
|
||||
// Add NameIDFormats
|
||||
foreach (var nameIdFormat in descriptor.NameIdFormats)
|
||||
{
|
||||
element.Add(new XElement(MdNamespace + SamlConstants.MetadataElements.NameIdFormat, nameIdFormat));
|
||||
}
|
||||
|
||||
// Add SingleSignOnServices
|
||||
foreach (var ssoService in descriptor.SingleSignOnServices)
|
||||
{
|
||||
element.Add(SerializeSingleSignOnService(ssoService));
|
||||
}
|
||||
|
||||
// Add SingleLogoutServices
|
||||
foreach (var sloService in descriptor.SingleLogoutServices)
|
||||
{
|
||||
element.Add(SerializeSingleLogoutService(sloService));
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
private static XElement SerializeKeyDescriptor(KeyDescriptor descriptor)
|
||||
{
|
||||
var element = new XElement(MdNamespace + SamlConstants.MetadataElements.KeyDescriptor);
|
||||
|
||||
// Add use attribute if specified
|
||||
if (descriptor.Use.HasValue)
|
||||
{
|
||||
element.Add(new XAttribute(SamlConstants.MetadataAttributes.Use, SamlConstants.MetadataAttributes.ToString(descriptor.Use.Value)));
|
||||
}
|
||||
|
||||
// Add KeyInfo with X509Data
|
||||
var keyInfo = new XElement(DsNamespace + SamlConstants.MetadataElements.KeyInfo,
|
||||
new XElement(DsNamespace + SamlConstants.MetadataElements.X509Data,
|
||||
new XElement(DsNamespace + SamlConstants.MetadataElements.X509Certificate,
|
||||
descriptor.X509Certificate)));
|
||||
|
||||
element.Add(keyInfo);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
private static XElement SerializeSingleSignOnService(SingleSignOnService service) => new(MdNamespace + SamlConstants.MetadataElements.SingleSignOnService,
|
||||
new XAttribute(SamlConstants.MetadataAttributes.Binding, service.Binding.ToUrn()),
|
||||
new XAttribute(SamlConstants.MetadataAttributes.Location, service.Location));
|
||||
|
||||
private static XElement SerializeSingleLogoutService(SingleLogoutService service) => new(MdNamespace + SamlConstants.MetadataElements.SingleLogoutService,
|
||||
new XAttribute(SamlConstants.MetadataAttributes.Binding, service.Binding.ToUrn()),
|
||||
new XAttribute(SamlConstants.MetadataAttributes.Location, service.Location));
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML entity descriptor that describes a SAML entity (IdP or SP).
|
||||
/// </summary>
|
||||
internal record EntityDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the entity ID (typically the IdP issuer URI).
|
||||
/// This uniquely identifies the SAML entity.
|
||||
/// </summary>
|
||||
internal required string EntityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the IdP SSO descriptor.
|
||||
/// Contains the Identity Provider's SSO configuration and capabilities.
|
||||
/// </summary>
|
||||
internal IdpSsoDescriptor? IdpSsoDescriptor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the validity period end time (optional).
|
||||
/// If set, indicates when this metadata expires.
|
||||
/// </summary>
|
||||
internal DateTime? ValidUntil { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a SAML Identity Provider's SSO capabilities.
|
||||
/// This element contains all the information needed for a Service Provider
|
||||
/// to interact with the Identity Provider.
|
||||
/// </summary>
|
||||
internal record IdpSsoDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the protocol support enumeration.
|
||||
/// Typically "urn:oasis:names:tc:SAML:2.0:protocol".
|
||||
/// Indicates which SAML protocols this IdP supports.
|
||||
/// </summary>
|
||||
internal required string ProtocolSupportEnumeration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the IdP requires authentication requests to be signed.
|
||||
/// </summary>
|
||||
internal bool WantAuthnRequestsSigned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the signing certificates.
|
||||
/// Contains the public keys used to verify signatures from this IdP.
|
||||
/// </summary>
|
||||
internal Collection<KeyDescriptor> KeyDescriptors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the supported NameID formats.
|
||||
/// Indicates which name identifier formats this IdP can provide.
|
||||
/// </summary>
|
||||
internal Collection<string> NameIdFormats { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SingleSignOnService endpoints.
|
||||
/// Defines where and how Service Providers can initiate SSO.
|
||||
/// </summary>
|
||||
internal Collection<SingleSignOnService> SingleSignOnServices { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SingleLogoutService endpoints.
|
||||
/// Defines where and how Service Providers can send logout requests.
|
||||
/// </summary>
|
||||
internal Collection<SingleLogoutService> SingleLogoutServices { get; init; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a cryptographic key used by a SAML entity.
|
||||
/// Contains certificate information for signature verification or encryption.
|
||||
/// </summary>
|
||||
internal record KeyDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the key usage (signing, encryption, or null for both).
|
||||
/// When null, the key can be used for both signing and encryption.
|
||||
/// </summary>
|
||||
internal KeyUse? Use { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the X.509 certificate in base64 encoding (without BEGIN/END markers).
|
||||
/// This is the public key used to verify signatures or encrypt data.
|
||||
/// </summary>
|
||||
internal required string X509Certificate { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a SAML SingleLogoutService endpoint.
|
||||
/// Specifies where and how a Service Provider can send logout requests.
|
||||
/// </summary>
|
||||
internal record SingleLogoutService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the binding (HTTP-Redirect, HTTP-POST, etc.).
|
||||
/// Indicates the protocol binding to use for this endpoint.
|
||||
/// </summary>
|
||||
internal required SamlBinding Binding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the location URI.
|
||||
/// The endpoint URI where logout requests should be sent.
|
||||
/// </summary>
|
||||
internal required Uri Location { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Metadata.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a SAML SingleSignOnService endpoint.
|
||||
/// Specifies where and how a Service Provider can send authentication requests.
|
||||
/// </summary>
|
||||
internal record SingleSignOnService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the binding (HTTP-Redirect, HTTP-POST, etc.).
|
||||
/// Indicates the protocol binding to use for this endpoint.
|
||||
/// </summary>
|
||||
internal required SamlBinding Binding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the location URI.
|
||||
/// The endpoint URI where authentication requests should be sent.
|
||||
/// </summary>
|
||||
internal required Uri Location { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.Metadata.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.Metadata;
|
||||
|
||||
internal class SamlMetaDataEndpoint(
|
||||
TimeProvider timeProvider,
|
||||
IOptions<SamlOptions> samlOptions,
|
||||
IIssuerNameService issuerNameService,
|
||||
IServerUrls urls,
|
||||
ISamlSigningService samlSigningService) : IEndpointHandler
|
||||
{
|
||||
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
|
||||
{
|
||||
using var activity = Tracing.BasicActivitySource.StartActivity("SamlMetaDataEndpoint");
|
||||
|
||||
if (!HttpMethods.IsGet(context.Request.Method))
|
||||
{
|
||||
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
var options = samlOptions.Value;
|
||||
var issuerUri = await issuerNameService.GetCurrentAsync();
|
||||
var baseUrl = urls.BaseUrl;
|
||||
|
||||
var certificateBase64 = await samlSigningService.GetSigningCertificateBase64Async();
|
||||
|
||||
var singleSignOnService = BuildServiceUrl(baseUrl, options.UserInteraction.Route, options.UserInteraction.SignInPath);
|
||||
var singleLogoutService = BuildServiceUrl(baseUrl, options.UserInteraction.Route, options.UserInteraction.SingleLogoutPath);
|
||||
|
||||
var descriptor = new EntityDescriptor
|
||||
{
|
||||
EntityId = issuerUri,
|
||||
ValidUntil = options.MetadataValidityDuration != null
|
||||
? timeProvider.GetUtcNow().Add(options.MetadataValidityDuration.Value).UtcDateTime
|
||||
: null,
|
||||
IdpSsoDescriptor = new IdpSsoDescriptor
|
||||
{
|
||||
ProtocolSupportEnumeration = SamlConstants.Namespaces.Protocol,
|
||||
WantAuthnRequestsSigned = options.WantAuthnRequestsSigned,
|
||||
KeyDescriptors =
|
||||
[
|
||||
new KeyDescriptor
|
||||
{
|
||||
Use = KeyUse.Signing,
|
||||
X509Certificate = certificateBase64
|
||||
}
|
||||
],
|
||||
NameIdFormats = options.SupportedNameIdFormats,
|
||||
SingleSignOnServices =
|
||||
[
|
||||
new SingleSignOnService
|
||||
{
|
||||
Binding = SamlBinding.HttpPost,
|
||||
Location = singleSignOnService
|
||||
},
|
||||
new SingleSignOnService
|
||||
{
|
||||
Binding = SamlBinding.HttpRedirect,
|
||||
Location = singleSignOnService
|
||||
}
|
||||
],
|
||||
SingleLogoutServices =
|
||||
[
|
||||
new SingleLogoutService
|
||||
{
|
||||
Binding = SamlBinding.HttpPost,
|
||||
Location = singleLogoutService
|
||||
},
|
||||
new SingleLogoutService
|
||||
{
|
||||
Binding = SamlBinding.HttpRedirect,
|
||||
Location = singleLogoutService
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return new SamlMetadataResult(descriptor);
|
||||
}
|
||||
|
||||
private static Uri BuildServiceUrl(string baseUrl, string route, string path)
|
||||
{
|
||||
var builder = new UriBuilder(baseUrl);
|
||||
|
||||
// Preserve existing base path and append new segments
|
||||
var segments = new[] { builder.Path, route, path }
|
||||
.Select(s => s.Trim('/'))
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
|
||||
var combinedPath = string.Join('/', segments);
|
||||
|
||||
// UriBuilder.Path automatically adds leading slash
|
||||
builder.Path = string.IsNullOrEmpty(combinedPath) ? "/" : combinedPath;
|
||||
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint result that writes SAML metadata XML to the response.
|
||||
/// </summary>
|
||||
internal class SamlMetadataResult(EntityDescriptor descriptor) : IEndpointResult
|
||||
{
|
||||
public async Task ExecuteAsync(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = 200;
|
||||
context.Response.ContentType = SamlConstants.ContentTypes.Metadata;
|
||||
var descriptorXml = EntityDescriptorSerializer.SerializeToXml(descriptor);
|
||||
await descriptorXml.SaveAsync(context.Response.Body, SaveOptions.DisableFormatting, context.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Security.Claims;
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml;
|
||||
|
||||
internal class SamlClaimsService(
|
||||
IProfileService profileService,
|
||||
ILogger<SamlClaimsService> logger,
|
||||
IOptions<SamlOptions> options,
|
||||
ISamlClaimsMapper? customMapper = null)
|
||||
{
|
||||
private async Task<IEnumerable<Claim>> GetClaimsAsync(ClaimsPrincipal user, SamlServiceProvider serviceProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
|
||||
var requestedClaimTypes = user.Claims.Select(c => c.Type).Distinct();
|
||||
|
||||
// Use IdentityServer's IProfileService to get claims
|
||||
var context = new ProfileDataRequestContext
|
||||
{
|
||||
Subject = user,
|
||||
Client = new Client
|
||||
{
|
||||
ClientId = serviceProvider.EntityId.ToString()
|
||||
},
|
||||
RequestedClaimTypes = requestedClaimTypes,
|
||||
Caller = "SAML"
|
||||
};
|
||||
|
||||
await profileService.GetProfileDataAsync(context);
|
||||
|
||||
var claims = context.IssuedClaims;
|
||||
|
||||
logger.RetrievedClaimsFromProfileService(LogLevel.Debug, claims.Count);
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
internal async Task<IEnumerable<SamlAttribute>> GetMappedAttributesAsync(
|
||||
ClaimsPrincipal user,
|
||||
SamlServiceProvider serviceProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
|
||||
var claims = await GetClaimsAsync(user, serviceProvider);
|
||||
|
||||
if (customMapper != null)
|
||||
{
|
||||
logger.UsingCustomClaimMapper(LogLevel.Debug, serviceProvider.EntityId);
|
||||
var claimsMappingContext = new SamlClaimsMappingContext { UserClaims = claims, ServiceProvider = serviceProvider };
|
||||
return await customMapper.MapClaimsAsync(claimsMappingContext);
|
||||
}
|
||||
|
||||
return MapClaimsToAttributes(claims, serviceProvider);
|
||||
}
|
||||
|
||||
private List<SamlAttribute> MapClaimsToAttributes(
|
||||
IEnumerable<Claim> claims,
|
||||
SamlServiceProvider serviceProvider)
|
||||
{
|
||||
var samlOptions = options.Value;
|
||||
var attributes = new List<SamlAttribute>();
|
||||
var claimsList = claims.ToList();
|
||||
|
||||
foreach (var claim in claimsList)
|
||||
{
|
||||
// Determine attribute name: SP mapping > Global mapping > null (exclude)
|
||||
var attributeName = GetAttributeName(claim.Type, serviceProvider, samlOptions);
|
||||
|
||||
// Skip claims that aren't mapped
|
||||
if (attributeName == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if attribute already exists (for multi-valued attributes)
|
||||
var existingAttr = attributes.FirstOrDefault(a => a.Name == attributeName);
|
||||
if (existingAttr != null)
|
||||
{
|
||||
existingAttr.Values.Add(claim.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
attributes.Add(new SamlAttribute
|
||||
{
|
||||
Name = attributeName,
|
||||
NameFormat = samlOptions.DefaultAttributeNameFormat,
|
||||
FriendlyName = attributeName,
|
||||
Values = [claim.Value]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.MappedClaimsToAttributes(LogLevel.Debug, claimsList.Count, attributes.Count, serviceProvider.EntityId);
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static string? GetAttributeName(
|
||||
string claimType,
|
||||
SamlServiceProvider serviceProvider,
|
||||
SamlOptions options)
|
||||
{
|
||||
if (serviceProvider.ClaimMappings.TryGetValue(claimType, out var spMapping))
|
||||
{
|
||||
return spMapping;
|
||||
}
|
||||
|
||||
if (options.DefaultClaimMappings.TryGetValue(claimType, out var globalMapping))
|
||||
{
|
||||
return globalMapping;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml;
|
||||
|
||||
internal static class SamlConstants
|
||||
{
|
||||
internal class Urls
|
||||
{
|
||||
public const string SamlRoute = "/saml";
|
||||
public const string Metadata = "/metadata";
|
||||
public const string SignIn = "/signin";
|
||||
public const string SigninCallback = "/signin_callback";
|
||||
public const string IdpInitiated = "/idp-initiated";
|
||||
public const string Signout = "/signout";
|
||||
public const string SingleLogout = "/logout";
|
||||
public const string SingleLogoutCallback = "/logout_callback";
|
||||
}
|
||||
|
||||
internal class RequestProperties
|
||||
{
|
||||
public const string SAMLRequest = "SAMLRequest";
|
||||
public const string SAMLResponse = "SAMLResponse";
|
||||
public const string RelayState = "RelayState";
|
||||
public const string Signature = "Signature";
|
||||
public const string SigAlg = "SigAlg";
|
||||
}
|
||||
|
||||
internal class ContentTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// https://www.iana.org/assignments/media-types/application/samlmetadata+xml
|
||||
/// </summary>
|
||||
public const string Metadata = "application/samlmetadata+xml";
|
||||
}
|
||||
internal static class Namespaces
|
||||
{
|
||||
public const string Assertion = "urn:oasis:names:tc:SAML:2.0:assertion";
|
||||
public const string Protocol = "urn:oasis:names:tc:SAML:2.0:protocol";
|
||||
public const string Metadata = "urn:oasis:names:tc:SAML:2.0:metadata";
|
||||
public const string XmlSignature = "http://www.w3.org/2000/09/xmldsig#";
|
||||
public const string XmlEncryption = "http://www.w3.org/2001/04/xmlenc#";
|
||||
}
|
||||
|
||||
internal static class NameIdentifierFormats
|
||||
{
|
||||
public const string EmailAddress = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
||||
public const string Persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent";
|
||||
public const string Transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient";
|
||||
public const string Unspecified = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified";
|
||||
}
|
||||
|
||||
internal static class Bindings
|
||||
{
|
||||
public const string HttpRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
|
||||
public const string HttpPost = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
||||
}
|
||||
|
||||
internal static class MetadataElements
|
||||
{
|
||||
public const string EntityDescriptor = "EntityDescriptor";
|
||||
public const string IdpSsoDescriptor = "IDPSSODescriptor";
|
||||
public const string KeyDescriptor = "KeyDescriptor";
|
||||
public const string KeyInfo = "KeyInfo";
|
||||
public const string X509Data = "X509Data";
|
||||
public const string X509Certificate = "X509Certificate";
|
||||
public const string NameIdFormat = "NameIDFormat";
|
||||
public const string SingleSignOnService = "SingleSignOnService";
|
||||
public const string SingleLogoutService = "SingleLogoutService";
|
||||
}
|
||||
|
||||
internal static class AuthenticationRequestAttributes
|
||||
{
|
||||
public const string RootElementName = "AuthnRequest";
|
||||
}
|
||||
|
||||
internal static class MetadataAttributes
|
||||
{
|
||||
public const string EntityId = "entityID";
|
||||
public const string ValidUntil = "validUntil";
|
||||
public const string ProtocolSupportEnumeration = "protocolSupportEnumeration";
|
||||
public const string WantAuthnRequestsSigned = "WantAuthnRequestsSigned";
|
||||
public const string Use = "use";
|
||||
public const string Binding = "Binding";
|
||||
public const string Location = "Location";
|
||||
|
||||
/// <summary>
|
||||
/// Converts a KeyUse enum value to its string representation.
|
||||
/// </summary>
|
||||
internal static string ToString(KeyUse keyUse) => keyUse switch
|
||||
{
|
||||
KeyUse.Signing => "signing",
|
||||
KeyUse.Encryption => "encryption",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(keyUse), keyUse, "Unknown key use")
|
||||
};
|
||||
}
|
||||
|
||||
public static class AttributeNameFormats
|
||||
{
|
||||
/// <summary>
|
||||
/// Attribute name is interpreted as a URI reference (most common for OID format)
|
||||
/// </summary>
|
||||
public const string Uri = "urn:oasis:names:tc:SAML:2.0:attrname-format:uri";
|
||||
}
|
||||
|
||||
public static class ClaimTypes
|
||||
{
|
||||
public const string AuthnContextClassRef = "saml:acr";
|
||||
}
|
||||
|
||||
internal static class LogoutReasons
|
||||
{
|
||||
public const string User = "urn:oasis:names:tc:SAML:2.0:logout:user";
|
||||
public const string Admin = "urn:oasis:names:tc:SAML:2.0:logout:admin";
|
||||
public const string GlobalTimeout = "urn:oasis:names:tc:SAML:2.0:logout:global-timeout";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Security.Claims;
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml;
|
||||
|
||||
internal class SamlResponseBuilder(
|
||||
IServerUrls serverUrls,
|
||||
IIssuerNameService issuerNameService,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<SamlOptions> samlOptions,
|
||||
SamlClaimsService samlClaimsService,
|
||||
SamlNameIdGenerator nameIdGenerator
|
||||
)
|
||||
{
|
||||
internal SamlErrorResponse BuildErrorResponse(SamlServiceProvider serviceProvider, SamlSigninRequest request,
|
||||
SamlError error)
|
||||
{
|
||||
// Use the ACS URL from the request if present and valid, otherwise fall back to SP config
|
||||
var acsUrl = request.AuthNRequest.AssertionConsumerServiceUrl
|
||||
?? (request.AuthNRequest.AssertionConsumerServiceIndex != null
|
||||
? serviceProvider.AssertionConsumerServiceUrls.ElementAtOrDefault(request.AuthNRequest
|
||||
.AssertionConsumerServiceIndex.Value)
|
||||
: null)
|
||||
?? serviceProvider.AssertionConsumerServiceUrls.First();
|
||||
|
||||
return new SamlErrorResponse
|
||||
{
|
||||
ServiceProvider = serviceProvider,
|
||||
Binding = serviceProvider.AssertionConsumerServiceBinding,
|
||||
StatusCode = error.StatusCode,
|
||||
SubStatusCode = error.SubStatusCode,
|
||||
Message = error.Message,
|
||||
AssertionConsumerServiceUrl = acsUrl,
|
||||
Issuer = serverUrls.Origin, // Todo: not sure if this is a valid issuer
|
||||
InResponseTo = request.AuthNRequest.Id,
|
||||
RelayState = request.RelayState
|
||||
};
|
||||
}
|
||||
|
||||
private static Conditions CreateConditions(
|
||||
SamlServiceProvider samlServiceProvider,
|
||||
DateTime issueInstant,
|
||||
TimeSpan defaultRequestMaxAge,
|
||||
TimeSpan defaultAllowedClockSkew)
|
||||
{
|
||||
var lifetime = samlServiceProvider.RequestMaxAge ?? defaultRequestMaxAge;
|
||||
var clockSkew = samlServiceProvider.ClockSkew ?? defaultAllowedClockSkew;
|
||||
|
||||
return new Conditions
|
||||
{
|
||||
NotBefore = issueInstant.Subtract(clockSkew),
|
||||
NotOnOrAfter = issueInstant.Add(lifetime),
|
||||
AudienceRestrictions = [samlServiceProvider.EntityId]
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthnStatement CreateAuthnStatement(ClaimsPrincipal user, DateTime issueInstant, string sessionIndex)
|
||||
{
|
||||
// Determine AuthnContext based on request and user claims
|
||||
var authnContextClassRef = GetAuthnContextClassRef(user);
|
||||
|
||||
return new AuthnStatement
|
||||
{
|
||||
AuthnInstant = issueInstant,
|
||||
SessionIndex = sessionIndex,
|
||||
AuthnContext = new AuthnContext { AuthnContextClassRef = authnContextClassRef }
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetAuthnContextClassRef(ClaimsPrincipal user)
|
||||
{
|
||||
var contextClaim = user.FindFirst(SamlConstants.ClaimTypes.AuthnContextClassRef);
|
||||
if (contextClaim == null || string.IsNullOrWhiteSpace(contextClaim.Value))
|
||||
{
|
||||
return "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified";
|
||||
}
|
||||
|
||||
return contextClaim.Value.Trim();
|
||||
}
|
||||
|
||||
private static string GetEmailNameId(ClaimsPrincipal user)
|
||||
{
|
||||
// Try to get email claim
|
||||
var email = user.FindFirst("email")?.Value
|
||||
?? user.FindFirst(ClaimTypes.Email)?.Value;
|
||||
|
||||
return !string.IsNullOrEmpty(email) ? email : user.GetSubjectId();
|
||||
}
|
||||
|
||||
private static Subject CreateSubject(
|
||||
SamlAuthenticationState samlAuthenticationState,
|
||||
NameIdentifier nameId,
|
||||
SamlServiceProvider serviceProvider,
|
||||
AuthNRequest? request,
|
||||
TimeSpan defaultRequestMaxAge,
|
||||
DateTime issueInstant)
|
||||
{
|
||||
var lifetime = serviceProvider.RequestMaxAge ?? defaultRequestMaxAge;
|
||||
var notOnOrAfter = issueInstant.Add(lifetime);
|
||||
|
||||
return new Subject
|
||||
{
|
||||
NameId = nameId,
|
||||
SubjectConfirmations =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Method = "urn:oasis:names:tc:SAML:2.0:cm:bearer",
|
||||
Data = new SubjectConfirmationData
|
||||
{
|
||||
NotOnOrAfter = notOnOrAfter,
|
||||
Recipient = samlAuthenticationState.AssertionConsumerServiceUrl,
|
||||
InResponseTo = request?.Id // Null for IdP-initiated
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
internal async Task<SamlResponse> BuildSuccessResponseAsync(
|
||||
ClaimsPrincipal user,
|
||||
SamlServiceProvider samlServiceProvider,
|
||||
SamlAuthenticationState samlAuthenticationState,
|
||||
string sessionIndex)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow().DateTime;
|
||||
var options = samlOptions.Value;
|
||||
var nameId = nameIdGenerator.GenerateNameIdentifier(user, samlServiceProvider, samlAuthenticationState.Request);
|
||||
var attributes = await samlClaimsService.GetMappedAttributesAsync(user, samlServiceProvider);
|
||||
|
||||
var acsUrl = GetAcsUrl(samlAuthenticationState.Request, samlServiceProvider);
|
||||
|
||||
var issuer = await issuerNameService.GetCurrentAsync();
|
||||
|
||||
return new SamlResponse
|
||||
{
|
||||
ServiceProvider = samlServiceProvider,
|
||||
InResponseTo = samlAuthenticationState.Request?.Id,
|
||||
Destination = acsUrl,
|
||||
IssueInstant = now,
|
||||
Issuer = issuer,
|
||||
Status = new Status
|
||||
{
|
||||
StatusCode = SamlStatusCode.Success,
|
||||
NestedStatusCode = samlAuthenticationState.Request?.RequestedAuthnContext != null && !samlAuthenticationState.RequestedAuthnContextRequirementsWereMet ? (string)SamlStatusCode.NoAuthnContext : null,
|
||||
},
|
||||
Assertion = new Assertion
|
||||
{
|
||||
IssueInstant = now,
|
||||
Issuer = issuer,
|
||||
Subject = CreateSubject(samlAuthenticationState, nameId, samlServiceProvider,
|
||||
samlAuthenticationState.Request, options.DefaultRequestMaxAge,
|
||||
now),
|
||||
Conditions = CreateConditions(
|
||||
samlServiceProvider, now,
|
||||
options.DefaultRequestMaxAge,
|
||||
options.DefaultClockSkew),
|
||||
AuthnStatements = [CreateAuthnStatement(user, now, sessionIndex)],
|
||||
AttributeStatements = [new AttributeStatement { Attributes = attributes.ToList() }]
|
||||
},
|
||||
RelayState = samlAuthenticationState.RelayState
|
||||
};
|
||||
}
|
||||
|
||||
private static Uri GetAcsUrl(AuthNRequest? request, SamlServiceProvider samlServiceProvider)
|
||||
{
|
||||
if (request?.AssertionConsumerServiceUrl != null)
|
||||
{
|
||||
return request.AssertionConsumerServiceUrl;
|
||||
}
|
||||
|
||||
if (request?.AssertionConsumerServiceIndex != null)
|
||||
{
|
||||
return samlServiceProvider.AssertionConsumerServiceUrls.ElementAt(request.AssertionConsumerServiceIndex.Value);
|
||||
}
|
||||
return samlServiceProvider.AssertionConsumerServiceUrls.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("No ACS Url defined for service provider " + samlServiceProvider.EntityId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
|
||||
/// <summary>
|
||||
/// Parses SAML LogoutRequest messages.
|
||||
/// </summary>
|
||||
internal class LogoutRequestParser(ILogger<LogoutRequestParser> logger) : SamlProtocolMessageParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a LogoutRequest from XML.
|
||||
/// </summary>
|
||||
internal LogoutRequest Parse(XDocument doc)
|
||||
{
|
||||
try
|
||||
{
|
||||
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
|
||||
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
|
||||
|
||||
var root = doc.Root;
|
||||
if (root?.Name != protocolNs + LogoutRequest.ElementNames.RootElement)
|
||||
{
|
||||
throw new FormatException($"Root element is not LogoutRequest. Found: {root?.Name}");
|
||||
}
|
||||
|
||||
var request = new LogoutRequest
|
||||
{
|
||||
Id = GetRequiredAttribute(root, LogoutRequest.AttributeNames.Id),
|
||||
Version = GetRequiredAttribute(root, LogoutRequest.AttributeNames.Version),
|
||||
IssueInstant = ParseDateTime(root, LogoutRequest.AttributeNames.IssueInstant),
|
||||
Destination = GetOptionalAttribute(root, LogoutRequest.AttributeNames.Destination) is { } dest ? new Uri(dest) : null,
|
||||
Issuer = ParseIssuerValue(root, assertionNs, "LogoutRequest"),
|
||||
NameId = ParseNameIdAsIdentifier(root, assertionNs),
|
||||
SessionIndex = ParseSessionIndex(root, protocolNs),
|
||||
Reason = ParseReason(GetOptionalAttribute(root, LogoutRequest.AttributeNames.Reason)),
|
||||
};
|
||||
|
||||
var notOnOrAfterAttr = root.Attribute(LogoutRequest.AttributeNames.NotOnOrAfter)?.Value;
|
||||
if (!string.IsNullOrEmpty(notOnOrAfterAttr))
|
||||
{
|
||||
request.NotOnOrAfter = DateTime.Parse(notOnOrAfterAttr, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal);
|
||||
}
|
||||
|
||||
logger.ParsedLogoutRequest(LogLevel.Debug, request.Id, request.Issuer, request.SessionIndex);
|
||||
|
||||
return request;
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
logger.FailedToParseLogoutRequest(ex, ex.Message);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.UnexpectedErrorParsingLogoutRequest(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static NameIdentifier ParseNameIdAsIdentifier(XElement root, XNamespace assertionNs)
|
||||
{
|
||||
var nameIdElement = root.Element(assertionNs + LogoutRequest.ElementNames.NameID);
|
||||
if (nameIdElement == null)
|
||||
{
|
||||
throw new InvalidOperationException("NameID element is required in LogoutRequest");
|
||||
}
|
||||
|
||||
var nameId = nameIdElement.Value?.Trim();
|
||||
if (string.IsNullOrEmpty(nameId))
|
||||
{
|
||||
throw new InvalidOperationException("NameID element cannot be empty");
|
||||
}
|
||||
|
||||
var format = nameIdElement.Attribute(NameIdPolicy.AttributeNames.Format)?.Value;
|
||||
var nameQualifier = nameIdElement.Attribute("NameQualifier")?.Value;
|
||||
var spNameQualifierAttr = nameIdElement.Attribute(NameIdPolicy.AttributeNames.SPNameQualifier);
|
||||
|
||||
string? spNameQualifier = null;
|
||||
if (spNameQualifierAttr != null && !string.IsNullOrWhiteSpace(spNameQualifierAttr.Value))
|
||||
{
|
||||
spNameQualifier = spNameQualifierAttr.Value;
|
||||
}
|
||||
|
||||
return new NameIdentifier
|
||||
{
|
||||
Value = nameId,
|
||||
Format = format,
|
||||
NameQualifier = nameQualifier,
|
||||
SPNameQualifier = spNameQualifier
|
||||
};
|
||||
}
|
||||
|
||||
private static string ParseSessionIndex(XElement root, XNamespace protocolNs)
|
||||
{
|
||||
var sessionIndexElement = root.Element(protocolNs + LogoutRequest.ElementNames.SessionIndex);
|
||||
if (sessionIndexElement == null)
|
||||
{
|
||||
throw new InvalidOperationException("SessionIndex element is required in LogoutRequest");
|
||||
}
|
||||
|
||||
var sessionIndex = sessionIndexElement.Value.Trim();
|
||||
if (string.IsNullOrEmpty(sessionIndex))
|
||||
{
|
||||
throw new InvalidOperationException("SessionIndex element cannot be empty");
|
||||
}
|
||||
|
||||
return sessionIndex;
|
||||
}
|
||||
|
||||
private static LogoutReason? ParseReason(string? reasonUrn) => reasonUrn switch
|
||||
{
|
||||
SamlConstants.LogoutReasons.User => LogoutReason.User,
|
||||
SamlConstants.LogoutReasons.Admin => LogoutReason.Admin,
|
||||
SamlConstants.LogoutReasons.GlobalTimeout => LogoutReason.GlobalTimeout,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
|
||||
internal class LogoutResponseBuilder(
|
||||
IIssuerNameService issuerNameService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
internal async Task<LogoutResponse> BuildSuccessResponseAsync(
|
||||
RequestId logoutRequestId,
|
||||
SamlServiceProvider serviceProvider,
|
||||
string? relayState)
|
||||
{
|
||||
var issuer = await issuerNameService.GetCurrentAsync();
|
||||
var destination = serviceProvider.SingleLogoutServiceUrl ?? throw new InvalidOperationException("No SingleLogout service url configured");
|
||||
|
||||
return new LogoutResponse
|
||||
{
|
||||
Id = ResponseId.New(),
|
||||
Version = SamlVersion.V2,
|
||||
IssueInstant = timeProvider.GetUtcNow().UtcDateTime,
|
||||
Destination = destination.Location,
|
||||
Issuer = issuer,
|
||||
InResponseTo = logoutRequestId.ToString(),
|
||||
Status = new Status
|
||||
{
|
||||
StatusCode = SamlStatusCode.Success
|
||||
},
|
||||
ServiceProvider = serviceProvider,
|
||||
RelayState = relayState
|
||||
};
|
||||
}
|
||||
|
||||
internal async Task<LogoutResponse> BuildErrorResponseAsync(
|
||||
SamlLogoutRequest request,
|
||||
SamlServiceProvider serviceProvider,
|
||||
SamlError error)
|
||||
{
|
||||
var issuer = await issuerNameService.GetCurrentAsync();
|
||||
var destination = serviceProvider.SingleLogoutServiceUrl ?? throw new InvalidOperationException("No SingleLogout service url configured");
|
||||
|
||||
return new LogoutResponse
|
||||
{
|
||||
Id = ResponseId.New(),
|
||||
Version = SamlVersion.V2,
|
||||
IssueInstant = timeProvider.GetUtcNow().UtcDateTime,
|
||||
Destination = destination.Location,
|
||||
Issuer = issuer,
|
||||
InResponseTo = request.LogoutRequest.Id.ToString(),
|
||||
Status = new Status
|
||||
{
|
||||
StatusCode = error.StatusCode,
|
||||
StatusMessage = error.Message,
|
||||
NestedStatusCode = error.SubStatusCode
|
||||
},
|
||||
ServiceProvider = serviceProvider,
|
||||
RelayState = request.RelayState
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 LogoutRequest message.
|
||||
/// </summary>
|
||||
internal record LogoutRequest : ISamlRequest
|
||||
{
|
||||
public static string MessageName => "SAML logout request";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for this request.
|
||||
/// </summary>
|
||||
public required RequestId Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML version. Must be "2.0".
|
||||
/// </summary>
|
||||
public SamlVersion Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time instant of issue in UTC.
|
||||
/// </summary>
|
||||
public required DateTime IssueInstant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URI of the destination endpoint where this request is sent.
|
||||
/// </summary>
|
||||
public Uri? Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entity identifier of the issuer (sender) of this request.
|
||||
/// </summary>
|
||||
public required string Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the NameID identifying the principal that is being logged out.
|
||||
/// </summary>
|
||||
public required NameIdentifier NameId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SessionIndex identifying the session to be terminated.
|
||||
/// </summary>
|
||||
public required string SessionIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the reason for the logout (optional).
|
||||
/// </summary>
|
||||
public LogoutReason? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the NotOnOrAfter time limit for the logout operation.
|
||||
/// </summary>
|
||||
public DateTime? NotOnOrAfter { get; set; }
|
||||
|
||||
internal static class AttributeNames
|
||||
{
|
||||
public const string Id = "ID";
|
||||
public const string Version = "Version";
|
||||
public const string IssueInstant = "IssueInstant";
|
||||
public const string Reason = "Reason";
|
||||
public const string NotOnOrAfter = "NotOnOrAfter";
|
||||
public const string Destination = "Destination";
|
||||
}
|
||||
|
||||
internal static class ElementNames
|
||||
{
|
||||
public const string RootElement = "LogoutRequest";
|
||||
public const string Issuer = "Issuer";
|
||||
public const string NameID = "NameID";
|
||||
public const string SessionIndex = "SessionIndex";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the reason for logout in a LogoutRequest.
|
||||
/// </summary>
|
||||
internal enum LogoutReason
|
||||
{
|
||||
/// <summary>
|
||||
/// User initiated the logout.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// Administrator initiated the logout.
|
||||
/// </summary>
|
||||
Admin,
|
||||
|
||||
/// <summary>
|
||||
/// Logout due to global timeout.
|
||||
/// </summary>
|
||||
GlobalTimeout
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 LogoutResponse message.
|
||||
/// </summary>
|
||||
internal class LogoutResponse : EndpointResult<LogoutResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for this response.
|
||||
/// </summary>
|
||||
public required ResponseId Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML version. Must be "2.0".
|
||||
/// </summary>
|
||||
public SamlVersion Version { get; set; } = SamlVersion.V2;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time instant of issue in UTC.
|
||||
/// </summary>
|
||||
public required DateTime IssueInstant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URI of the destination endpoint where this response is sent.
|
||||
/// </summary>
|
||||
public required Uri Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entity identifier of the issuer (sender) of this response.
|
||||
/// </summary>
|
||||
public required string Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the LogoutRequest to which this is a response.
|
||||
/// </summary>
|
||||
public required string InResponseTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the status of the logout operation.
|
||||
/// </summary>
|
||||
public required Status Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service provider configuration for this response.
|
||||
/// </summary>
|
||||
public required SamlServiceProvider ServiceProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional RelayState parameter to return to the SP.
|
||||
/// </summary>
|
||||
public string? RelayState { get; set; }
|
||||
|
||||
internal static class ElementNames
|
||||
{
|
||||
public const string RootElement = "LogoutResponse";
|
||||
}
|
||||
|
||||
internal class ResponseWriter(ISamlResultSerializer<LogoutResponse> serializer, SamlProtocolMessageSigner samlProtocolMessageSigner) : IHttpResponseWriter<LogoutResponse>
|
||||
{
|
||||
public async Task WriteHttpResponse(LogoutResponse result, HttpContext httpContext)
|
||||
{
|
||||
var responseXml = serializer.Serialize(result);
|
||||
|
||||
var signedResponseXml = await samlProtocolMessageSigner.SignProtocolMessage(responseXml, result.ServiceProvider);
|
||||
|
||||
var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedResponseXml));
|
||||
|
||||
var html = HttpResponseBindings.GenerateAutoPostForm(SamlMessageName.SamlResponse, encodedResponse, result.Destination, result.RelayState);
|
||||
|
||||
httpContext.Response.ContentType = "text/html";
|
||||
httpContext.Response.Headers.CacheControl = "no-cache, no-store";
|
||||
httpContext.Response.Headers.Pragma = "no-cache";
|
||||
|
||||
await httpContext.Response.WriteAsync(html);
|
||||
}
|
||||
}
|
||||
|
||||
internal class Serializer : ISamlResultSerializer<LogoutResponse>
|
||||
{
|
||||
public XElement Serialize(LogoutResponse toSerialize)
|
||||
{
|
||||
var issueInstant = toSerialize.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
|
||||
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
|
||||
|
||||
// Build Status element
|
||||
var statusCodeElement = new XElement(protocolNs + "StatusCode",
|
||||
new XAttribute("Value", toSerialize.Status.StatusCode.ToString()));
|
||||
|
||||
if (!string.IsNullOrEmpty(toSerialize.Status.NestedStatusCode))
|
||||
{
|
||||
statusCodeElement.Add(
|
||||
new XElement(protocolNs + "StatusCode",
|
||||
new XAttribute("Value", toSerialize.Status.NestedStatusCode)));
|
||||
}
|
||||
|
||||
var statusElement = new XElement(protocolNs + "Status", statusCodeElement);
|
||||
|
||||
if (!string.IsNullOrEmpty(toSerialize.Status.StatusMessage))
|
||||
{
|
||||
statusElement.Add(new XElement(protocolNs + "StatusMessage", toSerialize.Status.StatusMessage));
|
||||
}
|
||||
|
||||
// Build LogoutResponse element
|
||||
var responseElement = new XElement(protocolNs + ElementNames.RootElement,
|
||||
new XAttribute("ID", toSerialize.Id.Value),
|
||||
new XAttribute("Version", toSerialize.Version.ToString()),
|
||||
new XAttribute("IssueInstant", issueInstant),
|
||||
new XAttribute("Destination", toSerialize.Destination),
|
||||
new XAttribute("InResponseTo", toSerialize.InResponseTo),
|
||||
new XElement(XNamespace.Get(SamlConstants.Namespaces.Assertion) + "Issuer", toSerialize.Issuer),
|
||||
statusElement);
|
||||
|
||||
return responseElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML logout request with binding information.
|
||||
/// </summary>
|
||||
internal record SamlLogoutRequest : SamlRequestBase<LogoutRequest>
|
||||
{
|
||||
public static async ValueTask<SamlLogoutRequest?> BindAsync(HttpContext context)
|
||||
{
|
||||
var extractor = context.RequestServices.GetRequiredService<SamlLogoutRequestExtractor>();
|
||||
return await extractor.ExtractAsync(context);
|
||||
}
|
||||
|
||||
public LogoutRequest LogoutRequest => Request;
|
||||
}
|
||||
|
||||
internal class SamlLogoutRequestExtractor : SamlRequestExtractor<LogoutRequest, SamlLogoutRequest>
|
||||
{
|
||||
private readonly LogoutRequestParser _parser;
|
||||
|
||||
public SamlLogoutRequestExtractor(LogoutRequestParser parser) => _parser = parser;
|
||||
|
||||
protected override LogoutRequest ParseRequest(XDocument xmlDocument) => _parser.Parse(xmlDocument);
|
||||
|
||||
protected override SamlLogoutRequest CreateResult(
|
||||
LogoutRequest parsedRequest,
|
||||
XDocument requestXml,
|
||||
SamlBinding binding,
|
||||
string? relayState,
|
||||
string? signature = null,
|
||||
string? signatureAlgorithm = null,
|
||||
string? encodedSamlRequest = null) => new SamlLogoutRequest
|
||||
{
|
||||
Request = parsedRequest,
|
||||
RequestXml = requestXml,
|
||||
Binding = binding,
|
||||
RelayState = relayState,
|
||||
Signature = signature,
|
||||
SignatureAlgorithm = signatureAlgorithm,
|
||||
EncodedSamlRequest = encodedSamlRequest
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using LogoutRequest = Duende.IdentityServer.Internal.Saml.SingleLogout.Models.LogoutRequest;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
|
||||
internal class SamlFrontChannelLogoutRequestBuilder(
|
||||
TimeProvider timeProvider,
|
||||
SamlProtocolMessageSigner samlProtocolMessageSigner)
|
||||
{
|
||||
internal async Task<ISamlFrontChannelLogout> BuildLogoutRequestAsync(
|
||||
SamlServiceProvider serviceProvider,
|
||||
string nameId,
|
||||
string? nameIdFormat,
|
||||
string sessionIndex,
|
||||
string issuer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
|
||||
if (serviceProvider.SingleLogoutServiceUrl == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Service Provider '{serviceProvider.EntityId}' has no SingleLogoutServiceUrl configured");
|
||||
}
|
||||
|
||||
var logoutRequest = new LogoutRequest
|
||||
{
|
||||
Id = RequestId.New(),
|
||||
Version = SamlVersion.V2,
|
||||
IssueInstant = timeProvider.GetUtcNow().UtcDateTime,
|
||||
Destination = serviceProvider.SingleLogoutServiceUrl.Location,
|
||||
Issuer = issuer,
|
||||
NameId = new NameIdentifier { Value = nameId, Format = nameIdFormat },
|
||||
SessionIndex = sessionIndex
|
||||
};
|
||||
|
||||
var requestXml = SerializeLogoutRequest(logoutRequest);
|
||||
|
||||
return serviceProvider.SingleLogoutServiceUrl.Binding switch
|
||||
{
|
||||
SamlBinding.HttpRedirect => await BuildRedirectLogoutRequest(serviceProvider.SingleLogoutServiceUrl.Location, requestXml),
|
||||
SamlBinding.HttpPost => await BuildHttpPostLogoutRequest(serviceProvider, requestXml),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Binding '{serviceProvider.SingleLogoutServiceUrl.Binding}' is not supported")
|
||||
};
|
||||
}
|
||||
|
||||
private static XElement SerializeLogoutRequest(LogoutRequest logoutRequest)
|
||||
{
|
||||
var issueInstant =
|
||||
logoutRequest.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
|
||||
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
|
||||
|
||||
var requestElement = new XElement(protocolNs + LogoutRequest.ElementNames.RootElement,
|
||||
new XAttribute("ID", logoutRequest.Id.Value),
|
||||
new XAttribute("Version", logoutRequest.Version.ToString()),
|
||||
new XAttribute("IssueInstant", issueInstant),
|
||||
new XAttribute("Destination", logoutRequest.Destination!),
|
||||
new XElement(assertionNs + LogoutRequest.ElementNames.Issuer, logoutRequest.Issuer));
|
||||
|
||||
var nameIdElement = new XElement(assertionNs + LogoutRequest.ElementNames.NameID, logoutRequest.NameId.Value);
|
||||
if (!string.IsNullOrEmpty(logoutRequest.NameId.Format))
|
||||
{
|
||||
nameIdElement.Add(new XAttribute("Format", logoutRequest.NameId.Format));
|
||||
}
|
||||
|
||||
requestElement.Add(nameIdElement);
|
||||
|
||||
requestElement.Add(new XElement(protocolNs + LogoutRequest.ElementNames.SessionIndex,
|
||||
logoutRequest.SessionIndex));
|
||||
|
||||
if (logoutRequest.Reason.HasValue)
|
||||
{
|
||||
var reasonValue = logoutRequest.Reason.Value switch
|
||||
{
|
||||
LogoutReason.User => "urn:oasis:names:tc:SAML:2.0:logout:user",
|
||||
LogoutReason.Admin => "urn:oasis:names:tc:SAML:2.0:logout:admin",
|
||||
LogoutReason.GlobalTimeout => "urn:oasis:names:tc:SAML:2.0:logout:global-timeout",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (reasonValue != null)
|
||||
{
|
||||
requestElement.Add(new XAttribute("Reason", reasonValue));
|
||||
}
|
||||
}
|
||||
|
||||
if (logoutRequest.NotOnOrAfter.HasValue)
|
||||
{
|
||||
var notOnOrAfter =
|
||||
logoutRequest.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
requestElement.Add(new XAttribute("NotOnOrAfter", notOnOrAfter));
|
||||
}
|
||||
|
||||
return requestElement;
|
||||
}
|
||||
|
||||
private async Task<ISamlFrontChannelLogout> BuildRedirectLogoutRequest(Uri singleLogoutServiceUri, XElement requestXml)
|
||||
{
|
||||
var encodedRequest = DeflateAndEncode(requestXml.ToString());
|
||||
|
||||
var queryString = $"?SAMLRequest={Uri.EscapeDataString(encodedRequest)}";
|
||||
|
||||
var signedQueryString = await samlProtocolMessageSigner.SignQueryString(queryString);
|
||||
|
||||
return new SamlHttpRedirectFrontChannelLogout(singleLogoutServiceUri, signedQueryString);
|
||||
}
|
||||
|
||||
private static string DeflateAndEncode(string xml)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(xml);
|
||||
|
||||
using var output = new MemoryStream();
|
||||
using (var deflateStream = new DeflateStream(output, CompressionLevel.Optimal))
|
||||
{
|
||||
deflateStream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
return Convert.ToBase64String(output.ToArray());
|
||||
}
|
||||
|
||||
private async Task<ISamlFrontChannelLogout> BuildHttpPostLogoutRequest(SamlServiceProvider serviceProvider, XElement requestXml)
|
||||
{
|
||||
var signedRequestXml = await samlProtocolMessageSigner.SignProtocolMessage(requestXml, serviceProvider);
|
||||
|
||||
var encodedXml = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedRequestXml));
|
||||
|
||||
return new SamlHttpPostFrontChannelLogout(serviceProvider.SingleLogoutServiceUrl!.Location, encodedXml, null);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
|
||||
/// <summary>
|
||||
/// Processes SAML Single Logout callback requests after user logout completes.
|
||||
/// </summary>
|
||||
internal class SamlLogoutCallbackProcessor(
|
||||
IMessageStore<LogoutMessage> logoutMessageStore,
|
||||
ISamlServiceProviderStore serviceProviderStore,
|
||||
LogoutResponseBuilder logoutResponseBuilder,
|
||||
ILogger<SamlLogoutCallbackProcessor> logger)
|
||||
{
|
||||
internal async Task<Result<LogoutResponse, SamlLogoutCallbackError>> ProcessAsync(string logoutId, CancellationToken ct = default)
|
||||
{
|
||||
var logoutMessage = await logoutMessageStore.ReadAsync(logoutId);
|
||||
if (logoutMessage?.Data == null)
|
||||
{
|
||||
logger.NoLogoutMessageFound(LogLevel.Warning, logoutId);
|
||||
return new SamlLogoutCallbackError("No logout message found");
|
||||
}
|
||||
|
||||
var data = logoutMessage.Data;
|
||||
if (data.SamlServiceProviderEntityId == null)
|
||||
{
|
||||
logger.LogoutMessageMissingSamlEntityId(LogLevel.Warning);
|
||||
return new SamlLogoutCallbackError("Logout message does not contain SAML SP entity ID");
|
||||
}
|
||||
|
||||
logger.BuildingLogoutResponseForSp(LogLevel.Debug, data.SamlServiceProviderEntityId);
|
||||
|
||||
var sp = await serviceProviderStore.FindByEntityIdAsync(data.SamlServiceProviderEntityId);
|
||||
if (sp == null)
|
||||
{
|
||||
logger.ServiceProviderNotFound(LogLevel.Error, data.SamlServiceProviderEntityId);
|
||||
return new SamlLogoutCallbackError($"Service Provider not found: {data.SamlServiceProviderEntityId}");
|
||||
}
|
||||
|
||||
if (!sp.Enabled)
|
||||
{
|
||||
logger.ServiceProviderDisabled(LogLevel.Error, sp.EntityId);
|
||||
return new SamlLogoutCallbackError($"Service Provider is disabled: {sp.EntityId}");
|
||||
}
|
||||
|
||||
if (sp.SingleLogoutServiceUrl == null)
|
||||
{
|
||||
logger.SamlLogoutNoSingleLogoutServiceUrl(LogLevel.Error, sp.EntityId);
|
||||
return new SamlLogoutCallbackError($"Service Provider has no SingleLogoutServiceUrl configured: {sp.EntityId}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(data.SamlLogoutRequestId))
|
||||
{
|
||||
logger.LogoutMessageMissingRequestId(LogLevel.Error);
|
||||
return new SamlLogoutCallbackError("Logout message does not contain SAML logout request ID");
|
||||
}
|
||||
|
||||
var response = await logoutResponseBuilder.BuildSuccessResponseAsync(
|
||||
new RequestId(data.SamlLogoutRequestId),
|
||||
sp,
|
||||
data.SamlRelayState);
|
||||
|
||||
logger.SuccessfullyBuiltLogoutResponse(LogLevel.Information, data.SamlServiceProviderEntityId, data.SamlLogoutRequestId);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an error during SAML logout callback processing.
|
||||
/// </summary>
|
||||
internal record SamlLogoutCallbackError(string Message);
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
|
||||
internal class SamlLogoutNotificationService(
|
||||
IIssuerNameService issuerNameService,
|
||||
ISamlServiceProviderStore serviceProviderStore,
|
||||
SamlFrontChannelLogoutRequestBuilder frontChannelLogoutRequestBuilder,
|
||||
ILogger<SamlLogoutNotificationService> logger) : ISamlLogoutNotificationService
|
||||
{
|
||||
public async Task<IEnumerable<ISamlFrontChannelLogout>> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context)
|
||||
{
|
||||
using var activity = Tracing.ServiceActivitySource.StartActivity("LogoutNotificationService.GetSamlFrontChannelLogoutUrls");
|
||||
|
||||
var logoutUrls = new List<ISamlFrontChannelLogout>();
|
||||
|
||||
if (context.SamlSessions?.Any() == true)
|
||||
{
|
||||
logger.NoSamlServiceProvidersToNotifyForLogout(LogLevel.Debug);
|
||||
return logoutUrls;
|
||||
}
|
||||
|
||||
var issuer = await issuerNameService.GetCurrentAsync();
|
||||
|
||||
foreach (var sessionData in context.SamlSessions ?? [])
|
||||
{
|
||||
var sp = await serviceProviderStore.FindByEntityIdAsync(sessionData.EntityId);
|
||||
if (sp?.Enabled != true)
|
||||
{
|
||||
logger.SkippingLogoutUrlGenerationForUnknownOrDisabledServiceProvider(LogLevel.Debug, sessionData.EntityId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sp.SingleLogoutServiceUrl == null)
|
||||
{
|
||||
logger.SkippingLogoutUrlGenerationForServiceProviderWithNoSingleLogout(LogLevel.Debug, sessionData.EntityId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var logoutUrl = await frontChannelLogoutRequestBuilder.BuildLogoutRequestAsync(
|
||||
sp,
|
||||
sessionData.NameId,
|
||||
sessionData.NameIdFormat,
|
||||
sessionData.SessionIndex,
|
||||
issuer);
|
||||
|
||||
logoutUrls.Add(logoutUrl);
|
||||
}
|
||||
#pragma warning disable CA1031 // Do not catch general exception types: one failure should not stop the whole process
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
logger.FailedToGenerateLogoutUrlForServiceProvider(ex, sessionData.EntityId);
|
||||
}
|
||||
}
|
||||
|
||||
if (logoutUrls.Count > 0)
|
||||
{
|
||||
logger.GeneratedSamlFrontChannelLogoutUrls(LogLevel.Debug, logoutUrls.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.NoSamlFrontChannelLogoutUrlsGenerated(LogLevel.Debug);
|
||||
}
|
||||
|
||||
return logoutUrls;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Security.Claims;
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using LogoutRequest = Duende.IdentityServer.Internal.Saml.SingleLogout.Models.LogoutRequest;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
|
||||
internal class SamlLogoutRequestProcessor : SamlRequestProcessorBase<LogoutRequest, SamlLogoutRequest, SamlLogoutSuccess>
|
||||
{
|
||||
private readonly IUserSession _userSession;
|
||||
private readonly LogoutResponseBuilder _logoutResponseBuilder;
|
||||
private readonly IMessageStore<LogoutMessage> _logoutMessageStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SamlUrlBuilder _urlBuilder;
|
||||
|
||||
public SamlLogoutRequestProcessor(
|
||||
ISamlServiceProviderStore serviceProviderStore,
|
||||
IUserSession userSession,
|
||||
SamlRequestSignatureValidator<SamlLogoutRequest, LogoutRequest> signatureValidator,
|
||||
LogoutResponseBuilder logoutResponseBuilder,
|
||||
IServerUrls serverUrls,
|
||||
IOptions<SamlOptions> options,
|
||||
IMessageStore<LogoutMessage> logoutMessageStore,
|
||||
TimeProvider timeProvider,
|
||||
SamlUrlBuilder urlBuilder,
|
||||
SamlRequestValidator requestValidator,
|
||||
ILogger<SamlLogoutRequestProcessor> logger)
|
||||
: base(
|
||||
serviceProviderStore,
|
||||
options,
|
||||
requestValidator,
|
||||
signatureValidator,
|
||||
logger,
|
||||
serverUrls.GetAbsoluteUrl(options.Value.UserInteraction.Route + options.Value.UserInteraction.SingleLogoutPath))
|
||||
{
|
||||
_userSession = userSession;
|
||||
_logoutResponseBuilder = logoutResponseBuilder;
|
||||
_logoutMessageStore = logoutMessageStore;
|
||||
_timeProvider = timeProvider;
|
||||
_urlBuilder = urlBuilder;
|
||||
}
|
||||
|
||||
protected override async Task<Result<SamlLogoutSuccess, SamlRequestError<SamlLogoutRequest>>> ProcessValidatedRequestAsync(
|
||||
SamlServiceProvider sp,
|
||||
SamlLogoutRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var logoutRequest = request.LogoutRequest;
|
||||
|
||||
if (sp.SingleLogoutServiceUrl == null)
|
||||
{
|
||||
Logger.SamlLogoutNoSingleLogoutServiceUrl(LogLevel.Error, sp.EntityId);
|
||||
return new SamlRequestError<SamlLogoutRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"Service Provider '{sp.EntityId}' has no SingleLogoutServiceUrl configured"
|
||||
};
|
||||
}
|
||||
|
||||
Logger.ProcessingSamlLogoutRequest(LogLevel.Debug, logoutRequest.Id, sp.DisplayName, logoutRequest.Issuer);
|
||||
|
||||
var user = await _userSession.GetUserAsync();
|
||||
if (user == null)
|
||||
{
|
||||
Logger.SamlLogoutRequestReceivedButNoActiveUserSession(LogLevel.Debug, logoutRequest.Id, logoutRequest.Issuer);
|
||||
var noUserAuthenticatedResponse = await _logoutResponseBuilder.BuildSuccessResponseAsync(logoutRequest.Id, sp, request.RelayState);
|
||||
// there is no user to log out, return success
|
||||
return SamlLogoutSuccess.CreateResponse(noUserAuthenticatedResponse);
|
||||
}
|
||||
|
||||
var sessionMatch = await ValidateSessionIndexAsync(sp, logoutRequest.SessionIndex);
|
||||
if (!sessionMatch)
|
||||
{
|
||||
Logger.SamlLogoutRequestReceivedWithWrongSessionIndex(LogLevel.Warning, logoutRequest.Id, logoutRequest.SessionIndex);
|
||||
var noSessionIndexResponse = await _logoutResponseBuilder.BuildSuccessResponseAsync(logoutRequest.Id, sp, request.RelayState);
|
||||
// there is no session to terminate, return success
|
||||
return SamlLogoutSuccess.CreateResponse(noSessionIndexResponse);
|
||||
}
|
||||
|
||||
Logger.SamlLogoutRedirectToLogoutPage(LogLevel.Information, logoutRequest.Issuer);
|
||||
|
||||
var logoutId = await StoreLogoutMessageAsync(user, sp, request);
|
||||
var logoutUri = _urlBuilder.SamlLogoutUri(logoutId);
|
||||
|
||||
return SamlLogoutSuccess.CreateRedirect(logoutUri);
|
||||
}
|
||||
|
||||
protected override bool RequireSignature(SamlServiceProvider sp) =>
|
||||
// SAML 2.0 spec requires LogoutRequest to be signed
|
||||
true;
|
||||
|
||||
protected override SamlRequestError<SamlLogoutRequest>? ValidateMessageSpecific(SamlServiceProvider sp, SamlLogoutRequest request)
|
||||
{
|
||||
var logoutRequest = request.LogoutRequest;
|
||||
|
||||
// Validate NotOnOrAfter if present
|
||||
if (logoutRequest.NotOnOrAfter.HasValue)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var clockSkew = sp.ClockSkew ?? SamlOptions.DefaultClockSkew;
|
||||
|
||||
if (now.Subtract(clockSkew) > logoutRequest.NotOnOrAfter.Value)
|
||||
{
|
||||
Logger.SamlLogoutRequestExpired(LogLevel.Warning, logoutRequest.Id, logoutRequest.NotOnOrAfter.Value);
|
||||
return new SamlRequestError<SamlLogoutRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Protocol,
|
||||
ProtocolError = new SamlProtocolError<SamlLogoutRequest>(sp, request, new SamlError
|
||||
{
|
||||
StatusCode = SamlStatusCode.Requester,
|
||||
Message = "Logout request expired (NotOnOrAfter is in the past)"
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSessionIndexAsync(SamlServiceProvider sp, string sessionIndex)
|
||||
{
|
||||
var samlSessions = await _userSession.GetSamlSessionListAsync();
|
||||
|
||||
var spSession = samlSessions.FirstOrDefault(s => s.EntityId == sp.EntityId.ToString());
|
||||
|
||||
if (spSession == null)
|
||||
{
|
||||
Logger.SamlLogoutNoSessionFoundForServiceProvider(LogLevel.Debug, sessionIndex, sp.EntityId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (spSession.SessionIndex != sessionIndex)
|
||||
{
|
||||
Logger.SamlLogoutSessionIndexMisMatch(LogLevel.Debug, spSession.SessionIndex, sessionIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<string> StoreLogoutMessageAsync(ClaimsPrincipal user, SamlServiceProvider serviceProvider, SamlLogoutRequest logoutRequest)
|
||||
{
|
||||
var samlSessions = await _userSession.GetSamlSessionListAsync();
|
||||
|
||||
var oidcClientIds = await _userSession.GetClientListAsync();
|
||||
|
||||
var logoutMessage = new LogoutMessage
|
||||
{
|
||||
SubjectId = user.GetSubjectId(),
|
||||
SessionId = await _userSession.GetSessionIdAsync(),
|
||||
ClientIds = oidcClientIds,
|
||||
SamlServiceProviderEntityId = serviceProvider.EntityId,
|
||||
SamlSessions = samlSessions,
|
||||
SamlLogoutRequestId = logoutRequest.LogoutRequest.Id.Value,
|
||||
SamlRelayState = logoutRequest.RelayState,
|
||||
PostLogoutRedirectUri = _urlBuilder.SamlLogoutCallBackUri().ToString()
|
||||
};
|
||||
|
||||
var msg = new Message<LogoutMessage>(logoutMessage, _timeProvider.GetUtcNow().UtcDateTime);
|
||||
|
||||
return await _logoutMessageStore.WriteAsync(msg);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Net;
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint for completing SAML Single Logout and sending the LogoutResponse back to the initiating Service Provider.
|
||||
/// This is called after the user completes logout and all front-channel logout notifications have been sent.
|
||||
/// </summary>
|
||||
internal class SamlSingleLogoutCallbackEndpoint(
|
||||
SamlLogoutCallbackProcessor processor,
|
||||
ILogger<SamlSingleLogoutCallbackEndpoint> logger) : IEndpointHandler
|
||||
{
|
||||
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
|
||||
{
|
||||
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSingleLogoutCallbackEndpoint");
|
||||
|
||||
logger.ProcessingSamlLogoutCallbackRequest(LogLevel.Debug);
|
||||
|
||||
var logoutId = context.Request.Query["logoutId"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(logoutId))
|
||||
{
|
||||
logger.MissingLogoutIdParameter(LogLevel.Warning);
|
||||
return new StatusCodeResult(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
var result = await processor.ProcessAsync(logoutId, context.RequestAborted);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
logger.ErrorProcessingLogoutCallback(LogLevel.Error, result.Error.Message);
|
||||
return new StatusCodeResult(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
logger.SuccessfullyProcessedLogoutCallback(LogLevel.Information);
|
||||
return result.Value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
|
||||
internal class SamlSingleLogoutEndpoint(
|
||||
SamlLogoutRequestExtractor extractor,
|
||||
SamlLogoutRequestProcessor processor,
|
||||
LogoutResponseBuilder responseBuilder,
|
||||
ILogger<SamlSingleLogoutEndpoint> logger) : IEndpointHandler
|
||||
{
|
||||
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
|
||||
{
|
||||
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSingleLogoutEndpoint");
|
||||
|
||||
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsPost(context.Request.Method))
|
||||
{
|
||||
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
// Extract the SAML logout request from query string (GET/Redirect) or form (POST)
|
||||
var logoutRequest = await extractor.ExtractAsync(context);
|
||||
|
||||
return await ProcessLogoutRequest(logoutRequest, context.RequestAborted);
|
||||
}
|
||||
|
||||
internal async Task<IEndpointResult> ProcessLogoutRequest(SamlLogoutRequest logoutRequest, CancellationToken ct = default)
|
||||
{
|
||||
logger.ReceivedLogoutRequest(LogLevel.Debug, logoutRequest.LogoutRequest.Issuer, logoutRequest.LogoutRequest.Id, logoutRequest.LogoutRequest.SessionIndex);
|
||||
|
||||
var result = await processor.ProcessAsync(logoutRequest, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Error;
|
||||
return error.Type switch
|
||||
{
|
||||
SamlRequestErrorType.Validation => HandleValidationError(error),
|
||||
SamlRequestErrorType.Protocol => await HandleProtocolError(error),
|
||||
_ => throw new InvalidOperationException($"Unexpected error type: {error.Type}")
|
||||
};
|
||||
}
|
||||
|
||||
var success = result.Value;
|
||||
logger.SuccessfullyProcessedLogoutRequest(LogLevel.Information, logoutRequest.LogoutRequest.Id, logoutRequest.LogoutRequest.SessionIndex);
|
||||
|
||||
return success.Result;
|
||||
}
|
||||
|
||||
private ValidationProblemResult HandleValidationError(SamlRequestError<SamlLogoutRequest> error)
|
||||
{
|
||||
logger.SamlLogoutValidationError(LogLevel.Information, error.ValidationMessage!);
|
||||
return new ValidationProblemResult(error.ValidationMessage!);
|
||||
}
|
||||
|
||||
private async Task<LogoutResponse> HandleProtocolError(SamlRequestError<SamlLogoutRequest> error)
|
||||
{
|
||||
var protocolError = error.ProtocolError!;
|
||||
logger.SamlLogoutProtocolError(LogLevel.Information,
|
||||
protocolError.Error.StatusCode,
|
||||
protocolError.Error.Message);
|
||||
|
||||
return await responseBuilder.BuildErrorResponseAsync(
|
||||
protocolError.Request,
|
||||
protocolError.ServiceProvider,
|
||||
protocolError.Error);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class AuthNRequestParser : SamlProtocolMessageParser
|
||||
{
|
||||
private readonly ILogger<AuthNRequestParser> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthNRequestParser"/> class.
|
||||
/// </summary>
|
||||
public AuthNRequestParser(ILogger<AuthNRequestParser> logger) => _logger = logger;
|
||||
|
||||
internal AuthNRequest Parse(XDocument doc)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ns = XNamespace.Get(SamlConstants.Namespaces.Protocol);
|
||||
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
|
||||
|
||||
var root = doc.Root;
|
||||
if (root?.Name != ns + SamlConstants.AuthenticationRequestAttributes.RootElementName)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Root element is not AuthnRequest. Found: {root?.Name}");
|
||||
}
|
||||
|
||||
var request = new AuthNRequest
|
||||
{
|
||||
Id = GetRequiredAttribute(root, AuthNRequest.AttributeNames.Id),
|
||||
Version = GetRequiredAttribute(root, AuthNRequest.AttributeNames.Version),
|
||||
IssueInstant = ParseDateTime(root, AuthNRequest.AttributeNames.IssueInstant),
|
||||
Destination = GetOptionalAttribute(root, AuthNRequest.AttributeNames.Destination) is { } dest ? new Uri(dest) : null,
|
||||
Consent = GetOptionalAttribute(root, AuthNRequest.AttributeNames.Consent),
|
||||
Issuer = ParseIssuerValue(root, assertionNs, "AuthnRequest"),
|
||||
ForceAuthn = ParseBooleanAttribute(root, AuthNRequest.AttributeNames.ForceAuthn, false),
|
||||
IsPassive = ParseBooleanAttribute(root, AuthNRequest.AttributeNames.IsPassive, false),
|
||||
AssertionConsumerServiceUrl =
|
||||
GetOptionalAttribute(root, AuthNRequest.AttributeNames.AssertionConsumerServiceUrl) is { } acsUrl ? new Uri(acsUrl) : null,
|
||||
AssertionConsumerServiceIndex =
|
||||
ParseIntegerAttribute(root, AuthNRequest.AttributeNames.AssertionConsumerServiceIndex),
|
||||
ProtocolBinding =
|
||||
SamlBindingExtensions.FromUrnOrDefault(GetOptionalAttribute(root, AuthNRequest.AttributeNames.ProtocolBinding))
|
||||
};
|
||||
|
||||
// Parse optional elements
|
||||
// request.Subject = ParseSubject(root, assertionNs);
|
||||
request.NameIdPolicy = ParseNameIdPolicy(root, ns);
|
||||
// request.Conditions = ParseConditions(root, assertionNs);
|
||||
request.RequestedAuthnContext = ParseRequestedAuthnContext(root, ns);
|
||||
// request.Scoping = ParseScoping(root, ns);
|
||||
|
||||
_logger.ParsedAuthenticationRequest(request.Id, request.Issuer);
|
||||
|
||||
return request;
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.FailedToParseAuthNRequest(ex, ex.Message);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.UnexpectedErrorParsingAuthNRequest(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static NameIdPolicy? ParseNameIdPolicy(XElement root, XNamespace ns)
|
||||
{
|
||||
var nameIdPolicyElement = root.Element(ns + AuthNRequest.ElementNames.NameIdPolicy);
|
||||
if (nameIdPolicyElement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var format = GetOptionalAttribute(nameIdPolicyElement, NameIdPolicy.AttributeNames.Format);
|
||||
var spNameQualifier = GetOptionalAttribute(nameIdPolicyElement, NameIdPolicy.AttributeNames.SPNameQualifier);
|
||||
|
||||
// If element exists but all attributes are null/default, still return object
|
||||
// to indicate element was present (SP may want default behavior explicitly)
|
||||
return new NameIdPolicy
|
||||
{
|
||||
Format = string.IsNullOrWhiteSpace(format) ? null : format.Trim(),
|
||||
SPNameQualifier = string.IsNullOrWhiteSpace(spNameQualifier) ? null : spNameQualifier.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private static int? ParseIntegerAttribute(XElement element, string attributeName)
|
||||
{
|
||||
var value = GetOptionalAttribute(element, attributeName);
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static RequestedAuthnContext? ParseRequestedAuthnContext(XElement root, XNamespace ns)
|
||||
{
|
||||
var requestedAuthnContextElement = root.Element(ns + AuthNRequest.ElementNames.RequestedAuthnContext);
|
||||
if (requestedAuthnContextElement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse Comparison attribute (defaults to "exact" per spec)
|
||||
var comparisonAttr = requestedAuthnContextElement.Attribute(RequestedAuthnContext.AttributeNames.Comparison)?.Value;
|
||||
var comparison = AuthnContextComparisonExtensions.Parse(comparisonAttr);
|
||||
|
||||
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
|
||||
var classRefs = requestedAuthnContextElement
|
||||
.Elements(assertionNs + RequestedAuthnContext.ElementNames.AuthnContextClassRef)
|
||||
.Select(e => e.Value?.Trim())
|
||||
.Where(v => !string.IsNullOrEmpty(v))
|
||||
.Select(v => v!)
|
||||
.ToList();
|
||||
|
||||
if (classRefs.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No AuthnContextClassRef element found in requestedAuthnContext");
|
||||
}
|
||||
|
||||
return new RequestedAuthnContext
|
||||
{
|
||||
AuthnContextClassRefs = classRefs.AsReadOnly(),
|
||||
Comparison = comparison
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SamlStatusCode = Duende.IdentityServer.Saml.Models.SamlStatusCode;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class DefaultSamlSigninInteractionResponseGenerator(
|
||||
IUserSession userSession,
|
||||
ILogger<DefaultSamlSigninInteractionResponseGenerator> logger,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
: ISamlSigninInteractionResponseGenerator
|
||||
{
|
||||
public async Task<SamlInteractionResponse> ProcessInteractionAsync(SamlServiceProvider sp, AuthNRequest request, CancellationToken ct)
|
||||
{
|
||||
var signedInUser = await userSession.GetUserAsync();
|
||||
|
||||
if (signedInUser != null)
|
||||
{
|
||||
if (request.IsPassive && request.ForceAuthn)
|
||||
{
|
||||
// Below is quite ambiguous in the spec. IsPassive means no user interaction. But ForceAuthn means we must re-authenticate.
|
||||
// For now, we have no way to re-authenticate the user without user interaction.
|
||||
|
||||
// From the spec:
|
||||
//ForceAuthn[Optional]
|
||||
//A Boolean value.If "true", the identity provider MUST authenticate the presenter directly rather than
|
||||
//rely on a previous security context. If a value is not provided, the default is "false".However, if both
|
||||
// ForceAuthn and IsPassive are "true", the identity provider MUST NOT freshly authenticate the
|
||||
//presenter unless the constraints of IsPassive can be met.
|
||||
logger.SamlInteractionPassiveAndForced(LogLevel.Debug);
|
||||
return SamlInteractionResponse.CreateError(SamlStatusCode.NoPassive, "The user is not currently logged in");
|
||||
}
|
||||
|
||||
if (request.ForceAuthn)
|
||||
{
|
||||
logger.SamlInteractionForced(LogLevel.Debug);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(httpContextAccessor.HttpContext, nameof(httpContextAccessor.HttpContext));
|
||||
await httpContextAccessor.HttpContext.SignOutAsync();
|
||||
|
||||
return SamlInteractionResponse.Create(SamlInteractionResponseType.Login);
|
||||
}
|
||||
|
||||
logger.SamlInteractionAlreadyAuthenticated(LogLevel.Debug);
|
||||
return SamlInteractionResponse.Create(SamlInteractionResponseType.AlreadyAuthenticated);
|
||||
}
|
||||
|
||||
if (request.IsPassive)
|
||||
{
|
||||
logger.SamlInteractionNoPassive(LogLevel.Debug);
|
||||
return SamlInteractionResponse.CreateError(SamlStatusCode.NoPassive, "The user is not currently logged in and passive login was requested.");
|
||||
}
|
||||
|
||||
// Todo: The AuthN request may contain hints on account creation 3.4.1.1 Element <NameIDPolicy>: AllowCreate
|
||||
|
||||
|
||||
// Consent is a weird one.
|
||||
// There is no way for SAML for an SP to mandate that a consent screen should be shown.
|
||||
if (sp.RequireConsent && !IsConsentAcquired(request.Consent))
|
||||
{
|
||||
logger.SamlInteractionConsent(LogLevel.Debug);
|
||||
return SamlInteractionResponse.Create(SamlInteractionResponseType.Consent);
|
||||
}
|
||||
|
||||
logger.SamlInteractionLogin(LogLevel.Debug);
|
||||
return SamlInteractionResponse.Create(SamlInteractionResponseType.Login);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether consent has been acquired based on the SAML consent URN value.
|
||||
/// See SAML 2.0 Core spec section 8.4.
|
||||
/// </summary>
|
||||
private static bool IsConsentAcquired(string? consent) => consent is
|
||||
"urn:oasis:names:tc:SAML:2.0:consent:obtained" or
|
||||
"urn:oasis:names:tc:SAML:2.0:consent:prior" or
|
||||
"urn:oasis:names:tc:SAML:2.0:consent:current-implicit" or
|
||||
"urn:oasis:names:tc:SAML:2.0:consent:current-explicit";
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class DistributedCacheSamlSigninStateStore(IDistributedCache cache) : ISamlSigninStateStore
|
||||
{
|
||||
private const string KeyPrefix = "saml-signin-state:";
|
||||
private static readonly DistributedCacheEntryOptions CacheOptions = new()
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
|
||||
};
|
||||
|
||||
public async Task<StateId> StoreSigninRequestStateAsync(SamlAuthenticationState state, CancellationToken ct = default)
|
||||
{
|
||||
var stateId = StateId.NewId();
|
||||
var key = GetKey(stateId);
|
||||
var json = JsonSerializer.Serialize(state);
|
||||
|
||||
await cache.SetStringAsync(key, json, CacheOptions, ct);
|
||||
|
||||
return stateId;
|
||||
}
|
||||
|
||||
public async Task<SamlAuthenticationState?> RetrieveSigninRequestStateAsync(StateId stateId, CancellationToken ct = default)
|
||||
{
|
||||
var key = GetKey(stateId);
|
||||
var json = await cache.GetStringAsync(key, ct);
|
||||
|
||||
if (json == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await cache.RemoveAsync(key, ct);
|
||||
|
||||
return JsonSerializer.Deserialize<SamlAuthenticationState>(json);
|
||||
}
|
||||
|
||||
public async Task UpdateSigninRequestStateAsync(StateId stateId, SamlAuthenticationState state, CancellationToken ct = default)
|
||||
{
|
||||
var key = GetKey(stateId);
|
||||
var json = JsonSerializer.Serialize(state);
|
||||
|
||||
await cache.SetStringAsync(key, json, CacheOptions, ct);
|
||||
}
|
||||
|
||||
private static string GetKey(StateId stateId) => $"{KeyPrefix}{stateId.Value}";
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal interface ISamlSigninStateStore
|
||||
{
|
||||
Task<StateId> StoreSigninRequestStateAsync(SamlAuthenticationState request, CancellationToken ct = default);
|
||||
Task<SamlAuthenticationState?> RetrieveSigninRequestStateAsync(StateId stateId, CancellationToken ct = default);
|
||||
Task UpdateSigninRequestStateAsync(StateId stateId, SamlAuthenticationState state, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
internal record Assertion
|
||||
{
|
||||
// ev: This can also be a UUIDV7
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this assertion
|
||||
/// Must start with a _ character and be unique
|
||||
///
|
||||
/// According to SAML 2.0 Core Specification (Section 1.3.4):
|
||||
///- ID attributes must be of type xs:ID
|
||||
///- xs:ID must conform to the NCName production (Non-Colonized Name) from the XML Namespaces specification
|
||||
///- NCName cannot start with a digit, colon, or certain other characters
|
||||
/// </summary>
|
||||
public AssertionId Id { get; } = AssertionId.NewId();
|
||||
|
||||
/// <summary>
|
||||
/// SAML version (must be "2.0")
|
||||
/// </summary>
|
||||
public SamlVersion Version { get; } = SamlVersion.V2;
|
||||
|
||||
/// <summary>
|
||||
/// Time instant of issuance
|
||||
/// </summary>
|
||||
public required DateTime IssueInstant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the entity that issued the assertion
|
||||
/// </summary>
|
||||
public required string Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject of the assertion
|
||||
/// </summary>
|
||||
public Subject? Subject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Conditions under which the assertion is valid
|
||||
/// </summary>
|
||||
public Conditions? Conditions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication statements
|
||||
/// </summary>
|
||||
public List<AuthnStatement> AuthnStatements { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Attribute statements
|
||||
/// </summary>
|
||||
public List<AttributeStatement> AttributeStatements { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Authorization decision statements
|
||||
/// </summary>
|
||||
public List<AuthzDecisionStatement> AuthzDecisionStatements { get; set; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this assertion
|
||||
/// Must start with a _ character and be unique
|
||||
///
|
||||
/// According to SAML 2.0 Core Specification (Section 1.3.4):
|
||||
///- ID attributes must be of type xs:ID
|
||||
///- xs:ID must conform to the NCName production (Non-Colonized Name) from the XML Namespaces specification
|
||||
///- NCName cannot start with a digit, colon, or certain other characters
|
||||
/// </summary>
|
||||
internal readonly record struct AssertionId(string Value)
|
||||
{
|
||||
public static AssertionId NewId() => new("_" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public static implicit operator AssertionId(string value) => new(value);
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 AttributeStatement element
|
||||
/// </summary>
|
||||
internal record AttributeStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Attributes in this statement
|
||||
/// </summary>
|
||||
public List<SamlAttribute> Attributes { get; set; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 AuthnContext element
|
||||
/// </summary>
|
||||
internal record AuthnContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Authentication context class reference (URI)
|
||||
/// </summary>
|
||||
public string? AuthnContextClassRef { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 AuthnStatement element
|
||||
/// </summary>
|
||||
internal record AuthnStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Time at which the authentication took place
|
||||
/// </summary>
|
||||
public required DateTime AuthnInstant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Session index for the authenticated session
|
||||
/// </summary>
|
||||
public string? SessionIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time instant at which the session expires
|
||||
/// </summary>
|
||||
public DateTime? SessionNotOnOrAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication context
|
||||
/// </summary>
|
||||
public AuthnContext? AuthnContext { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 AuthzDecisionStatement element (Section 2.7.4)
|
||||
/// </summary>
|
||||
internal record AuthzDecisionStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// URI reference identifying the resource to which access authorization is sought
|
||||
/// </summary>
|
||||
public required string Resource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision rendered by the SAML authority with respect to the specified resource
|
||||
/// </summary>
|
||||
public DecisionType Decision { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A set of assertions that the SAML authority relied on in making the decision (optional)
|
||||
/// </summary>
|
||||
public Evidence? Evidence { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents SAML 2.0 Conditions element
|
||||
/// </summary>
|
||||
internal record Conditions
|
||||
{
|
||||
/// <summary>
|
||||
/// Time instant before which the assertion is invalid
|
||||
/// </summary>
|
||||
public DateTime? NotBefore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time instant at which the assertion expires
|
||||
/// </summary>
|
||||
public DateTime? NotOnOrAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audience restrictions for the assertion
|
||||
/// </summary>
|
||||
public ReadOnlyCollection<string> AudienceRestrictions { get; init; } = new List<string>().AsReadOnly();
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the decision rendered by the SAML authority
|
||||
/// </summary>
|
||||
internal enum DecisionType
|
||||
{
|
||||
/// <summary>
|
||||
/// The specified action is permitted
|
||||
/// </summary>
|
||||
Permit,
|
||||
|
||||
/// <summary>
|
||||
/// The specified action is denied
|
||||
/// </summary>
|
||||
Deny,
|
||||
|
||||
/// <summary>
|
||||
/// The SAML authority cannot determine whether the action is permitted or denied
|
||||
/// </summary>
|
||||
Indeterminate
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents evidence supporting the authorization decision (optional)
|
||||
/// </summary>
|
||||
internal record Evidence
|
||||
{
|
||||
/// <summary>
|
||||
/// URI references to assertions
|
||||
/// </summary>
|
||||
public List<string> AssertionIDRefs { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// URI references to assertions
|
||||
/// </summary>
|
||||
public List<string> AssertionURIRefs { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Embedded assertions
|
||||
/// </summary>
|
||||
public List<Assertion> Assertions { get; set; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
#nullable enable
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 NameID element
|
||||
/// </summary>
|
||||
internal record NameIdentifier
|
||||
{
|
||||
/// <summary>
|
||||
/// The name identifier value
|
||||
/// </summary>
|
||||
public required string Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The format of the name identifier (URI)
|
||||
/// </summary>
|
||||
public string? Format { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The NameQualifier attribute
|
||||
/// </summary>
|
||||
public string? NameQualifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The SPNameQualifier attribute
|
||||
/// </summary>
|
||||
public string? SPNameQualifier { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this assertion
|
||||
/// Must start with a _ character and be unique
|
||||
///
|
||||
/// According to SAML 2.0 Core Specification (Section 1.3.4):
|
||||
///- ID attributes must be of type xs:ID
|
||||
///- xs:ID must conform to the NCName production (Non-Colonized Name) from the XML Namespaces specification
|
||||
///- NCName cannot start with a digit, colon, or certain other characters
|
||||
/// </summary>
|
||||
internal readonly record struct RequestId(string Value)
|
||||
{
|
||||
public static RequestId New() => new("_" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public static implicit operator RequestId(string value) => new(value);
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this assertion
|
||||
/// Must start with a _ character and be unique
|
||||
///
|
||||
/// According to SAML 2.0 Core Specification (Section 1.3.4):
|
||||
///- ID attributes must be of type xs:ID
|
||||
///- xs:ID must conform to the NCName production (Non-Colonized Name) from the XML Namespaces specification
|
||||
///- NCName cannot start with a digit, colon, or certain other characters
|
||||
/// </summary>
|
||||
internal readonly record struct ResponseId(string Value)
|
||||
{
|
||||
public static ResponseId New() => new("_" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public static implicit operator ResponseId(string value) => new(value);
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the stored context for a SAML authentication flow.
|
||||
/// </summary>
|
||||
internal record SamlAuthenticationState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the original AuthnRequest.
|
||||
/// Will be null for IdP-initiated SSO flows.
|
||||
/// </summary>
|
||||
public AuthNRequest? Request { get; set; }
|
||||
|
||||
public required string ServiceProviderEntityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the RelayState parameter from the original request.
|
||||
/// For IdP-initiated SSO, this typically contains the target URL at the SP.
|
||||
/// </summary>
|
||||
public string? RelayState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this is an IdP-initiated SSO flow.
|
||||
/// If true, there was no AuthnRequest and the response will be unsolicited.
|
||||
/// </summary>
|
||||
public bool IsIdpInitiated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when this context was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedUtc { get; set; }
|
||||
|
||||
public required Uri AssertionConsumerServiceUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the RequestedAuthnContext in the request were met.
|
||||
/// </summary>
|
||||
public bool RequestedAuthnContextRequirementsWereMet { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
internal class SamlResponse : EndpointResult<SamlResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for this response.
|
||||
/// </summary>
|
||||
public ResponseId Id { get; } = ResponseId.New();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML version. Must be "2.0".
|
||||
/// </summary>
|
||||
public SamlVersion Version { get; } = SamlVersion.V2;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time instant of issue in UTC.
|
||||
/// </summary>
|
||||
public required DateTime IssueInstant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URI of the destination endpoint where this response is sent.
|
||||
/// This is the SP's Assertion Consumer Service (ACS) URL.
|
||||
/// </summary>
|
||||
public required Uri Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entity identifier of the Identity Provider sending this response.
|
||||
/// </summary>
|
||||
public required string Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the request to which this is a response.
|
||||
/// Will be null for IdP-initiated SSO (unsolicited responses).
|
||||
/// </summary>
|
||||
public string? InResponseTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the status of this response.
|
||||
/// </summary>
|
||||
public required Status Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the assertion included in this response.
|
||||
/// Will be null for error responses.
|
||||
/// </summary>
|
||||
public Assertion? Assertion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the relay state included in this response.
|
||||
/// </summary>
|
||||
public string? RelayState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Service Provider where the response will be sent.
|
||||
/// </summary>
|
||||
public required SamlServiceProvider ServiceProvider { get; init; }
|
||||
|
||||
internal class ResponseWriter(
|
||||
ISamlResultSerializer<SamlResponse> serializer,
|
||||
SamlResponseSigner samlResponseSigner,
|
||||
SamlAssertionEncryptor samlAssertionEncryptor) : IHttpResponseWriter<SamlResponse>
|
||||
{
|
||||
public async Task WriteHttpResponse(SamlResponse result, HttpContext httpContext)
|
||||
{
|
||||
var responseXml = serializer.Serialize(result);
|
||||
|
||||
var signedResponseXml = await samlResponseSigner.SignResponse(responseXml, result.ServiceProvider);
|
||||
|
||||
if (result.ServiceProvider.EncryptAssertions)
|
||||
{
|
||||
signedResponseXml = samlAssertionEncryptor.EncryptAssertion(signedResponseXml, result.ServiceProvider);
|
||||
}
|
||||
|
||||
var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedResponseXml));
|
||||
|
||||
var html = HttpResponseBindings.GenerateAutoPostForm(SamlMessageName.SamlResponse, encodedResponse, result.Destination, result.RelayState);
|
||||
|
||||
httpContext.Response.ContentType = "text/html";
|
||||
httpContext.Response.Headers.CacheControl = "no-cache, no-store";
|
||||
httpContext.Response.Headers.Pragma = "no-cache";
|
||||
|
||||
await httpContext.Response.WriteAsync(html);
|
||||
}
|
||||
}
|
||||
|
||||
internal class Serializer : ISamlResultSerializer<SamlResponse>
|
||||
{
|
||||
public XElement Serialize(SamlResponse toSerialize)
|
||||
{
|
||||
var issueInstant = toSerialize.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
|
||||
var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol);
|
||||
var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion);
|
||||
|
||||
// Build Status element
|
||||
var statusCodeElement = new XElement(protocolNs + "StatusCode",
|
||||
new XAttribute("Value", toSerialize.Status.StatusCode.ToString()));
|
||||
|
||||
if (!string.IsNullOrEmpty(toSerialize.Status.NestedStatusCode))
|
||||
{
|
||||
statusCodeElement.Add(
|
||||
new XElement(protocolNs + "StatusCode",
|
||||
new XAttribute("Value", toSerialize.Status.NestedStatusCode)));
|
||||
}
|
||||
|
||||
var statusElement = new XElement(protocolNs + "Status", statusCodeElement);
|
||||
|
||||
if (!string.IsNullOrEmpty(toSerialize.Status.StatusMessage))
|
||||
{
|
||||
statusElement.Add(new XElement(protocolNs + "StatusMessage", toSerialize.Status.StatusMessage));
|
||||
}
|
||||
|
||||
// Build Response element
|
||||
var responseElement = new XElement(protocolNs + "Response",
|
||||
new XAttribute("ID", toSerialize.Id.ToString()),
|
||||
new XAttribute("Version", toSerialize.Version.ToString()),
|
||||
new XAttribute("IssueInstant", issueInstant),
|
||||
new XAttribute("Destination", toSerialize.Destination.ToString()),
|
||||
new XElement(assertionNs + "Issuer", toSerialize.Issuer.ToString()),
|
||||
statusElement);
|
||||
|
||||
if (toSerialize.InResponseTo != null)
|
||||
{
|
||||
responseElement.Add(new XAttribute("InResponseTo", toSerialize.InResponseTo));
|
||||
}
|
||||
|
||||
// Add Assertion if present
|
||||
if (toSerialize.Assertion != null)
|
||||
{
|
||||
responseElement.Add(GenerateAssertionElement(toSerialize.Assertion, assertionNs, protocolNs));
|
||||
}
|
||||
|
||||
return responseElement;
|
||||
}
|
||||
|
||||
|
||||
private static XElement GenerateAssertionElement(Assertion assertion, XNamespace assertionNs, XNamespace protocolNs)
|
||||
{
|
||||
var assertionElement = new XElement(assertionNs + "Assertion",
|
||||
new XAttribute("ID", assertion.Id.ToString()),
|
||||
new XAttribute("Version", assertion.Version.ToString()),
|
||||
new XAttribute("IssueInstant", assertion.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)),
|
||||
new XElement(assertionNs + "Issuer", assertion.Issuer.ToString()));
|
||||
|
||||
// Add Subject
|
||||
if (assertion.Subject != null)
|
||||
{
|
||||
assertionElement.Add(GenerateSubjectElement(assertion.Subject, assertionNs));
|
||||
}
|
||||
|
||||
// Add Conditions
|
||||
if (assertion.Conditions != null)
|
||||
{
|
||||
assertionElement.Add(GenerateConditionsElement(assertion.Conditions, assertionNs));
|
||||
}
|
||||
|
||||
// Add AuthnStatements
|
||||
foreach (var authnStatement in assertion.AuthnStatements)
|
||||
{
|
||||
assertionElement.Add(GenerateAuthnStatementElement(authnStatement, assertionNs));
|
||||
}
|
||||
|
||||
// Add AttributeStatements
|
||||
foreach (var attributeStatement in assertion.AttributeStatements)
|
||||
{
|
||||
assertionElement.Add(GenerateAttributeStatementElement(attributeStatement, assertionNs));
|
||||
}
|
||||
|
||||
return assertionElement;
|
||||
}
|
||||
|
||||
private static XElement GenerateSubjectElement(Subject subject, XNamespace assertionNs)
|
||||
{
|
||||
var subjectElement = new XElement(assertionNs + "Subject");
|
||||
|
||||
if (subject.NameId != null)
|
||||
{
|
||||
var nameIdElement = new XElement(assertionNs + "NameID", subject.NameId.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(subject.NameId.Format))
|
||||
{
|
||||
nameIdElement.Add(new XAttribute("Format", subject.NameId.Format));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(subject.NameId.NameQualifier))
|
||||
{
|
||||
nameIdElement.Add(new XAttribute("NameQualifier", subject.NameId.NameQualifier));
|
||||
}
|
||||
|
||||
if (subject.NameId.SPNameQualifier != null)
|
||||
{
|
||||
nameIdElement.Add(new XAttribute("SPNameQualifier", subject.NameId.SPNameQualifier));
|
||||
}
|
||||
|
||||
subjectElement.Add(nameIdElement);
|
||||
}
|
||||
|
||||
foreach (var confirmation in subject.SubjectConfirmations)
|
||||
{
|
||||
var confirmationElement = new XElement(assertionNs + "SubjectConfirmation",
|
||||
new XAttribute("Method", confirmation.Method));
|
||||
|
||||
if (confirmation.Data != null)
|
||||
{
|
||||
var dataElement = new XElement(assertionNs + "SubjectConfirmationData");
|
||||
|
||||
if (confirmation.Data.NotBefore.HasValue)
|
||||
{
|
||||
dataElement.Add(new XAttribute("NotBefore",
|
||||
confirmation.Data.NotBefore.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (confirmation.Data.NotOnOrAfter.HasValue)
|
||||
{
|
||||
dataElement.Add(new XAttribute("NotOnOrAfter",
|
||||
confirmation.Data.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (confirmation.Data.Recipient != null)
|
||||
{
|
||||
dataElement.Add(new XAttribute("Recipient", confirmation.Data.Recipient.ToString()));
|
||||
}
|
||||
|
||||
if (confirmation.Data.InResponseTo != null)
|
||||
{
|
||||
dataElement.Add(new XAttribute("InResponseTo", confirmation.Data.InResponseTo));
|
||||
}
|
||||
|
||||
confirmationElement.Add(dataElement);
|
||||
}
|
||||
|
||||
subjectElement.Add(confirmationElement);
|
||||
}
|
||||
|
||||
return subjectElement;
|
||||
}
|
||||
|
||||
private static XElement GenerateConditionsElement(Conditions conditions, XNamespace assertionNs)
|
||||
{
|
||||
var conditionsElement = new XElement(assertionNs + "Conditions");
|
||||
|
||||
if (conditions.NotBefore.HasValue)
|
||||
{
|
||||
conditionsElement.Add(new XAttribute("NotBefore",
|
||||
conditions.NotBefore.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (conditions.NotOnOrAfter.HasValue)
|
||||
{
|
||||
conditionsElement.Add(new XAttribute("NotOnOrAfter",
|
||||
conditions.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (conditions.AudienceRestrictions.Count > 0)
|
||||
{
|
||||
var audienceRestrictionElement = new XElement(assertionNs + "AudienceRestriction");
|
||||
foreach (var audience in conditions.AudienceRestrictions)
|
||||
{
|
||||
audienceRestrictionElement.Add(new XElement(assertionNs + "Audience", audience));
|
||||
}
|
||||
conditionsElement.Add(audienceRestrictionElement);
|
||||
}
|
||||
|
||||
return conditionsElement;
|
||||
}
|
||||
|
||||
private static XElement GenerateAuthnStatementElement(AuthnStatement authnStatement, XNamespace assertionNs)
|
||||
{
|
||||
var authnStatementElement = new XElement(assertionNs + "AuthnStatement",
|
||||
new XAttribute("AuthnInstant", authnStatement.AuthnInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
|
||||
|
||||
if (!string.IsNullOrEmpty(authnStatement.SessionIndex))
|
||||
{
|
||||
authnStatementElement.Add(new XAttribute("SessionIndex", authnStatement.SessionIndex));
|
||||
}
|
||||
|
||||
if (authnStatement.SessionNotOnOrAfter.HasValue)
|
||||
{
|
||||
authnStatementElement.Add(new XAttribute("SessionNotOnOrAfter",
|
||||
authnStatement.SessionNotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (authnStatement.AuthnContext != null && !string.IsNullOrEmpty(authnStatement.AuthnContext.AuthnContextClassRef))
|
||||
{
|
||||
var authnContextElement = new XElement(assertionNs + "AuthnContext",
|
||||
new XElement(assertionNs + "AuthnContextClassRef", authnStatement.AuthnContext.AuthnContextClassRef));
|
||||
authnStatementElement.Add(authnContextElement);
|
||||
}
|
||||
|
||||
return authnStatementElement;
|
||||
}
|
||||
|
||||
private static XElement GenerateAttributeStatementElement(AttributeStatement attributeStatement, XNamespace assertionNs)
|
||||
{
|
||||
var attributeStatementElement = new XElement(assertionNs + "AttributeStatement");
|
||||
|
||||
foreach (var attribute in attributeStatement.Attributes)
|
||||
{
|
||||
var attributeElement = new XElement(assertionNs + "Attribute",
|
||||
new XAttribute("Name", attribute.Name));
|
||||
|
||||
if (!string.IsNullOrEmpty(attribute.NameFormat))
|
||||
{
|
||||
attributeElement.Add(new XAttribute("NameFormat", attribute.NameFormat));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(attribute.FriendlyName))
|
||||
{
|
||||
attributeElement.Add(new XAttribute("FriendlyName", attribute.FriendlyName));
|
||||
}
|
||||
|
||||
foreach (var value in attribute.Values)
|
||||
{
|
||||
attributeElement.Add(new XElement(assertionNs + "AttributeValue", value));
|
||||
}
|
||||
|
||||
attributeStatementElement.Add(attributeElement);
|
||||
}
|
||||
|
||||
return attributeStatementElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Xml.Linq;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a saml signin request, either as a Redirect Binding or a Post Binding.
|
||||
/// </summary>
|
||||
internal record SamlSigninRequest : SamlRequestBase<AuthNRequest>
|
||||
{
|
||||
public static async ValueTask<SamlSigninRequest?> BindAsync(HttpContext context)
|
||||
{
|
||||
var extractor = context.RequestServices.GetRequiredService<SamlSigninRequestExtractor>();
|
||||
return await extractor.ExtractAsync(context);
|
||||
}
|
||||
|
||||
public AuthNRequest AuthNRequest => Request;
|
||||
}
|
||||
|
||||
internal class SamlSigninRequestExtractor(AuthNRequestParser parser)
|
||||
: SamlRequestExtractor<AuthNRequest, SamlSigninRequest>
|
||||
{
|
||||
protected override AuthNRequest ParseRequest(XDocument xmlDocument) => parser.Parse(xmlDocument);
|
||||
|
||||
protected override SamlSigninRequest CreateResult(
|
||||
AuthNRequest parsedRequest,
|
||||
XDocument requestXml,
|
||||
SamlBinding binding,
|
||||
string? relayState,
|
||||
string? signature = null,
|
||||
string? signatureAlgorithm = null,
|
||||
string? encodedSamlRequest = null) => new()
|
||||
{
|
||||
Request = parsedRequest,
|
||||
RequestXml = requestXml,
|
||||
Binding = binding,
|
||||
RelayState = relayState,
|
||||
Signature = signature,
|
||||
SignatureAlgorithm = signatureAlgorithm,
|
||||
EncodedSamlRequest = encodedSamlRequest
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the status of a SAML Response.
|
||||
/// </summary>
|
||||
internal record Status
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the status code indicating the success or failure of the request.
|
||||
/// </summary>
|
||||
public required SamlStatusCode StatusCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional human-readable message providing additional information about the status.
|
||||
/// </summary>
|
||||
public string? StatusMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional nested status code for more detailed error information.
|
||||
/// </summary>
|
||||
public string? NestedStatusCode { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
#nullable enable
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 Subject element
|
||||
/// </summary>
|
||||
internal record Subject
|
||||
{
|
||||
/// <summary>
|
||||
/// The name identifier of the subject
|
||||
/// </summary>
|
||||
public NameIdentifier? NameId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject confirmation data
|
||||
/// </summary>
|
||||
public ReadOnlyCollection<SubjectConfirmation> SubjectConfirmations { get; init; } = new List<SubjectConfirmation>().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML 2.0 SubjectConfirmation element
|
||||
/// </summary>
|
||||
internal record SubjectConfirmation
|
||||
{
|
||||
/// <summary>
|
||||
/// The method used to confirm the subject (URI)
|
||||
/// </summary>
|
||||
public required string Method { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject confirmation data
|
||||
/// </summary>
|
||||
public SubjectConfirmationData? Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents SAML 2.0 SubjectConfirmationData element
|
||||
/// </summary>
|
||||
internal record SubjectConfirmationData
|
||||
{
|
||||
/// <summary>
|
||||
/// Time instant before which the subject cannot be confirmed
|
||||
/// </summary>
|
||||
public DateTime? NotBefore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time instant at which the subject can no longer be confirmed
|
||||
/// </summary>
|
||||
public DateTime? NotOnOrAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URI of a recipient entity
|
||||
/// </summary>
|
||||
public Uri? Recipient { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of a SAML request to which this is a response
|
||||
/// </summary>
|
||||
public string? InResponseTo { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class SamlIdpInitiatedEndpoint(
|
||||
SamlIdpInitiatedRequestProcessor requestProcessor,
|
||||
ILogger<SamlIdpInitiatedEndpoint> logger) : IEndpointHandler
|
||||
{
|
||||
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
|
||||
{
|
||||
using var activity = Tracing.BasicActivitySource.StartActivity("SamlIdpInitiatedEndpoint");
|
||||
|
||||
if (!HttpMethods.IsGet(context.Request.Method))
|
||||
{
|
||||
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
var spEntityId = context.Request.Query["spEntityId"].ToString();
|
||||
var relayState = context.Request.Query["relayState"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(spEntityId))
|
||||
{
|
||||
return new ValidationProblemResult("Missing required 'spEntityId' query parameter");
|
||||
}
|
||||
|
||||
return await ProcessInternalAsync(
|
||||
spEntityId,
|
||||
string.IsNullOrEmpty(relayState) ? null : relayState,
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
internal async Task<IEndpointResult> ProcessInternalAsync(
|
||||
string spEntityId,
|
||||
string? relayState,
|
||||
CancellationToken ct)
|
||||
{
|
||||
logger.StartIdpInitiatedRequest(LogLevel.Debug, spEntityId);
|
||||
|
||||
var result = await requestProcessor.ProcessAsync(spEntityId, relayState, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Error;
|
||||
logger.IdpInitiatedRequestFailed(LogLevel.Information, error.ValidationMessage!);
|
||||
return new ValidationProblemResult(error.ValidationMessage!);
|
||||
}
|
||||
|
||||
var success = result.Value;
|
||||
logger.IdpInitiatedRequestSuccess(LogLevel.Debug, success.RedirectUri);
|
||||
return new RedirectResult(success.RedirectUri);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class SamlIdpInitiatedRequestProcessor(
|
||||
ISamlServiceProviderStore serviceProviderStore,
|
||||
ISamlSigninStateStore stateStore,
|
||||
SamlUrlBuilder samlUrlBuilder,
|
||||
SamlSigninStateIdCookie stateIdCookie,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<SamlOptions> options)
|
||||
{
|
||||
private readonly SamlOptions _samlOptions = options.Value;
|
||||
|
||||
internal async Task<Result<SamlSigninSuccess, SamlRequestError<SamlSigninRequest>>> ProcessAsync(
|
||||
string spEntityId,
|
||||
string? relayState,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sp = await serviceProviderStore.FindByEntityIdAsync(spEntityId);
|
||||
if (sp == null)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"Service Provider '{spEntityId}' is not registered"
|
||||
};
|
||||
}
|
||||
|
||||
if (!sp.Enabled)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"Service Provider '{spEntityId}' is disabled"
|
||||
};
|
||||
}
|
||||
|
||||
if (!sp.AllowIdpInitiated)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"Service Provider '{spEntityId}' does not allow IdP-initiated SSO"
|
||||
};
|
||||
}
|
||||
|
||||
if (relayState != null)
|
||||
{
|
||||
var relayStateBytes = System.Text.Encoding.UTF8.GetByteCount(relayState);
|
||||
if (relayStateBytes > _samlOptions.MaxRelayStateLength)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"RelayState exceeds maximum length of {_samlOptions.MaxRelayStateLength} bytes"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var acsUrl = sp.AssertionConsumerServiceUrls.FirstOrDefault();
|
||||
if (acsUrl == null)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"Service Provider '{spEntityId}' has no AssertionConsumerServiceUrls configured"
|
||||
};
|
||||
}
|
||||
|
||||
string? relayStateParam = null;
|
||||
if (!string.IsNullOrEmpty(relayState))
|
||||
{
|
||||
relayStateParam = relayState;
|
||||
}
|
||||
|
||||
var state = new SamlAuthenticationState
|
||||
{
|
||||
Request = null, // No AuthNRequest for IdP-initiated
|
||||
RelayState = relayStateParam,
|
||||
ServiceProviderEntityId = sp.EntityId,
|
||||
AssertionConsumerServiceUrl = acsUrl,
|
||||
IsIdpInitiated = true,
|
||||
CreatedUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var storedStateId = await stateStore.StoreSigninRequestStateAsync(state, ct);
|
||||
stateIdCookie.StoreSamlSigninStateId(storedStateId);
|
||||
|
||||
// Determine redirect based on authentication status
|
||||
var httpContext = httpContextAccessor.HttpContext
|
||||
?? throw new InvalidOperationException("No HttpContext available");
|
||||
|
||||
var isAuthenticated = httpContext.User.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
Uri redirectUrl;
|
||||
if (isAuthenticated)
|
||||
{
|
||||
redirectUrl = samlUrlBuilder.SamlSignInCallBackUri();
|
||||
}
|
||||
else
|
||||
{
|
||||
redirectUrl = samlUrlBuilder.SamlLoginUri();
|
||||
}
|
||||
|
||||
return SamlSigninSuccess.CreateRedirectSuccess(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using System.Security.Claims;
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class SamlNameIdGenerator(IOptions<SamlOptions> samlOptions, ILogger<SamlNameIdGenerator> logger)
|
||||
{
|
||||
private readonly SamlOptions _samlOptions = samlOptions.Value;
|
||||
|
||||
internal NameIdentifier GenerateNameIdentifier(
|
||||
ClaimsPrincipal user,
|
||||
SamlServiceProvider samlServiceProvider,
|
||||
AuthNRequest? request)
|
||||
{
|
||||
// Format selection priority: Request > SP Default > IdP Default
|
||||
var format = request?.NameIdPolicy?.Format
|
||||
?? samlServiceProvider.DefaultNameIdFormat
|
||||
?? SamlConstants.NameIdentifierFormats.Unspecified;
|
||||
|
||||
logger.UsingNameIdFormat(LogLevel.Debug, format);
|
||||
|
||||
var value = format switch
|
||||
{
|
||||
SamlConstants.NameIdentifierFormats.EmailAddress => GetEmailNameId(user),
|
||||
SamlConstants.NameIdentifierFormats.Persistent => GetPersistentNameId(samlServiceProvider, user),
|
||||
SamlConstants.NameIdentifierFormats.Transient => Guid.NewGuid().ToString(),
|
||||
_ => user.GetSubjectId()
|
||||
};
|
||||
|
||||
var nameId = new NameIdentifier
|
||||
{
|
||||
Value = value,
|
||||
Format = format,
|
||||
};
|
||||
|
||||
if (format == SamlConstants.NameIdentifierFormats.Persistent)
|
||||
{
|
||||
nameId.SPNameQualifier = samlServiceProvider.EntityId;
|
||||
}
|
||||
|
||||
return nameId;
|
||||
}
|
||||
|
||||
private static string GetEmailNameId(ClaimsPrincipal user)
|
||||
{
|
||||
// Try to get email claim
|
||||
var email = user.FindFirst("email")?.Value
|
||||
?? user.FindFirst(ClaimTypes.Email)?.Value;
|
||||
if (string.IsNullOrEmpty(email))
|
||||
{
|
||||
throw new InvalidOperationException("Could not find email address for authenticated user");
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
private string GetPersistentNameId(SamlServiceProvider samlServiceProvider, ClaimsPrincipal user)
|
||||
{
|
||||
var persistentIdClaimType = samlServiceProvider.DefaultPersistentNameIdentifierClaimType ??
|
||||
_samlOptions.DefaultPersistentNameIdentifierClaimType;
|
||||
|
||||
var persistentIdentifier = user.FindFirst(persistentIdClaimType);
|
||||
if (persistentIdentifier == null || string.IsNullOrEmpty(persistentIdentifier.Value))
|
||||
{
|
||||
throw new InvalidOperationException("Could not find persistent identifier for authenticated user");
|
||||
}
|
||||
|
||||
return persistentIdentifier.Value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class SamlSigninCallbackEndpoint(SamlResponseBuilder responseBuilder, SamlSigninCallbackRequestProcessor samlSigninCallbackRequestProcessor, ILogger<SamlSigninEndpoint> logger) : IEndpointHandler
|
||||
{
|
||||
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
|
||||
{
|
||||
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSigninCallbackEndpoint");
|
||||
|
||||
if (!HttpMethods.IsGet(context.Request.Method))
|
||||
{
|
||||
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
return await Process(context.RequestAborted);
|
||||
}
|
||||
|
||||
internal async Task<IEndpointResult> Process(CancellationToken ct)
|
||||
{
|
||||
logger.StartSamlSigninCallbackRequest(LogLevel.Debug);
|
||||
|
||||
var result = await samlSigninCallbackRequestProcessor.ProcessAsync(ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Error;
|
||||
return error.Type switch
|
||||
{
|
||||
SamlRequestErrorType.Validation =>
|
||||
new ValidationProblemResult(error.ValidationMessage!),
|
||||
|
||||
SamlRequestErrorType.Protocol =>
|
||||
responseBuilder.BuildErrorResponse(
|
||||
error.ProtocolError!.ServiceProvider,
|
||||
error.ProtocolError.Request,
|
||||
error.ProtocolError.Error),
|
||||
|
||||
_ => throw new InvalidOperationException($"Unexpected error type: {error.Type}")
|
||||
};
|
||||
}
|
||||
|
||||
return result.Value.SuccessType switch
|
||||
{
|
||||
SamlSigninSuccessType.Redirect => new RedirectResult(result.Value.RedirectUri),
|
||||
SamlSigninSuccessType.Response => result.Value.SamlResponse,
|
||||
_ => throw new InvalidOperationException($"Unexpected success type: {result.Value.SuccessType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class SamlSigninCallbackRequestProcessor(
|
||||
SamlSigninStateIdCookie stateIdCookie,
|
||||
IUserSession userSession,
|
||||
ISamlServiceProviderStore serviceProviderStore,
|
||||
ISamlSigninStateStore stateStore,
|
||||
SamlUrlBuilder samlUrlBuilder,
|
||||
SamlResponseBuilder responseBuilder)
|
||||
{
|
||||
internal async Task<Result<SamlSigninSuccess, SamlRequestError<SamlSigninRequest>>> ProcessAsync(CancellationToken ct)
|
||||
{
|
||||
if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId))
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = "No state id could be found."
|
||||
};
|
||||
}
|
||||
|
||||
var authenticationState = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct);
|
||||
if (authenticationState == null)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage = $"The request {stateId} could not be found."
|
||||
};
|
||||
}
|
||||
|
||||
var user = await userSession.GetUserAsync();
|
||||
if (user == null || !user.IsAuthenticated())
|
||||
{
|
||||
var loginUri = samlUrlBuilder.SamlLoginUri();
|
||||
|
||||
return SamlSigninSuccess.CreateRedirectSuccess(loginUri);
|
||||
}
|
||||
|
||||
var samlServiceProvider =
|
||||
await serviceProviderStore.FindByEntityIdAsync(authenticationState.ServiceProviderEntityId);
|
||||
|
||||
if (samlServiceProvider is not { Enabled: true })
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage =
|
||||
$"Service Provider '{authenticationState.ServiceProviderEntityId}' is not registered or is disabled"
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this SP already has a session - if so, reuse the SessionIndex
|
||||
var existingSessions = await userSession.GetSamlSessionListAsync();
|
||||
var existingSession = existingSessions.FirstOrDefault(s => s.EntityId == samlServiceProvider.EntityId);
|
||||
string sessionIndex;
|
||||
|
||||
if (existingSession != null)
|
||||
{
|
||||
// Reuse existing SessionIndex (e.g., for step-up authentication)
|
||||
sessionIndex = existingSession.SessionIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate new SessionIndex for this SP
|
||||
sessionIndex = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
var samlResponse = await responseBuilder.BuildSuccessResponseAsync(user, samlServiceProvider, authenticationState, sessionIndex);
|
||||
|
||||
if (string.IsNullOrEmpty(samlResponse.Assertion?.Subject?.NameId?.Value))
|
||||
{
|
||||
throw new InvalidOperationException("SAML success response created without a NameId value");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(samlResponse.Assertion?.Subject?.NameId?.Format))
|
||||
{
|
||||
throw new InvalidOperationException("SAML success response created without a NameId format");
|
||||
}
|
||||
|
||||
// Track the SAML SP session for logout coordination
|
||||
var sessionData = new SamlSpSessionData
|
||||
{
|
||||
EntityId = samlServiceProvider.EntityId,
|
||||
SessionIndex = sessionIndex,
|
||||
NameId = samlResponse.Assertion.Subject.NameId.Value,
|
||||
NameIdFormat = samlResponse.Assertion.Subject.NameId.Format
|
||||
};
|
||||
await userSession.AddSamlSessionAsync(sessionData);
|
||||
|
||||
stateIdCookie.ClearAuthenticationState();
|
||||
|
||||
return SamlSigninSuccess.CreateResponseSuccess(samlResponse);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Endpoints.Results;
|
||||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class SamlSigninEndpoint(
|
||||
SamlSigninRequestExtractor extractor,
|
||||
SamlSigninRequestProcessor signinRequestProcessor,
|
||||
ILogger<SamlSigninEndpoint> logger,
|
||||
SamlResponseBuilder responseBuilder) : IEndpointHandler
|
||||
{
|
||||
public async Task<IEndpointResult?> ProcessAsync(HttpContext context)
|
||||
{
|
||||
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSigninEndpoint");
|
||||
|
||||
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsPost(context.Request.Method))
|
||||
{
|
||||
return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
// Extract the SAML request from query string (GET/Redirect) or form (POST)
|
||||
var signinRequest = await extractor.ExtractAsync(context);
|
||||
|
||||
return await ProcessSpInitiatedSignin(signinRequest, context.RequestAborted);
|
||||
}
|
||||
|
||||
internal async Task<IEndpointResult> ProcessSpInitiatedSignin(
|
||||
SamlSigninRequest signinRequest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
logger.StartSamlSigninRequest(LogLevel.Debug);
|
||||
|
||||
var result = await signinRequestProcessor.ProcessAsync(signinRequest, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Error;
|
||||
return error.Type switch
|
||||
{
|
||||
SamlRequestErrorType.Validation => HandleValidationError(error),
|
||||
SamlRequestErrorType.Protocol => HandleProtocolError(error),
|
||||
_ => throw new InvalidOperationException($"Unexpected error type: {error.Type}")
|
||||
};
|
||||
}
|
||||
|
||||
var success = result.Value;
|
||||
logger.SamlSigninSuccess(LogLevel.Debug, success.RedirectUri);
|
||||
return new RedirectResult(success.RedirectUri);
|
||||
}
|
||||
|
||||
private ValidationProblemResult HandleValidationError(SamlRequestError<SamlSigninRequest> error)
|
||||
{
|
||||
logger.SamlSigninValidationError(LogLevel.Information, error.ValidationMessage!);
|
||||
return new ValidationProblemResult(error.ValidationMessage!);
|
||||
}
|
||||
|
||||
private SamlErrorResponse HandleProtocolError(SamlRequestError<SamlSigninRequest> error)
|
||||
{
|
||||
var protocolError = error.ProtocolError!;
|
||||
logger.SamlSigninProtocolError(
|
||||
LogLevel.Information,
|
||||
protocolError.Error.StatusCode,
|
||||
protocolError.Error.Message);
|
||||
|
||||
return responseBuilder.BuildErrorResponse(
|
||||
protocolError.ServiceProvider,
|
||||
protocolError.Request,
|
||||
protocolError.Error);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
using Duende.IdentityServer.Configuration;
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Internal.Saml.Infrastructure;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Saml;
|
||||
using Duende.IdentityServer.Saml.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SamlStatusCode = Duende.IdentityServer.Saml.Models.SamlStatusCode;
|
||||
|
||||
namespace Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
|
||||
internal class SamlSigninRequestProcessor(
|
||||
ISamlServiceProviderStore serviceProviderStore,
|
||||
ISamlSigninInteractionResponseGenerator interactionResponseGenerator,
|
||||
ISamlSigninStateStore stateStore,
|
||||
SamlUrlBuilder samlUrlBuilder,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<SamlOptions> options,
|
||||
IServerUrls serverUrls,
|
||||
SamlSigninStateIdCookie stateIdCookie,
|
||||
SamlRequestSignatureValidator<SamlSigninRequest, AuthNRequest> signatureValidator,
|
||||
SamlRequestValidator requestValidator,
|
||||
ILogger<SamlSigninRequestProcessor> logger)
|
||||
: SamlRequestProcessorBase<AuthNRequest, SamlSigninRequest, SamlSigninSuccess>(serviceProviderStore,
|
||||
options,
|
||||
requestValidator,
|
||||
signatureValidator,
|
||||
logger,
|
||||
serverUrls.GetAbsoluteUrl(options.Value.UserInteraction.Route + options.Value.UserInteraction.SignInPath))
|
||||
{
|
||||
protected override async Task<Result<SamlSigninSuccess, SamlRequestError<SamlSigninRequest>>> ProcessValidatedRequestAsync(
|
||||
SamlServiceProvider sp,
|
||||
SamlSigninRequest signinRequest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var authNRequest = signinRequest.AuthNRequest;
|
||||
|
||||
var getAcsUrlResult = GetAcsUrl(sp, authNRequest);
|
||||
if (!getAcsUrlResult.Success)
|
||||
{
|
||||
return getAcsUrlResult.Error;
|
||||
}
|
||||
|
||||
var result = await interactionResponseGenerator.ProcessInteractionAsync(sp, authNRequest, ct);
|
||||
|
||||
if (result.IsError)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Protocol,
|
||||
ProtocolError = new SamlProtocolError<SamlSigninRequest>(sp, signinRequest, result.Error!)
|
||||
};
|
||||
}
|
||||
|
||||
var assertionConsumerServiceUrl = getAcsUrlResult.Value;
|
||||
switch (result.ResultType)
|
||||
{
|
||||
case SamlInteractionResponseType.Login:
|
||||
{
|
||||
await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct);
|
||||
var redirectUri = samlUrlBuilder.SamlLoginUri();
|
||||
|
||||
return SamlSigninSuccess.CreateRedirectSuccess(redirectUri);
|
||||
}
|
||||
case SamlInteractionResponseType.AlreadyAuthenticated:
|
||||
{
|
||||
await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct);
|
||||
var samlCallBackUri = samlUrlBuilder.SamlSignInCallBackUri();
|
||||
|
||||
return SamlSigninSuccess.CreateRedirectSuccess(samlCallBackUri);
|
||||
}
|
||||
case SamlInteractionResponseType.Consent:
|
||||
{
|
||||
await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct);
|
||||
var samlConsentUri = samlUrlBuilder.SamlConsentUri();
|
||||
|
||||
return SamlSigninSuccess.CreateRedirectSuccess(samlConsentUri);
|
||||
}
|
||||
case SamlInteractionResponseType.CreateAccount:
|
||||
throw new NotImplementedException("Create account isn't implemented yet");
|
||||
default:
|
||||
throw new InvalidOperationException("Unexpected result type: " + result.ResultType);
|
||||
}
|
||||
}
|
||||
|
||||
private static Result<Uri, SamlRequestError<SamlSigninRequest>> GetAcsUrl(SamlServiceProvider serviceProvider,
|
||||
AuthNRequest authNRequest)
|
||||
{
|
||||
if (authNRequest.AssertionConsumerServiceUrl != null)
|
||||
{
|
||||
if (!serviceProvider.AssertionConsumerServiceUrls.Contains(authNRequest.AssertionConsumerServiceUrl))
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage =
|
||||
$"AssertionConsumerServiceUrl '{authNRequest.AssertionConsumerServiceUrl}' is not valid"
|
||||
};
|
||||
}
|
||||
|
||||
return authNRequest.AssertionConsumerServiceUrl;
|
||||
}
|
||||
|
||||
if (authNRequest.AssertionConsumerServiceIndex != null)
|
||||
{
|
||||
if (authNRequest.AssertionConsumerServiceIndex.Value < 0 ||
|
||||
authNRequest.AssertionConsumerServiceIndex.Value >= serviceProvider.AssertionConsumerServiceUrls.Count)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage =
|
||||
$"AssertionConsumerServiceIndex '{authNRequest.AssertionConsumerServiceIndex}' is not valid"
|
||||
};
|
||||
}
|
||||
|
||||
return serviceProvider.AssertionConsumerServiceUrls.ElementAt(authNRequest.AssertionConsumerServiceIndex
|
||||
.Value);
|
||||
}
|
||||
|
||||
if (serviceProvider.AssertionConsumerServiceUrls.Count == 0)
|
||||
{
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Validation,
|
||||
ValidationMessage =
|
||||
$"The Service Provider '{serviceProvider.EntityId}' does not have any configured Assertion Consumer Service URLs"
|
||||
};
|
||||
}
|
||||
|
||||
return serviceProvider.AssertionConsumerServiceUrls.First();
|
||||
}
|
||||
|
||||
protected override bool RequireSignature(SamlServiceProvider sp) => sp.RequireSignedAuthnRequests;
|
||||
|
||||
protected override SamlRequestError<SamlSigninRequest>? ValidateMessageSpecific(SamlServiceProvider sp, SamlSigninRequest signinRequest)
|
||||
{
|
||||
var authNRequest = signinRequest.AuthNRequest;
|
||||
|
||||
// AuthNRequest-specific validation (NameIdPolicy)
|
||||
if (authNRequest.NameIdPolicy?.Format != null)
|
||||
{
|
||||
var requestedFormat = authNRequest.NameIdPolicy.Format;
|
||||
var supportedFormats = SamlOptions.SupportedNameIdFormats;
|
||||
|
||||
if (!supportedFormats.Contains(requestedFormat))
|
||||
{
|
||||
Logger.RequestedNameIdFormatNotSupported(LogLevel.Debug, requestedFormat);
|
||||
|
||||
var samlError = new SamlError
|
||||
{
|
||||
StatusCode = SamlStatusCode.Responder,
|
||||
SubStatusCode = SamlStatusCode.InvalidNameIdPolicy,
|
||||
Message = $"Requested NameID format '{requestedFormat}' is not supported by this IdP"
|
||||
};
|
||||
return new SamlRequestError<SamlSigninRequest>
|
||||
{
|
||||
Type = SamlRequestErrorType.Protocol,
|
||||
ProtocolError = new SamlProtocolError<SamlSigninRequest>(sp, signinRequest, samlError)
|
||||
};
|
||||
}
|
||||
|
||||
Logger.NameIdPolicyParsed(LogLevel.Debug, authNRequest.NameIdPolicy.Format, authNRequest.NameIdPolicy.SPNameQualifier);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task StoreStateAsync(
|
||||
SamlSigninRequest signinRequest,
|
||||
Uri assertionConsumerServiceUrl,
|
||||
AuthNRequest authNRequest,
|
||||
SamlServiceProvider sp,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var state = new SamlAuthenticationState
|
||||
{
|
||||
Request = authNRequest,
|
||||
ServiceProviderEntityId = sp.EntityId,
|
||||
RelayState = signinRequest.RelayState,
|
||||
IsIdpInitiated = false,
|
||||
CreatedUtc = timeProvider.GetUtcNow(),
|
||||
AssertionConsumerServiceUrl = assertionConsumerServiceUrl
|
||||
};
|
||||
|
||||
var stateId = await stateStore.StoreSigninRequestStateAsync(state, ct);
|
||||
|
||||
stateIdCookie.StoreSamlSigninStateId(stateId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ using Duende.IdentityServer.Events;
|
|||
using Duende.IdentityServer.Hosting;
|
||||
using Duende.IdentityServer.Hosting.DynamicProviders;
|
||||
using Duende.IdentityServer.Internal;
|
||||
using Duende.IdentityServer.Internal.Saml;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleLogout;
|
||||
using Duende.IdentityServer.Internal.Saml.SingleSignin;
|
||||
using Duende.IdentityServer.ResponseHandling;
|
||||
using Duende.IdentityServer.Saml;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Services.Default;
|
||||
using Duende.IdentityServer.Services.KeyManagement;
|
||||
|
|
@ -52,6 +56,15 @@ internal class RegisteredImplementationsDiagnosticEntry(ServiceCollectionAccesso
|
|||
new(typeof(IUserInfoResponseGenerator), [typeof(UserInfoResponseGenerator)]),
|
||||
]
|
||||
},
|
||||
{
|
||||
"SAML", [
|
||||
new(typeof(ISamlClaimsMapper), []),
|
||||
new(typeof(ISamlFrontChannelLogout), [typeof(SamlHttpPostFrontChannelLogout), typeof(SamlHttpRedirectFrontChannelLogout)]),
|
||||
new(typeof(ISamlInteractionService),[typeof(DefaultSamlInteractionService)]),
|
||||
new(typeof(ISamlLogoutNotificationService), [typeof(SamlLogoutNotificationService)]),
|
||||
new(typeof(ISamlSigninInteractionResponseGenerator),[typeof(DefaultSamlSigninInteractionResponseGenerator)]),
|
||||
]
|
||||
},
|
||||
{
|
||||
"Services", [
|
||||
new(typeof(IAutomaticKeyManagerKeyStore), [typeof(AutomaticKeyManagerKeyStore)]),
|
||||
|
|
@ -83,6 +96,7 @@ internal class RegisteredImplementationsDiagnosticEntry(ServiceCollectionAccesso
|
|||
new(typeof(IRefreshTokenService), [typeof(DefaultRefreshTokenService)]),
|
||||
new(typeof(IReplayCache), [typeof(DefaultReplayCache)]),
|
||||
new(typeof(IReturnUrlParser), [typeof(OidcReturnUrlParser)]),
|
||||
new(typeof(ISamlServiceProviderStore), [typeof(InMemorySamlServiceProviderStore)]),
|
||||
new(typeof(IServerUrls), [typeof(DefaultServerUrls)]),
|
||||
new(typeof(ISessionCoordinationService), [typeof(DefaultSessionCoordinationService)]),
|
||||
new(typeof(ISessionManagementService), []),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue