diff --git a/docker-compose.local.yml b/docker-compose.local.yml index b46be1c..b76969c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -9,7 +9,7 @@ services: image: postgres:14 hostname: postgres ports: - - "5433:5432" + - "5430:5432" volumes: - auth_postgres_data:/var/lib/postgresql/data - auth_postgres_data_backups:/backups diff --git a/src/IIIFAuth2/IIIFAuth2.API.Tests/Infrastructure/Web/UrlPathProviderTests.cs b/src/IIIFAuth2/IIIFAuth2.API.Tests/Infrastructure/Web/UrlPathProviderTests.cs new file mode 100644 index 0000000..107e79e --- /dev/null +++ b/src/IIIFAuth2/IIIFAuth2.API.Tests/Infrastructure/Web/UrlPathProviderTests.cs @@ -0,0 +1,102 @@ +using FakeItEasy; +using IIIFAuth2.API.Infrastructure.Web; +using IIIFAuth2.API.Settings; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace IIIFAuth2.API.Tests.Infrastructure.Web; + +public class UrlPathProviderTests +{ + private const string CurrentHost = "dlcs.test.example"; + private const string OtherHost = "dlcs.test.other"; + + [Fact] + public void GetGesturePostbackRelativePath_HandlesNoConfiguredDefault() + { + // Arrange + var gestureTemplates = new Dictionary + { + [OtherHost] = "/access/specific-host" + }; + var sut = GetSut(CurrentHost, gestureTemplates); + + // Act + var result = sut.GetGesturePostbackRelativePath(123); + + // Asset + result.IsAbsoluteUri.Should().BeFalse(); + result.ToString().Should().Be("/access/123/gesture"); + } + + [Fact] + public void GetGesturePostbackRelativePath_HandlesNoConfiguredDefault_WithPathBase() + { + // Arrange + var gestureTemplates = new Dictionary + { + [OtherHost] = "/access/specific-host" + }; + var sut = GetSut(CurrentHost, gestureTemplates, "auth/v2/"); + + // Act + var result = sut.GetGesturePostbackRelativePath(123); + + // Asset + result.IsAbsoluteUri.Should().BeFalse(); + result.ToString().Should().Be("auth/v2/access/123/gesture"); + } + + [Fact] + public void GetGesturePostbackRelativePath_UsesConfiguredDefault() + { + // Arrange + var gestureTemplates = new Dictionary + { + ["Default"] = "/access/other", + [OtherHost] = "/access/specific-host" + }; + var sut = GetSut(CurrentHost, gestureTemplates); + + // Act + var result = sut.GetGesturePostbackRelativePath(123); + + // Asset + result.IsAbsoluteUri.Should().BeFalse(); + result.ToString().Should().Be("/access/other"); + } + + [Fact] + public void GetGesturePostbackRelativePath_UsesSpecifiedHost_IfFound() + { + // Arrange + var gestureTemplates = new Dictionary + { + ["Default"] = "/access/other", + [CurrentHost] = "/{customerId}/access/gesture" + }; + var sut = GetSut(CurrentHost, gestureTemplates); + + // Act + var result = sut.GetGesturePostbackRelativePath(123); + + // Asset + result.IsAbsoluteUri.Should().BeFalse(); + result.ToString().Should().Be("/123/access/gesture"); + } + + private UrlPathProvider GetSut(string host, Dictionary gestureTemplates, string? pathBase = null) + { + var context = new DefaultHttpContext(); + var request = context.Request; + request.Host = new HostString(host); + request.Scheme = "https"; + var contextAccessor = A.Fake(); + A.CallTo(() => contextAccessor.HttpContext).Returns(context); + + var authSettings = new AuthSettings { GesturePathTemplateForDomain = gestureTemplates }; + var apiSettings = Options.Create(new ApiSettings { Auth = authSettings, PathBase = pathBase }); + + return new UrlPathProvider(contextAccessor, apiSettings); + } +} \ No newline at end of file diff --git a/src/IIIFAuth2/IIIFAuth2.API.Tests/Integration/AccessServiceTests.cs b/src/IIIFAuth2/IIIFAuth2.API.Tests/Integration/AccessServiceTests.cs index d48a8c6..65ef5f8 100644 --- a/src/IIIFAuth2/IIIFAuth2.API.Tests/Integration/AccessServiceTests.cs +++ b/src/IIIFAuth2/IIIFAuth2.API.Tests/Integration/AccessServiceTests.cs @@ -233,7 +233,7 @@ public async Task AccessService_Clickthrough_RendersWindowClose_IfSameHost() public async Task SignificantGesture_Returns400_IfNoSingleUseToken() { // Arrange - const string path = "/access/gesture"; + const string path = "/access/99/gesture"; // Act var formContent = new FormUrlEncodedContent(new[] @@ -250,7 +250,7 @@ public async Task SignificantGesture_Returns400_IfNoSingleUseToken() public async Task SignificantGesture_RendersWindowClose_WithError_IfTokenExpired() { // Arrange - const string path = "/access/gesture"; + const string path = "/access/99/gesture"; var expiredToken = ExpiringToken.GenerateNewToken(DateTime.UtcNow.AddHours(-1)); // Act @@ -270,7 +270,7 @@ public async Task SignificantGesture_RendersWindowClose_WithError_IfTokenExpired public async Task SignificantGesture_RendersWindowClose_WithError_IfTokenValidButNotInDatabase() { // Arrange - const string path = "/access/gesture"; + const string path = "/access/99/gesture"; var expiredToken = ExpiringToken.GenerateNewToken(DateTime.UtcNow.AddHours(1)); // Act @@ -290,7 +290,7 @@ public async Task SignificantGesture_RendersWindowClose_WithError_IfTokenValidBu public async Task SignificantGesture_RendersWindowClose_WithError_IfTokenValidButUsed() { // Arrange - const string path = "/access/gesture"; + const string path = "/access/99/gesture"; var validToken = ExpiringToken.GenerateNewToken(DateTime.UtcNow.AddHours(1)); await dbContext.RoleProvisionTokens.AddAsync(CreateToken(validToken, true, Array.Empty())); await dbContext.SaveChangesAsync(); @@ -312,7 +312,7 @@ public async Task SignificantGesture_RendersWindowClose_WithError_IfTokenValidBu public async Task SignificantGesture_CreatesSession_AndSetsCookie_AndMarksTokenAsUsed() { // Arrange - const string path = "/access/gesture"; + const string path = "/access/99/gesture"; var validToken = ExpiringToken.GenerateNewToken(DateTime.UtcNow.AddHours(1)); var roles = new string[] { DatabaseFixture.ClickthroughRoleUri }; var tokenEntity = await dbContext.RoleProvisionTokens.AddAsync(CreateToken(validToken, false, roles)); diff --git a/src/IIIFAuth2/IIIFAuth2.API.Tests/Models/Converters/AccessServiceConverterTests.cs b/src/IIIFAuth2/IIIFAuth2.API.Tests/Models/Converters/AccessServiceConverterTests.cs index a742b97..8a84303 100644 --- a/src/IIIFAuth2/IIIFAuth2.API.Tests/Models/Converters/AccessServiceConverterTests.cs +++ b/src/IIIFAuth2/IIIFAuth2.API.Tests/Models/Converters/AccessServiceConverterTests.cs @@ -226,7 +226,10 @@ public Uri GetAccessServicePath(AccessService accessService) public Uri GetAccessServiceLogoutPath(AccessService accessService) => new($"http://test.example/access/{accessService.Name}/logout"); - public Uri GetAccessTokenServicePath(int customer) - => new($"http://test.example/token/{customer}"); + public Uri GetAccessTokenServicePath(int customerId) + => new($"http://test.example/token/{customerId}"); + + public Uri GetGesturePostbackRelativePath(int customerId) + => new($"/access/{customerId}/gesture", UriKind.Relative); } } \ No newline at end of file diff --git a/src/IIIFAuth2/IIIFAuth2.API.Tests/Utils/HttpRequestXTests.cs b/src/IIIFAuth2/IIIFAuth2.API.Tests/Utils/HttpRequestXTests.cs index 62d9f2d..78f2a24 100644 --- a/src/IIIFAuth2/IIIFAuth2.API.Tests/Utils/HttpRequestXTests.cs +++ b/src/IIIFAuth2/IIIFAuth2.API.Tests/Utils/HttpRequestXTests.cs @@ -121,6 +121,26 @@ public void GetDisplayUrl_WithPathBase_ReturnsFullUrl_WithoutQueryParam_WhenCall // Assert result.Should().Be(expected); } + + [Fact] + public void GetDisplayUrl_WithPathBase_ReturnsFullUrl_WithoutQueryParam_WhenCalledWithDoNotIncludeHost() + { + // Arrange + var httpRequest = new DefaultHttpContext().Request; + httpRequest.Path = new PathString("/anything"); + httpRequest.QueryString = new QueryString("?foo=bar"); + httpRequest.Host = new HostString("test.example"); + httpRequest.PathBase = new PathString("/v2"); + httpRequest.Scheme = "https"; + + const string expected = "/v2/test"; + + // Act + var result = httpRequest.GetDisplayUrl("/test", includeQueryParams: false, includeHost: false); + + // Assert + result.Should().Be(expected); + } [Theory] [InlineData("https://test.example")] diff --git a/src/IIIFAuth2/IIIFAuth2.API/Features/Access/AccessController.cs b/src/IIIFAuth2/IIIFAuth2.API/Features/Access/AccessController.cs index 2eaa54b..5073332 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Features/Access/AccessController.cs +++ b/src/IIIFAuth2/IIIFAuth2.API/Features/Access/AccessController.cs @@ -57,7 +57,7 @@ public async Task AccessService( /// This is required for us to issue a cookie to user. /// [HttpPost] - [Route("gesture")] + [Route("{customerId}/gesture")] public async Task SignificantGesture( [FromForm] string singleUseToken, CancellationToken cancellationToken) diff --git a/src/IIIFAuth2/IIIFAuth2.API/Features/Access/Views/SignificantGesture.cshtml b/src/IIIFAuth2/IIIFAuth2.API/Features/Access/Views/SignificantGesture.cshtml index 6e0d5b9..8455cf5 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Features/Access/Views/SignificantGesture.cshtml +++ b/src/IIIFAuth2/IIIFAuth2.API/Features/Access/Views/SignificantGesture.cshtml @@ -8,10 +8,9 @@

@Model.SignificantGestureMessage

-@using (Html.BeginForm("SignificantGesture", "Access")) -{ +
@Html.HiddenFor(m => m.SingleUseToken) -} +
\ No newline at end of file diff --git a/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/Models/SignificantGestureModel.cs b/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/Models/SignificantGestureModel.cs index dab3009..224c74e 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/Models/SignificantGestureModel.cs +++ b/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/Models/SignificantGestureModel.cs @@ -5,12 +5,14 @@ namespace IIIFAuth2.API.Infrastructure.Auth.Models; /// domain for a cookie to be issued, this captures a confirmation click to allow DLCS to issue a token that the browser /// will honour /// +/// Relative Uri to post message back to. Can differ depending on hostname /// Title of page /// Information message to display to user /// /// Single use correlation id, from this we can lookup CustomerId, AccessServiceName + Roles to grant to user. /// public record SignificantGestureModel( + Uri PostbackUri, string SignificantGestureTitle, string SignificantGestureMessage, string SingleUseToken); \ No newline at end of file diff --git a/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/RoleProvisioning/ClickthroughRoleProviderHandler.cs b/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/RoleProvisioning/ClickthroughRoleProviderHandler.cs index 19a12cd..103c1f5 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/RoleProvisioning/ClickthroughRoleProviderHandler.cs +++ b/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Auth/RoleProvisioning/ClickthroughRoleProviderHandler.cs @@ -1,6 +1,7 @@ using IIIFAuth2.API.Data; using IIIFAuth2.API.Data.Entities; using IIIFAuth2.API.Infrastructure.Auth.Models; +using IIIFAuth2.API.Infrastructure.Web; using IIIFAuth2.API.Models.Domain; using IIIFAuth2.API.Settings; using Microsoft.Extensions.Options; @@ -16,16 +17,19 @@ public class ClickthroughRoleProviderHandler : IRoleProviderHandler private readonly SessionManagementService sessionManagementService; private readonly ILogger logger; private readonly ApiSettings apiSettings; + private readonly IUrlPathProvider urlPathProvider; public ClickthroughRoleProviderHandler( AuthServicesContext dbContext, SessionManagementService sessionManagementService, + IUrlPathProvider urlPathProvider, IOptions apiOptions, ILogger logger) { this.dbContext = dbContext; this.sessionManagementService = sessionManagementService; this.logger = logger; + this.urlPathProvider = urlPathProvider; apiSettings = apiOptions.Value; } @@ -79,7 +83,9 @@ private async Task GetSignificantGestureModel(int custo { var expiringToken = await sessionManagementService.CreateRoleProvisionToken(customerId, roles, origin, cancellationToken); + var relativePath = urlPathProvider.GetGesturePostbackRelativePath(customerId); var gestureModel = new SignificantGestureModel( + relativePath, configuration.GestureTitle ?? apiSettings.DefaultSignificantGestureTitle, configuration.GestureMessage ?? apiSettings.DefaultSignificantGestureMessage, expiringToken); diff --git a/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs b/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs index 999d851..3cef712 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs +++ b/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs @@ -1,4 +1,5 @@ -using IIIFAuth2.API.Data.Entities; +using System.Text.RegularExpressions; +using IIIFAuth2.API.Data.Entities; using IIIFAuth2.API.Models.Domain; using IIIFAuth2.API.Settings; using IIIFAuth2.API.Utils; @@ -26,7 +27,14 @@ public interface IUrlPathProvider /// /// Get the path for AccessService /// - Uri GetAccessTokenServicePath(int customer); + Uri GetAccessTokenServicePath(int customerId); + + /// + /// Get the relative Uri for posting back + /// + /// + /// + Uri GetGesturePostbackRelativePath(int customerId); } public class UrlPathProvider : IUrlPathProvider @@ -80,10 +88,10 @@ public Uri GetAccessServiceLogoutPath(AccessService accessService) } /// - public Uri GetAccessTokenServicePath(int customer) + public Uri GetAccessTokenServicePath(int customerId) { var baseUrl = GetCurrentBaseUrl(); - var path = $"/auth/v2/access/{customer}/token"; + var path = $"/auth/v2/access/{customerId}/token"; var builder = new UriBuilder(baseUrl) { Path = path @@ -92,6 +100,39 @@ public Uri GetAccessTokenServicePath(int customer) return builder.Uri; } + /// + public Uri GetGesturePostbackRelativePath(int customerId) + { + var request = httpContextAccessor.SafeHttpContext().Request; + var host = request.Host.Value; + + var template = GetPopulatedTemplate(host, customerId); + return new Uri(template, UriKind.Relative); + } + + private string GetPopulatedTemplate(string host, int customerId) + { + var template = GetTemplate(host); + return template.Replace("{customerId}", customerId.ToString()); + } + + private string GetTemplate(string host) + { + const string defaultPathTemplate = "/access/{customerId}/gesture"; + const string defaultKey = "Default"; + + var pathTemplates = apiSettings.Auth.GesturePathTemplateForDomain; + + if (pathTemplates.TryGetValue(host, out var hostTemplate)) return hostTemplate; + if (pathTemplates.TryGetValue(defaultKey, out var pathTemplate)) return pathTemplate; + if (apiSettings.PathBase.IsNullOrEmpty()) return defaultPathTemplate; + + // Replace any duplicate slashes after joining path elements + var candidate = $"{apiSettings.PathBase}/{defaultPathTemplate}"; + var duplicateSlashRegex = new Regex("(/)+", RegexOptions.Compiled); + return duplicateSlashRegex.Replace(candidate, "$1"); + } + private string GetCurrentBaseUrl() => - httpContextAccessor.HttpContext?.Request.GetDisplayUrl(null, false) ?? string.Empty; + httpContextAccessor.SafeHttpContext().Request.GetDisplayUrl(null, false); } diff --git a/src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs b/src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs index 6266898..9fc2b52 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs +++ b/src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs @@ -10,6 +10,8 @@ public class ApiSettings /// Used to generate Probe request paths public Uri OrchestratorRoot { get; set; } = null!; + public AuthSettings Auth { get; set; } = new(); + /// /// Fallback title for Significant Gesture view, if none specified by RoleProvider /// @@ -39,4 +41,6 @@ public class AuthSettings /// /// This avoids constant churn in db public int RefreshThreshold { get; set; } = 120; + + public Dictionary GesturePathTemplateForDomain { get; set; } = new(); } \ No newline at end of file diff --git a/src/IIIFAuth2/IIIFAuth2.API/Utils/HttpRequestX.cs b/src/IIIFAuth2/IIIFAuth2.API/Utils/HttpRequestX.cs index 9cd9610..dbab582 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Utils/HttpRequestX.cs +++ b/src/IIIFAuth2/IIIFAuth2.API/Utils/HttpRequestX.cs @@ -5,21 +5,24 @@ namespace IIIFAuth2.API.Utils; public static class HttpRequestX { private const string SchemeDelimiter = "://"; - + /// /// Generate a full display URL, deriving values from specified HttpRequest /// /// HttpRequest to generate display URL for /// Path to append to URL /// If true, query params are included in path. Else they are omitted + /// If true, host and scheme are included in path. Else it is omitted /// Full URL, including scheme, host, pathBase, path and queryString /// /// based on Microsoft.AspNetCore.Http.Extensions.UriHelper.GetDisplayUrl(this HttpRequest request) /// - public static string GetDisplayUrl(this HttpRequest request, string? path = null, bool includeQueryParams = true) + public static string GetDisplayUrl(this HttpRequest request, string? path = null, bool includeQueryParams = true, + bool includeHost = true) { - var host = request.Host.Value ?? string.Empty; - var scheme = request.Scheme ?? string.Empty; + var host = includeHost ? request.Host.Value : string.Empty; + var scheme = includeHost ? request.Scheme : string.Empty; + var schemeDelimiterToUse = includeHost ? SchemeDelimiter : string.Empty; var pathBase = request.PathBase.Value ?? string.Empty; var queryString = includeQueryParams ? request.QueryString.Value ?? string.Empty @@ -27,12 +30,12 @@ public static string GetDisplayUrl(this HttpRequest request, string? path = null var pathElement = path ?? string.Empty; // PERF: Calculate string length to allocate correct buffer size for StringBuilder. - var length = scheme.Length + SchemeDelimiter.Length + host.Length + var length = scheme.Length + schemeDelimiterToUse.Length + host.Length + pathBase.Length + pathElement.Length + queryString.Length; return new StringBuilder(length) .Append(scheme) - .Append(SchemeDelimiter) + .Append(schemeDelimiterToUse) .Append(host) .Append(pathBase) .Append(path)