diff --git a/Frontend/CO.CDP.OrganisationApp.Tests/FtsUrlServiceTests.cs b/Frontend/CO.CDP.OrganisationApp.Tests/FtsUrlServiceTests.cs index 6de546262..5f385fdb0 100644 --- a/Frontend/CO.CDP.OrganisationApp.Tests/FtsUrlServiceTests.cs +++ b/Frontend/CO.CDP.OrganisationApp.Tests/FtsUrlServiceTests.cs @@ -7,16 +7,31 @@ namespace CO.CDP.OrganisationApp.Tests; public class FtsUrlServiceTests { + private readonly Mock _sessionMock; private readonly Mock _configurationMock; private readonly Mock _cookiePreferencesService; private readonly IFtsUrlService _service; public FtsUrlServiceTests() { + _sessionMock = new(); _configurationMock = new Mock(); _cookiePreferencesService = new Mock(); _configurationMock.Setup(c => c["FtsService"]).Returns("https://example.com/"); - _service = new FtsUrlService(_configurationMock.Object, _cookiePreferencesService.Object); + _service = new FtsUrlService(_configurationMock.Object, _cookiePreferencesService.Object, _sessionMock.Object); + } + + [Fact] + public void BuildUrl_WhenFtsServiceOriginSessionIsSet_BaseServiceUrlOriginShouldBeSessionValue() + { + _sessionMock.Setup(s => s.Get(Session.FtsServiceOrigin)).Returns("https://example1.com/"); + _configurationMock.Setup(c => c["FtsService"]).Returns("https://example2.com/"); + var service = new FtsUrlService(_configurationMock.Object, _cookiePreferencesService.Object, _sessionMock.Object); + CultureInfo.CurrentUICulture = new CultureInfo("en-GB"); + + var result = service.BuildUrl("test-endpoint"); + + result.Should().Be("https://example1.com/test-endpoint?language=en_GB&cookies_accepted=unknown"); } [Fact] @@ -24,7 +39,7 @@ public void Constructor_ShouldThrowException_WhenFtsServiceIsNotConfigured() { _configurationMock.Setup(c => c["FtsService"]).Returns((string?)null); - Action action = () => new FtsUrlService(_configurationMock.Object, _cookiePreferencesService.Object); + Action action = () => new FtsUrlService(_configurationMock.Object, _cookiePreferencesService.Object, _sessionMock.Object); action.Should().Throw() .WithMessage("FtsService is not configured."); @@ -34,7 +49,7 @@ public void Constructor_ShouldThrowException_WhenFtsServiceIsNotConfigured() public void BuildUrl_ShouldTrimTrailingSlashFromBaseServiceUrl() { _configurationMock.Setup(c => c["FtsService"]).Returns("https://example.com/"); - var service = new FtsUrlService(_configurationMock.Object, _cookiePreferencesService.Object); + var service = new FtsUrlService(_configurationMock.Object, _cookiePreferencesService.Object, _sessionMock.Object); var endpoint = "test-endpoint"; CultureInfo.CurrentUICulture = new CultureInfo("en-GB"); diff --git a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/OneLoginTest.cs b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/OneLoginTest.cs index 40574616d..de4afbb14 100644 --- a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/OneLoginTest.cs +++ b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/OneLoginTest.cs @@ -1,4 +1,5 @@ using CO.CDP.OrganisationApp.Authentication; +using CO.CDP.OrganisationApp.Constants; using CO.CDP.OrganisationApp.Models; using CO.CDP.OrganisationApp.Pages; using CO.CDP.OrganisationApp.WebApiClients; @@ -9,7 +10,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.FeatureManagement; using Moq; using System.Security.Claims; @@ -24,6 +27,8 @@ public class OneLoginTest private readonly Mock oneLoginAuthorityMock = new(); private readonly Mock authService = new(); private readonly Mock authorityClientMock = new(); + private readonly Mock featureManagerMock = new(); + private readonly Mock configMock = new(); private const string urn = "urn:fdc:gov.uk:2022:7wTqYGMFQxgukTSpSI2GodMwe9"; [Fact] @@ -48,6 +53,45 @@ public async Task OnGetSignIn_WhenRedirectUrlProvides_ShouldReturnAuthChallangeW .Which.Properties!.RedirectUri.Should().Be("/one-login/user-info?redirectUri=%2Forg%2F1"); } + [Fact] + public async Task OnGetSignIn_WhenOriginIsNotNull_ShouldSetSessionWithFtsServiceOriginKey() + { + configMock.Setup(c => c["FtsServiceAllowedOrigins"]).Returns("http://example1.com,http://example2.com"); + featureManagerMock.Setup(f => f.IsEnabledAsync(FeatureFlags.AllowDynamicFtsOrigins)).ReturnsAsync(true); + var origin = "http://example1.com"; + var model = GivenOneLoginModel("sign-in"); + + var result = await model.OnGetAsync("/org/1", origin: origin); + + sessionMock.Verify(s => s.Set(Session.FtsServiceOrigin, origin), Times.Once); + } + + [Fact] + public async Task OnGetSignIn_WhenOriginIsNotListedInConfiguration_ShouldNotSetSessionWithFtsServiceOriginKey() + { + configMock.Setup(c => c["FtsServiceAllowedOrigins"]).Returns("http://example1.com,http://example2.com"); + featureManagerMock.Setup(f => f.IsEnabledAsync(FeatureFlags.AllowDynamicFtsOrigins)).ReturnsAsync(true); + var origin = "http://example3.com"; + var model = GivenOneLoginModel("sign-in"); + + var result = await model.OnGetAsync("/org/1", origin: origin); + + sessionMock.Verify(s => s.Set(Session.FtsServiceOrigin, origin), Times.Never); + } + + [Fact] + public async Task OnGetSignIn_WhenAllowDynamicFtsOriginsFeatureIsDisabled_ShouldNotSetSessionWithFtsServiceOriginKey() + { + configMock.Setup(c => c["FtsServiceAllowedOrigins"]).Returns("http://example1.com,http://example2.com"); + featureManagerMock.Setup(f => f.IsEnabledAsync(FeatureFlags.AllowDynamicFtsOrigins)).ReturnsAsync(false); + + var model = GivenOneLoginModel("sign-in"); + + var result = await model.OnGetAsync("/org/1", origin: "http://example1.com"); + + sessionMock.Verify(s => s.Set(Session.FtsServiceOrigin, "origin"), Times.Never); + } + [Fact] public async Task OnGetUserInfo_OnSuccessfulAuthentication_ShouldRetrieveUserProfile() { @@ -320,7 +364,9 @@ private OneLoginModel GivenOneLoginModel(string pageAction) logoutManagerMock.Object, oneLoginAuthorityMock.Object, authorityClientMock.Object, - new Mock>().Object) + new Mock>().Object, + featureManagerMock.Object, + configMock.Object) { PageAction = pageAction }; } } \ No newline at end of file diff --git a/Frontend/CO.CDP.OrganisationApp/Constants/FeatureFlags.cs b/Frontend/CO.CDP.OrganisationApp/Constants/FeatureFlags.cs index 19e20fad1..54793e8aa 100644 --- a/Frontend/CO.CDP.OrganisationApp/Constants/FeatureFlags.cs +++ b/Frontend/CO.CDP.OrganisationApp/Constants/FeatureFlags.cs @@ -3,4 +3,5 @@ namespace CO.CDP.OrganisationApp.Constants; public static class FeatureFlags { public const string Consortium = "Consortium"; + public const string AllowDynamicFtsOrigins = "AllowDynamicFtsOrigins"; } \ No newline at end of file diff --git a/Frontend/CO.CDP.OrganisationApp/FtsUrlService.cs b/Frontend/CO.CDP.OrganisationApp/FtsUrlService.cs index 1410bb96c..812cd5de2 100644 --- a/Frontend/CO.CDP.OrganisationApp/FtsUrlService.cs +++ b/Frontend/CO.CDP.OrganisationApp/FtsUrlService.cs @@ -7,10 +7,14 @@ public class FtsUrlService : IFtsUrlService { private readonly string _ftsService; private readonly ICookiePreferencesService _cookiePreferencesService; + private readonly ISession _session; - public FtsUrlService(IConfiguration configuration, ICookiePreferencesService cookiePreferencesService) + public FtsUrlService(IConfiguration configuration, ICookiePreferencesService cookiePreferencesService, ISession session) { - var ftsService = configuration["FtsService"] ?? throw new InvalidOperationException("FtsService is not configured."); + _session = session; + var ftsService = _session.Get(Session.FtsServiceOrigin) + ?? configuration["FtsService"] + ?? throw new InvalidOperationException("FtsService is not configured."); _ftsService = ftsService.TrimEnd('/'); _cookiePreferencesService = cookiePreferencesService; } diff --git a/Frontend/CO.CDP.OrganisationApp/Pages/OneLogin.cshtml.cs b/Frontend/CO.CDP.OrganisationApp/Pages/OneLogin.cshtml.cs index 91e116114..230d8834a 100644 --- a/Frontend/CO.CDP.OrganisationApp/Pages/OneLogin.cshtml.cs +++ b/Frontend/CO.CDP.OrganisationApp/Pages/OneLogin.cshtml.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.FeatureManagement; using System.Net; namespace CO.CDP.OrganisationApp.Pages; @@ -22,16 +23,18 @@ public class OneLoginModel( ILogoutManager logoutManager, IOneLoginAuthority oneLoginAuthority, IAuthorityClient authorityClient, - ILogger logger) : PageModel + ILogger logger, + IFeatureManager featureManager, + IConfiguration config) : PageModel { [BindProperty(SupportsGet = true)] public required string PageAction { get; set; } - public async Task OnGetAsync(string? redirectUri = null) + public async Task OnGetAsync(string? redirectUri = null, string? origin = null) { return PageAction.ToLower() switch { - "sign-in" => SignIn(redirectUri), + "sign-in" => await SignIn(redirectUri, origin), "user-info" => await UserInfo(redirectUri), "sign-out" => await SignOut(), _ => Redirect("/"), @@ -68,8 +71,19 @@ public async Task OnPostAsync(string logout_token) return Page(); } - private IActionResult SignIn(string? redirectUri = null) + private async Task SignIn(string? redirectUri = null, string? origin = null) { + session.Remove(Session.FtsServiceOrigin); + if (!string.IsNullOrWhiteSpace(origin) + && await featureManager.IsEnabledAsync(FeatureFlags.AllowDynamicFtsOrigins)) + { + var allowedOrigins = config["FtsServiceAllowedOrigins"] ?? ""; + if (allowedOrigins.Split(",", StringSplitOptions.RemoveEmptyEntries).Contains(origin)) + { + session.Set(Session.FtsServiceOrigin, origin); + } + } + var uri = "/one-login/user-info"; if (Helper.ValidRelativeUri(redirectUri)) { @@ -84,7 +98,7 @@ private async Task UserInfo(string? redirectUri = null) var userInfo = await httpContextAccessor.HttpContext!.AuthenticateAsync(); if (!userInfo.Succeeded) { - return SignIn(); + return await SignIn(redirectUri); } var urn = userInfo.Principal.FindFirst(JwtClaimTypes.Subject)?.Value; @@ -93,7 +107,7 @@ private async Task UserInfo(string? redirectUri = null) if (urn == null) { - return SignIn(); + return await SignIn(redirectUri); } await logoutManager.RemoveAsLoggedOut(urn); diff --git a/Frontend/CO.CDP.OrganisationApp/Session.cs b/Frontend/CO.CDP.OrganisationApp/Session.cs index 578770e63..166278ca8 100644 --- a/Frontend/CO.CDP.OrganisationApp/Session.cs +++ b/Frontend/CO.CDP.OrganisationApp/Session.cs @@ -8,6 +8,7 @@ public class Session(IHttpContextAccessor httpContextAccessor) : ISession public const string RegistrationDetailsKey = "RegistrationDetails"; public const string ConnectedPersonKey = "ConnectedPerson"; public const string ConsortiumKey = "Consortium"; + public const string FtsServiceOrigin = "FtsServiceOrigin"; public T? Get(string key) { diff --git a/Frontend/CO.CDP.OrganisationApp/appsettings.AwsIntegration.json b/Frontend/CO.CDP.OrganisationApp/appsettings.AwsIntegration.json index 098242d82..f145844d8 100644 --- a/Frontend/CO.CDP.OrganisationApp/appsettings.AwsIntegration.json +++ b/Frontend/CO.CDP.OrganisationApp/appsettings.AwsIntegration.json @@ -4,6 +4,7 @@ "DiagnosticPage": { "Enabled": false, "Path": "" - } + }, + "AllowDynamicFtsOrigins": true } } diff --git a/Frontend/CO.CDP.OrganisationApp/appsettings.json b/Frontend/CO.CDP.OrganisationApp/appsettings.json index 2878d914b..0f19399bc 100644 --- a/Frontend/CO.CDP.OrganisationApp/appsettings.json +++ b/Frontend/CO.CDP.OrganisationApp/appsettings.json @@ -21,6 +21,7 @@ "DataSharingService": "", "EntityVerificationService": "", "FtsService": "", + "FtsServiceAllowedOrigins": "", "OneLogin": { "Authority": "", "ClientId": "", @@ -56,7 +57,8 @@ }, "SharedSessions": false, "Consortium": false, - "ContentSecurityPolicy": true + "ContentSecurityPolicy": true, + "AllowDynamicFtsOrigins": false }, "CompaniesHouse": { "Url": "",