From 4e816c1a2add3e850f0b2e4025adc83ccfa80666 Mon Sep 17 00:00:00 2001 From: jxnkwlp Date: Fri, 18 Aug 2023 17:51:05 +0800 Subject: [PATCH] add saml2 authentication --- Passingwind.CommonLibs.sln | 6 + README.md | 6 +- .../Configuration/ConfigurationManager.cs | 89 ++++++++ .../Configuration/IConfigurationManager.cs | 10 + .../StaticConfigurationManager.cs | 20 ++ .../Extensions.cs | 39 ++++ .../Passingwind.Authentication.Saml2.csproj | 22 ++ .../Saml2Defaults.cs | 8 + .../Saml2Events.cs | 146 ++++++++++++ .../Saml2Extensions.cs | 26 +++ .../Saml2Handler.cs | 210 ++++++++++++++++++ .../Saml2Options.cs | 81 +++++++ .../Saml2PostConfigureOptions.cs | 64 ++++++ 13 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 src/Passingwind.Authentication.Saml2/Configuration/ConfigurationManager.cs create mode 100644 src/Passingwind.Authentication.Saml2/Configuration/IConfigurationManager.cs create mode 100644 src/Passingwind.Authentication.Saml2/Configuration/StaticConfigurationManager.cs create mode 100644 src/Passingwind.Authentication.Saml2/Extensions.cs create mode 100644 src/Passingwind.Authentication.Saml2/Passingwind.Authentication.Saml2.csproj create mode 100644 src/Passingwind.Authentication.Saml2/Saml2Defaults.cs create mode 100644 src/Passingwind.Authentication.Saml2/Saml2Events.cs create mode 100644 src/Passingwind.Authentication.Saml2/Saml2Extensions.cs create mode 100644 src/Passingwind.Authentication.Saml2/Saml2Handler.cs create mode 100644 src/Passingwind.Authentication.Saml2/Saml2Options.cs create mode 100644 src/Passingwind.Authentication.Saml2/Saml2PostConfigureOptions.cs diff --git a/Passingwind.CommonLibs.sln b/Passingwind.CommonLibs.sln index 99b5c24..f90575c 100644 --- a/Passingwind.CommonLibs.sln +++ b/Passingwind.CommonLibs.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.6.33723.286 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SwaggerExtensions", "src\SwaggerExtensions\SwaggerExtensions\SwaggerExtensions.csproj", "{CC9E858E-8E03-420F-9EA2-90F90485C7CA}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Passingwind.Authentication.Saml2", "src\Passingwind.Authentication.Saml2\Passingwind.Authentication.Saml2.csproj", "{8EF49E09-42AC-4B7C-BDBB-96EDE277F030}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {CC9E858E-8E03-420F-9EA2-90F90485C7CA}.Debug|Any CPU.Build.0 = Debug|Any CPU {CC9E858E-8E03-420F-9EA2-90F90485C7CA}.Release|Any CPU.ActiveCfg = Release|Any CPU {CC9E858E-8E03-420F-9EA2-90F90485C7CA}.Release|Any CPU.Build.0 = Release|Any CPU + {8EF49E09-42AC-4B7C-BDBB-96EDE277F030}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EF49E09-42AC-4B7C-BDBB-96EDE277F030}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EF49E09-42AC-4B7C-BDBB-96EDE277F030}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EF49E09-42AC-4B7C-BDBB-96EDE277F030}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 5d06a89..95d088b 100644 --- a/README.md +++ b/README.md @@ -1 +1,5 @@ -# Common Libs \ No newline at end of file +# Common Libs + +## SwaggerExtensions + +## Saml2 Authentication diff --git a/src/Passingwind.Authentication.Saml2/Configuration/ConfigurationManager.cs b/src/Passingwind.Authentication.Saml2/Configuration/ConfigurationManager.cs new file mode 100644 index 0000000..31dd350 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Configuration/ConfigurationManager.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ITfoxtec.Identity.Saml2; +using ITfoxtec.Identity.Saml2.Schemas.Metadata; + +namespace Passingwind.Authentication.Saml2.Configuration; + +public class ConfigurationManager : IConfigurationManager +{ + private Saml2Configuration? _saml2Configuration; + + private readonly Saml2Options _options; + private readonly Uri _idpMetadataUri; + private readonly HttpClient _httpClient; + + public ConfigurationManager(Saml2Options options, Uri idpMetadataUrl, HttpClient httpClient) + { + _options = options; + _idpMetadataUri = idpMetadataUrl; + _httpClient = httpClient; + } + + public async Task GetConfigurationAsync(CancellationToken cancellationToken = default) + { + if (_saml2Configuration != null) + return _saml2Configuration; + + var configuration = new Saml2Configuration() + { + Issuer = _options.Issuer, + CertificateValidationMode = _options.CertificateValidationMode, + SigningCertificate = _options.SigningCertificate, + }; + + _options.SignatureValidationCertificates?.ForEach(configuration.SignatureValidationCertificates.Add); + + configuration.AllowedAudienceUris.Add(configuration.Issuer); + + var entityDescriptor = new EntityDescriptor(); + + if (_idpMetadataUri.IsFile) + { + entityDescriptor.ReadIdPSsoDescriptorFromFile(_idpMetadataUri.ToString()); + } + else + { + // await entityDescriptor.ReadIdPSsoDescriptorFromUrlAsync(_httpClientFactory, _idpMetadata, cancellationToken); + var metadataGetResponse = await _httpClient.GetAsync(_idpMetadataUri, cancellationToken); + metadataGetResponse.EnsureSuccessStatusCode(); + + var metadataString = await metadataGetResponse.Content.ReadAsStringAsync(); + entityDescriptor.ReadIdPSsoDescriptor(metadataString); + } + + if (entityDescriptor.IdPSsoDescriptor != null) + { + configuration.AllowedIssuer = entityDescriptor.EntityId; + + configuration.SingleSignOnDestination = entityDescriptor.IdPSsoDescriptor.SingleSignOnServices.First().Location; + configuration.SingleLogoutDestination = entityDescriptor.IdPSsoDescriptor.SingleLogoutServices.First().Location; + + foreach (var signingCertificate in entityDescriptor.IdPSsoDescriptor.SigningCertificates) + { + if (signingCertificate.IsValidLocalTime()) + { + configuration.SignatureValidationCertificates.Add(signingCertificate); + } + } + if (configuration.SignatureValidationCertificates.Count == 0) + { + throw new Exception("The saml2 idp signing certificates has expired."); + } + if (entityDescriptor.IdPSsoDescriptor.WantAuthnRequestsSigned.HasValue) + { + configuration.SignAuthnRequest = entityDescriptor.IdPSsoDescriptor.WantAuthnRequestsSigned.Value; + } + } + else + { + throw new Exception("The saml2 idp entity descriptor not loaded from metadata."); + } + + _saml2Configuration = configuration; + return configuration; + } +} diff --git a/src/Passingwind.Authentication.Saml2/Configuration/IConfigurationManager.cs b/src/Passingwind.Authentication.Saml2/Configuration/IConfigurationManager.cs new file mode 100644 index 0000000..8524e8f --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Configuration/IConfigurationManager.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; +using ITfoxtec.Identity.Saml2; + +namespace Passingwind.Authentication.Saml2.Configuration; + +public interface IConfigurationManager +{ + Task GetConfigurationAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Passingwind.Authentication.Saml2/Configuration/StaticConfigurationManager.cs b/src/Passingwind.Authentication.Saml2/Configuration/StaticConfigurationManager.cs new file mode 100644 index 0000000..d2181e7 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Configuration/StaticConfigurationManager.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; +using ITfoxtec.Identity.Saml2; + +namespace Passingwind.Authentication.Saml2.Configuration; + +public class StaticConfigurationManager : IConfigurationManager +{ + private readonly Saml2Configuration _saml2Configuration; + + public StaticConfigurationManager(Saml2Configuration saml2Configuration) + { + _saml2Configuration = saml2Configuration; + } + + public Task GetConfigurationAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(_saml2Configuration); + } +} diff --git a/src/Passingwind.Authentication.Saml2/Extensions.cs b/src/Passingwind.Authentication.Saml2/Extensions.cs new file mode 100644 index 0000000..2f747d0 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Extensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Passingwind.Authentication.Saml2; + +static class Extensions +{ + public static ITfoxtec.Identity.Saml2.Http.HttpRequest ToGenericHttpRequest(this HttpRequest request, bool readBodyAsString = false) + { + return new ITfoxtec.Identity.Saml2.Http.HttpRequest + { + Method = request.Method, + QueryString = request.QueryString.Value, + Query = ToNameValueCollection(request.Query), + Form = "POST".Equals(request.Method, StringComparison.InvariantCultureIgnoreCase) ? ToNameValueCollection(request.Form) : null + }; + } + + private static NameValueCollection ToNameValueCollection(IEnumerable> items) + { + var nv = new NameValueCollection(); + foreach (var item in items) + { + nv.Add(item.Key, item.Value[0]); + } + return nv; + } + + //private static async Task ReadBodyStringAsync(HttpRequest request) + //{ + // using (var reader = new StreamReader(request.Body)) + // { + // return await reader.ReadToEndAsync(); + // } + //} +} diff --git a/src/Passingwind.Authentication.Saml2/Passingwind.Authentication.Saml2.csproj b/src/Passingwind.Authentication.Saml2/Passingwind.Authentication.Saml2.csproj new file mode 100644 index 0000000..2f3dcc1 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Passingwind.Authentication.Saml2.csproj @@ -0,0 +1,22 @@ + + + + net6;net7 + enable + Passingwind.Authentication.Saml2 + Passingwind.Authentication.Saml2 + Passingwind + https://github.com/jxnkwlp/Passingwind.CommonLibs + https://github.com/jxnkwlp/Passingwind.CommonLibs + github + SAML2, authentication + ASP.NET Core authentication handler for the SAML2 protocol + 0.1.0 + + + + + + + + diff --git a/src/Passingwind.Authentication.Saml2/Saml2Defaults.cs b/src/Passingwind.Authentication.Saml2/Saml2Defaults.cs new file mode 100644 index 0000000..3d802a0 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Saml2Defaults.cs @@ -0,0 +1,8 @@ +namespace Passingwind.Authentication.Saml2; + +public static class Saml2Defaults +{ + public const string AuthenticationScheme = "Saml2"; + + public const string DisplayName = "Saml2"; +} diff --git a/src/Passingwind.Authentication.Saml2/Saml2Events.cs b/src/Passingwind.Authentication.Saml2/Saml2Events.cs new file mode 100644 index 0000000..d3dc730 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Saml2Events.cs @@ -0,0 +1,146 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using ITfoxtec.Identity.Saml2; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Passingwind.Authentication.Saml2; + +public class Saml2Events : RemoteAuthenticationEvents +{ + /// + /// Invoked when a protocol message is first received. + /// + public Func OnMessageReceived { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed. + /// + public Func OnAuthenticationFailed { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked to manipulate redirects to the identity provider for SignIn, SignOut, or Challenge. + /// + public Func OnRedirectToIdentityProvider { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked when a wsignoutcleanup request is received at the RemoteSignOutPath endpoint. + /// + public Func OnRemoteSignOut { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked with the security token that has been extracted from the protocol message. + /// + public Func OnSecurityTokenReceived { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked after the security token has passed validation and a ClaimsIdentity has been generated. + /// + public Func OnSecurityTokenValidated { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed. + /// + /// + public virtual Task AuthenticationFailed(AuthenticationFailedContext context) => OnAuthenticationFailed(context); + + /// + /// Invoked to manipulate redirects to the identity provider for SignIn, SignOut, or Challenge. + /// + /// + public virtual Task RedirectToIdentityProvider(RedirectContext context) => OnRedirectToIdentityProvider(context); + + /// + /// Invoked when a protocol message is first received. + /// + /// + public virtual Task MessageReceived(MessageReceivedContext context) => OnMessageReceived(context); + + /// + /// Invoked when a wsignoutcleanup request is received at the RemoteSignOutPath endpoint. + /// + /// + public virtual Task RemoteSignOut(RemoteSignOutContext context) => OnRemoteSignOut(context); + + /// + /// Invoked with the security token that has been extracted from the protocol message. + /// + /// + public virtual Task SecurityTokenReceived(SecurityTokenReceivedContext context) => OnSecurityTokenReceived(context); + + /// + /// Invoked after the security token has passed validation and a ClaimsIdentity has been generated. + /// + /// + public virtual Task SecurityTokenValidated(SecurityTokenValidatedContext context) => OnSecurityTokenValidated(context); +} + +public class RedirectContext : PropertiesContext +{ + public RedirectContext(HttpContext context, AuthenticationScheme scheme, Saml2Options options, AuthenticationProperties? properties) : base(context, scheme, options, properties) + { + } + + public Saml2AuthnRequest Saml2AuthnRequest { get; set; } = default!; + + public Saml2RedirectBinding RedirectBinding { get; set; } = default!; + + /// + /// If true, will skip any default logic for this redirect. + /// + public bool Handled { get; private set; } + + /// + /// Skips any default logic for this redirect. + /// + public void HandleResponse() => Handled = true; +} + +public class RemoteSignOutContext : RemoteAuthenticationContext +{ + public RemoteSignOutContext(HttpContext context, AuthenticationScheme scheme, Saml2Options options, AuthenticationProperties? properties) : base(context, scheme, options, properties) + { + } + + public Saml2AuthnResponse Saml2AuthnResponse { get; set; } = default!; +} + +public class MessageReceivedContext : RemoteAuthenticationContext +{ + public MessageReceivedContext(HttpContext context, AuthenticationScheme scheme, Saml2Options options, AuthenticationProperties? properties) : base(context, scheme, options, properties) + { + } + + public Saml2AuthnResponse Saml2AuthnResponse { get; set; } = default!; +} + +public class SecurityTokenReceivedContext : RemoteAuthenticationContext +{ + public SecurityTokenReceivedContext(HttpContext context, AuthenticationScheme scheme, Saml2Options options, AuthenticationProperties? properties) : base(context, scheme, options, properties) + { + } + + public Saml2AuthnResponse Saml2AuthnResponse { get; set; } = default!; +} + +public class SecurityTokenValidatedContext : RemoteAuthenticationContext +{ + public SecurityTokenValidatedContext(HttpContext context, AuthenticationScheme scheme, Saml2Options options, ClaimsPrincipal principal, AuthenticationProperties? properties) : base(context, scheme, options, properties) + { + Principal = principal; + } + + public Saml2AuthnResponse Saml2AuthnResponse { get; set; } = default!; +} + +public class AuthenticationFailedContext : RemoteAuthenticationContext +{ + public AuthenticationFailedContext(HttpContext context, AuthenticationScheme scheme, Saml2Options options) : base(context, scheme, options, null) + { + } + + public Saml2AuthnResponse Saml2AuthnResponse { get; set; } = default!; + + public Exception Exception { get; set; } = default!; +} diff --git a/src/Passingwind.Authentication.Saml2/Saml2Extensions.cs b/src/Passingwind.Authentication.Saml2/Saml2Extensions.cs new file mode 100644 index 0000000..75b0c6e --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Saml2Extensions.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Passingwind.Authentication.Saml2; + +public static class Saml2Extensions +{ + public static AuthenticationBuilder AddSaml2(this AuthenticationBuilder builder, Action? configureOptions = null) + { + return AddSaml2(builder, Saml2Defaults.AuthenticationScheme, Saml2Defaults.AuthenticationScheme, configureOptions); + } + + public static AuthenticationBuilder AddSaml2(this AuthenticationBuilder builder, string scheme, string? displayName = null, Action? configureOptions = null) + { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, Saml2PostConfigureOptions>()); + + builder.AddScheme(scheme, displayName, configureOptions); + + builder.Services.AddTransient(); + + return builder; + } +} diff --git a/src/Passingwind.Authentication.Saml2/Saml2Handler.cs b/src/Passingwind.Authentication.Saml2/Saml2Handler.cs new file mode 100644 index 0000000..897b8c8 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Saml2Handler.cs @@ -0,0 +1,210 @@ +using System.Collections.Generic; +using System.Security.Authentication; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using ITfoxtec.Identity.Saml2; +using ITfoxtec.Identity.Saml2.Schemas; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Passingwind.Authentication.Saml2; + +public class Saml2Handler : RemoteAuthenticationHandler, IAuthenticationSignOutHandler +{ + private Saml2Configuration? _configuration; + private const string RelayStateName = "State"; + private const string CorrelationProperty = ".xsrf"; + + protected new Saml2Events Events + { + get { return (Saml2Events)base.Events; } + set { base.Events = value; } + } + + public Saml2Handler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) + { + } + + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + properties ??= new AuthenticationProperties(); + + if (_configuration == null) + { + _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); + } + + // Save the original challenge URI so we can redirect back to it when we're done. + if (string.IsNullOrEmpty(properties.RedirectUri)) + { + properties.RedirectUri = OriginalPathBase + OriginalPath + Request.QueryString; + } + + var saml2AuthnRequest = new Saml2AuthnRequest(_configuration); + saml2AuthnRequest.ForceAuthn = Options.ForceAuthn; + saml2AuthnRequest.NameIdPolicy = Options.NameIdPolicy; + saml2AuthnRequest.RequestedAuthnContext = new RequestedAuthnContext + { + Comparison = AuthnContextComparisonTypes.Exact, + AuthnContextClassRef = new string[] { AuthnContextClassTypes.PasswordProtectedTransport.OriginalString }, + }; + + var relayStateQuery = new Dictionary(); + + if (!string.IsNullOrEmpty(properties.RedirectUri)) + relayStateQuery[Options.ReturnUrlParameter] = properties.RedirectUri; + + relayStateQuery[RelayStateName] = Options.StateDataFormat.Protect(properties); + + GenerateCorrelationId(properties); + + var binding = new Saml2RedirectBinding(); + binding.SetRelayStateQuery(relayStateQuery); + + binding = binding.Bind(saml2AuthnRequest); + + var redirectContext = new RedirectContext(Context, Scheme, Options, properties) + { + Saml2AuthnRequest = saml2AuthnRequest, + RedirectBinding = binding, + }; + + await Events.RedirectToIdentityProvider(redirectContext); + + if (redirectContext.Handled) + { + return; + } + + binding = redirectContext.RedirectBinding; + + Response.Redirect(binding.RedirectLocation.OriginalString); + } + + public Task SignOutAsync(AuthenticationProperties? properties) + { + return Task.CompletedTask; + } + + protected override async Task HandleRemoteAuthenticateAsync() + { + if (_configuration == null) + { + _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); + } + + var saml2AuthnResponse = new Saml2AuthnResponse(_configuration); + + AuthenticationProperties? properties = null; + + var relayStateQuery = new Dictionary(); + + try + { + if (Request.Method == HttpMethods.Get) + { + var binding = new Saml2RedirectBinding(); + + var saml2Request = Request.ToGenericHttpRequest(); + + binding.ReadSamlResponse(saml2Request, saml2AuthnResponse); + + relayStateQuery = binding.GetRelayStateQuery(); + } + else if (Request.Method == HttpMethods.Post) + { + var binding = new Saml2PostBinding(); + + var saml2Request = Request.ToGenericHttpRequest(); + + binding.ReadSamlResponse(saml2Request, saml2AuthnResponse); + + relayStateQuery = binding.GetRelayStateQuery(); + } + else + { + throw new AuthenticationException($"Saml2 response method '{Request.Method}' not support"); + } + + if (!relayStateQuery.ContainsKey(RelayStateName)) + { + throw new AuthenticationException("Saml2 response missing relay state "); + } + + var state = relayStateQuery[RelayStateName]; + + properties = Options.StateDataFormat.Unprotect(state); + + var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options, properties) + { + Saml2AuthnResponse = saml2AuthnResponse + }; + await Events.MessageReceived(messageReceivedContext); + if (messageReceivedContext.Result != null) + { + return messageReceivedContext.Result; + } + + saml2AuthnResponse = messageReceivedContext.Saml2AuthnResponse; + properties = messageReceivedContext.Properties!; // Provides a new instance if not set. + + // If state did flow from the challenge then validate it. See AllowUnsolicitedLogins above. + if (properties.Items.TryGetValue(CorrelationProperty, out string? correlationId) + && !ValidateCorrelationId(properties)) + { + return HandleRequestResult.Fail("Correlation failed.", properties); + } + + if (saml2AuthnResponse.Status != Saml2StatusCodes.Success) + { + return HandleRequestResult.Fail($"Saml2 response status: {saml2AuthnResponse.Status}", properties); + } + + ClaimsPrincipal? principal = new ClaimsPrincipal(saml2AuthnResponse.ClaimsIdentity); + + var securityTokenReceivedContext = new SecurityTokenReceivedContext(Context, Scheme, Options, properties) + { + Saml2AuthnResponse = saml2AuthnResponse + }; + await Events.SecurityTokenReceived(securityTokenReceivedContext); + if (securityTokenReceivedContext.Result != null) + { + return securityTokenReceivedContext.Result; + } + + var securityTokenValidatedContext = new SecurityTokenValidatedContext(Context, Scheme, Options, principal, properties) + { + Saml2AuthnResponse = saml2AuthnResponse, + }; + + await Events.SecurityTokenValidated(securityTokenValidatedContext); + if (securityTokenValidatedContext.Result != null) + { + return securityTokenValidatedContext.Result; + } + + // Flow possible changes + principal = securityTokenValidatedContext.Principal!; + properties = securityTokenValidatedContext.Properties; + + return HandleRequestResult.Success(new AuthenticationTicket(principal, properties, Scheme.Name)); + } + catch (System.Exception ex) + { + Logger.LogError(ex, "Exception occurred while processing message"); + + var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options) + { + Saml2AuthnResponse = saml2AuthnResponse, + Exception = ex + }; + + await Events.AuthenticationFailed(authenticationFailedContext); + + return authenticationFailedContext.Result ?? HandleRequestResult.Fail(ex, properties); + } + } +} diff --git a/src/Passingwind.Authentication.Saml2/Saml2Options.cs b/src/Passingwind.Authentication.Saml2/Saml2Options.cs new file mode 100644 index 0000000..20e7127 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Saml2Options.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Security.Cryptography.X509Certificates; +using System.ServiceModel.Security; +using ITfoxtec.Identity.Saml2; +using ITfoxtec.Identity.Saml2.Schemas; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Passingwind.Authentication.Saml2.Configuration; + +namespace Passingwind.Authentication.Saml2; + +public class Saml2Options : RemoteAuthenticationOptions +{ + public string Issuer { get; set; } = null!; + + public NameIdPolicy? NameIdPolicy { get; set; } + + public string SignOutScheme { get; set; } = null!; + + public PathString RemoteSignOutPath { get; set; } + + public bool ForceAuthn { get; set; } + + [EditorBrowsable(EditorBrowsableState.Never)] + public new bool SaveTokens { get; set; } + + public new Saml2Events Events + { + get => (Saml2Events)base.Events; + set => base.Events = value; + } + + public Uri? IdpMetadataUri { get; set; } + + public X509Certificate2? SigningCertificate { get; set; } + + public List? SignatureValidationCertificates { get; set; } + + public X509CertificateValidationMode CertificateValidationMode { get; set; } + + public Saml2Configuration Configuration { get; set; } = null!; + + public IConfigurationManager ConfigurationManager { get; set; } = default!; + + public ISecureDataFormat StateDataFormat { get; set; } = default!; + + public Saml2Options() + { + Events = new Saml2Events(); + CallbackPath = new PathString("/signin-saml2"); + RemoteSignOutPath = new PathString("/signout-saml2"); + CertificateValidationMode = X509CertificateValidationMode.PeerOrChainTrust; + + //Saml2RequestConfigure = (config) => + //{ + // config.ForceAuthn = ForceAuthn; + // //config.NameIdPolicy = new NameIdPolicy + // //{ + // // AllowCreate = true, + // // Format = "urn:oasis:names:tc:SAML:2.0:nameid-form.at:persistent" + // //}; + // config.RequestedAuthnContext = new RequestedAuthnContext + // { + // Comparison = AuthnContextComparisonTypes.Exact, + // AuthnContextClassRef = new string[] { AuthnContextClassTypes.PasswordProtectedTransport.OriginalString }, + // }; + //}; + } + + public override void Validate() + { + base.Validate(); + + if (ConfigurationManager == null) + { + throw new InvalidOperationException($"Provide {nameof(IdpMetadataUri)}, {nameof(Configuration)}, or {nameof(ConfigurationManager)} to {nameof(Saml2Options)}"); + } + } +} diff --git a/src/Passingwind.Authentication.Saml2/Saml2PostConfigureOptions.cs b/src/Passingwind.Authentication.Saml2/Saml2PostConfigureOptions.cs new file mode 100644 index 0000000..df28f93 --- /dev/null +++ b/src/Passingwind.Authentication.Saml2/Saml2PostConfigureOptions.cs @@ -0,0 +1,64 @@ +using System; +using System.Net.Http; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; +using Passingwind.Authentication.Saml2.Configuration; + +namespace Passingwind.Authentication.Saml2; + +public class Saml2PostConfigureOptions : IPostConfigureOptions +{ + private readonly IDataProtectionProvider _dp; + + public Saml2PostConfigureOptions(IDataProtectionProvider dp) + { + _dp = dp; + } + + public void PostConfigure(string name, Saml2Options options) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException($"'{nameof(name)}' cannot be null or empty.", nameof(name)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (string.IsNullOrEmpty(options.SignOutScheme)) + { + options.SignOutScheme = options.SignInScheme!; + } + + options.DataProtectionProvider ??= _dp; + + if (options.Backchannel == null) + { + options.Backchannel = new HttpClient(options.BackchannelHttpHandler ?? new HttpClientHandler()); + options.Backchannel.DefaultRequestHeaders.UserAgent.ParseAdd("Saml2 handler"); + options.Backchannel.Timeout = options.BackchannelTimeout; + options.Backchannel.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB + } + + if (options.StateDataFormat == null) + { + var dataProtector = options.DataProtectionProvider.CreateProtector(typeof(Saml2Handler).FullName!, name, "v1"); + options.StateDataFormat = new PropertiesDataFormat(dataProtector); + } + + if (options.ConfigurationManager == null) + { + if (options.Configuration != null) + { + options.ConfigurationManager = new StaticConfigurationManager(options.Configuration); + } + else if (options.IdpMetadataUri != null) + { + options.ConfigurationManager = new ConfigurationManager(options, options.IdpMetadataUri, options.Backchannel); + } + } + } +}