Skip to content

Commit

Permalink
Add support to use the RelayState as the return url
Browse files Browse the repository at this point in the history
  • Loading branch information
AndersAbel authored Feb 20, 2019
2 parents 1fb894c + 20aa29e commit 8545f96
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 13 deletions.
17 changes: 17 additions & 0 deletions Sustainsys.Saml2/Configuration/IdentityProviderElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,5 +218,22 @@ public bool DisableOutboundLogoutRequests
base[disableOutboundLogoutRequests] = value;
}
}

/// <summary>
/// Indicates that the IDP sends the return url as part of the RelayState.
/// This is used when <see cref="AllowUnsolicitedAuthnResponse"/> is enabled.
/// </summary>
[ConfigurationProperty("relayStateUsedAsReturnUrl", IsRequired = false, DefaultValue = false)]
public bool RelayStateUsedAsReturnUrl
{
get
{
return (bool)base["relayStateUsedAsReturnUrl"];
}
set
{
base["relayStateUsedAsReturnUrl"] = value;
}
}
}
}
8 changes: 8 additions & 0 deletions Sustainsys.Saml2/IdentityProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ internal IdentityProvider(IdentityProviderElement config, SPOptions spOptions)
{
Validate();
}

RelayStateUsedAsReturnUrl = config.RelayStateUsedAsReturnUrl;
}

private void Validate()
Expand Down Expand Up @@ -254,6 +256,12 @@ public Saml2BindingType SingleLogoutServiceBinding
/// </summary>
public bool AllowUnsolicitedAuthnResponse { get; set; }

/// <summary>
/// Does the RelayState contains the return url?,
/// This setting is used only when the AllowUnsolicitedAuthnResponse setting is enabled.
/// </summary>
public bool RelayStateUsedAsReturnUrl { get; set; }

private string metadataLocation;

/// <summary>
Expand Down
73 changes: 67 additions & 6 deletions Sustainsys.Saml2/WebSSO/AcsCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Sustainsys.Saml2.Configuration;
using Sustainsys.Saml2.Exceptions;
using Sustainsys.Saml2.Internal;
using Sustainsys.Saml2.Metadata;
using Sustainsys.Saml2.Saml2P;
using System;
using System.Configuration;
Expand Down Expand Up @@ -45,16 +47,22 @@ public CommandResult Run(HttpRequestData request, IOptions options)
try
{
unbindResult = binding.Unbind(request, options);

options.Notifications.MessageUnbound(unbindResult);

var samlResponse = new Saml2Response(unbindResult.Data, request.StoredRequestState?.MessageId, options);

var idpContext = GetIdpContext(unbindResult.Data, request, options);

var result = ProcessResponse(options, samlResponse, request.StoredRequestState, idpContext, unbindResult.RelayState);

var result = ProcessResponse(options, samlResponse, request.StoredRequestState);
if(unbindResult.RelayState != null)
if (request.StoredRequestState != null)
{
result.ClearCookieName = StoredRequestState.CookieNameBase + unbindResult.RelayState;
}

options.Notifications.AcsCommandResultCreated(result, samlResponse);

return result;
}
catch (FormatException ex)
Expand Down Expand Up @@ -88,6 +96,42 @@ public CommandResult Run(HttpRequestData request, IOptions options)
throw new NoSamlResponseFoundException();
}

private static IdentityProvider GetIdpContext(XmlElement xml, HttpRequestData request, IOptions options)
{
var entityId = new EntityId(xml["Issuer", Saml2Namespaces.Saml2Name].GetTrimmedTextIfNotNull());

var identityProvider = options.Notifications.GetIdentityProvider(entityId, request.StoredRequestState?.RelayData, options);

return identityProvider;
}

private static Uri GetLocation(StoredRequestState storedRequestState, IdentityProvider identityProvider, string relayState, IOptions options)
{
// When SP-Initiated
if (storedRequestState != null)
{
return storedRequestState.ReturnUrl ?? options.SPOptions.ReturnUrl;

}
else
{ //When IDP-Initiated

if (identityProvider.RelayStateUsedAsReturnUrl)
{
if (!PathHelper.IsLocalWebUrl(relayState))
{
if (!options.Notifications.ValidateAbsoluteReturnUrl(relayState))
{
throw new InvalidOperationException("Return Url must be a relative Url.");
}
}
return new Uri(relayState, UriKind.RelativeOrAbsolute);
}
}

return options.SPOptions.ReturnUrl;
}

