Fixed subtle issues introduced in initial code import

This commit is contained in:
Brett Hazen 2026-02-20 08:45:56 -06:00
parent 900af2eeab
commit f5c0a3ca70
17 changed files with 288 additions and 166 deletions

View file

@ -282,8 +282,8 @@ public static class IdentityServerConstants
public const string SamlSignin = SamlPathPrefix + "/signin";
public const string SamlSigninCallback = SamlPathPrefix + "/signin_callback";
public const string SamlIdpInitiated = SamlPathPrefix + "/idp-initiated";
public const string SamlLogout = SamlPathPrefix + "/slo";
public const string SamlLogoutCallback = SamlLogout + "/callback";
public const string SamlLogout = SamlPathPrefix + "/logout";
public const string SamlLogoutCallback = SamlPathPrefix + "/logout_callback";
public static readonly string[] CorsPaths =
{

View file

@ -22,7 +22,7 @@ internal class SamlErrorResponseXmlSerializer : ISamlResultSerializer<SamlErrorR
new XAttribute("Value", result.StatusCode.ToString()));
// Add sub-status code if provided
if (result.SubStatusCode.HasValue)
if (result.SubStatusCode?.Value != null)
{
statusCodeElement.Add(
new XElement(protocolNs + "StatusCode",

View file

@ -64,6 +64,12 @@ internal abstract class SamlRequestExtractor<TRequest, TResult>
var signature = request.Query[SamlConstants.RequestProperties.Signature].ToString();
var sigAlg = request.Query[SamlConstants.RequestProperties.SigAlg].ToString();
// Normalize empty relay state to null (important for signature validation)
if (string.IsNullOrEmpty(relayState))
{
relayState = null;
}
// HTTP-Redirect uses deflate compression
byte[] compressedXmlBytes;
try
@ -108,6 +114,12 @@ internal abstract class SamlRequestExtractor<TRequest, TResult>
var relayState = form[SamlConstants.RequestProperties.RelayState].ToString();
// Normalize empty relay state to null
if (string.IsNullOrEmpty(relayState))
{
relayState = null;
}
// HTTP-POST has no compression
byte[] xmlBytes;
try

View file

@ -40,7 +40,7 @@ internal class SamlResponseBuilder(
ServiceProvider = serviceProvider,
Binding = serviceProvider.AssertionConsumerServiceBinding,
StatusCode = error.StatusCode,
SubStatusCode = error.SubStatusCode,
SubStatusCode = error.SubStatusCode != null ? new SamlStatusCode(error.SubStatusCode) : null,
Message = error.Message,
AssertionConsumerServiceUrl = acsUrl,
Issuer = serverUrls.Origin, // Todo: not sure if this is a valid issuer

View file

@ -21,7 +21,7 @@ internal class SamlLogoutNotificationService(
var logoutUrls = new List<ISamlFrontChannelLogout>();
if (context.SamlSessions?.Any() == true)
if (!context.SamlSessions.Any())
{
logger.NoSamlServiceProvidersToNotifyForLogout(LogLevel.Debug);
return logoutUrls;

View file

@ -22,6 +22,11 @@ internal class SamlSingleLogoutCallbackEndpoint(
{
using var activity = Tracing.BasicActivitySource.StartActivity("SamlSingleLogoutCallbackEndpoint");
if (!HttpMethods.IsGet(context.Request.Method))
{
return new StatusCodeResult(HttpStatusCode.MethodNotAllowed);
}
logger.ProcessingSamlLogoutCallbackRequest(LogLevel.Debug);
var logoutId = context.Request.Query["logoutId"].ToString();

View file

@ -27,7 +27,7 @@ internal class SamlSigninStateIdCookie(IHttpContextAccessor httpContextAccessor)
Expires = DateTimeOffset.UtcNow.Add(CookieLifetime)
};
HttpContext.Response.Cookies.Append(CookieName, stateId.Value.ToString("N"), cookieOptions);
HttpContext.Response.Cookies.Append(CookieName, stateId.Value.ToString(), cookieOptions);
}
internal bool TryGetSamlSigninStateId([NotNullWhen(true)] out StateId? stateId)

View file

@ -38,7 +38,7 @@ public class InMemorySamlServiceProviderStore : ISamlServiceProviderStore
var query =
from sp in _serviceProviders
where sp.EntityId == entityId
where sp.EntityId == entityId && sp.Enabled
select sp;
return Task.FromResult(query.SingleOrDefault());

View file

@ -0,0 +1,62 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using Microsoft.Extensions.Caching.Distributed;
namespace Duende.IdentityServer.IntegrationTests.Common;
internal class FakeDistributedCache(TimeProvider timeProvider) : IDistributedCache
{
private readonly Dictionary<string, CacheEntry> _items = new();
private record CacheEntry(byte[] Value, DateTimeOffset? AbsoluteExpiration);
public byte[]? Get(string key)
{
if (!_items.TryGetValue(key, out var entry))
{
return null;
}
if (!entry.AbsoluteExpiration.HasValue || timeProvider.GetUtcNow() <= entry.AbsoluteExpiration.Value)
{
return entry.Value;
}
_items.Remove(key);
return null;
}
public Task<byte[]?> GetAsync(string key, CancellationToken token = default) => Task.FromResult(Get(key));
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
var absoluteExpiration = options.AbsoluteExpirationRelativeToNow.HasValue
? timeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value)
: options.AbsoluteExpiration;
_items[key] = new CacheEntry(value, absoluteExpiration);
}
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
{
Set(key, value, options);
return Task.CompletedTask;
}
public void Remove(string key) => _items.Remove(key);
public Task RemoveAsync(string key, CancellationToken token = default)
{
Remove(key);
return Task.CompletedTask;
}
public void Refresh(string key)
{
// not currently needed
}
public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask;
}

View file

@ -6,12 +6,15 @@
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.IntegrationTests.Common;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
@ -69,40 +72,72 @@ internal class SamlFixture : IAsyncLifetime
public AuthenticationProperties? PropsToSignIn { get; set; }
public TestFramework.GenericHost? Host { get; private set; }
private IdentityServerPipeline _pipeline = null!;
public HttpClient Client { get; private set; } = null!;
public IdentityServerPipeline Pipeline => _pipeline;
public HttpClient NonRedirectingClient { get; private set; } = null!;
public BrowserClient Client { get; private set; } = null!;
public T Get<T>() where T : notnull => Host!.Resolve<T>();
public BrowserClient NonRedirectingClient { get; private set; } = null!;
public string Url(string path = "")
{
if (!path.StartsWith('/') && !string.IsNullOrEmpty(path))
{
path = '/' + path;
}
return IdentityServerPipeline.BaseUrl + path;
}
public T Get<T>() where T : notnull => _pipeline.Resolve<T>();
public async ValueTask InitializeAsync()
{
var selfSignedCertificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(StableSigningCert), null);
Host = new TestFramework.GenericHost();
Host.OnConfigureServices += services =>
_pipeline = new IdentityServerPipeline();
_pipeline.OnPreConfigureServices += services =>
{
services.AddSingleton<TimeProvider>(Data.FakeTimeProvider);
services.AddDistributedMemoryCache();
services.AddSingleton<IDistributedCache>(sp => new FakeDistributedCache(sp.GetRequiredService<TimeProvider>()));
services.AddRouting();
services.AddAuthorization();
};
services.AddIdentityServer(options =>
_pipeline.OnPostConfigureServices += services =>
{
// Configure IdentityServer options (pipeline already calls AddIdentityServer)
services.Configure<IdentityServerOptions>(options =>
{
options.UserInteraction.LoginUrl = LoginUrl.ToString();
options.UserInteraction.LogoutUrl = LogoutUrl.ToString();
options.UserInteraction.ConsentUrl = ConsentUrl.ToString();
ConfigureIdentityServerOptions(options);
})
.AddSigningCredential(selfSignedCertificate)
.AddSamlServices();
options.KeyManagement.Enabled = false; // Disable key management to use our custom credential
});
services.Configure(ConfigureIdentityServerOptions);
// Configure SAML options
services.Configure<SamlOptions>(ConfigureSamlOptions);
services.Configure(ConfigureSamlOptions);
// Register in-memory SAML service provider store with our service providers
services.AddSingleton<ISamlServiceProviderStore>(new InMemorySamlServiceProviderStore(_serviceProviders));
// Replace the developer signing credential with our X509 certificate
// Remove the ISigningCredentialStore registration added by AddDeveloperSigningCredential
var signingCredentialDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ISigningCredentialStore));
if (signingCredentialDescriptor != null)
{
services.Remove(signingCredentialDescriptor);
}
// Add our X509 signing credential
services.AddIdentityServerBuilder()
.AddSigningCredential(selfSignedCertificate)
.AddProfileService<DefaultProfileService>()
.AddSamlServices();
ConfigureServices(services);
services.AddProblemDetails(opt => opt.CustomizeProblemDetails = context =>
@ -116,123 +151,137 @@ internal class SamlFixture : IAsyncLifetime
});
};
Host.OnConfigure += app =>
_pipeline.OnPreConfigure += app =>
{
app.UseExceptionHandler();
app.UseIdentityServer();
app.MapGet(LoginUrl.ToString(), () => Microsoft.AspNetCore.Http.Results.Ok());
app.MapGet(ConsentUrl.ToString(), () => Microsoft.AspNetCore.Http.Results.Ok());
app.MapGet(LogoutUrl.ToString(), () => Microsoft.AspNetCore.Http.Results.Ok());
app.MapGet("/__signin", async (HttpContext ctx, ISamlInteractionService samlInteractionService) =>
{
var props = PropsToSignIn ?? new AuthenticationProperties();
if (UserToSignIn?.Identity == null)
{
throw new InvalidOperationException(
$"Must set {nameof(UserToSignIn)} prior to signin and must have an identity");
}
await ctx.SignInAsync(UserToSignIn, props);
if (UserMetRequestedAuthnContextRequirements.HasValue)
{
await samlInteractionService.StoreRequestedAuthnContextResultAsync(
UserMetRequestedAuthnContextRequirements.Value, ctx.RequestAborted);
}
ctx.Response.StatusCode = 204;
});
app.MapGet("/__signout", async ctx =>
{
await ctx.SignOutAsync();
ctx.Response.StatusCode = 204;
});
app.MapGet("/__authentication-request", async (ISamlInteractionService samlInteractionService) =>
{
var authenticationRequest =
await samlInteractionService.GetAuthenticationRequestContextAsync(CancellationToken.None);
if (authenticationRequest == null)
{
throw new InvalidOperationException("Could not find authentication request");
}
return authenticationRequest.RequestedAuthnContext;
});
app.MapGet("/__protected-resource", () => "Protected Resource").RequireAuthorization();
app.UseExceptionHandler("/error");
};
await Host.InitializeAsync();
_pipeline.OnPostConfigure += app =>
{
// Error handling endpoint
app.Map("/error", path =>
{
path.Run(async context =>
{
var exceptionFeature = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
if (exceptionFeature?.Error is Microsoft.AspNetCore.Http.BadHttpRequestException badRequestEx)
{
context.Response.StatusCode = badRequestEx.StatusCode;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Status = badRequestEx.StatusCode,
Title = "Bad Request",
Detail = badRequestEx.Message
});
}
else
{
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Internal Server Error");
}
});
});
app.Map(LoginUrl.ToString(), path =>
{
path.Run(ctx =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
});
});
app.Map(ConsentUrl.ToString(), path =>
{
path.Run(ctx =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
});
});
app.Map(LogoutUrl.ToString(), path =>
{
path.Run(ctx =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
});
});
app.Map("/__signin", path =>
{
path.Run(async ctx =>
{
var samlInteractionService = ctx.RequestServices.GetRequiredService<ISamlInteractionService>();
var props = PropsToSignIn ?? new AuthenticationProperties();
if (UserToSignIn?.Identity == null)
{
throw new InvalidOperationException(
$"Must set {nameof(UserToSignIn)} prior to signin and must have an identity");
}
await ctx.SignInAsync(UserToSignIn, props);
if (UserMetRequestedAuthnContextRequirements.HasValue)
{
await samlInteractionService.StoreRequestedAuthnContextResultAsync(
UserMetRequestedAuthnContextRequirements.Value, ctx.RequestAborted);
}
ctx.Response.StatusCode = 204;
});
});
app.Map("/__signout", path =>
{
path.Run(async ctx =>
{
await ctx.SignOutAsync();
ctx.Response.StatusCode = 204;
});
});
app.Map("/__authentication-request", path =>
{
path.Run(async ctx =>
{
var samlInteractionService = ctx.RequestServices.GetRequiredService<ISamlInteractionService>();
var authenticationRequest =
await samlInteractionService.GetAuthenticationRequestContextAsync(CancellationToken.None);
if (authenticationRequest == null)
{
throw new InvalidOperationException("Could not find authentication request");
}
await ctx.Response.WriteAsJsonAsync(authenticationRequest.RequestedAuthnContext);
});
});
};
_pipeline.Initialize(enableLogging: true);
// Mark as initialized after seeding
_isInitialized = true;
Client = Host!.HttpClient;
NonRedirectingClient = Host!.Server.CreateClient();
// Create two BrowserClient instances with different redirect behaviors
Client = _pipeline.BrowserClient;
Client.BaseAddress = new Uri(IdentityServerPipeline.BaseUrl);
NonRedirectingClient = new BrowserClient(new BrowserHandler(_pipeline.Handler) { AllowAutoRedirect = false })
{
BaseAddress = new Uri(IdentityServerPipeline.BaseUrl)
};
}
public async ValueTask DisposeAsync()
{
if (Host != null)
{
// GenericHost doesn't implement IAsyncDisposable, so nothing to dispose
await Task.CompletedTask;
}
}
/// <summary>
/// Adds a service provider to the fixture after initialization.
/// </summary>
public async Task AddServiceProviderAsync(SamlServiceProvider serviceProvider)
{
if (!_isInitialized)
{
throw new InvalidOperationException(
"Cannot call AddServiceProviderAsync before initialization. " +
"Add service providers to the ServiceProviders list before calling InitializeAsync.");
}
if (Host == null)
{
throw new InvalidOperationException("Host is not initialized");
}
// With InMemorySamlServiceProviderStore, we need to replace the store instance
// This is a limitation - in integration tests, we should add all SPs before initialization
_serviceProviders.Add(serviceProvider);
// Re-register the store with the updated list
var serviceProvider2 = Host.Server.Services;
var scope = serviceProvider2.CreateScope();
// This won't work properly with the current architecture
// The store is registered as a singleton, so we can't easily update it
// For now, throw an exception to indicate this is not supported
throw new NotSupportedException(
"Adding service providers after initialization is not currently supported with InMemorySamlServiceProviderStore. " +
"Please add all service providers to the ServiceProviders list before calling InitializeAsync.");
}
public async ValueTask DisposeAsync() =>
// IdentityServerPipeline doesn't implement IAsyncDisposable, so nothing to dispose
await Task.CompletedTask;
/// <summary>
/// Removes all service providers from the fixture after initialization.
/// </summary>
public async Task ClearServiceProvidersAsync()
{
if (!_isInitialized)
{
throw new InvalidOperationException(
"Cannot call ClearServiceProvidersAsync before initialization. " +
"Modify the ServiceProviders list directly before calling InitializeAsync.");
}
throw new NotSupportedException(
"Clearing service providers after initialization is not currently supported with InMemorySamlServiceProviderStore. " +
"The service provider store is immutable after initialization.");
}
public void ClearServiceProvidersAsync() => _serviceProviders.Clear();
}

