Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public virtual async ValueTask InitializeAsync()
String dbConnString = GetLocalConnectionString($"TransactionProcessorReadModel-{this.TestId}");

this.factory = new CustomWebApplicationFactory<Startup>(dbConnString);
this.factory.DefaultEstateId = this.TestId;
this.Client = this.factory.CreateClient();

this.context = new EstateManagementContext(dbConnString);
Expand Down
36 changes: 34 additions & 2 deletions EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
Expand Down Expand Up @@ -32,6 +33,7 @@ public CustomWebApplicationFactory(string databaseConnectionString)
}

public string DefaultUserId { get; set; } = "1";
public Guid DefaultEstateId { get; set; }

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Expand Down Expand Up @@ -67,7 +69,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)

containerBuilder.AddSingleton(manager);

containerBuilder.Configure<TestAuthHandlerOptions>(options => options.DefaultUserId = DefaultUserId);
containerBuilder.Configure<TestAuthHandlerOptions>(options =>
{
options.DefaultUserId = DefaultUserId;
options.DefaultEstateId = DefaultEstateId;
});

containerBuilder.AddAuthentication(TestAuthHandler.AuthenticationScheme)
.AddScheme<TestAuthHandlerOptions, TestAuthHandler>(TestAuthHandler.AuthenticationScheme, options => { });
Expand All @@ -82,14 +88,19 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
public class TestAuthHandlerOptions : AuthenticationSchemeOptions
{
public string DefaultUserId { get; set; } = null!;
public Guid DefaultEstateId { get; set; }
}

public class TestAuthHandler : AuthenticationHandler<TestAuthHandlerOptions>
{
public const string UserId = "UserId";
public const string OmitEstateClaim = "Test-Omit-Estate-Claim";
public const string EstateClaim = "Test-Estate-Claim";
public const string EstateClaimType = "Test-Estate-Claim-Type";

public const string AuthenticationScheme = "Test";
private readonly string _defaultUserId;
private readonly Guid _defaultEstateId;

public TestAuthHandler(
IOptionsMonitor<TestAuthHandlerOptions> options,
Expand All @@ -98,10 +109,18 @@ public TestAuthHandler(
ISystemClock clock) : base(options, logger, encoder, clock)
{
_defaultUserId = options.CurrentValue.DefaultUserId;
_defaultEstateId = options.CurrentValue.DefaultEstateId;
}

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (Context.Request.Headers.TryGetValue("Authorization", out var authorization) == false ||
AuthenticationHeaderValue.TryParse(authorization.ToString(), out AuthenticationHeaderValue? authenticationHeader) == false ||
String.Equals(authenticationHeader.Scheme, AuthenticationScheme, StringComparison.OrdinalIgnoreCase) == false)
{
return Task.FromResult(AuthenticateResult.NoResult());
}

var claims = new List<Claim> { new Claim(ClaimTypes.Name, "Test user") };

// Extract User ID from the request headers if it exists,
Expand All @@ -115,6 +134,19 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
claims.Add(new Claim(ClaimTypes.NameIdentifier, _defaultUserId));
}

if (Context.Request.Headers.ContainsKey(OmitEstateClaim) == false)
{
string estateClaim = Context.Request.Headers.TryGetValue(EstateClaim, out var requestedEstateClaim)
? requestedEstateClaim.ToString()
: _defaultEstateId.ToString();

string estateClaimType = Context.Request.Headers.TryGetValue(EstateClaimType, out var requestedEstateClaimType)
? requestedEstateClaimType.ToString()
: "estateId";

claims.Add(new Claim(estateClaimType, estateClaim));
}

// TODO: Add as many claims as you need here

var identity = new ClaimsIdentity(claims, AuthenticationScheme);
Expand All @@ -125,4 +157,4 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()

return Task.FromResult(result);
}
}
}
109 changes: 109 additions & 0 deletions EstateReportingAPI.IntegrationTests/EstateEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using EstateReportingAPI.DataTransferObjects;
Expand Down Expand Up @@ -54,6 +57,112 @@ public async Task EstateEndpoint_GetEstateOperator_EstateOperatorsReturned() {
estateOperators.Single(e => e.Name == "Voucher").OperatorId.ShouldBe(this.context.Operators.Single(o => o.Name == "Voucher").OperatorId);
}

