From 937913e43b08d539ee2765e181ed0b308596fe76 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 29 Oct 2026 18:09:08 +0000 Subject: [PATCH] extract the client type from token and use it for validation --- .../ControllerTestsBase.cs | 2 +- .../CustomWebApplicationFactory.cs | 12 +++ .../EstateEndpointTests.cs | 48 ++++++++++- EstateReportingAPI/Common/EstateContext.cs | 79 ++++++++----------- 4 files changed, 93 insertions(+), 48 deletions(-) diff --git a/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs b/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs index d699b0d..c781f73 100644 --- a/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs +++ b/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs @@ -151,7 +151,7 @@ private static T Deserialize(string json) public static (String usename, String password) SqlCredentials = ("sa", "thisisalongpassword123!"); public String GetLocalConnectionString(String databaseName) { - return $"server=localhost,{this.SqlPort};database={databaseName};user id={SqlCredentials.usename};password={SqlCredentials.password};Encrypt=false"; + return $"server=127.0.0.1,{this.SqlPort};database={databaseName};user id={SqlCredentials.usename};password={SqlCredentials.password};Encrypt=false"; } //internal async Task StartSqlContainer(){ diff --git a/EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs b/EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs index d8914bb..ee22669 100644 --- a/EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs +++ b/EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs @@ -105,6 +105,9 @@ public class TestAuthHandler : AuthenticationHandler 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 ClientType = "Test-Client-Type"; + public const string OmitClientTypeClaim = "Test-Omit-Client-Type-Claim"; + public const string ClientTypeClaim = "client_type"; public const string AuthenticationScheme = "Test"; private readonly string _defaultUserId; @@ -155,6 +158,15 @@ protected override Task HandleAuthenticateAsync() claims.Add(new Claim(estateClaimType, estateClaim)); } + if (Context.Request.Headers.ContainsKey(OmitClientTypeClaim) == false) + { + string clientType = Context.Request.Headers.TryGetValue(ClientType, out var requestedClientType) + ? requestedClientType.ToString() + : "user"; + + claims.Add(new Claim(ClientTypeClaim, clientType)); + } + // TODO: Add as many claims as you need here var identity = new ClaimsIdentity(claims, AuthenticationScheme); diff --git a/EstateReportingAPI.IntegrationTests/EstateEndpointTests.cs b/EstateReportingAPI.IntegrationTests/EstateEndpointTests.cs index 728a6ab..acec010 100644 --- a/EstateReportingAPI.IntegrationTests/EstateEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/EstateEndpointTests.cs @@ -77,6 +77,44 @@ public async Task EstateEndpoint_ValidEstateClaimWithoutHeader_ReturnsEstate() response.StatusCode.ShouldBe(HttpStatusCode.OK); } + [Fact] + public async Task EstateEndpoint_MissingClientTypeClaim_ReturnsForbidden() + { + using HttpResponseMessage response = await SendEstateRequest(omitClientTypeClaim: true); + + response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task EstateEndpoint_UserClientType_UsesEstateClaim() + { + await this.helper.AddEstate("Test Estate", "Ref1"); + + using HttpResponseMessage response = await SendEstateRequest(clientType: "user"); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task EstateEndpoint_ClientCredentialsClientType_UsesEstateHeader() + { + await this.helper.AddEstate("Test Estate", "Ref1"); + + using HttpResponseMessage response = await SendEstateRequest( + estateHeader: this.TestId.ToString(), + clientType: "client"); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task EstateEndpoint_ClientCredentialsClientTypeWithoutEstateHeader_ReturnsForbidden() + { + using HttpResponseMessage response = await SendEstateRequest(clientType: "client"); + + response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + } + [Fact] public async Task EstateEndpoint_DifferentEstateHeader_ReturnsForbidden() { @@ -156,7 +194,9 @@ private async Task SendEstateRequest( string? estateHeader = null, bool omitEstateClaim = false, string? estateClaim = null, - string? estateClaimType = null) + string? estateClaimType = null, + string? clientType = null, + bool omitClientTypeClaim = false) { using HttpRequestMessage request = new(HttpMethod.Get, this.BaseRoute); request.Headers.Authorization = new AuthenticationHeaderValue("Test"); @@ -173,6 +213,12 @@ private async Task SendEstateRequest( if (estateClaimType != null) request.Headers.Add(TestAuthHandler.EstateClaimType, estateClaimType); + if (clientType != null) + request.Headers.Add(TestAuthHandler.ClientType, clientType); + + if (omitClientTypeClaim) + request.Headers.Add(TestAuthHandler.OmitClientTypeClaim, "true"); + return await this.Client.SendAsync(request); } diff --git a/EstateReportingAPI/Common/EstateContext.cs b/EstateReportingAPI/Common/EstateContext.cs index 19a173d..f8da0a1 100644 --- a/EstateReportingAPI/Common/EstateContext.cs +++ b/EstateReportingAPI/Common/EstateContext.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using OpenIddict.Abstractions; using System.Security.Claims; namespace EstateReportingAPI.Common @@ -21,22 +22,18 @@ internal void SetEstate(Guid estateId) } } - public sealed class EstateContextMiddleware - { + public sealed class EstateContextMiddleware { private readonly RequestDelegate _next; - public EstateContextMiddleware(RequestDelegate next) - { + public EstateContextMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync( HttpContext httpContext, EstateContext estateContext, - IConfiguration configuration) - { - if (IsAnonymousEndpoint(httpContext)) - { + IConfiguration configuration) { + if (IsAnonymousEndpoint(httpContext)) { await _next(httpContext); return; } @@ -44,20 +41,17 @@ public async Task InvokeAsync( bool disableAuthorisation = configuration.GetValue("AppSettings:DisableAuthorisation"); bool isAuthenticated = httpContext.User.Identity?.IsAuthenticated == true; - if (isAuthenticated == false && disableAuthorisation == false) - { + if (isAuthenticated == false && disableAuthorisation == false) { httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } - if (TryGetHeaderEstateId(httpContext, out Guid? headerEstateId) == false) - { + if (TryGetHeaderEstateId(httpContext, out Guid? headerEstateId) == false) { httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; return; } - if (isAuthenticated) - { + if (isAuthenticated) { await HandleAuthenticatedRequestAsync(httpContext, estateContext, headerEstateId); return; } @@ -74,27 +68,22 @@ private static bool IsAnonymousEndpoint(HttpContext httpContext) { || path.StartsWithSegments("/openapi", StringComparison.OrdinalIgnoreCase); } - private static bool TryGetHeaderEstateId(HttpContext httpContext, out Guid? estateId) - { + private static bool TryGetHeaderEstateId(HttpContext httpContext, out Guid? estateId) { estateId = null; - if (httpContext.Request.Headers.TryGetValue("estateId", out var headerValues) == false) - { + if (httpContext.Request.Headers.TryGetValue("estateId", out var headerValues) == false) { return true; } - if (headerValues.Count != 1) - { + if (headerValues.Count != 1) { return false; } - if (Guid.TryParse(headerValues[0], out Guid parsedEstateId) == false) - { + if (Guid.TryParse(headerValues[0], out Guid parsedEstateId) == false) { return false; } - if (parsedEstateId == Guid.Empty) - { + if (parsedEstateId == Guid.Empty) { return false; } @@ -105,10 +94,8 @@ private static bool TryGetHeaderEstateId(HttpContext httpContext, out Guid? esta private async Task HandleUnauthenticatedRequestAsync( HttpContext httpContext, EstateContext estateContext, - Guid? headerEstateId) - { - if (headerEstateId.HasValue == false) - { + Guid? headerEstateId) { + if (headerEstateId.HasValue == false) { httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; return; } @@ -117,38 +104,38 @@ private async Task HandleUnauthenticatedRequestAsync( await _next(httpContext); } - private async Task HandleAuthenticatedRequestAsync( - HttpContext httpContext, - EstateContext estateContext, - Guid? headerEstateId) - { - bool isUserToken = httpContext.User.HasClaim(claim => claim.Type == ClaimTypes.NameIdentifier); + private async Task HandleAuthenticatedRequestAsync(HttpContext httpContext, + EstateContext estateContext, + Guid? headerEstateId) { + Boolean hasClientTypeClaim = httpContext.User.HasClaim(claim => claim.Type == "client_type"); + if (hasClientTypeClaim == false) { + httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + Boolean isUserToken = httpContext.User.GetClaim("client_type") switch { + "user" => true, + _ => false + }; - if (isUserToken) - { + if (isUserToken) { string? estateClaim = httpContext.User.FindFirst("estateId")?.Value; - if (!Guid.TryParse(estateClaim, out Guid authenticatedEstateId) || - authenticatedEstateId == Guid.Empty) - { + if (!Guid.TryParse(estateClaim, out Guid authenticatedEstateId) || authenticatedEstateId == Guid.Empty) { httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; return; } - if (headerEstateId.HasValue && - headerEstateId.Value != authenticatedEstateId) - { + if (headerEstateId.HasValue && headerEstateId.Value != authenticatedEstateId) { httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; return; } estateContext.SetEstate(authenticatedEstateId); } - else - { + else { // Client-credentials token: tenant comes from the trusted ACL header. - if (!headerEstateId.HasValue) - { + if (!headerEstateId.HasValue) { httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; return; }