Skip to content

Commit

Permalink
Fix broken test and then got all green again
Browse files Browse the repository at this point in the history
- Reading of AuthnRequests
- Reworked the serialization, this model looks better
  • Loading branch information
AndersAbel committed Feb 2, 2024
1 parent e2aa2bc commit 269ecb6
Show file tree
Hide file tree
Showing 30 changed files with 898 additions and 175 deletions.
11 changes: 7 additions & 4 deletions src/Sustainsys.Saml2.AspNetCore/ServiceResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ namespace Sustainsys.Saml2.AspNetCore;
/// <summary>
/// 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.
/// </summary>
public class ServiceResolver
{
Expand Down Expand Up @@ -68,12 +69,14 @@ public class ResolverContext(
public Func<ResolverContext, ISamlXmlReader> GetSamlXmlReader { get; set; }
= _ => new SamlXmlReader();

// TODO: Can this be a static instance?
/// <summary>
/// Factory for <see cref="ISamlXmlWriter"/>
/// </summary>
public Func<ResolverContext, ISamlXmlWriter> GetSamlXmlWriter { get; set; }
= _ => new SamlXmlWriter();

// TODO: Can this be a static instance?
/// <summary>
/// Factory for collection of front channel bindings.
/// </summary>
Expand Down Expand Up @@ -118,4 +121,4 @@ public class BindingResolverContext(
.SingleOrDefault(b => b.Identifier == ctx.Binding)
?? throw new NotImplementedException($"Unknown binding {ctx.Binding} requested");
};
}
}
106 changes: 89 additions & 17 deletions src/Sustainsys.Saml2/Bindings/HttpRedirectBinding.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
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;

/// <summary>
/// Redirect binding implementation
/// </summary>
public interface IHttpRedirectBinding
public interface IHttpRedirectBinding : IFrontChannelBinding
{
/// <summary>
/// Unbind from a URL.
/// </summary>
/// <param name="url">Url to unbind from</param>
/// <param name="getSaml2Entity">Func that returns Identity provider from an entity id</param>
/// <returns>Unbound message</returns>
Saml2Message UnBindAsync(
string url,
Task<Saml2Message> UnBindAsync(
string url,
Func<string, Task<Saml2Entity>> getSaml2Entity);
}

Expand All @@ -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.

/// <summary>
/// Unbinds a Saml2 mesasge from a URL
/// </summary>
/// <param name="url">URL with Saml message</param>
/// <param name="getSaml2Entity">Func that returns Identity provider from an entity id</param>
/// <returns>Unbount message</returns>
public Saml2Message UnBindAsync(
/// <inheritdoc/>
public virtual Task<Saml2Message> UnBindAsync(
string url,
Func<string, Task<Saml2Entity>> 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!
});
}

/// <inheritdoc/>
public override Task<Saml2Message> UnbindAsync(
HttpRequest httpRequest,
Func<string, Task<Saml2Entity>> getSaml2Entity) => throw new NotImplementedException();

/// <inheritdoc/>
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();
}
}
4 changes: 4 additions & 0 deletions src/Sustainsys.Saml2/Common/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@
/// </summary>
public class Extensions
{
/// <summary>
/// Collection of conentent nodes read by extension-aware readers.
/// </summary>
public List<object> Contents { get; set; } = new List<object>();
}
47 changes: 47 additions & 0 deletions src/Sustainsys.Saml2/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,22 @@ public static class Elements
/// PDPDescriptor XML element name.
/// </summary>
public const string PDPDescriptor = nameof(PDPDescriptor);

/// <summary>
/// AuthnRequest XML element name.
/// </summary>
public const string AuthnRequest = nameof(AuthnRequest);


/// <summary>
/// Subject XML element name
/// </summary>
public const string Subject = nameof(Subject);

/// <summary>
/// NameID element name
/// </summary>
public const string NameID = nameof(NameID);
}

/// <summary>
Expand Down Expand Up @@ -304,5 +320,36 @@ public static class AttributeNames
/// Destination attribute name.
/// </summary>
public const string Destination = nameof(Destination);