View file

@ -292,7 +292,7 @@ public class SamlIdpInitiatedEndpointTests
var samlResponse = await ExtractSamlSuccessFromPostAsync(callbackResult, CancellationToken.None);
samlResponse.ShouldNotBeNull();
samlResponse.Issuer.ShouldBe(Fixture.Host!.Url());
samlResponse.Issuer.ShouldBe(Fixture.Url());
samlResponse.Destination.ShouldBe(Data.AcsUrl.ToString());
samlResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success");

View file

@ -29,7 +29,7 @@ public class SamlMetadataEndpointTests
var content = await result.Content.ReadAsStringAsync(CancellationToken.None);
var settings = new VerifySettings();
var hostUri = Fixture.Host!.Url();
var hostUri = Fixture.Url();
settings.AddScrubber(sb =>
{
sb.Replace(hostUri, "https://localhost");

View file

@ -1 +1 @@
<?xml version="1.0" encoding="utf-8"?><EntityDescriptor entityID="http://localhost" validUntil="2000-01-09T03:04:05Z" xmlns="urn:oasis:names:tc:SAML:2.0:metadata"><IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>MIICojCCAYqgAwIBAgIIIjGqKDo3ME4wDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAxMGZm9vYmFyMB4XDTI1MTExMDE0MTEyNloXDTMwMTExMDE0MTEyNlowETEPMA0GA1UEAxMGZm9vYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fcM55jlB810lyxGpgk0Zhw83Liqz80l3zLLAZgJ/IUdBx9VFD28BeO37eByHDXxBIQdHFYXQj+lv2g3KFRxVzfZhiFUrb1UydJYFZ951sQUEsP4T/Fpbyb95HNrwG2NwE5/fk1MXr9no4ydsQTZA6EWOfbxn6o2YQs/8QdDykhCzpZcWYbk5AKS/G6nYLpwuW4UsyMQ6ur9ZQXtwDS/hGyP3RjK8pjqkckbQG9ZapI+hWezIJkGmkXcuIx+FpZbdjjwu/SIcNNrBIXLbrbWyxoWt4y2jWfDixanBAubBLtx6tCg69trJ3M5gZkFZBR3CVqs78fYZUThKBTS20afQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCxB08tE2bDWpF5mR14kQvRUA/2hZKeC6CYYGEwOu1hbh5m3rVj4T9GPgOh+s6tX+rCb0IoV1uD9iSeTd3XaJ/1sSFkgVD/PaA6NRgzKVeDXLl9rZGAnOmp/Es3Pz35FbPxZKTe8UDyFHySbioLaLvtODhzX7SeGP3BcRpp8rZLvggMYiqo3w39+qZcgZPIBP4yRSulBYb3r9qagQ/n//gp7SmenCQmjA5L7pTn7QggFQsSQmB6dyNS54cUk0niUsTihT9oqpMnXmsXonXf5cv3tnaydreiB4aPea+OjjY3oy8hvHUH6FuQQX7t3RllZlPGJQFZe61rYMVmRRjlHWTA</X509Certificate></X509Data></KeyInfo></KeyDescriptor><NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat><NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat><NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat><NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</NameIDFormat><SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost/saml/signin" /><SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost/saml/signin" /><SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost/saml/logout" /><SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost/saml/logout" /></IDPSSODescriptor></EntityDescriptor>
<?xml version="1.0" encoding="utf-8"?><EntityDescriptor entityID="https://localhost" validUntil="2000-01-09T03:04:05Z" xmlns="urn:oasis:names:tc:SAML:2.0:metadata"><IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>MIICojCCAYqgAwIBAgIIIjGqKDo3ME4wDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAxMGZm9vYmFyMB4XDTI1MTExMDE0MTEyNloXDTMwMTExMDE0MTEyNlowETEPMA0GA1UEAxMGZm9vYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fcM55jlB810lyxGpgk0Zhw83Liqz80l3zLLAZgJ/IUdBx9VFD28BeO37eByHDXxBIQdHFYXQj+lv2g3KFRxVzfZhiFUrb1UydJYFZ951sQUEsP4T/Fpbyb95HNrwG2NwE5/fk1MXr9no4ydsQTZA6EWOfbxn6o2YQs/8QdDykhCzpZcWYbk5AKS/G6nYLpwuW4UsyMQ6ur9ZQXtwDS/hGyP3RjK8pjqkckbQG9ZapI+hWezIJkGmkXcuIx+FpZbdjjwu/SIcNNrBIXLbrbWyxoWt4y2jWfDixanBAubBLtx6tCg69trJ3M5gZkFZBR3CVqs78fYZUThKBTS20afQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCxB08tE2bDWpF5mR14kQvRUA/2hZKeC6CYYGEwOu1hbh5m3rVj4T9GPgOh+s6tX+rCb0IoV1uD9iSeTd3XaJ/1sSFkgVD/PaA6NRgzKVeDXLl9rZGAnOmp/Es3Pz35FbPxZKTe8UDyFHySbioLaLvtODhzX7SeGP3BcRpp8rZLvggMYiqo3w39+qZcgZPIBP4yRSulBYb3r9qagQ/n//gp7SmenCQmjA5L7pTn7QggFQsSQmB6dyNS54cUk0niUsTihT9oqpMnXmsXonXf5cv3tnaydreiB4aPea+OjjY3oy8hvHUH6FuQQX7t3RllZlPGJQFZe61rYMVmRRjlHWTA</X509Certificate></X509Data></KeyInfo></KeyDescriptor><NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat><NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat><NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat><NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</NameIDFormat><SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://localhost/saml/signin" /><SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://localhost/saml/signin" /><SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://localhost/saml/logout" /><SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://localhost/saml/logout" /></IDPSSODescriptor></EntityDescriptor>

View file

@ -129,7 +129,7 @@ public class SamlSigninCallbackEndpointTests
var redirectUri = signinResult.Headers.Location;
redirectUri.ShouldNotBeNull();
await Fixture.ClearServiceProvidersAsync();
Fixture.ClearServiceProvidersAsync();
var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
@ -159,10 +159,9 @@ public class SamlSigninCallbackEndpointTests
var redirectUri = signinResult.Headers.Location;
redirectUri.ShouldNotBeNull();
await Fixture.ClearServiceProvidersAsync();
var disabledSp = Build.SamlServiceProvider();
// Ideally we would fetch the SP via a store and update it, but since the store doesn't provide that functionality
// we'll rely on everything being in memory and holding onto a reference to the SP in the store for now
sp.Enabled = false;
await Fixture.AddServiceProviderAsync(disabledSp);
var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);

View file

@ -105,7 +105,7 @@ public class SamlSigninEndpointTests
errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:VersionMismatch");
errorResponse.StatusMessage.ShouldNotBeNull();
errorResponse.StatusMessage.ShouldContain("Only Version 2.0 is supported");
errorResponse.Issuer.ShouldBe(Fixture.Host!.Url());
errorResponse.Issuer.ShouldBe(Fixture.Url());
errorResponse.InResponseTo.ShouldNotBeNullOrEmpty();
errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString());
}
@ -222,7 +222,7 @@ public class SamlSigninEndpointTests
errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:VersionMismatch");
errorResponse.StatusMessage.ShouldNotBeNull();
errorResponse.StatusMessage.ShouldContain("Only Version 2.0 is supported");
errorResponse.Issuer.ShouldBe(Fixture.Host!.Url());
errorResponse.Issuer.ShouldBe(Fixture.Url());
errorResponse.InResponseTo.ShouldNotBeNullOrEmpty();
errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString());
}
@ -315,7 +315,7 @@ public class SamlSigninEndpointTests
errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester");
errorResponse.StatusMessage.ShouldBe("Request IssueInstant is in the future");
errorResponse.Issuer.ShouldBe(Fixture.Host!.Url());
errorResponse.Issuer.ShouldBe(Fixture.Url());
errorResponse.InResponseTo.ShouldNotBeNullOrEmpty();
errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString());
}
@ -497,7 +497,7 @@ public class SamlSigninEndpointTests
await Fixture.InitializeAsync();
// Use the correct Destination attribute
var correctDestination = new Uri(Fixture.Host!.Url() + "/saml/signin");
var correctDestination = new Uri(Fixture.Url() + "/saml/signin");
var authnRequestXml = Build.AuthNRequestXml(destination: correctDestination);
var urlEncoded = await EncodeRequest(authnRequestXml);
@ -539,7 +539,7 @@ public class SamlSigninEndpointTests
successResponse.ResponseId.ShouldNotBeNullOrEmpty();
successResponse.Destination.ShouldBe(Fixture.Data.AcsUrl.ToString());
successResponse.IssueInstant.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
successResponse.Issuer.ShouldBe(Fixture.Host?.Url());
successResponse.Issuer.ShouldBe(Fixture.Url());
successResponse.InResponseTo.ShouldBe(Data.RequestId);
successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value);
@ -549,7 +549,7 @@ public class SamlSigninEndpointTests
assertion.Id.ShouldNotBeNullOrEmpty();
assertion.Version.ShouldBe("2.0");
assertion.IssueInstant.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
assertion.Issuer.ShouldBe(Fixture.Host?.Url());
assertion.Issuer.ShouldBe(Fixture.Url());
var subject = assertion.Subject;
subject.ShouldNotBeNull();
@ -788,7 +788,7 @@ public class SamlSigninEndpointTests
errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:NoPassive");
errorResponse.StatusMessage.ShouldBe("The user is not currently logged in and passive login was requested.");
errorResponse.Issuer.ShouldBe(Fixture.Host!.Url());
errorResponse.Issuer.ShouldBe(Fixture.Url());
errorResponse.InResponseTo.ShouldNotBeNullOrEmpty();
errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString());
}
@ -813,7 +813,7 @@ public class SamlSigninEndpointTests
errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:NoPassive");
errorResponse.StatusMessage.ShouldBe("The user is not currently logged in");
errorResponse.Issuer.ShouldBe(Fixture.Host!.Url());
errorResponse.Issuer.ShouldBe(Fixture.Url());
errorResponse.InResponseTo.ShouldNotBeNullOrEmpty();
errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString());
}