[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "AuthenticationProperty")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "RedirectUri")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "returnUrl")]
Expand All @@ -96,11 +140,13 @@ public CommandResult Run(HttpRequestData request, IOptions options)
private static CommandResult ProcessResponse(
IOptions options,
Saml2Response samlResponse,
StoredRequestState storedRequestState)
StoredRequestState storedRequestState,
IdentityProvider identityProvider,
string relayState)
{
var principal = new ClaimsPrincipal(samlResponse.GetClaims(options, storedRequestState?.RelayData));

if(options.SPOptions.ReturnUrl == null)
if (options.SPOptions.ReturnUrl == null && !identityProvider.RelayStateUsedAsReturnUrl)
{
if (storedRequestState == null)
{
Expand All @@ -112,19 +158,29 @@ private static CommandResult ProcessResponse(
}
}

if (identityProvider.RelayStateUsedAsReturnUrl)
{
if (relayState == null)
{
throw new ConfigurationErrorsException(RelayStateMissing);
}
}

options.SPOptions.Logger.WriteInformation("Successfully processed SAML response " + samlResponse.Id
+ " and authenticated " + principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);

return new CommandResult()
{
HttpStatusCode = HttpStatusCode.SeeOther,
Location = storedRequestState?.ReturnUrl ?? options.SPOptions.ReturnUrl,
Location = GetLocation(storedRequestState, identityProvider, relayState, options),
Principal = principal,
RelayData = storedRequestState?.RelayData,
SessionNotOnOrAfter = samlResponse.SessionNotOnOrAfter
};
}



internal const string UnsolicitedMissingReturnUrlMessage =
@"Unsolicited SAML response received, but no ReturnUrl is configured.
Expand All @@ -144,5 +200,10 @@ Saml2 will redirect the client to the configured ReturnUrl after
When initiating a request, pass a ReturnUrl query parameter (case matters) or
use the RedirectUri AuthenticationProperty for owin. Or add a default ReturnUrl
in the configuration.";

internal const string RelayStateMissing =
@"Relay state data missing from the response.
the application is expecting a return url as part of the RelayState response from the IDP.
This is expected because the setting 'relayStateUsedAsReturnUrl' has been set to true.";
}
}
3 changes: 3 additions & 0 deletions Tests/Tests.NETCore/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
<add entityId="https://idp4.example.com" signOnUrl="https://idp4.example.com/idp" allowUnsolicitedAuthnResponse="false" binding="HttpPost" outboundSigningAlgorithm="sha256" wantAuthnRequestsSigned="true">
<signingCertificate fileName="Sustainsys.Saml2.Tests.pfx"/>
</add>
<add entityId="https://idp5.example.com" signOnUrl="https://idp5.example.com/idp" allowUnsolicitedAuthnResponse="true" binding="HttpPost" relayStateUsedAsReturnUrl="true">
<signingCertificate fileName="Sustainsys.Saml2.Tests.pfx"/>
</add>
</identityProviders>
<federations>
<add metadataLocation="http://localhost:13428/federationMetadataSigned" allowUnsolicitedAuthnResponse="true">
Expand Down
3 changes: 3 additions & 0 deletions Tests/Tests.NETFramework/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
<add entityId="https://idp4.example.com" signOnUrl="https://idp4.example.com/idp" allowUnsolicitedAuthnResponse="false" binding="HttpPost" outboundSigningAlgorithm="sha256" wantAuthnRequestsSigned="true">
<signingCertificate fileName="Sustainsys.Saml2.Tests.pfx" />
</add>
<add entityId="https://idp5.example.com" signOnUrl="https://idp5.example.com/idp" allowUnsolicitedAuthnResponse="true" binding="HttpPost" relayStateUsedAsReturnUrl="true">
<signingCertificate fileName="Sustainsys.Saml2.Tests.pfx"/>
</add>
</identityProviders>
<federations>
<add metadataLocation="http://localhost:13428/federationMetadataSigned" allowUnsolicitedAuthnResponse="true">
Expand Down
116 changes: 109 additions & 7 deletions Tests/Tests.Shared/WebSSO/AcsCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Sustainsys.Saml2.Tests.WebSSO;
using Sustainsys.Saml2.TestHelpers;
using Microsoft.IdentityModel.Tokens.Saml2;
using System.Runtime.CompilerServices;

namespace Sustainsys.Saml2.Tests.WebSso
{
Expand Down Expand Up @@ -91,10 +92,10 @@ public void AcsCommand_Run_ErrorOnIncorrectXml()

Action a = () => new AcsCommand().Run(r, Options.FromConfiguration);

a.Should().Throw<BadFormatSamlResponseException>()
.WithMessage("The SAML response contains incorrect XML")
.Where(ex => ex.Data["Saml2Response"] as string == "<foo />")
.WithInnerException<XmlException>();
a.Should().Throw<BadFormatSamlResponseException>()
.WithMessage("The SAML response contains incorrect XML")
.Where(ex => ex.Data["Saml2Response"] as string == "<foo />")
.WithInnerException<XmlException>();
}

[TestMethod]
Expand Down Expand Up @@ -274,7 +275,7 @@ public void AcsCommand_Run_WithReturnUrl_SuccessfulResult()
null)
);

var ids = new ClaimsIdentity[] { new ClaimsIdentity("Federation")};
var ids = new ClaimsIdentity[] { new ClaimsIdentity("Federation") };
ids[0].AddClaim(new Claim(ClaimTypes.NameIdentifier, "SomeUser", null, "https://idp.example.com"));