[Fact]
public async Task EstateEndpoint_MatchingEstateClaimAndHeader_ReturnsEstate()
{
await this.helper.AddEstate("Test Estate", "Ref1");

using HttpResponseMessage response = await SendEstateRequest(this.TestId.ToString());

response.StatusCode.ShouldBe(HttpStatusCode.OK);
}

[Fact]
public async Task EstateEndpoint_ValidEstateClaimWithoutHeader_ReturnsEstate()
{
await this.helper.AddEstate("Test Estate", "Ref1");

using HttpResponseMessage response = await SendEstateRequest();

response.StatusCode.ShouldBe(HttpStatusCode.OK);
}

[Fact]
public async Task EstateEndpoint_DifferentEstateHeader_ReturnsForbidden()
{
using HttpResponseMessage response = await SendEstateRequest(Guid.NewGuid().ToString());

response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}

[Fact]
public async Task EstateEndpoint_MalformedEstateHeader_ReturnsForbidden()
{
using HttpResponseMessage response = await SendEstateRequest("not-a-guid");

response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}

[Fact]
public async Task EstateEndpoint_MissingEstateClaim_ReturnsForbidden()
{
using HttpResponseMessage response = await SendEstateRequest(omitEstateClaim: true);

response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}

[Fact]
public async Task EstateEndpoint_MalformedEstateClaim_ReturnsForbidden()
{
using HttpResponseMessage response = await SendEstateRequest(estateClaim: "not-a-guid");

response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}

[Fact]
public async Task EstateEndpoint_EmptyEstateClaim_ReturnsForbidden()
{
using HttpResponseMessage response = await SendEstateRequest(estateClaim: Guid.Empty.ToString());

response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}

[Fact]
public async Task EstateEndpoint_EstateIdClaim_ReturnsEstate()
{
await this.helper.AddEstate("Test Estate", "Ref1");

using HttpResponseMessage response = await SendEstateRequest(
estateHeader: this.TestId.ToString(),
estateClaimType: "estateId");

response.StatusCode.ShouldBe(HttpStatusCode.OK);
}

[Fact]
public async Task EstateEndpoint_MissingAuthentication_ReturnsUnauthorized()
{
using HttpRequestMessage request = new(HttpMethod.Get, this.BaseRoute);

using HttpResponseMessage response = await this.Client.SendAsync(request);

response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}

private async Task<HttpResponseMessage> SendEstateRequest(
string? estateHeader = null,
bool omitEstateClaim = false,
string? estateClaim = null,
string? estateClaimType = null)
{
using HttpRequestMessage request = new(HttpMethod.Get, this.BaseRoute);
request.Headers.Authorization = new AuthenticationHeaderValue("Test");

if (estateHeader != null)
request.Headers.Add("estateId", estateHeader);

if (omitEstateClaim)
request.Headers.Add(TestAuthHandler.OmitEstateClaim, "true");

if (estateClaim != null)
request.Headers.Add(TestAuthHandler.EstateClaim, estateClaim);

if (estateClaimType != null)
request.Headers.Add(TestAuthHandler.EstateClaimType, estateClaimType);

return await this.Client.SendAsync(request);
}

protected override async Task ClearStandingData() {

}
Expand Down
56 changes: 56 additions & 0 deletions EstateReportingAPI.IntegrationTests/TenantContextEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Xunit;
using Shouldly;

namespace EstateReportingAPI.IntegrationTests;