/// <summary>
/// AssertionConsumerServiceURL attribute name.
/// </summary>
public const string AssertionConsumerServiceURL = nameof(AssertionConsumerServiceURL);


/// <summary>
/// ForceAuthn attribute name
/// </summary>
public const string ForceAuthn = nameof(ForceAuthn);

/// <summary>
/// IsPassive attribute name
/// </summary>
public const string IsPassive = nameof(IsPassive);

/// <summary>
/// AssertionConsumerServiceIndex attribute name
/// </summary>
public const string AssertionConsumerServiceIndex = nameof(AssertionConsumerServiceIndex);

/// <summary>
/// ProtocolBinding attribute name
/// </summary>
public const string ProtocolBinding = nameof(ProtocolBinding);

/// <summary>
/// Consent attribute name
/// </summary>
public const string Consent = nameof(Consent);
}
}
20 changes: 20 additions & 0 deletions src/Sustainsys.Saml2/Saml/Subject.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Sustainsys.Saml2.Saml;

/// <summary>
/// A Saml2 Subject, see core 2.4.1.
/// </summary>
public class Subject
{
/// <summary>
/// NameId
/// </summary>
public NameId? NameId { get; set; }

// TODO: Support full Subject, including SubjectConfirmation
}
30 changes: 29 additions & 1 deletion src/Sustainsys.Saml2/Samlp/AuthnRequest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace Sustainsys.Saml2.Samlp;
using Sustainsys.Saml2.Saml;

namespace Sustainsys.Saml2.Samlp;

/// <summary>
/// Authentication request
Expand All @@ -9,4 +11,30 @@ public class AuthnRequest : RequestAbstractType
/// The assertion consumer service Url where the response should be posted back to.
/// </summary>
public string? AssertionConsumerServiceUrl { get; set; }

/// <summary>
/// Identifying Uri for protocol binding
/// </summary>
public string? ProtocolBinding { get; set; }

/// <summary>
/// Requested Subject
/// </summary>
public Subject? Subject { get; set; }

/// <summary>
/// Indicates that the identity provider should force (re)authentication and not
/// rely on an existing session to do single sign on.
/// </summary>
public bool ForceAuthn { get; set; } = false;

/// <summary>
/// Indicates that the identity provider should not show any UI nor require interaction.
/// </summary>
public bool IsPassive { get; set; } = false;

/// <summary>
/// Index of assertion consumer service where the SP wants the response.
/// </summary>
public int? AssertionConsumerServiceIndex { get; set; }
}
21 changes: 21 additions & 0 deletions src/Sustainsys.Saml2/Samlp/RequestAbstractType.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Sustainsys.Saml2.Xml;
using Sustainsys.Saml2.Saml;
using Sustainsys.Saml2.Common;

namespace Sustainsys.Saml2.Samlp;

Expand Down Expand Up @@ -27,4 +28,24 @@ public class RequestAbstractType
/// Identifies the entity that generated the request message.
/// </summary>
public NameId? Issuer { get; set; }

/// <summary>
/// Destination Url that the messages is/was sent to.
/// </summary>
public string? Destination { get; set; }

/// <summary>
/// URI reference for consent.
/// </summary>
public string? Consent { get; set; }

/// <summary>
/// Extensions
/// </summary>
public Extensions? Extensions { get; set; }

/// <summary>
/// Trust level, based on signature validation
/// </summary>
public TrustLevel TrustLevel { get; set; }
}
3 changes: 2 additions & 1 deletion src/Sustainsys.Saml2/Serialization/ISamlXmlReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public interface ISamlXmlReader
/// Read an <see cref="AuthnRequest"/>
/// </summary>
/// <param name="source">Xml Traverser to read from</param>
/// <param name="errorInspector">Callback that can inspect and alter errors before throwing</param>
/// <returns><see cref="AuthnRequest"/></returns>
AuthnRequest ReadAuthnRequest(XmlTraverser source);
AuthnRequest ReadAuthnRequest(XmlTraverser source, Action<AuthnRequest, IList<Error>>? errorInspector = null);
}
Loading

0 comments on commit 269ecb6

Please sign in to comment.