diff --git a/src/Sustainsys.Saml2.AspNetCore/ServiceResolver.cs b/src/Sustainsys.Saml2.AspNetCore/ServiceResolver.cs index 726c5bd23..d532afa7e 100644 --- a/src/Sustainsys.Saml2.AspNetCore/ServiceResolver.cs +++ b/src/Sustainsys.Saml2.AspNetCore/ServiceResolver.cs @@ -14,9 +14,10 @@ namespace Sustainsys.Saml2.AspNetCore; /// /// The Sustainsys.Saml2 library uses multiple loosely coupled services internally. The /// default implementation is to not register these in the main dependency injection -/// system to avoid clutter. All services are resolved using the service resolver. -/// To override services, override the factory method here. The resolver context -/// always contains the HttpContext, which can be used to resolve services from DI. +/// system to avoid clutter and to allow per scheme registrations. All services are +/// resolved using the service resolver. To override services, override the factory +/// method here. The resolver context always contains the HttpContext, which can be +/// used to resolve services from DI. /// public class ServiceResolver { @@ -68,12 +69,14 @@ public class ResolverContext( public Func GetSamlXmlReader { get; set; } = _ => new SamlXmlReader(); + // TODO: Can this be a static instance? /// /// Factory for /// public Func GetSamlXmlWriter { get; set; } = _ => new SamlXmlWriter(); + // TODO: Can this be a static instance? /// /// Factory for collection of front channel bindings. /// @@ -118,4 +121,4 @@ public class BindingResolverContext( .SingleOrDefault(b => b.Identifier == ctx.Binding) ?? throw new NotImplementedException($"Unknown binding {ctx.Binding} requested"); }; -} +} \ No newline at end of file diff --git a/src/Sustainsys.Saml2/Bindings/HttpRedirectBinding.cs b/src/Sustainsys.Saml2/Bindings/HttpRedirectBinding.cs index 8ee908655..08e2faa90 100644 --- a/src/Sustainsys.Saml2/Bindings/HttpRedirectBinding.cs +++ b/src/Sustainsys.Saml2/Bindings/HttpRedirectBinding.cs @@ -1,12 +1,18 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Unicode; +using System.Xml; +using static Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable; namespace Sustainsys.Saml2.Bindings; /// /// Redirect binding implementation /// -public interface IHttpRedirectBinding +public interface IHttpRedirectBinding : IFrontChannelBinding { /// /// Unbind from a URL. @@ -14,8 +20,8 @@ public interface IHttpRedirectBinding /// Url to unbind from /// Func that returns Identity provider from an entity id /// Unbound message - Saml2Message UnBindAsync( - string url, + Task UnBindAsync( + string url, Func> getSaml2Entity); } @@ -33,38 +39,104 @@ public HttpRedirectBinding() : base(Constants.BindingUris.HttpRedirect) { } public override bool CanUnbind(HttpRequest httpRequest) => false; // Because we haven't implemented redirect unbind yet. - /// - /// Unbinds a Saml2 mesasge from a URL - /// - /// URL with Saml message - /// Func that returns Identity provider from an entity id - /// Unbount message - public Saml2Message UnBindAsync( + /// + public virtual Task UnBindAsync( string url, Func> getSaml2Entity) - => throw new NotImplementedException(); - + { + var uri = new Uri(url); + var query = new QueryStringEnumerable(uri.Query); + + string? messageName = null; + string? message = null; + string? relayState = null; + + foreach (var param in query) + { + // Simplicity is more important than performance here. + var encodedName = param.EncodedName.ToString(); + + // The standard is specific about the naming and they should never be + // encoded. So let's find encoded only. + if (encodedName == Constants.SamlResponse + || encodedName == Constants.SamlRequest) + { + if (messageName != null) + { + throw new InvalidOperationException($"Duplicate message parameters found: {messageName}, {encodedName}"); + } + + messageName = encodedName; + message = param.DecodeValue().ToString(); + } + + if (encodedName == Constants.RelayState) + { + if(relayState != null) + { + throw new InvalidOperationException("Duplicate RelayState parameters found"); + } + relayState = param.DecodeValue().ToString(); + } + } + + if (messageName == null || message == null) + { + throw new InvalidOperationException("SAMLResponse or SAMLRequest parameter not found"); + } + + var xd = new XmlDocument(); + xd.LoadXml(Inflate(message)); + + return Task.FromResult(new Saml2Message() + { + // We're not supporting destinations containing a query string. + Destination = uri.Scheme + "://" + uri.Host + uri.AbsolutePath, + Name = messageName, + RelayState = relayState, + Xml = xd.DocumentElement! + }); + } + /// public override Task UnbindAsync( HttpRequest httpRequest, Func> getSaml2Entity) => throw new NotImplementedException(); /// - protected override async Task DoBindAsync(HttpResponse httpResponse, Saml2Message message) + protected override Task DoBindAsync(HttpResponse httpResponse, Saml2Message message) { var xmlString = message.Xml.OuterXml; + var encoded = Deflate(xmlString); + + var location = $"{message.Destination}?{message.Name}={encoded}&RelayState={message.RelayState}"; + + httpResponse.Redirect(location); + + return Task.CompletedTask; + } + + private static string Deflate(string source) + { using var compressed = new MemoryStream(); using (var deflateStream = new DeflateStream(compressed, CompressionLevel.Optimal)) { using var writer = new StreamWriter(deflateStream); - await writer.WriteAsync(xmlString); + writer.Write(source); } - var encoded = Uri.EscapeDataString(Convert.ToBase64String(compressed.ToArray())); + return Uri.EscapeDataString(Convert.ToBase64String(compressed.ToArray())); + } + + private static string Inflate(string source) + { + var compressedBytes = Convert.FromBase64String(Uri.UnescapeDataString(source)); - var location = $"{message.Destination}?{message.Name}={encoded}&RelayState={message.RelayState}"; + using var compressed = new MemoryStream(compressedBytes); + using var deflateStream = new DeflateStream(compressed, CompressionMode.Decompress); + using var reader = new StreamReader(deflateStream); - httpResponse.Redirect(location); + return reader.ReadToEnd(); } } diff --git a/src/Sustainsys.Saml2/Common/Extensions.cs b/src/Sustainsys.Saml2/Common/Extensions.cs index b62546a0c..55fafd651 100644 --- a/src/Sustainsys.Saml2/Common/Extensions.cs +++ b/src/Sustainsys.Saml2/Common/Extensions.cs @@ -5,4 +5,8 @@ /// public class Extensions { + /// + /// Collection of conentent nodes read by extension-aware readers. + /// + public List Contents { get; set; } = new List(); } diff --git a/src/Sustainsys.Saml2/Constants.cs b/src/Sustainsys.Saml2/Constants.cs index ac2388b5f..b0f4f6865 100644 --- a/src/Sustainsys.Saml2/Constants.cs +++ b/src/Sustainsys.Saml2/Constants.cs @@ -215,6 +215,22 @@ public static class Elements /// PDPDescriptor XML element name. /// public const string PDPDescriptor = nameof(PDPDescriptor); + + /// + /// AuthnRequest XML element name. + /// + public const string AuthnRequest = nameof(AuthnRequest); + + + /// + /// Subject XML element name + /// + public const string Subject = nameof(Subject); + + /// + /// NameID element name + /// + public const string NameID = nameof(NameID); } /// @@ -304,5 +320,36 @@ public static class AttributeNames /// Destination attribute name. /// public const string Destination = nameof(Destination); + + /// + /// AssertionConsumerServiceURL attribute name. + /// + public const string AssertionConsumerServiceURL = nameof(AssertionConsumerServiceURL); + + + /// + /// ForceAuthn attribute name + /// + public const string ForceAuthn = nameof(ForceAuthn); + + /// + /// IsPassive attribute name + /// + public const string IsPassive = nameof(IsPassive); + + /// + /// AssertionConsumerServiceIndex attribute name + /// + public const string AssertionConsumerServiceIndex = nameof(AssertionConsumerServiceIndex); + + /// + /// ProtocolBinding attribute name + /// + public const string ProtocolBinding = nameof(ProtocolBinding); + + /// + /// Consent attribute name + /// + public const string Consent = nameof(Consent); } } diff --git a/src/Sustainsys.Saml2/Saml/Subject.cs b/src/Sustainsys.Saml2/Saml/Subject.cs new file mode 100644 index 000000000..955b8a741 --- /dev/null +++ b/src/Sustainsys.Saml2/Saml/Subject.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Sustainsys.Saml2.Saml; + +/// +/// A Saml2 Subject, see core 2.4.1. +/// +public class Subject +{ + /// + /// NameId + /// + public NameId? NameId { get; set; } + + // TODO: Support full Subject, including SubjectConfirmation +} diff --git a/src/Sustainsys.Saml2/Samlp/AuthnRequest.cs b/src/Sustainsys.Saml2/Samlp/AuthnRequest.cs index e1de00b6b..76ed69b35 100644 --- a/src/Sustainsys.Saml2/Samlp/AuthnRequest.cs +++ b/src/Sustainsys.Saml2/Samlp/AuthnRequest.cs @@ -1,4 +1,6 @@ -namespace Sustainsys.Saml2.Samlp; +using Sustainsys.Saml2.Saml; + +namespace Sustainsys.Saml2.Samlp; /// /// Authentication request @@ -9,4 +11,30 @@ public class AuthnRequest : RequestAbstractType /// The assertion consumer service Url where the response should be posted back to. /// public string? AssertionConsumerServiceUrl { get; set; } + + /// + /// Identifying Uri for protocol binding + /// + public string? ProtocolBinding { get; set; } + + /// + /// Requested Subject + /// + public Subject? Subject { get; set; } + + /// + /// Indicates that the identity provider should force (re)authentication and not + /// rely on an existing session to do single sign on. + /// + public bool ForceAuthn { get; set; } = false; + + /// + /// Indicates that the identity provider should not show any UI nor require interaction. + /// + public bool IsPassive { get; set; } = false; + + /// + /// Index of assertion consumer service where the SP wants the response. + /// + public int? AssertionConsumerServiceIndex { get; set; } } diff --git a/src/Sustainsys.Saml2/Samlp/RequestAbstractType.cs b/src/Sustainsys.Saml2/Samlp/RequestAbstractType.cs index ff74ef1dd..0ceed0946 100644 --- a/src/Sustainsys.Saml2/Samlp/RequestAbstractType.cs +++ b/src/Sustainsys.Saml2/Samlp/RequestAbstractType.cs @@ -1,5 +1,6 @@ using Sustainsys.Saml2.Xml; using Sustainsys.Saml2.Saml; +using Sustainsys.Saml2.Common; namespace Sustainsys.Saml2.Samlp; @@ -27,4 +28,24 @@ public class RequestAbstractType /// Identifies the entity that generated the request message. /// public NameId? Issuer { get; set; } + + /// + /// Destination Url that the messages is/was sent to. + /// + public string? Destination { get; set; } + + /// + /// URI reference for consent. + /// + public string? Consent { get; set; } + + /// + /// Extensions + /// + public Extensions? Extensions { get; set; } + + /// + /// Trust level, based on signature validation + /// + public TrustLevel TrustLevel { get; set; } } diff --git a/src/Sustainsys.Saml2/Serialization/ISamlXmlReader.cs b/src/Sustainsys.Saml2/Serialization/ISamlXmlReader.cs index b251ecf1c..82df938ac 100644 --- a/src/Sustainsys.Saml2/Serialization/ISamlXmlReader.cs +++ b/src/Sustainsys.Saml2/Serialization/ISamlXmlReader.cs @@ -45,6 +45,7 @@ public interface ISamlXmlReader /// Read an /// /// Xml Traverser to read from + /// Callback that can inspect and alter errors before throwing /// - AuthnRequest ReadAuthnRequest(XmlTraverser source); + AuthnRequest ReadAuthnRequest(XmlTraverser source, Action>? errorInspector = null); } diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.AuthnRequest.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.AuthnRequest.cs index c90f0a492..76115a0ad 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.AuthnRequest.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.AuthnRequest.cs @@ -1,12 +1,78 @@ using Sustainsys.Saml2.Samlp; using Sustainsys.Saml2.Xml; +using static Sustainsys.Saml2.Constants; namespace Sustainsys.Saml2.Serialization; public partial class SamlXmlReader { + /// + /// Create an empty AuthnRequest instance + /// + /// AuthnRequest + protected virtual AuthnRequest CreateAuthnRequest() => new(); + + //TODO: Convert other reads to follow this pattern with a callback for errors + /// - public virtual AuthnRequest ReadAuthnRequest(XmlTraverser source) + public virtual AuthnRequest ReadAuthnRequest( + XmlTraverser source, + Action>? errorInspector = null) + { + var authnRequest = ReadAuthnRequest(source); + + source.ThrowOnErrors(); + + return authnRequest; + } + + /// + /// Read an + /// + /// Xml Traverser to read from + /// The AuthnRequest read + protected virtual AuthnRequest ReadAuthnRequest(XmlTraverser source) + { + var authnRequest = CreateAuthnRequest(); + + if (source.EnsureName(Namespaces.SamlpUri, Elements.AuthnRequest)) + { + ReadAttributes(source, authnRequest); + ReadElements(source.GetChildren(), authnRequest); + } + + source.MoveNext(true); + + return authnRequest; + } + + /// + /// Reads the child elements of an AuthnRequest. + /// + /// Xml traverser to read from + /// AuthnRequest to pupulate + protected virtual void ReadElements(XmlTraverser source, AuthnRequest authnRequest) { - throw new NotImplementedException(); + ReadElements(source, (RequestAbstractType)authnRequest); + + if (source.HasName(Namespaces.SamlUri, Elements.Subject)) + { + authnRequest.Subject = ReadSubject(source); + } + } + + /// + /// Reads attributes of an AuthnRequest + /// + /// Xml Traverser to read from + /// The AuthnRequest to populate + protected virtual void ReadAttributes(XmlTraverser source, AuthnRequest authnRequest) + { + ReadAttributes(source, (RequestAbstractType)authnRequest); + + authnRequest.ForceAuthn = source.GetBoolAttribute(AttributeNames.ForceAuthn) ?? authnRequest.ForceAuthn; + authnRequest.IsPassive = source.GetBoolAttribute(AttributeNames.IsPassive) ?? authnRequest.IsPassive; + authnRequest.AssertionConsumerServiceIndex = source.GetIntAttribute(AttributeNames.AssertionConsumerServiceIndex); + authnRequest.AssertionConsumerServiceUrl = source.GetAttribute(AttributeNames.AssertionConsumerServiceURL); + authnRequest.ProtocolBinding = source.GetAttribute(AttributeNames.ProtocolBinding); } } diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.EndPoint.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.EndPoint.cs index 8fa3cbe3e..e8b61fb9b 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.EndPoint.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.EndPoint.cs @@ -5,6 +5,18 @@ namespace Sustainsys.Saml2.Serialization; partial class SamlXmlReader { + /// + /// Factory for Endpoint. + /// + /// Created Endpoint + protected virtual Endpoint CreateEndpoint() => new(); + + /// + /// Factory for indexed endpoint + /// + /// Created IndexedEndpoint + protected virtual IndexedEndpoint CreateIndexedEndpoint() => new(); + /// /// Reads an endpoint. /// @@ -12,7 +24,7 @@ partial class SamlXmlReader /// Endpoint read protected virtual Endpoint ReadEndpoint(XmlTraverser source) { - var result = new Endpoint(); + var result = CreateEndpoint(); ReadAttributes(source, result); return result; @@ -36,11 +48,9 @@ protected virtual void ReadAttributes(XmlTraverser source, Endpoint endpoint) /// IndexedEndpoint protected virtual IndexedEndpoint ReadIndexedEndpoint(XmlTraverser source) { - var result = new IndexedEndpoint() - { - Index = source.GetRequiredIntAttribute(AttributeNames.index), - IsDefault = source.GetBoolAttribute(AttributeNames.isDefault) ?? false - }; + var result = CreateIndexedEndpoint(); + result.Index = source.GetRequiredIntAttribute(AttributeNames.index); + result.IsDefault = source.GetBoolAttribute(AttributeNames.isDefault) ?? false; ReadAttributes(source, result); diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.EntityDescriptor.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.EntityDescriptor.cs index 30a4b84ef..78ec1fe85 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.EntityDescriptor.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.EntityDescriptor.cs @@ -76,16 +76,16 @@ protected virtual void ReadElements(XmlTraverser source, EntityDescriptor entity { switch (source.CurrentNode?.LocalName) { - case Constants.Elements.RoleDescriptor: + case Elements.RoleDescriptor: entityDescriptor.RoleDescriptors.Add(ReadRoleDescriptor(source)); break; - case Constants.Elements.IDPSSODescriptor: + case Elements.IDPSSODescriptor: entityDescriptor.RoleDescriptors.Add(ReadIDPSSODescriptor(source)); break; - case Constants.Elements.SPSSODescriptor: - case Constants.Elements.AuthnAuthorityDescriptor: - case Constants.Elements.AttributeAuthorityDescriptor: - case Constants.Elements.PDPDescriptor: + case Elements.SPSSODescriptor: + case Elements.AuthnAuthorityDescriptor: + case Elements.AttributeAuthorityDescriptor: + case Elements.PDPDescriptor: source.IgnoreChildren(); break; default: diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.IDPSSODescriptor.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.IDPSSODescriptor.cs index 3f6bce306..7ce26fdab 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.IDPSSODescriptor.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.IDPSSODescriptor.cs @@ -30,19 +30,19 @@ protected virtual IDPSSODescriptor ReadIDPSSODescriptor(XmlTraverser source) /// /// Read attributes of IDPSSODescriptor. /// - /// Source + /// Xml traverser to read from /// Result protected virtual void ReadAttributes(XmlTraverser source, IDPSSODescriptor result) { - result.WantAuthnRequestsSigned = source.GetBoolAttribute(AttributeNames.WantAuthnRequestsSigned) ?? false; - ReadAttributes(source, (SSODescriptor)result); + + result.WantAuthnRequestsSigned = source.GetBoolAttribute(AttributeNames.WantAuthnRequestsSigned) ?? false; } /// /// Read child elements of IDPSSODescriptor /// - /// + /// Xml traverser to read from /// protected virtual void ReadElements(XmlTraverser source, IDPSSODescriptor result) { diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.RequestAbstractType.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.RequestAbstractType.cs new file mode 100644 index 000000000..c7e88eab2 --- /dev/null +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.RequestAbstractType.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Antiforgery; +using Sustainsys.Saml2.Samlp; +using Sustainsys.Saml2.Xml; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using static Sustainsys.Saml2.Constants; + +namespace Sustainsys.Saml2.Serialization; +public partial class SamlXmlReader +{ + /// + /// Read attributes of + /// + /// Xml travers + /// RequestAbstractType + protected virtual void ReadAttributes(XmlTraverser source, RequestAbstractType request) + { + request.Id = source.GetRequiredAttribute(AttributeNames.ID); + request.IssueInstant = source.GetRequiredDateTimeAttribute(AttributeNames.IssueInstant); + request.Version = source.GetRequiredAttribute(AttributeNames.Version); + request.Destination = source.GetAttribute(AttributeNames.Destination); + request.Consent = source.GetAttribute(AttributeNames.Consent); + } + + /// + /// Reads the child elements of a RequestAbstractType + /// + /// + /// + protected virtual void ReadElements(XmlTraverser source, RequestAbstractType request) + { + source.MoveNext(true); + + if (source.HasName(Namespaces.SamlUri, Elements.Issuer)) + { + request.Issuer = ReadNameId(source); + source.MoveNext(true); + } + + (var trustedSigningKeys, var allowedHashAlgorithms) = + GetSignatureValidationParametersFromIssuer(source, request.Issuer); + + if (source.ReadAndValidateOptionalSignature(trustedSigningKeys, allowedHashAlgorithms, out var trustLevel)) + { + request.TrustLevel = trustLevel; + source.MoveNext(); + } + + if (source.HasName(Namespaces.SamlpUri, Elements.Extensions)) + { + request.Extensions = ReadExtensions(source); + source.MoveNext(true); + } + } +} diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.RoleDescriptor.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.RoleDescriptor.cs index 3d8f3c838..79ad72a0b 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.RoleDescriptor.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.RoleDescriptor.cs @@ -47,7 +47,7 @@ protected virtual void ReadAttributes(XmlTraverser source, RoleDescriptor result /// Source data /// Target to set properties on /// More elements available? - protected virtual bool ReadElements(XmlTraverser source, RoleDescriptor result) + protected virtual void ReadElements(XmlTraverser source, RoleDescriptor result) { source.MoveNext(true); @@ -88,8 +88,6 @@ protected virtual bool ReadElements(XmlTraverser source, RoleDescriptor result) source.MoveNext(true); } - - return true; } } diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.SSODescriptor.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.SSODescriptor.cs index 6e01eaa5a..ef6333ee7 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.SSODescriptor.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.SSODescriptor.cs @@ -20,13 +20,9 @@ protected virtual void ReadAttributes(XmlTraverser source, SSODescriptor result) /// /// Source data /// Target to set properties on - /// More elements available? - protected virtual bool ReadElements(XmlTraverser source, SSODescriptor result) + protected virtual void ReadElements(XmlTraverser source, SSODescriptor result) { - if(!ReadElements(source, (RoleDescriptor)result)) - { - return false; - } + ReadElements(source, (RoleDescriptor)result); while (source.HasName(Namespaces.MetadataUri, Elements.ArtifactResolutionService)) { @@ -50,7 +46,5 @@ protected virtual bool ReadElements(XmlTraverser source, SSODescriptor result) source.MoveNext(true); } - - return true; } } \ No newline at end of file diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.StatusResponseType.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.StatusResponseType.cs index b1c421e73..952f55c74 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.StatusResponseType.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.StatusResponseType.cs @@ -1,4 +1,5 @@ -using Sustainsys.Saml2.Samlp; +using Sustainsys.Saml2.Saml; +using Sustainsys.Saml2.Samlp; using Sustainsys.Saml2.Xml; using System.Security.Cryptography.Xml; using static Sustainsys.Saml2.Constants; @@ -29,9 +30,6 @@ protected virtual void ReadElements(XmlTraverser source, StatusResponseType resp { source.MoveNext(); - var trustedSigningKeys = TrustedSigningKeys; - var allowedHashAlgorithms = AllowedHashAlgorithms; - if (source.HasName(Namespaces.SamlUri, Elements.Issuer)) { response.Issuer = ReadNameId(source); @@ -39,23 +37,8 @@ protected virtual void ReadElements(XmlTraverser source, StatusResponseType resp source.MoveNext(); } - if (source.HasName(SignedXml.XmlDsigNamespaceUrl, Elements.Signature)) - { - if (response.Issuer == null) - { - source.Errors.Add(new(ErrorReason.MissingElement, Elements.Issuer, source.CurrentNode, - "A signature was found, but there was no Issuer specified. See profile spec 4.1.4.2, 4.4.4.2")); - } - else - { - if (EntityResolver != null) - { - var entity = EntityResolver(response.Issuer.Value); - trustedSigningKeys = entity.TrustedSigningKeys; - allowedHashAlgorithms = entity.AllowedHashAlgorithms ?? AllowedHashAlgorithms; - } - } - } + (var trustedSigningKeys, var allowedHashAlgorithms) = + GetSignatureValidationParametersFromIssuer(source, response.Issuer); if (source.ReadAndValidateOptionalSignature(trustedSigningKeys, allowedHashAlgorithms, out var trustLevel)) { diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.Subject.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.Subject.cs new file mode 100644 index 000000000..96d85b21a --- /dev/null +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.Subject.cs @@ -0,0 +1,61 @@ +using Sustainsys.Saml2.Metadata; +using Sustainsys.Saml2.Saml; +using Sustainsys.Saml2.Xml; +using System.Diagnostics; +using static Sustainsys.Saml2.Constants; + +namespace Sustainsys.Saml2.Serialization; +partial class SamlXmlReader +{ + /// + /// Factory for Subject + /// + protected virtual Subject CreateSubject() => new(); + + /// + /// Reads a Subject. + /// + /// Source data + /// Subject read + protected virtual Subject ReadSubject(XmlTraverser source) + { + var result = CreateSubject(); + + ReadAttributes(source, result); + + ReadElements(source.GetChildren(), result); + + return result; + } + + /// + /// Extension point to add reading of attributes for Subject + /// + /// Source + /// Subject + protected virtual void ReadAttributes(XmlTraverser source, Subject subject) + { + } + + /// + /// Reads elements of a subject. + /// + /// Source Xml Reader + /// Subject to populate + protected virtual void ReadElements(XmlTraverser source, Subject subject) + { + source.MoveNext(true); + + if (source.HasName(Namespaces.SamlUri, Elements.NameID)) + { + subject.NameId = ReadNameId(source); + source.MoveNext(true); + } + else + { + // TODO: Support BaseID and EncryptedID + } + + // TODO: Support SubjectConfirmation + } +} diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.cs index 17b5777c7..ad0e8a60d 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlReader.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlReader.cs @@ -1,11 +1,14 @@ using Sustainsys.Saml2.Common; +using Sustainsys.Saml2.Saml; using Sustainsys.Saml2.Xml; using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Security.Cryptography.Xml; using System.Text; using System.Threading.Tasks; +using static Sustainsys.Saml2.Constants; namespace Sustainsys.Saml2.Serialization; @@ -16,13 +19,16 @@ public partial class SamlXmlReader : ISamlXmlReader { /// public virtual IEnumerable? AllowedHashAlgorithms { get; set; } = - new[] + defaultAllowedHashAlgorithms; + + private static IEnumerable defaultAllowedHashAlgorithms = + new ReadOnlyCollection(new[] { SignedXml.XmlDsigRSASHA256Url, SignedXml.XmlDsigRSASHA384Url, SignedXml.XmlDsigRSASHA512Url, SignedXml.XmlDsigDSAUrl - }; + }); /// public virtual IEnumerable? TrustedSigningKeys { get; set; } @@ -36,4 +42,41 @@ public partial class SamlXmlReader : ISamlXmlReader /// protected virtual void ThrowOnErrors(XmlTraverser source) => source.ThrowOnErrors(); + + /// + /// Helper method to get the signing keys and allowed signature algorithms for + /// an issuer. + /// + /// Xml Travers source - used to report errors + /// The issuer to find parameters for + /// + /// Trusted signig keys and allowedHashAlgorithms. Hash algorithms uses the + /// if there is no specific configuration on the Issuer. + /// + protected (IEnumerable? trustedSigningKeys, IEnumerable? allowedHashAlgorithms) + GetSignatureValidationParametersFromIssuer(XmlTraverser source, NameId? issuer) + { + var trustedSigningKeys = TrustedSigningKeys; + var allowedHashAlgorithms = AllowedHashAlgorithms; + if (source.HasName(SignedXml.XmlDsigNamespaceUrl, Elements.Signature)) + { + if (issuer == null) + { + source.Errors.Add(new(ErrorReason.MissingElement, Elements.Issuer, source.CurrentNode, + "A signature was found, but there was no Issuer specified. See profile spec 4.1.4.1, 4.1.4.2, 4.4.4.2")); + } + else + { + if (EntityResolver != null) + { + var entity = EntityResolver(issuer.Value); + trustedSigningKeys = entity.TrustedSigningKeys; + allowedHashAlgorithms = entity.AllowedHashAlgorithms ?? AllowedHashAlgorithms; + } + } + } + + return (trustedSigningKeys, allowedHashAlgorithms); + } + } diff --git a/src/Sustainsys.Saml2/Serialization/SamlXmlWriter.AuthnRequest.cs b/src/Sustainsys.Saml2/Serialization/SamlXmlWriter.AuthnRequest.cs index e2df99998..062b8bb8c 100644 --- a/src/Sustainsys.Saml2/Serialization/SamlXmlWriter.AuthnRequest.cs +++ b/src/Sustainsys.Saml2/Serialization/SamlXmlWriter.AuthnRequest.cs @@ -23,8 +23,8 @@ public virtual XmlDocument Write(AuthnRequest authnRequest) /// AuthnRequest protected virtual void Append(XmlNode node, AuthnRequest authnRequest) { - var xe = Append(node, authnRequest, "AuthnRequest"); - xe.SetAttributeIfValue("AssertionConsumerServiceURL", authnRequest.AssertionConsumerServiceUrl); + var xe = Append(node, authnRequest, Elements.AuthnRequest); + xe.SetAttributeIfValue(AttributeNames.AssertionConsumerServiceURL, authnRequest.AssertionConsumerServiceUrl); AppendIfValue(xe, authnRequest.Issuer, Namespaces.Saml, Elements.Issuer); } } diff --git a/src/Sustainsys.Saml2/Xml/XmlTraverser.cs b/src/Sustainsys.Saml2/Xml/XmlTraverser.cs index 9934b4755..040b40227 100644 --- a/src/Sustainsys.Saml2/Xml/XmlTraverser.cs +++ b/src/Sustainsys.Saml2/Xml/XmlTraverser.cs @@ -66,9 +66,9 @@ private XmlTraverser(XmlTraverser parent, List errors) Errors = errors; } - private void AddError(ErrorReason reason, string message) + private void AddError(ErrorReason reason, string message, string? localName = null) { - Errors.Add(new(reason, CurrentNode!.LocalName, CurrentNode, message)); + Errors.Add(new(reason, localName ?? CurrentNode!.LocalName, CurrentNode, message)); } //TODO: Add callback function as parameter that allows ignoring - easier way to wire up with events. @@ -344,7 +344,8 @@ public string GetRequiredAttribute(string localName) { AddError( ErrorReason.MissingAttribute, - $"Required attribute {localName} not found on {CurrentNode.Name}."); + $"Required attribute {localName} not found on {CurrentNode.Name}.", + localName); } return value!; @@ -425,7 +426,7 @@ public DateTime GetRequiredDateTimeAttribute(string localName) => TryGetAttribute(localName, s => Enum.Parse(s, ignoreCase)); /// - /// Get a required attribute as int. On missing attribute or parse errors the Error + /// Get a required int attribute. On missing attribute or parse errors the error /// is reported to the errors collection. /// /// Local name of the attribute @@ -433,6 +434,15 @@ public DateTime GetRequiredDateTimeAttribute(string localName) public int GetRequiredIntAttribute(string localName) => GetRequiredAttribute(localName, int.Parse); + /// + /// Get an optional int attribute. On parse errors the error is reported to the + /// errors collection + /// + /// the local name of the attribute + /// Int, if available + public int? GetIntAttribute(string localName) + => TryGetAttribute(localName, int.Parse); + private TTarget GetRequiredAttribute(string localName, Func converter) where TTarget : struct { diff --git a/src/Tests/Sustainsys.Saml2.AspNetCore.Tests/Saml2HandlerTests.cs b/src/Tests/Sustainsys.Saml2.AspNetCore.Tests/Saml2HandlerTests.cs index 9465b30b3..e417f9987 100644 --- a/src/Tests/Sustainsys.Saml2.AspNetCore.Tests/Saml2HandlerTests.cs +++ b/src/Tests/Sustainsys.Saml2.AspNetCore.Tests/Saml2HandlerTests.cs @@ -34,7 +34,8 @@ public class Saml2HandlerTests var scheme = new AuthenticationScheme("Saml2", "Saml2", typeof(Saml2Handler)); var httpContext = Substitute.For(); - httpContext.Request.Returns(Substitute.For()); + httpContext.Response.HttpContext.Returns(httpContext); + httpContext.Request.Scheme = "https"; httpContext.Request.Host = new HostString("sp.example.com", 8888); httpContext.Request.Path = "/path"; @@ -116,29 +117,30 @@ public async Task ChallengeSetsRedirect() (var subject, var httpContext) = await CreateSubject(options); var props = new AuthenticationProperties(); + + httpContext.Response.Redirect(Arg.Do(validateLocation)); + bool validated = false; + await subject.ChallengeAsync(props); - //bool validated = false; - void validateLocation(string location) { - location.Should().StartWith("https://idp.example.com/sso?SamlRequest="); + location.Should().StartWith("https://idp.example.com/sso?SAMLRequest="); - var message = new HttpRedirectBinding().UnBindAsync(location, _ => throw new NotImplementedException()); + var message = new HttpRedirectBinding().UnBindAsync(location, _ => throw new NotImplementedException()).Result; var deserializedAuthnRequest = new SamlXmlReader() .ReadAuthnRequest(message.Xml.GetXmlTraverser()); deserializedAuthnRequest.Should().BeEquivalentTo(authnRequest); - // validated = true; + validated = true; } - httpContext.Response.Received().Redirect(Arg.Do(validateLocation)); + httpContext.Response.Received().Redirect(Arg.Any()); - // TODO: Check that validation was called. - //validated.Should().BeTrue(); + validated.Should().BeTrue("The validation should have been called."); } diff --git a/src/Tests/Sustainsys.Saml2.Tests/Bindings/HttpRedirectBindingTests.cs b/src/Tests/Sustainsys.Saml2.Tests/Bindings/HttpRedirectBindingTests.cs new file mode 100644 index 000000000..a8224b22c --- /dev/null +++ b/src/Tests/Sustainsys.Saml2.Tests/Bindings/HttpRedirectBindingTests.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Sustainsys.Saml2.Bindings; +using System; +using System.ComponentModel; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using System.Xml; + +namespace Sustainsys.Saml2.Tests.Bindings; + +public class HttpRedirectBindingTests +{ + private const string Xml = ""; + + [Fact] + public async Task Bind() + { + var xd = new XmlDocument(); + xd.LoadXml(Xml); + + var message = new Saml2Message + { + Name = "SamlRequest", + Xml = xd.DocumentElement!, + Destination = "https://example.com/destination", + RelayState = "someRelayState" + }; + + var subject = new HttpRedirectBinding(); + + var httpResponse = Substitute.For(); + + bool validated = false; + + httpResponse.Redirect(Arg.Do(validateUrl)); + + await subject.BindAsync(httpResponse, message); + + void validateUrl(string url) + { + url.Should().StartWith(message.Destination); + + Uri uri = new Uri(url); + + var query = uri.Query.TrimStart('?').Split("&"); + + var expectedParam = $"{message!.Name}="; + + query[0].Should().StartWith(expectedParam); + + var value = query[0][expectedParam.Length..]; + + using var inflated = new MemoryStream(Convert.FromBase64String(Uri.UnescapeDataString(value))); + using var deflateStream = new DeflateStream(inflated, CompressionMode.Decompress); + using var reader = new StreamReader(deflateStream); + + reader.ReadToEnd().Should().Be(Xml); + + query[1].Should().Be("RelayState=someRelayState"); + + validated = true; + } + + httpResponse.Received().Redirect(Arg.Any()); + + validated.Should().BeTrue(); + } + private static string GetEncodedXml() + { + using var compressed = new MemoryStream(); + using (var deflateStream = new DeflateStream(compressed, CompressionLevel.Optimal)) + { + using var writer = new StreamWriter(deflateStream); + writer.Write(Xml); + } + + return Uri.EscapeDataString(Convert.ToBase64String(compressed.ToArray())); + } + + [Theory] + [InlineData(Constants.SamlRequest, null)] + [InlineData(Constants.SamlResponse, null)] + [InlineData("Invalid", "SAMLResponse or SAMLRequest parameter not found")] + [InlineData("SAMLRequest=x&SAMLResponse", "Duplicate message parameters*")] + [InlineData("SAMLRequest=x&SAMLRequest", "Duplicate message parameters found*")] + [InlineData("SAMLResponse=x&SAMLRequest", "Duplicate message parameters found: SAMLResponse, SAMLRequest")] + [InlineData("RelayState=x&SAMLRequest", "Duplicate RelayState parameters found")] + public async Task UnBind_String(string messageName, string? expectedException) + { + var encoded = GetEncodedXml(); + + var url = $"https://idp.example.com/sso?{messageName}={encoded}&RelayState=xyz123"; + + var subject = new HttpRedirectBinding(); + + var xd = new XmlDocument(); + xd.LoadXml(Xml); + + var expected = new Saml2Message + { + Destination = "https://idp.example.com/sso", + Name = messageName, + RelayState = "xyz123", + Xml = xd.DocumentElement! + }; + + bool caughtException = false; + Saml2Message actual = default!; + try + { + actual = await subject.UnBindAsync(url, x => Task.FromResult(new Saml2Entity())); + } + catch (Exception ex) + { + caughtException = true; + ex.Message.Should().Match(expectedException); + } + + if (!caughtException) + { + expectedException.Should().BeNull(); + actual.Should().BeEquivalentTo(expected); + } + } +} diff --git a/src/Tests/Sustainsys.Saml2.Tests/Bindings/RedirectBindingTests.cs b/src/Tests/Sustainsys.Saml2.Tests/Bindings/RedirectBindingTests.cs deleted file mode 100644 index 07bc80a84..000000000 --- a/src/Tests/Sustainsys.Saml2.Tests/Bindings/RedirectBindingTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.AspNetCore.Http; -using NSubstitute; -using Sustainsys.Saml2.Bindings; -using System; -using System.IO; -using System.IO.Compression; -using System.Threading.Tasks; -using System.Xml; - -namespace Sustainsys.Saml2.Tests.Bindings; - -public class RedirectBindingTests -{ - private const string Xml = ""; - - [Fact] - public async Task Bind() - { - var xd = new XmlDocument(); - xd.LoadXml(Xml); - - var message = new Saml2Message - { - Name = "SamlRequest", - Xml = xd.DocumentElement!, - Destination = "https://example.com/destination", - RelayState = "someRelayState" - }; - - var subject = new HttpRedirectBinding(); - - var httpResponse = Substitute.For(); - - await subject.BindAsync(httpResponse, message); - - void validateUrl(string url) - { - url.Should().StartWith(message.Destination); - - Uri uri = new Uri(url); - - var query = uri.Query.Split("&"); - - var expectedParam = $"{message!.Name}="; - - query[0].StartsWith(expectedParam).Should().BeTrue(); - - var value = query[0][expectedParam.Length..]; - - using var inflated = new MemoryStream(Convert.FromBase64String(Uri.UnescapeDataString(value))); - using var deflateStream = new DeflateStream(inflated, CompressionMode.Decompress); - using var reader = new StreamReader(deflateStream); - - reader.ReadToEnd().Should().Be(Xml); - - query[1].Should().Be("RelayState=someRelayState"); - } - - httpResponse.Received().Redirect(Arg.Do(validateUrl)); - } -} diff --git a/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.Assertion.cs b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.Assertion.cs new file mode 100644 index 000000000..1989576e1 --- /dev/null +++ b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.Assertion.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Sustainsys.Saml2.Tests.Serialization; +public partial class SamlXmlReaderTests +{ +} diff --git a/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.AuthnRequest.cs b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.AuthnRequest.cs new file mode 100644 index 000000000..fb96b1a40 --- /dev/null +++ b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.AuthnRequest.cs @@ -0,0 +1,82 @@ +using Sustainsys.Saml2.Saml; +using Sustainsys.Saml2.Samlp; +using Sustainsys.Saml2.Serialization; +using Sustainsys.Saml2.Tests.Helpers; +using Sustainsys.Saml2.Xml; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml; + +namespace Sustainsys.Saml2.Tests.Serialization; +partial class SamlXmlReaderTests +{ + [Fact] + public void ReadAuthnRequest_Mandatory() + { + var source = GetXmlTraverser(); + + var subject = new SamlXmlReader(); + + var actual = subject.ReadAuthnRequest(source); + + var expected = new AuthnRequest + { + Id = "x123", + IssueInstant = new DateTime(2023, 11, 24, 22, 44, 14, DateTimeKind.Utc), + Version = "2.0" + }; + + actual.Should().BeEquivalentTo(expected); + } + + // TODO: Test for missing mandatory + + // Test that an AuthnRequest with optional content present can be read. Start with the most common, + // add more later. + [Fact] + public void ReadAuthnRequest_CanReadOptional() + { + var source = GetXmlTraverser(); + ((XmlElement)source.CurrentNode!).Sign( + TestData.Certificate, source.CurrentNode![Constants.Elements.Issuer, Constants.Namespaces.SamlUri]!); + + var subject = new SamlXmlReader(); + + var actual = subject.ReadAuthnRequest(source); + + var expected = new AuthnRequest + { + Id = "x123", + IssueInstant = new DateTime(2023, 11, 24, 22, 44, 14, DateTimeKind.Utc), + Version = "2.0", + Destination = "https://idp.example.com/Sso", + Consent = "urn:oasis:names:tc:SAML:2.0:consent:obtained", + Issuer = "https://sp.example.com/Metadata", + Extensions = new(), + Subject = new() + { + NameId = "abc12345" + }, + // TODO: Add NameIDPolicy + // TODO: Add Conditions + // TODO: Add RequestedAuthnContext + // TODO: Add Scoping + ForceAuthn = true, + IsPassive = true, + AssertionConsumerServiceUrl = "https://sp.example.com/Acs", + AssertionConsumerServiceIndex = 42, + ProtocolBinding = Constants.BindingUris.HttpPOST, + // TODO: Add AttributeConsumingServiceIndex + // TODO: Add ProviderName + }; + + actual.Should().BeEquivalentTo(expected); + } + + // TODO: Test with AssertionConsumerServiceIndex - note mutually exclusive to AcsUrl + Binding + + // TODO: Test error callback +} diff --git a/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.SamlResponse.cs b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.SamlResponse.cs index ad5f9a3eb..f9f2f9876 100644 --- a/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.SamlResponse.cs +++ b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests.SamlResponse.cs @@ -68,7 +68,7 @@ public void ReadSamlResponse_MissingMandatory(string removeXPath, ErrorReason ex } // Test that a response with all optional content present in the Response can be read. This doesn't - // mean that we actual read everything, a lot of rarely used stuff is just ignored (for now) + // mean that we actually read everything, a lot of rarely used stuff is just ignored (for now) [Fact] public void ReadSamlResponse_CanReadCompleteResponse() { @@ -96,6 +96,7 @@ public void ReadSamlResponse_CanReadCompleteResponse() } }, Extensions = new() + // TODO: Trustlevel? }; actual.Should().BeEquivalentTo(expected); diff --git a/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests/ReadAuthnRequest_CanReadOptional.xml b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests/ReadAuthnRequest_CanReadOptional.xml new file mode 100644 index 000000000..dc99bb866 --- /dev/null +++ b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests/ReadAuthnRequest_CanReadOptional.xml @@ -0,0 +1,14 @@ + + https://sp.example.com/Metadata + + + abc12345 + + \ No newline at end of file diff --git a/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests/ReadAuthnRequest_Mandatory.xml b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests/ReadAuthnRequest_Mandatory.xml new file mode 100644 index 000000000..e5fc7ca0e --- /dev/null +++ b/src/Tests/Sustainsys.Saml2.Tests/Serialization/SamlXmlReaderTests/ReadAuthnRequest_Mandatory.xml @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/src/Tests/Sustainsys.Saml2.Tests/Xml/XmlTraverserTests.cs b/src/Tests/Sustainsys.Saml2.Tests/Xml/XmlTraverserTests.cs index 1788a2c86..aabb1cae7 100644 --- a/src/Tests/Sustainsys.Saml2.Tests/Xml/XmlTraverserTests.cs +++ b/src/Tests/Sustainsys.Saml2.Tests/Xml/XmlTraverserTests.cs @@ -3,13 +3,16 @@ using System; using System.Linq; using System.Xml; +using Microsoft.AspNetCore.Authentication.Cookies; +using Sustainsys.Saml2.Saml; +using NSubstitute; namespace Sustainsys.Saml2.Tests.Xml; public class XmlTraverserTests { - private const string xml = "