public sealed class TenantContextEndpointTests : ControllerTestsBase
{
[Theory]
[InlineData("api/calendars/comparisondates")]
[InlineData("api/contracts")]
[InlineData("api/estates")]
[InlineData("api/fileimportlogs")]
[InlineData("api/fileprofiles")]
[InlineData("api/merchants")]
[InlineData("api/operators")]
[InlineData("api/settlements/todayssettlements")]
[InlineData("api/transactions/todayssales")]
public async Task ProtectedEndpoint_DifferentEstateHeader_ReturnsForbidden(string route)
{
using HttpRequestMessage request = new(HttpMethod.Get, route);
request.Headers.Authorization = new AuthenticationHeaderValue("Test");
request.Headers.Add("estateId", Guid.NewGuid().ToString());

using HttpResponseMessage response = await this.Client.SendAsync(request);

response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}

[Theory]
[InlineData("health")]
[InlineData("healthui")]
public async Task HealthEndpoint_DoesNotRequireEstateContext(string route)
{
using HttpRequestMessage request = new(HttpMethod.Get, route);

using HttpResponseMessage response = await this.Client.SendAsync(request);

((int)response.StatusCode).ShouldNotBe((int)HttpStatusCode.Unauthorized);
((int)response.StatusCode).ShouldNotBe((int)HttpStatusCode.Forbidden);
}

protected override Task ClearStandingData()
{
return Task.CompletedTask;
}

protected override Task SetupStandingData()
{
return Task.CompletedTask;
}
}
6 changes: 4 additions & 2 deletions EstateReportingAPI/Bootstrapper/MiddlewareRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Shared.Middleware;

namespace EstateReportingAPI.Bootstrapper{
using EstateReportingAPI.Common;
using Lamar;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using OpenIddict.Validation.AspNetCore;
Expand All @@ -21,6 +22,8 @@ public MiddlewareRegistry()
this.ConfigureMiddlewareLogging();
this.ConfigureJsonOptions();
this.AddHealthMonitoringRegistration(Startup.Configuration);
this.AddScoped<EstateContext>();
this.AddScoped<IEstateContext>(sp => sp.GetRequiredService<EstateContext>());

}

Expand Down Expand Up @@ -55,7 +58,6 @@ private void ConfigureAuthentication(){
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});

this.AddOpenIddict()
.AddValidation(options =>
{
Expand Down Expand Up @@ -140,4 +142,4 @@ public static void ConfigureMinimalApi(JsonSerializerOptions serializerOptions)
serializerOptions.Converters.Add(new DateTimeSpaceConverter());
}
}
}
}
74 changes: 74 additions & 0 deletions EstateReportingAPI/Common/EstateContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Authorization;

namespace EstateReportingAPI.Common
{
public interface IEstateContext
{
Guid EstateId { get; }
}

public sealed class EstateContext : IEstateContext
{
public Guid EstateId { get; private set; }

internal void SetEstate(Guid estateId)
{
if (estateId == Guid.Empty)
throw new ArgumentException("Estate ID cannot be empty.", nameof(estateId));

EstateId = estateId;
}
}

public sealed class EstateContextMiddleware
{
private readonly RequestDelegate _next;

public EstateContextMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task InvokeAsync(HttpContext httpContext,EstateContext estateContext)
{
// Health endpoints or anonymous endpoints do not need estate context.
if (httpContext.GetEndpoint()?.Metadata
.GetMetadata<IAllowAnonymous>() != null)
{
await _next(httpContext);
return;
}

if (httpContext.User.Identity?.IsAuthenticated != true)
{
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
}

string? estateClaim = httpContext.User.FindFirst("estateId")?.Value;

if (!Guid.TryParse(estateClaim, out Guid authenticatedEstateId) ||
authenticatedEstateId == Guid.Empty)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}

// Backwards compatibility: if the header is supplied, it must agree
// with the authenticated claim.
if (httpContext.Request.Headers.TryGetValue("estateId", out var headerValue))
{
if (!Guid.TryParse(headerValue.SingleOrDefault(), out Guid headerEstateId) ||
headerEstateId != authenticatedEstateId)
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
}

estateContext.SetEstate(authenticatedEstateId);

await _next(httpContext);
}
}
}
Loading
Loading