View file

@ -150,7 +150,7 @@ public class SamlSingleLogoutEndpointTests
await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
sessionIndex: "session123");
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
@ -175,7 +175,7 @@ public class SamlSingleLogoutEndpointTests
await Fixture.InitializeAsync();
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
version: "1.0");
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
@ -199,7 +199,7 @@ public class SamlSingleLogoutEndpointTests
var futureTime = Data.Now.AddMinutes(10);
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
issueInstant: futureTime,
sessionIndex: "session123");
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
@ -225,7 +225,7 @@ public class SamlSingleLogoutEndpointTests
var oldTime = Data.Now.AddMinutes(-10);
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
issueInstant: oldTime,
sessionIndex: "session123");
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
@ -260,7 +260,7 @@ public class SamlSingleLogoutEndpointTests
// Assert
var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None);
logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Requester.Value);
logoutResponse.StatusMessage.ShouldBe($"Invalid destination. Expected '{Fixture.Host!.Url()}/saml/logout'");
logoutResponse.StatusMessage.ShouldBe($"Invalid destination. Expected '{Fixture.Url()}/saml/logout'");
}
[Fact]
@ -274,7 +274,7 @@ public class SamlSingleLogoutEndpointTests
await Fixture.InitializeAsync();
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
sessionIndex: "session123");
var urlEncoded = await EncodeRequest(logoutRequestXml, CancellationToken.None);
@ -300,7 +300,7 @@ public class SamlSingleLogoutEndpointTests
// Create a logout request without a signature
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
sessionIndex: "session123");
var urlEncoded = await EncodeRequest(logoutRequestXml, CancellationToken.None);
@ -351,7 +351,7 @@ public class SamlSingleLogoutEndpointTests
// Don't sign in a user - no authenticated session
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
sessionIndex: "session123");
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
@ -384,7 +384,7 @@ public class SamlSingleLogoutEndpointTests
// Use a different service provider than what was established
var logoutRequestXml = Build.LogoutRequestXml(
issuer: anotherSp.EntityId, // Use a different SP so session will not be found
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"));
destination: new Uri($"{Fixture.Url()}/saml/logout"));
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
// Act
@ -412,7 +412,7 @@ public class SamlSingleLogoutEndpointTests
// Use a different session index than what was established
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
sessionIndex: "wrong-session-index");
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
@ -443,7 +443,7 @@ public class SamlSingleLogoutEndpointTests
var sessionIndex = await PerformSigninAndExtractSessionIndex(sp);
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
sessionIndex: sessionIndex);
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
@ -481,7 +481,7 @@ public class SamlSingleLogoutEndpointTests
var sessionIndex = await PerformSigninAndExtractSessionIndex(sp);
var logoutRequestXml = Build.LogoutRequestXml(
destination: new Uri($"{Fixture.Host!.Url()}/saml/logout"),
destination: new Uri($"{Fixture.Url()}/saml/logout"),
sessionIndex: sessionIndex);
var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None);
@ -494,7 +494,7 @@ public class SamlSingleLogoutEndpointTests
// Verify user can no longer access protected resource and is redirected to login
var finalProtectedResourceResult = await Fixture.Client.GetAsync("__protected-resource", CancellationToken.None);
finalProtectedResourceResult.StatusCode.ShouldBe(HttpStatusCode.OK);
finalProtectedResourceResult.RequestMessage?.RequestUri?.AbsoluteUri.ShouldStartWith($"{Fixture.Host!.Url()}{Fixture.LoginUrl.ToString()}");
finalProtectedResourceResult.RequestMessage?.RequestUri?.AbsoluteUri.ShouldStartWith($"{Fixture.Url()}{Fixture.LoginUrl.ToString()}");
}
private static async Task<string> EncodeAndSignRequest(

View file

@ -27,7 +27,7 @@ internal class SustainSysSamlTestFixture : IAsyncLifetime
public HttpClient? BrowserClient = null!;
public X509Certificate2? SigningCertificate { get; private set; }
public Uri IdentityProviderLoginUri => new Uri(new Uri(_samlFixture.Host!.Url()), _samlFixture.LoginUrl);
public Uri IdentityProviderLoginUri => new Uri(new Uri(_samlFixture.Url()), _samlFixture.LoginUrl);
private readonly SamlFixture _samlFixture = new();
private bool _shouldGenerateSigningCertificate;
@ -37,7 +37,7 @@ internal class SustainSysSamlTestFixture : IAsyncLifetime
{
_samlFixture.UserToSignIn =
new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id"), new Claim("name", "Test User"), new Claim(JwtClaimTypes.AuthenticationMethod, "urn:oasis:names:tc:SAML:2.0:ac:classes:Password")], "Test"));
await BrowserClient!.GetAsync($"{_samlFixture.Host!.Url()}/__signin", CancellationToken.None);
await BrowserClient!.GetAsync($"{_samlFixture.Url()}/__signin", CancellationToken.None);
}
public void GenerateSigningCertificate() =>
@ -64,11 +64,8 @@ internal class SustainSysSamlTestFixture : IAsyncLifetime
publicCertificate = X509CertificateLoader.LoadCertificate(signingCertificate.Export(X509ContentType.Cert));
}
// Initialize the SAML fixture first so we can get the IDP URI
await _samlFixture.InitializeAsync();
// Now initialize the service provider host with the correct IDP URI
await InitializeServiceProvider(_samlFixture.Host!.Url(), signingCertificate);
// Initialize SP host first so Host is set when creating SP config for IdP
await InitializeServiceProvider(_samlFixture.Url(), signingCertificate);
// Configure the service provider with the actual host URI and add it to the SAML fixture
var serviceProvider = new SamlServiceProvider
@ -86,12 +83,10 @@ internal class SustainSysSamlTestFixture : IAsyncLifetime
// Note: With InMemorySamlServiceProviderStore, we cannot add SPs after initialization
// So we need to add it to the fixture before initialization
// This is a known limitation of the current implementation
// For now, we'll add it to the _samlFixture.ServiceProviders list before it was initialized
// But since we already initialized it, we need to work around this
// The best approach is to initialize both fixtures together, but that requires refactoring
// For now, we'll just note this limitation
_samlFixture.ServiceProviders.Add(serviceProvider);
// Initialize the SAML fixture first so we can get the IDP URI
await _samlFixture.InitializeAsync();
}
private async Task InitializeServiceProvider(string identityProviderHostUri, X509Certificate2? signingCertificate = null)