abc"; + private const string xml = "

abc"; private readonly XmlDocument signedXmlDocument; @@ -53,26 +56,12 @@ public XmlTraverserTests() [Theory] [InlineData("x", "1")] [InlineData("y", null)] - [InlineData("z", "5")] + [InlineData("five", "5")] public void GetAttribute(string localName, string expectedValue) { GetXmlTraverser().GetAttribute(localName).Should().Be(expectedValue); } - private enum GetEnumAttributeEnum - { - Xyz = 42 - } - - [Fact] - public void GetEnumAttribute() - - { - GetXmlTraverser().GetEnumAttribute("invalidTimeSpan", true).Should().Be(GetEnumAttributeEnum.Xyz); - - GetXmlTraverser().GetEnumAttribute("validTimeSpan", true).Should().Be(null); - } - [Theory] [InlineData("urn:r", "root")] [InlineData("urn:X", "root", ErrorReason.UnexpectedNamespace)] @@ -167,6 +156,37 @@ public void HandlesMixedSupression() .Which.Errors.Count().Should().Be(2); } + private enum GetEnumAttributeEnum + { + Def = 42 + } + + [Fact] + public void GetEnumAttribute() + + { + GetXmlTraverser().GetEnumAttribute("def", true).Should().Be(GetEnumAttributeEnum.Def); + } + + [Fact] + public void GetEnumAttribute_Missing() + { + GetXmlTraverser().GetEnumAttribute("notexisting", true) + .Should().BeNull(); + } + + [Fact] + public void GetEnumAttribute_ParseError() + { + var subject = GetXmlTraverser(); + + var actual = subject.GetEnumAttribute("xyz", true); + + actual.Should().BeNull(); + + ValidateParseError(subject); + } + [Fact] public void GetTimeSpanAttribute() { @@ -178,13 +198,22 @@ public void GetTimeSpanAttribute_ParseError() { var subject = GetXmlTraverser(); - var actual = subject.GetTimeSpanAttribute("invalidTimeSpan"); + var actual = subject.GetTimeSpanAttribute("xyz"); - actual.HasValue.Should().BeFalse(); + actual.Should().BeNull(); + ValidateParseError(subject); + } + + private static void ValidateParseError(XmlTraverser subject) + { subject.Errors.Should().HaveCount(1); - subject.Errors.Single().Reason.Should().Be(ErrorReason.ConversionFailed); - subject.Errors.Single().StringValue.Should().Be("XYZ"); + var error = subject.Errors.Single(); + + error.Reason.Should().Be(ErrorReason.ConversionFailed); + error.StringValue.Should().Be("XYZ"); + error.LocalName.Should().Be("xyz"); + error.Node.Should().BeSameAs(subject.CurrentNode); } [Fact] @@ -193,6 +222,27 @@ public void GetRequiredAbsoluteUriAttribute() GetXmlTraverser().GetRequiredAbsoluteUriAttribute("uri").Should().Be("urn:uri"); } + [Fact] + public void GetRequiredAbsoluteUriAttribute_Missing() + { + var subject = GetXmlTraverser(); + + subject.GetRequiredAbsoluteUriAttribute("notExisting").Should().BeNull(); + + ValidateMissing(subject); + } + + private static void ValidateMissing(XmlTraverser subject) + { + subject.Errors.Count().Should().Be(1); + var error = subject.Errors.Single(); + + error.Reason.Should().Be(ErrorReason.MissingAttribute); + error.StringValue.Should().BeNull(); + error.LocalName.Should().Be("notExisting"); + error.Node.Should().BeSameAs(subject.CurrentNode); + } + [Fact] public void GetRequiredAbsoluteUriAttribute_NotAbsoluteUri() { @@ -209,6 +259,82 @@ public void GetRequiredAbsoluteUriAttribute_NotAbsoluteUri() error.StringValue.Should().Be("1"); } + [Fact] + public void GetBoolAttribute() + { + GetXmlTraverser().GetBoolAttribute("bool").Should().BeTrue(); + } + + [Fact] + public void GetBoolAttribute_Missing() + { + GetXmlTraverser().GetBoolAttribute("notExisting").Should().BeNull(); + } + + [Fact] + public void GetBoolAttribute_ParseError() + { + var subject = GetXmlTraverser(); + + var actual = subject.GetBoolAttribute("xyz"); + + actual.Should().BeNull(); + + ValidateParseError(subject); + } + + [Fact] + public void GetIntAttribute() + { + GetXmlTraverser().GetIntAttribute("five").Should().Be(5); + } + + [Fact] + public void GetIntAttribute_Missing() + { + GetXmlTraverser().GetIntAttribute("notExisting").Should().BeNull(); + } + + [Fact] + public void GetIntAttribute_ParseError() + { + var subject = GetXmlTraverser(); + + var actual = subject.GetIntAttribute("xyz"); + + actual.HasValue.Should().BeFalse(); + + ValidateParseError(subject); + } + + [Fact] + public void GetRequiredIntAttribute() + { + GetXmlTraverser().GetRequiredIntAttribute("five").Should().Be(5); + } + + [Fact] + public void GetRequiredIntAttribute_Missing() + { + var subject = GetXmlTraverser(); + + subject.GetRequiredIntAttribute("notExisting").Should().Be(0); + + ValidateMissing(subject); + } + + [Fact] + public void GetRequiredIntAttribute_ParseError() + { + var subject = GetXmlTraverser(); + + var actual = subject.GetRequiredIntAttribute("xyz"); + + actual.Should().Be(default); + + ValidateParseError(subject); + } + [Fact] public void TraverseChildren() { diff --git a/src/exclusion.dic b/src/exclusion.dic index e746dd66f..ebc9af6f4 100644 --- a/src/exclusion.dic +++ b/src/exclusion.dic @@ -1,2 +1,3 @@ -Saml -Sustainsys +saml +sustainsys +authn