diff --git a/readme.md b/readme.md index 7efee91..8a80911 100644 --- a/readme.md +++ b/readme.md @@ -30,16 +30,19 @@ docker compose -f docker-compose.local.yml up The following appSetting configuration values are supported: -| Name | Description | Default | -| -------------------------------- | ------------------------------------------------------------ | ----------------------------------------------- | -| OrchestratorRoot | Base URI for Orchestrator, used to generate links | | -| DefaultSignificantGestureTitle | Fallback title to use on SignificantGesture.cshtml | `"Click to continue"` | -| DefaultSignificantGestureMessage | Fallback message to use on SignificantGesture.cshtml | `"You will now be redirected to DLCS to login"` | -| Auth__CookieNameFormat | Name of issued cookie, `{0}` value replaced with customer Id | `"dlcs-auth2-{0}` | -| Auth__SessionTtl | Default TTL for sessions + cookies (in seconds) | 600 | -| Auth__CookieDomains | An optional list of domains to issue cookies for | | -| Auth__UseCurrentDomainForCookie | Whether current domain is automatically added to auth token | `true` | - +| Name | Description | Default | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| OrchestratorRoot | Base URI for Orchestrator, used to generate links | | +| DefaultSignificantGestureTitle | Fallback title to use on SignificantGesture.cshtml | `"Click to continue"` | +| DefaultSignificantGestureMessage | Fallback message to use on SignificantGesture.cshtml | `"You will now be redirected to DLCS to login"` | +| Auth__CookieNameFormat | Name of issued cookie, `{0}` value replaced with customer Id | `"dlcs-auth2-{0}` | +| Auth__SessionTtl | Default TTL for sessions + cookies (in seconds) | 600 | +| Auth__RefreshThreshold | UserSession expiry not refreshed if LastChecked within this number of secs | 120 | +| Auth__JwksTtl | How long to cache results of JWKS calls for, in secs | 600 | +| GesturePathTemplateForDomain | Dictionary that allows control of domain-specific significant gesture paths. `{customerId}` replaced. | | +| OAuthCallbackPathTemplateForDomain | Dictionary that allows control of domain-specific oauth2 callback paths. `{customerId}` + `{accessService}` replaced. | | + +> A note on Dictionarys for domain-specific paths. A key of `"Default"` serves as fallback but isn't necessary if the default value matches the canonical DLCS path. ## Migrations diff --git a/src/IIIFAuth2/IIIFAuth2.API.Tests/Infrastructure/Web/UrlPathProviderTests.cs b/src/IIIFAuth2/IIIFAuth2.API.Tests/Infrastructure/Web/UrlPathProviderTests.cs index 97a9825..cbc9d96 100644 --- a/src/IIIFAuth2/IIIFAuth2.API.Tests/Infrastructure/Web/UrlPathProviderTests.cs +++ b/src/IIIFAuth2/IIIFAuth2.API.Tests/Infrastructure/Web/UrlPathProviderTests.cs @@ -20,7 +20,7 @@ public void GetGesturePostbackRelativePath_HandlesNoConfiguredDefault() { [OtherHost] = "/access/specific-host" }; - var sut = GetSut(CurrentHost, gestureTemplates); + var sut = GetSut(CurrentHost, settings => settings.GesturePathTemplateForDomain = gestureTemplates); // Act var result = sut.GetGesturePostbackRelativePath(123); @@ -38,7 +38,7 @@ public void GetGesturePostbackRelativePath_HandlesNoConfiguredDefault_WithPathBa { [OtherHost] = "/access/specific-host" }; - var sut = GetSut(CurrentHost, gestureTemplates, "auth/v2/"); + var sut = GetSut(CurrentHost, settings => settings.GesturePathTemplateForDomain = gestureTemplates, "auth/v2/"); // Act var result = sut.GetGesturePostbackRelativePath(123); @@ -57,7 +57,7 @@ public void GetGesturePostbackRelativePath_UsesConfiguredDefault() ["Default"] = "/access/other", [OtherHost] = "/access/specific-host" }; - var sut = GetSut(CurrentHost, gestureTemplates); + var sut = GetSut(CurrentHost, settings => settings.GesturePathTemplateForDomain = gestureTemplates); // Act var result = sut.GetGesturePostbackRelativePath(123); @@ -76,7 +76,7 @@ public void GetGesturePostbackRelativePath_UsesSpecifiedHost_IfFound() ["Default"] = "/access/other", [CurrentHost] = "/{customerId}/access/gesture" }; - var sut = GetSut(CurrentHost, gestureTemplates); + var sut = GetSut(CurrentHost, settings => settings.GesturePathTemplateForDomain = gestureTemplates); // Act var result = sut.GetGesturePostbackRelativePath(123); @@ -87,21 +87,84 @@ public void GetGesturePostbackRelativePath_UsesSpecifiedHost_IfFound() } [Fact] - public void GetAccessServiceOAuthCallbackPath_Correct() + public void GetAccessServiceOAuthCallbackPath_HandlesNoConfiguredDefault() { // Arrange - var sut = GetSut(CurrentHost); + var templates = new Dictionary + { + [OtherHost] = "/access/specific-host" + }; + var accessService = new AccessService { Customer = 99, Name = "ghosts" }; + var expected = new Uri("https://dlcs.test.example/access/99/ghosts/oauth2/callback"); + var sut = GetSut(CurrentHost, settings => settings.OAuthCallbackPathTemplateForDomain = templates); + + // Act + var result = sut.GetAccessServiceOAuthCallbackPath(accessService); + + // Asset + result.Should().BeEquivalentTo(expected); + } + + [Fact] + public void GetAccessServiceOAuthCallbackPath_HandlesNoConfiguredDefault_WithBaseBase() + { + // Arrange + var templates = new Dictionary + { + [OtherHost] = "/access/specific-host" + }; var accessService = new AccessService { Customer = 99, Name = "ghosts" }; var expected = new Uri("https://dlcs.test.example/auth/v2/access/99/ghosts/oauth2/callback"); + var sut = GetSut(CurrentHost, settings => settings.OAuthCallbackPathTemplateForDomain = templates, "auth/v2/"); + + // Act + var result = sut.GetAccessServiceOAuthCallbackPath(accessService); + + // Asset + result.Should().BeEquivalentTo(expected); + } + + [Fact] + public void GetAccessServiceOAuthCallbackPath_UsesConfiguredDefault() + { + // Arrange + var templates = new Dictionary + { + ["Default"] = "/access/other", + [OtherHost] = "/access/specific-host" + }; + var accessService = new AccessService { Customer = 99, Name = "ghosts" }; + var expected = new Uri("https://dlcs.test.example/access/other"); + var sut = GetSut(CurrentHost, settings => settings.OAuthCallbackPathTemplateForDomain = templates, "auth/v2/"); // Act var result = sut.GetAccessServiceOAuthCallbackPath(accessService); - // Assert + // Asset result.Should().BeEquivalentTo(expected); } - private UrlPathProvider GetSut(string host, Dictionary? gestureTemplates = null, string? pathBase = null) + [Fact] + public void GetAccessServiceOAuthCallbackPath_UsesSpecifiedHost_IfFound() + { + // Arrange + var templates = new Dictionary + { + ["Default"] = "/access/other", + [CurrentHost] = "/{customerId}/callback/{accessService}" + }; + var accessService = new AccessService { Customer = 99, Name = "ghosts" }; + var expected = new Uri("https://dlcs.test.example/99/callback/ghosts"); + var sut = GetSut(CurrentHost, settings => settings.OAuthCallbackPathTemplateForDomain = templates, "auth/v2/"); + + // Act + var result = sut.GetAccessServiceOAuthCallbackPath(accessService); + + // Asset + result.Should().BeEquivalentTo(expected); + } + + private UrlPathProvider GetSut(string host, Action? settingsConfig = null, string? pathBase = null) { var context = new DefaultHttpContext(); var request = context.Request; @@ -110,7 +173,8 @@ private UrlPathProvider GetSut(string host, Dictionary? gestureT var contextAccessor = A.Fake(); A.CallTo(() => contextAccessor.HttpContext).Returns(context); - var authSettings = new AuthSettings { GesturePathTemplateForDomain = gestureTemplates ?? new() }; + var authSettings = new AuthSettings(); + settingsConfig?.Invoke(authSettings); var apiSettings = Options.Create(new ApiSettings { Auth = authSettings, PathBase = pathBase }); return new UrlPathProvider(contextAccessor, apiSettings); diff --git a/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs b/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs index 0e292d5..e04f4ff 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs +++ b/src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs @@ -46,6 +46,7 @@ public class UrlPathProvider : IUrlPathProvider { private readonly IHttpContextAccessor httpContextAccessor; private readonly ApiSettings apiSettings; + private readonly Regex duplicateSlashRegex = new("(/)+", RegexOptions.Compiled); public UrlPathProvider(IHttpContextAccessor httpContextAccessor, IOptions apiOptions) { @@ -95,13 +96,19 @@ public Uri GetAccessServiceLogoutPath(AccessService accessService) /// public Uri GetAccessServiceOAuthCallbackPath(AccessService accessService) { + const string defaultPathTemplate = "/access/{customerId}/{accessService}/oauth2/callback"; + + var template = GetTemplate(apiSettings.Auth.OAuthCallbackPathTemplateForDomain, defaultPathTemplate); + var populatedTemplate = template + .Replace("{customerId}", accessService.Customer.ToString()) + .Replace("{accessService}", accessService.Name); + var baseUrl = GetCurrentBaseUrl(); - var path = $"/auth/v2/access/{accessService.Customer}/{accessService.Name}/oauth2/callback"; var builder = new UriBuilder(baseUrl) { - Path = path + Path = populatedTemplate }; - + return builder.Uri; } @@ -121,25 +128,18 @@ public Uri GetAccessTokenServicePath(int customerId) /// 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()); + const string defaultPathTemplate = "/access/{customerId}/gesture"; + + var template = GetTemplate(apiSettings.Auth.GesturePathTemplateForDomain, defaultPathTemplate); + var populatedTemplate = template.Replace("{customerId}", customerId.ToString()); + return new Uri(populatedTemplate, UriKind.Relative); } - private string GetTemplate(string host) + private string GetTemplate(Dictionary pathTemplates, string defaultPathTemplate) { - const string defaultPathTemplate = "/access/{customerId}/gesture"; const string defaultKey = "Default"; - - var pathTemplates = apiSettings.Auth.GesturePathTemplateForDomain; + var request = httpContextAccessor.SafeHttpContext().Request; + var host = request.Host.Value; if (pathTemplates.TryGetValue(host, out var hostTemplate)) return hostTemplate; if (pathTemplates.TryGetValue(defaultKey, out var pathTemplate)) return pathTemplate; @@ -147,7 +147,6 @@ private string GetTemplate(string host) // Replace any duplicate slashes after joining path elements var candidate = $"{apiSettings.PathBase}/{defaultPathTemplate}"; - var duplicateSlashRegex = new Regex("(/)+", RegexOptions.Compiled); return duplicateSlashRegex.Replace(candidate, "$1"); } diff --git a/src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs b/src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs index 24a3970..0dbd9d1 100644 --- a/src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs +++ b/src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs @@ -47,5 +47,17 @@ public class AuthSettings /// public int JwksTtl { get; set; } = 600; + /// + /// Dictionary that allows control of domain-specific significant gesture paths. Default value is + /// /access/{customerId}/gesture. + /// Replacement values: {customerId} + /// public Dictionary GesturePathTemplateForDomain { get; set; } = new(); + + /// + /// Dictionary that allows control of domain-specific oauth callback paths. Default value is + /// /access/{customerId}/{accessService}/oauth2/callback. + /// Replacement values: {customerId} and {accessService} + /// + public Dictionary OAuthCallbackPathTemplateForDomain { get; set; } = new(); } \ No newline at end of file