diff --git a/identity-server/src/IdentityServer/IdentityServerConstants.cs b/identity-server/src/IdentityServer/IdentityServerConstants.cs index 7d464ff27..21c9924fa 100644 --- a/identity-server/src/IdentityServer/IdentityServerConstants.cs +++ b/identity-server/src/IdentityServer/IdentityServerConstants.cs @@ -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 = { diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs index 686a17f70..de819f638 100644 --- a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs @@ -22,7 +22,7 @@ internal class SamlErrorResponseXmlSerializer : ISamlResultSerializer 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 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 diff --git a/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs index 8daded114..ebbb62414 100644 --- a/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs +++ b/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs @@ -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 diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs index 498395f43..1db38b0a1 100644 --- a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs @@ -21,7 +21,7 @@ internal class SamlLogoutNotificationService( var logoutUrls = new List(); - if (context.SamlSessions?.Any() == true) + if (!context.SamlSessions.Any()) { logger.NoSamlServiceProvidersToNotifyForLogout(LogLevel.Debug); return logoutUrls; diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs index 4007ff691..c2aa62ec3 100644 --- a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs @@ -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(); diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs index 4d3757cf9..a6818049c 100644 --- a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs @@ -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) diff --git a/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs b/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs index 8803ee3a2..81b9db4d8 100644 --- a/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs +++ b/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs @@ -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()); diff --git a/identity-server/test/IdentityServer.IntegrationTests/Common/FakeDistributedCache.cs b/identity-server/test/IdentityServer.IntegrationTests/Common/FakeDistributedCache.cs new file mode 100644 index 000000000..3bc0d899d --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Common/FakeDistributedCache.cs @@ -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 _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 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; +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs index da5924e9e..5e1667bdb 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs @@ -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() where T : notnull => Host!.Resolve(); + 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() where T : notnull => _pipeline.Resolve(); 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(Data.FakeTimeProvider); - services.AddDistributedMemoryCache(); + services.AddSingleton(sp => new FakeDistributedCache(sp.GetRequiredService())); + services.AddRouting(); + services.AddAuthorization(); + }; - services.AddIdentityServer(options => + _pipeline.OnPostConfigureServices += services => + { + // Configure IdentityServer options (pipeline already calls AddIdentityServer) + services.Configure(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(ConfigureSamlOptions); + services.Configure(ConfigureSamlOptions); // Register in-memory SAML service provider store with our service providers services.AddSingleton(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() + .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(); + 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(); + 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(); + 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; - } - } - - /// - /// Adds a service provider to the fixture after initialization. - /// - 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; /// /// Removes all service providers from the fixture after initialization. /// - 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(); } diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs index 152bbb403..2c4b51acc 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs @@ -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"); diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs index 292750882..a49986f4e 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs @@ -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"); diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.metadata_endpoint_should_return_metadata.verified.txt b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.metadata_endpoint_should_return_metadata.verified.txt index c5bab218d..198c0c5b1 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.metadata_endpoint_should_return_metadata.verified.txt +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.metadata_endpoint_should_return_metadata.verified.txt @@ -1 +1 @@ -MIICojCCAYqgAwIBAgIIIjGqKDo3ME4wDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAxMGZm9vYmFyMB4XDTI1MTExMDE0MTEyNloXDTMwMTExMDE0MTEyNlowETEPMA0GA1UEAxMGZm9vYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fcM55jlB810lyxGpgk0Zhw83Liqz80l3zLLAZgJ/IUdBx9VFD28BeO37eByHDXxBIQdHFYXQj+lv2g3KFRxVzfZhiFUrb1UydJYFZ951sQUEsP4T/Fpbyb95HNrwG2NwE5/fk1MXr9no4ydsQTZA6EWOfbxn6o2YQs/8QdDykhCzpZcWYbk5AKS/G6nYLpwuW4UsyMQ6ur9ZQXtwDS/hGyP3RjK8pjqkckbQG9ZapI+hWezIJkGmkXcuIx+FpZbdjjwu/SIcNNrBIXLbrbWyxoWt4y2jWfDixanBAubBLtx6tCg69trJ3M5gZkFZBR3CVqs78fYZUThKBTS20afQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCxB08tE2bDWpF5mR14kQvRUA/2hZKeC6CYYGEwOu1hbh5m3rVj4T9GPgOh+s6tX+rCb0IoV1uD9iSeTd3XaJ/1sSFkgVD/PaA6NRgzKVeDXLl9rZGAnOmp/Es3Pz35FbPxZKTe8UDyFHySbioLaLvtODhzX7SeGP3BcRpp8rZLvggMYiqo3w39+qZcgZPIBP4yRSulBYb3r9qagQ/n//gp7SmenCQmjA5L7pTn7QggFQsSQmB6dyNS54cUk0niUsTihT9oqpMnXmsXonXf5cv3tnaydreiB4aPea+OjjY3oy8hvHUH6FuQQX7t3RllZlPGJQFZe61rYMVmRRjlHWTAurn:oasis:names:tc:SAML:1.1:nameid-format:emailAddressurn:oasis:names:tc:SAML:2.0:nameid-format:persistenturn:oasis:names:tc:SAML:2.0:nameid-format:transienturn:oasis:names:tc:SAML:1.1:nameid-format:unspecified +MIICojCCAYqgAwIBAgIIIjGqKDo3ME4wDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAxMGZm9vYmFyMB4XDTI1MTExMDE0MTEyNloXDTMwMTExMDE0MTEyNlowETEPMA0GA1UEAxMGZm9vYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fcM55jlB810lyxGpgk0Zhw83Liqz80l3zLLAZgJ/IUdBx9VFD28BeO37eByHDXxBIQdHFYXQj+lv2g3KFRxVzfZhiFUrb1UydJYFZ951sQUEsP4T/Fpbyb95HNrwG2NwE5/fk1MXr9no4ydsQTZA6EWOfbxn6o2YQs/8QdDykhCzpZcWYbk5AKS/G6nYLpwuW4UsyMQ6ur9ZQXtwDS/hGyP3RjK8pjqkckbQG9ZapI+hWezIJkGmkXcuIx+FpZbdjjwu/SIcNNrBIXLbrbWyxoWt4y2jWfDixanBAubBLtx6tCg69trJ3M5gZkFZBR3CVqs78fYZUThKBTS20afQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCxB08tE2bDWpF5mR14kQvRUA/2hZKeC6CYYGEwOu1hbh5m3rVj4T9GPgOh+s6tX+rCb0IoV1uD9iSeTd3XaJ/1sSFkgVD/PaA6NRgzKVeDXLl9rZGAnOmp/Es3Pz35FbPxZKTe8UDyFHySbioLaLvtODhzX7SeGP3BcRpp8rZLvggMYiqo3w39+qZcgZPIBP4yRSulBYb3r9qagQ/n//gp7SmenCQmjA5L7pTn7QggFQsSQmB6dyNS54cUk0niUsTihT9oqpMnXmsXonXf5cv3tnaydreiB4aPea+OjjY3oy8hvHUH6FuQQX7t3RllZlPGJQFZe61rYMVmRRjlHWTAurn:oasis:names:tc:SAML:1.1:nameid-format:emailAddressurn:oasis:names:tc:SAML:2.0:nameid-format:persistenturn:oasis:names:tc:SAML:2.0:nameid-format:transienturn:oasis:names:tc:SAML:1.1:nameid-format:unspecified diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs index 0e96a0cba..729ecf96c 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs @@ -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); diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs index 2063a2772..c75808993 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs @@ -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()); } diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs index 4a0e04644..c8337b92e 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs @@ -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 EncodeAndSignRequest( diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs index 2ae4b0fbc..0ec005f2b 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs @@ -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)