var expected = new CommandResult()
Expand Down Expand Up @@ -346,8 +347,8 @@ public void AcsCommand_Run_WithReturnUrl_SuccessfulResult_NoConfigReturnUrl()
[TestMethod]
public void AcsCommand_Run_UnsolicitedResponse_ThrowsOnNoConfiguredReturnUrl()
{
var response =
@"<saml2p:Response xmlns:saml2p=""urn:oasis:names:tc:SAML:2.0:protocol""
var response =
@"<saml2p:Response xmlns:saml2p=""urn:oasis:names:tc:SAML:2.0:protocol""
xmlns:saml2=""urn:oasis:names:tc:SAML:2.0:assertion""
ID = """ + MethodBase.GetCurrentMethod().Name + @""" Version=""2.0"" IssueInstant=""2013-01-01T00:00:00Z"">
<saml2:Issuer>
Expand Down Expand Up @@ -694,5 +695,106 @@ public void AcsCommand_Run_UsesIdpFromNotification()

actual.Principal.Claims.First().Issuer.Should().Be("https://other.idp.example.com");
}

private void RelayStateAsReturnUrl(string relayState, IOptions options, [CallerMemberName] string caller = null)
{
if(string.IsNullOrEmpty(caller))
{
throw new ArgumentNullException(nameof(caller));
}

var response =
@"<saml2p:Response xmlns:saml2p=""urn:oasis:names:tc:SAML:2.0:protocol""
xmlns:saml2=""urn:oasis:names:tc:SAML:2.0:assertion""
ID = """ + caller + @""" Version=""2.0"" IssueInstant=""2013-01-01T00:00:00Z"">
<saml2:Issuer>
https://idp5.example.com
</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value=""urn:oasis:names:tc:SAML:2.0:status:Success"" />
</saml2p:Status>
<saml2:Assertion
Version=""2.0"" ID=""" + caller + @"_Assertion""
IssueInstant=""2013-09-25T00:00:00Z"">
<saml2:Issuer>https://idp5.example.com</saml2:Issuer>
<saml2:Subject>
<saml2:NameID>SomeUser</saml2:NameID>
<saml2:SubjectConfirmation Method=""urn:oasis:names:tc:SAML:2.0:cm:bearer"" />
</saml2:Subject>
<saml2:Conditions NotOnOrAfter=""2100-01-01T00:00:00Z"" />
</saml2:Assertion>
</saml2p:Response>";

var responseFormValue = Convert.ToBase64String
(Encoding.UTF8.GetBytes(SignedXmlHelper.SignXml(response)));

var formData = new List<KeyValuePair<string, IEnumerable<string>>>
{
new KeyValuePair<string, IEnumerable<string>>("SAMLResponse", new string[] { responseFormValue }),
};
if(relayState != null)
{
formData.Add(new KeyValuePair<string, IEnumerable<string>>("RelayState", new string[] { relayState }));
}

var r = new HttpRequestData(
"POST",
new Uri("http://localhost"),
"/ModulePath",
formData,
null);

var ids = new ClaimsIdentity[] { new ClaimsIdentity("Federation") };

ids[0].AddClaim(new Claim(ClaimTypes.NameIdentifier, "SomeUser", null, "https://idp5.example.com"));

var expected = new CommandResult()
{
Principal = new ClaimsPrincipal(ids),
HttpStatusCode = HttpStatusCode.SeeOther,
Location = relayState != null ? new Uri(relayState, UriKind.RelativeOrAbsolute) : null,
};

new AcsCommand().Run(r, options)
.Location.OriginalString.Should().Be(relayState);
}

[TestMethod]
public void AcsCommand_Run_WithRelayStateUsedAsReturnUrl_Success()
{
RelayStateAsReturnUrl("/someUrl", StubFactory.CreateOptions());
}

[TestMethod]
public void AcsCommand_Run_WithRelayStateUsedAsReturnUrl_Missing()
{
this.Invoking(t => t.RelayStateAsReturnUrl(null, StubFactory.CreateOptions()))
.Should().Throw<ConfigurationErrorsException>();
}

[TestMethod]
public void AcsCommand_Run_WithRelayStateUserAsReturnUrl_AbsolutUrlThrows()
{
this.Invoking(t => t.RelayStateAsReturnUrl("https://absolute.example.com/something", StubFactory.CreateOptions()))
.Should().Throw<InvalidOperationException>().WithMessage("*relative*");
}

[TestMethod]
public void AcsCommand_Run_WithRelayStateUserAsReturnUrl_AbsolutUrlValidatesThroughNotification()
{
var options = StubFactory.CreateOptions();

bool called = false;
options.Notifications.ValidateAbsoluteReturnUrl = url =>
{
called = true;
return true;
};

// Should not throw this time.
RelayStateAsReturnUrl("https://absolute.example.com/something", options);

called.Should().BeTrue("Notifaction should have been called");
}
}
}

0 comments on commit 8545f96

Please sign in to comment.