Skip to content

Commit

Permalink
Merge pull request #33 from dlcs/feature/gesture_path
Browse files Browse the repository at this point in the history
Add customerId to gesture path
  • Loading branch information
donaldgray authored Oct 27, 2023
2 parents 6fbab6b + 40bc0f9 commit b763923
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 23 deletions.
2 changes: 1 addition & 1 deletion docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>
{
[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<string, string>
{
[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<string, string>
{
["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<string, string>
{
["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<string, string> gestureTemplates, string? pathBase = null)
{
var context = new DefaultHttpContext();
var request = context.Request;
request.Host = new HostString(host);
request.Scheme = "https";
var contextAccessor = A.Fake<IHttpContextAccessor>();
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<string>()));
await dbContext.SaveChangesAsync();
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
20 changes: 20 additions & 0 deletions src/IIIFAuth2/IIIFAuth2.API.Tests/Utils/HttpRequestXTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public async Task<IActionResult> AccessService(
/// This is required for us to issue a cookie to user.
/// </summary>
[HttpPost]
[Route("gesture")]
[Route("{customerId}/gesture")]
public async Task<IActionResult> SignificantGesture(
[FromForm] string singleUseToken,
CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
</head>
<body>
<p>@Model.SignificantGestureMessage</p>
@using (Html.BeginForm("SignificantGesture", "Access"))
{
<form action="@Model.PostbackUri.ToString()" method="post">
@Html.HiddenFor(m => m.SingleUseToken)
<input type="submit">
}
</form>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// </summary>
/// <param name="PostbackUri">Relative Uri to post message back to. Can differ depending on hostname</param>
/// <param name="SignificantGestureTitle">Title of page</param>
/// <param name="SignificantGestureMessage">Information message to display to user</param>
/// <param name="SingleUseToken">
/// Single use correlation id, from this we can lookup CustomerId, AccessServiceName + Roles to grant to user.
/// </param>
public record SignificantGestureModel(
Uri PostbackUri,
string SignificantGestureTitle,
string SignificantGestureMessage,
string SingleUseToken);
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,16 +17,19 @@ public class ClickthroughRoleProviderHandler : IRoleProviderHandler
private readonly SessionManagementService sessionManagementService;
private readonly ILogger<ClickthroughRoleProviderHandler> logger;
private readonly ApiSettings apiSettings;
private readonly IUrlPathProvider urlPathProvider;

public ClickthroughRoleProviderHandler(
AuthServicesContext dbContext,
SessionManagementService sessionManagementService,
IUrlPathProvider urlPathProvider,
IOptions<ApiSettings> apiOptions,
ILogger<ClickthroughRoleProviderHandler> logger)
{
this.dbContext = dbContext;
this.sessionManagementService = sessionManagementService;
this.logger = logger;
this.urlPathProvider = urlPathProvider;
apiSettings = apiOptions.Value;
}

Expand Down Expand Up @@ -79,7 +83,9 @@ private async Task<SignificantGestureModel> 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);
Expand Down
51 changes: 46 additions & 5 deletions src/IIIFAuth2/IIIFAuth2.API/Infrastructure/Web/UrlPathProvider.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -26,7 +27,14 @@ public interface IUrlPathProvider
/// <summary>
/// Get the path for AccessService
/// </summary>
Uri GetAccessTokenServicePath(int customer);
Uri GetAccessTokenServicePath(int customerId);

/// <summary>
/// Get the relative Uri for posting back
/// </summary>
/// <param name="customerId"></param>
/// <returns></returns>
Uri GetGesturePostbackRelativePath(int customerId);
}

public class UrlPathProvider : IUrlPathProvider
Expand Down Expand Up @@ -80,10 +88,10 @@ public Uri GetAccessServiceLogoutPath(AccessService accessService)
}

/// <inheritdoc />
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
Expand All @@ -92,6 +100,39 @@ public Uri GetAccessTokenServicePath(int customer)
return builder.Uri;
}

/// <inheritdoc />
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);
}
4 changes: 4 additions & 0 deletions src/IIIFAuth2/IIIFAuth2.API/Settings/ApiSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public class ApiSettings
/// <remarks>Used to generate Probe request paths</remarks>
public Uri OrchestratorRoot { get; set; } = null!;

public AuthSettings Auth { get; set; } = new();

/// <summary>
/// Fallback title for Significant Gesture view, if none specified by RoleProvider
/// </summary>
Expand Down Expand Up @@ -39,4 +41,6 @@ public class AuthSettings
/// </summary>
/// <remarks>This avoids constant churn in db</remarks>
public int RefreshThreshold { get; set; } = 120;

public Dictionary<string, string> GesturePathTemplateForDomain { get; set; } = new();
}
Loading

0 comments on commit b763923

Please sign in to comment.