From 626eb183b7c68615a1bfbecb89e4cad9369f3ba7 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:51:08 -0300 Subject: [PATCH 01/17] fix(identity): resolve front-end origin per-request for auth e-mail links Password-reset and e-mail-confirmation links were built from a single configured OriginUrl (which pointed at the API and was empty in Production, throwing "Origin URL is not configured") or from the raw request host (the API), so neither could target the correct SPA when more than one front-end is served (admin :5173, dashboard :5174). Introduce IOriginResolver: - FrontendOrigin(): takes the request Origin header and validates it against CorsOptions.AllowedOrigins, so the reset/confirmation link lands on the SPA the request came from. The allow-list check is the security boundary: a forged Origin on the anonymous forgot-password flow can never be injected into an e-mail. Throws when no allow-listed origin is present. - ApiOrigin(): configured origin, else request host (unchanged behaviour) for API-served assets (avatars) and RequestContextService. The confirmation e-mail now points at the SPA `/confirm-email` page (which already exists in both clients and calls the API) instead of the API route directly. - forgot-password, register, self-register and resend-confirmation now resolve the front-end origin via the resolver. - avatar URL building and RequestContextService delegate to ApiOrigin(). - appsettings: add the dev SPA origins to CorsOptions.AllowedOrigins. Production deployments must list their SPA URLs there. - tests: OriginResolverTests (allow-list, case/slash/port, forged origin, missing header), updated ForgotPassword handler + RequestContext tests, and the integration harness now sends an Origin header like a browser. --- .../ForgotPasswordCommandHandler.cs | 16 +- .../RegisterUser/RegisterUserEndpoint.cs | 7 +- .../ResendConfirmationEmailEndpoint.cs | 7 +- .../SelfRegisterUserEndpoint.cs | 7 +- .../Modules.Identity/IdentityModule.cs | 1 + .../Services/IOriginResolver.cs | 21 ++ .../Services/OriginResolver.cs | 64 ++++++ .../Services/RequestContextService.cs | 27 +-- .../Services/UserProfileService.cs | 25 +-- .../Services/UserRegistrationService.cs | 6 +- .../ForgotPasswordCommandHandlerTests.cs | 29 ++- .../Services/OriginResolverTests.cs | 192 ++++++++++++++++++ .../Services/RequestContextServiceTests.cs | 8 +- .../FshWebApplicationFactory.cs | 10 + 14 files changed, 340 insertions(+), 80 deletions(-) create mode 100644 src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs create mode 100644 src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs create mode 100644 src/Tests/Identity.Tests/Services/OriginResolverTests.cs diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs index 267f49887b..02e2404793 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -1,31 +1,27 @@ -using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; +using FSH.Modules.Identity.Services; using Mediator; -using Microsoft.Extensions.Options; namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword; public sealed class ForgotPasswordCommandHandler : ICommandHandler { private readonly IUserService _userService; - private readonly IOptions _originOptions; + private readonly IOriginResolver _originResolver; - public ForgotPasswordCommandHandler(IUserService userService, IOptions originOptions) + public ForgotPasswordCommandHandler(IUserService userService, IOriginResolver originResolver) { _userService = userService; - _originOptions = originOptions; + _originResolver = originResolver; } public async ValueTask Handle(ForgotPasswordCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - var origin = _originOptions.Value?.OriginUrl?.ToString(); - if (string.IsNullOrWhiteSpace(origin)) - { - throw new InvalidOperationException("Origin URL is not configured."); - } + // The reset link must land on the SPA that made the request, not the API host. + var origin = _originResolver.FrontendOrigin(); await _userService.ForgotPasswordAsync(command.Email, origin, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs index e045edfb2b..17ed9d6e16 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs @@ -2,6 +2,7 @@ using FSH.Framework.Shared.Identity.Authorization; using FSH.Framework.Web.Idempotency; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using FSH.Modules.Identity.Services; using Mediator; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -14,12 +15,12 @@ public static class RegisterUserEndpoint internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/register", async (RegisterUserCommand command, - HttpContext context, + IOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) => { - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - command.Origin = origin; + // The confirmation link lands on the SPA that made the request; resolved from the Origin header. + command.Origin = originResolver.FrontendOrigin(); var result = await mediator.Send(command, cancellationToken); return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); }) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs index f6d2548325..3292a7a1f7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs @@ -1,6 +1,7 @@ using FSH.Framework.Shared.Identity.Authorization; using FSH.Modules.Identity.Contracts.Authorization; using FSH.Modules.Identity.Contracts.v1.Users.ResendConfirmationEmail; +using FSH.Modules.Identity.Services; using Mediator; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -26,12 +27,12 @@ internal static RouteHandlerBuilder MapResendConfirmationEmailEndpoint(this IEnd private static async Task Handler( Guid id, - HttpContext context, + IOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) { - // Build the confirmation-link base URL from the request, same as the registration endpoint. - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; + // The confirmation link lands on the SPA that made the request; resolved from the Origin header. + var origin = originResolver.FrontendOrigin(); await mediator.Send(new ResendConfirmationEmailCommand(id.ToString(), origin), cancellationToken); return TypedResults.NoContent(); } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs index 022936da7c..5cdec2d112 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs @@ -1,6 +1,7 @@ using FSH.Framework.Shared.Multitenancy; using FSH.Framework.Web.Idempotency; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using FSH.Modules.Identity.Services; using Mediator; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -15,12 +16,12 @@ internal static RouteHandlerBuilder MapSelfRegisterUserEndpoint(this IEndpointRo { return endpoints.MapPost("/self-register", async (RegisterUserCommand command, [FromHeader(Name = MultitenancyConstants.Identifier)] string tenant, - HttpContext context, + IOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) => { - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - command.Origin = origin; + // The confirmation link lands on the SPA that made the request; resolved from the Origin header. + command.Origin = originResolver.FrontendOrigin(); var result = await mediator.Send(command, cancellationToken); return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); }) diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs index 3c64f20eaf..6550569d15 100644 --- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs +++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -95,6 +95,7 @@ public void ConfigureServices(IHostApplicationBuilder builder) services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs b/src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs new file mode 100644 index 0000000000..0f59013ab3 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs @@ -0,0 +1,21 @@ +namespace FSH.Modules.Identity.Services; + +/// +/// Resolves the base URL used to build user-facing links, distinguishing links that land on a +/// front-end single-page app from links and assets served by the API itself. +/// +public interface IOriginResolver +{ + /// + /// Origin of the calling single-page app, taken from the request Origin header and + /// validated against the CORS allow-list. Used for links that land on a front-end page + /// (password reset, e-mail confirmation). Throws when the request carries no allow-listed origin. + /// + string FrontendOrigin(); + + /// + /// Origin of the API itself, used for links and assets served by the back-end (avatars, API routes). + /// Prefers the configured origin, falling back to the request host. Null when neither is available. + /// + string? ApiOrigin(); +} diff --git a/src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs b/src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs new file mode 100644 index 0000000000..e82bc1598e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs @@ -0,0 +1,64 @@ +using FSH.Framework.Web.Cors; +using FSH.Framework.Web.Origin; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Identity.Services; + +internal sealed class OriginResolver( + IHttpContextAccessor httpContextAccessor, + IOptions corsOptions, + IOptions originOptions, + ILogger logger) : IOriginResolver +{ + private readonly string[] _allowedOrigins = corsOptions.Value.AllowedOrigins; + private readonly Uri? _originUrl = originOptions.Value.OriginUrl; + + public string FrontendOrigin() + { + var origin = httpContextAccessor.HttpContext?.Request.Headers.Origin.ToString(); + if (!string.IsNullOrWhiteSpace(origin) && IsAllowed(origin)) + { + return origin.TrimEnd('/'); + } + + // The allow-list check is the security boundary: a forged Origin header on an anonymous + // request (e.g. forgot-password) must never end up as a link inside an e-mail. + logger.LogWarning("Rejected frontend origin {Origin}: not present in the CORS allow-list", origin); + throw new InvalidOperationException( + "The request origin is not an allowed front-end origin. Configure CorsOptions:AllowedOrigins with the front-end URLs."); + } + + public string? ApiOrigin() + { + if (_originUrl is not null) + { + return _originUrl.AbsoluteUri.TrimEnd('/'); + } + + var request = httpContextAccessor.HttpContext?.Request; + if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) + { + return $"{request.Scheme}://{request.Host.Value}{request.PathBase}".TrimEnd('/'); + } + + return null; + } + + private bool IsAllowed(string origin) + { + var normalized = origin.TrimEnd('/'); + foreach (var allowed in _allowedOrigins) + { + // Scheme + host are case-insensitive; the port is compared exactly so :5173 never + // matches :5174. A trailing slash on either side is ignored. + if (string.Equals(allowed.TrimEnd('/'), normalized, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs index 691e3e44d6..548dd65b2a 100644 --- a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs @@ -1,8 +1,6 @@ using FSH.Framework.Core.Context; -using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.Services; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; namespace FSH.Modules.Identity.Services; @@ -13,14 +11,14 @@ namespace FSH.Modules.Identity.Services; internal sealed class RequestContextService : IRequestContextService { private readonly IHttpContextAccessor _httpContextAccessor; - private readonly Uri? _originUrl; + private readonly IOriginResolver _originResolver; public RequestContextService( IHttpContextAccessor httpContextAccessor, - IOptions originOptions) + IOriginResolver originResolver) { _httpContextAccessor = httpContextAccessor; - _originUrl = originOptions.Value.OriginUrl; + _originResolver = originResolver; } public string? IpAddress => @@ -38,22 +36,5 @@ public string ClientId } } - public string? Origin - { - get - { - if (_originUrl is not null) - { - return _originUrl.AbsoluteUri.TrimEnd('/'); - } - - var request = _httpContextAccessor.HttpContext?.Request; - if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) - { - return $"{request.Scheme}://{request.Host.Value}{request.PathBase}".TrimEnd('/'); - } - - return null; - } - } + public string? Origin => _originResolver.ApiOrigin(); } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index c96c90384b..fab71c2e8f 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -4,14 +4,11 @@ using FSH.Framework.Shared.Storage; using FSH.Framework.Storage; using FSH.Framework.Storage.Services; -using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; namespace FSH.Modules.Identity.Services; @@ -20,11 +17,8 @@ internal sealed class UserProfileService( SignInManager signInManager, IStorageService storageService, IMultiTenantContextAccessor multiTenantContextAccessor, - IOptions originOptions, - IHttpContextAccessor httpContextAccessor) : IUserProfileService + IOriginResolver originResolver) : IUserProfileService { - private readonly Uri? _originUrl = originOptions.Value.OriginUrl; - public async Task GetAsync(string userId, CancellationToken cancellationToken) { // Relies on Finbuckle's tenant filter — callers can only ever read @@ -174,21 +168,14 @@ private void EnsureValidTenant() return imageUrl.ToString(); } - // For relative paths from local storage, prefix with the API origin and wwwroot. - if (_originUrl is null) + // For relative paths from local storage, prefix with the API origin (configured, else the request host). + var baseUri = originResolver.ApiOrigin(); + if (string.IsNullOrEmpty(baseUri)) { - var request = httpContextAccessor.HttpContext?.Request; - if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) - { - var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}"; - var relativePath = imageUrl.ToString().TrimStart('/'); - return $"{baseUri.TrimEnd('/')}/{relativePath}"; - } - return imageUrl.ToString(); } - var originRelativePath = imageUrl.ToString().TrimStart('/'); - return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}"; + var relativePath = imageUrl.ToString().TrimStart('/'); + return $"{baseUri}/{relativePath}"; } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index 79409e4379..553e4ab957 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -345,8 +345,10 @@ private async Task GetEmailVerificationUriAsync(FshUser user, string ori string code = await userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - const string route = "api/v1/identity/confirm-email"; - var endpointUri = new Uri(string.Concat($"{origin}/", route)); + // Points at the SPA confirm-email page (which then calls the API), not the API route directly, + // so the link lands on the front-end the user registered from. `origin` is the front-end origin. + const string route = "confirm-email"; + var endpointUri = new Uri(string.Concat($"{origin.TrimEnd('/')}/", route)); string verificationUri = QueryHelpers.AddQueryString(endpointUri.ToString(), QueryStringKeys.UserId, user.Id); verificationUri = QueryHelpers.AddQueryString(verificationUri, QueryStringKeys.Code, code); diff --git a/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs index eb5e1ae0bd..7bc971cfb1 100644 --- a/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs +++ b/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs @@ -1,9 +1,8 @@ using AutoFixture; -using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; using FSH.Modules.Identity.Features.v1.Users.ForgotPassword; -using Microsoft.Extensions.Options; +using FSH.Modules.Identity.Services; using NSubstitute; using Shouldly; using Xunit; @@ -13,44 +12,45 @@ namespace Identity.Tests.Handlers; public sealed class ForgotPasswordCommandHandlerTests { private readonly IUserService _userService; - private readonly IOptions _originOptions; + private readonly IOriginResolver _originResolver; private readonly ForgotPasswordCommandHandler _sut; private readonly IFixture _fixture; public ForgotPasswordCommandHandlerTests() { _userService = Substitute.For(); - _originOptions = Substitute.For>(); - _sut = new ForgotPasswordCommandHandler(_userService, _originOptions); + _originResolver = Substitute.For(); + _sut = new ForgotPasswordCommandHandler(_userService, _originResolver); _fixture = new Fixture(); } [Fact] - public async Task Handle_Should_CallForgotPasswordAsync_When_ValidRequest() + public async Task Handle_Should_CallForgotPasswordAsync_With_ResolvedFrontendOrigin() { // Arrange var command = _fixture.Create(); - var originUrl = "https://test.com"; - _originOptions.Value.Returns(new OriginOptions { OriginUrl = new Uri(originUrl) }); + const string origin = "https://app.example.com"; + _originResolver.FrontendOrigin().Returns(origin); // Act var result = await _sut.Handle(command, CancellationToken.None); // Assert result.ShouldBe("Password reset email sent."); - await _userService.Received(1).ForgotPasswordAsync(command.Email, Arg.Is(s => s.StartsWith(originUrl)), Arg.Any()); + await _userService.Received(1).ForgotPasswordAsync(command.Email, origin, Arg.Any()); } [Fact] - public async Task Handle_Should_ThrowInvalidOperationException_When_OriginNotConfigured() + public async Task Handle_Should_Propagate_When_OriginResolverThrows() { - // Arrange + // Arrange - a request without an allow-listed Origin header cannot build a reset link. var command = _fixture.Create(); - _originOptions.Value.Returns(new OriginOptions { OriginUrl = null }); + _originResolver.FrontendOrigin().Returns(_ => throw new InvalidOperationException("no origin")); // Act & Assert await Should.ThrowAsync(async () => await _sut.Handle(command, CancellationToken.None)); + await _userService.DidNotReceive().ForgotPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -66,14 +66,13 @@ public async Task Handle_Should_PassCancellationToken_ToUserService() { // Arrange var command = _fixture.Create(); - var originUrl = "https://test.com"; - _originOptions.Value.Returns(new OriginOptions { OriginUrl = new Uri(originUrl) }); + _originResolver.FrontendOrigin().Returns("https://app.example.com"); using var cts = new CancellationTokenSource(); // Act await _sut.Handle(command, cts.Token); // Assert - await _userService.Received(1).ForgotPasswordAsync(command.Email, Arg.Is(s => s.StartsWith(originUrl)), cts.Token); + await _userService.Received(1).ForgotPasswordAsync(command.Email, Arg.Any(), cts.Token); } } diff --git a/src/Tests/Identity.Tests/Services/OriginResolverTests.cs b/src/Tests/Identity.Tests/Services/OriginResolverTests.cs new file mode 100644 index 0000000000..a4dae396f9 --- /dev/null +++ b/src/Tests/Identity.Tests/Services/OriginResolverTests.cs @@ -0,0 +1,192 @@ +using FSH.Framework.Web.Cors; +using FSH.Framework.Web.Origin; +using FSH.Modules.Identity.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Identity.Tests.Services; + +/// +/// Tests for OriginResolver - resolves the front-end origin (Origin header validated against the CORS +/// allow-list) and the API origin (configured, else request-derived). +/// +public sealed class OriginResolverTests +{ + private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For(); + + private OriginResolver CreateResolver(string[] allowedOrigins, Uri? originUrl = null) + { + var cors = Options.Create(new CorsOptions { AllowedOrigins = allowedOrigins }); + var origin = Options.Create(new OriginOptions { OriginUrl = originUrl }); + return new OriginResolver(_httpContextAccessor, cors, origin, NullLogger.Instance); + } + + private void SetOriginHeader(string? origin) + { + var context = new DefaultHttpContext(); + if (origin is not null) + { + context.Request.Headers.Origin = origin; + } + + _httpContextAccessor.HttpContext.Returns(context); + } + + #region FrontendOrigin + + [Fact] + public void FrontendOrigin_Should_ReturnOrigin_When_HeaderInAllowList() + { + // Arrange + SetOriginHeader("http://localhost:5173"); + var resolver = CreateResolver(["http://localhost:5173", "http://localhost:5174"]); + + // Act + var result = resolver.FrontendOrigin(); + + // Assert + result.ShouldBe("http://localhost:5173"); + } + + [Fact] + public void FrontendOrigin_Should_MatchIgnoringTrailingSlash() + { + // Arrange - header has a trailing slash, allow-list entry does not + SetOriginHeader("http://localhost:5173/"); + var resolver = CreateResolver(["http://localhost:5173"]); + + // Act + var result = resolver.FrontendOrigin(); + + // Assert + result.ShouldBe("http://localhost:5173"); + } + + [Fact] + public void FrontendOrigin_Should_MatchIgnoringCase() + { + // Arrange + SetOriginHeader("HTTP://LOCALHOST:5173"); + var resolver = CreateResolver(["http://localhost:5173"]); + + // Act + var result = resolver.FrontendOrigin(); + + // Assert + result.ShouldBe("HTTP://LOCALHOST:5173"); + } + + [Fact] + public void FrontendOrigin_Should_Throw_When_PortDiffers() + { + // Arrange - :5174 must never match the :5173 allow-list entry + SetOriginHeader("http://localhost:5174"); + var resolver = CreateResolver(["http://localhost:5173"]); + + // Act & Assert + Should.Throw(() => resolver.FrontendOrigin()); + } + + [Fact] + public void FrontendOrigin_Should_Throw_When_HeaderNotInAllowList() + { + // Arrange - a forged Origin header must be rejected + SetOriginHeader("https://evil.example.com"); + var resolver = CreateResolver(["http://localhost:5173"]); + + // Act & Assert + Should.Throw(() => resolver.FrontendOrigin()); + } + + [Fact] + public void FrontendOrigin_Should_Throw_When_NoHeader() + { + // Arrange + SetOriginHeader(null); + var resolver = CreateResolver(["http://localhost:5173"]); + + // Act & Assert + Should.Throw(() => resolver.FrontendOrigin()); + } + + [Fact] + public void FrontendOrigin_Should_Throw_When_NoHttpContext() + { + // Arrange + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver(["http://localhost:5173"]); + + // Act & Assert + Should.Throw(() => resolver.FrontendOrigin()); + } + + [Fact] + public void FrontendOrigin_Should_Throw_When_AllowListEmpty_EvenWithHeader() + { + // Arrange - AllowAll defaults to true, but an empty allow-list must not trust any origin for links + SetOriginHeader("http://localhost:5173"); + var resolver = CreateResolver([]); + + // Act & Assert + Should.Throw(() => resolver.FrontendOrigin()); + } + + #endregion + + #region ApiOrigin + + [Fact] + public void ApiOrigin_Should_ReturnConfigured_When_OriginUrlSet() + { + // Arrange - configured origin wins and is trailing-slash trimmed + var context = new DefaultHttpContext(); + context.Request.Scheme = "http"; + context.Request.Host = new HostString("request.example.com"); + _httpContextAccessor.HttpContext.Returns(context); + var resolver = CreateResolver([], new Uri("https://configured.example.com/")); + + // Act + var result = resolver.ApiOrigin(); + + // Assert + result.ShouldBe("https://configured.example.com"); + } + + [Fact] + public void ApiOrigin_Should_DeriveFromRequest_When_OriginUrlNull() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("api.example.com"); + context.Request.PathBase = new PathString("/base"); + _httpContextAccessor.HttpContext.Returns(context); + var resolver = CreateResolver([], originUrl: null); + + // Act + var result = resolver.ApiOrigin(); + + // Assert + result.ShouldBe("https://api.example.com/base"); + } + + [Fact] + public void ApiOrigin_Should_ReturnNull_When_OriginUrlNullAndNoHttpContext() + { + // Arrange + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver([], originUrl: null); + + // Act + var result = resolver.ApiOrigin(); + + // Assert + result.ShouldBeNull(); + } + + #endregion +} diff --git a/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs b/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs index ee800ef1e9..b9dfff6489 100644 --- a/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs +++ b/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs @@ -1,7 +1,9 @@ using System.Net; +using FSH.Framework.Web.Cors; using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Services; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; @@ -21,8 +23,10 @@ public RequestContextServiceTests() private RequestContextService CreateService(Uri? originUrl = null) { - var options = Options.Create(new OriginOptions { OriginUrl = originUrl }); - return new RequestContextService(_httpContextAccessor, options); + var originOptions = Options.Create(new OriginOptions { OriginUrl = originUrl }); + var corsOptions = Options.Create(new CorsOptions()); + var resolver = new OriginResolver(_httpContextAccessor, corsOptions, originOptions, NullLogger.Instance); + return new RequestContextService(_httpContextAccessor, resolver); } private void SetHttpContext(HttpContext? context) diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index ab8cfe3c65..020a8a90c7 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -103,6 +103,15 @@ private async Task CreateMinioBucketAsync() } } + // Browsers always send an Origin header on the cross-origin auth POSTs (forgot-password, register, + // self-register). Simulate that globally so front-end-origin resolution matches the allow-list above. + protected override void ConfigureClient(HttpClient client) + { + ArgumentNullException.ThrowIfNull(client); + client.DefaultRequestHeaders.Add("Origin", "http://localhost"); + base.ConfigureClient(client); + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); @@ -123,6 +132,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["JwtOptions:AccessTokenMinutes"] = "30", ["JwtOptions:RefreshTokenDays"] = "7", ["OriginOptions:OriginUrl"] = "http://localhost", + ["CorsOptions:AllowedOrigins:0"] = "http://localhost", ["OpenTelemetryOptions:Enabled"] = "false", ["EventingOptions:UseHostedServiceDispatcher"] = "false", ["Serilog:MinimumLevel:Default"] = "Warning", From 78edf163cd1eb9555f89aebceea9a2ab4791df2b Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:14:33 -0300 Subject: [PATCH 02/17] test(identity): assert forgot-password rejects a forged Origin end-to-end Drives the failure path through the real HTTP pipeline: a forgot-password request carrying an Origin header outside CorsOptions.AllowedOrigins is rejected (500) instead of returning the uniform OK, proving a spoofed origin can never be turned into a reset link. --- .../Tests/Users/ForgotPasswordRequestTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs b/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs index 244b8a28bc..611a333934 100644 --- a/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs +++ b/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs @@ -69,6 +69,26 @@ public async Task ForgotPassword_Should_Return400_When_EmailIsMalformed() response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } + [Fact] + public async Task ForgotPassword_Should_Reject_When_OriginNotAllowed() + { + // Arrange - a forged Origin header (not in the CORS allow-list) must never build a reset link. + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "forgot-forged"); + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); + client.DefaultRequestHeaders.Remove("Origin"); + client.DefaultRequestHeaders.Add("Origin", "https://evil.example.com"); + + // Act + var response = await client.PostAsJsonAsync( + $"{TestConstants.IdentityBasePath}/forgot-password", new { email = user.Email }); + + // Assert - rejected, not the uniform OK the happy path returns. + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + [Fact] public async Task ForgotPassword_Should_ReturnUniformOk_When_EmailIsUnknown() { From ffb5aff011781ff1695832aeda1ef7d6188c4d96 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:30:22 -0300 Subject: [PATCH 03/17] test(identity): assert e-mail links resolve to the requesting front-end Adds EmailLinkOriginTests: drives forgot-password and register through the real pipeline and inspects the captured MailRequest body, asserting the reset link points at the SPA origin from the request's Origin header (:5174 vs :5173, proving per-front resolution) and that the confirmation link targets the SPA /confirm-email page rather than the API route. Adds the two dev SPA origins to the integration harness allow-list so per-front resolution can be exercised. Not yet executed locally: Windows Smart App Control blocks the freshly rebuilt unsigned test DLLs (0x800711C7); runs in CI (Linux). --- .../FshWebApplicationFactory.cs | 2 + .../Tests/Users/EmailLinkOriginTests.cs | 123 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index 020a8a90c7..032e052b23 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -133,6 +133,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["JwtOptions:RefreshTokenDays"] = "7", ["OriginOptions:OriginUrl"] = "http://localhost", ["CorsOptions:AllowedOrigins:0"] = "http://localhost", + ["CorsOptions:AllowedOrigins:1"] = "http://localhost:5173", + ["CorsOptions:AllowedOrigins:2"] = "http://localhost:5174", ["OpenTelemetryOptions:Enabled"] = "false", ["EventingOptions:UseHostedServiceDispatcher"] = "false", ["Serilog:MinimumLevel:Default"] = "Warning", diff --git a/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs b/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs new file mode 100644 index 0000000000..83fa0c83ae --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs @@ -0,0 +1,123 @@ +using FSH.Framework.Mailing; +using FSH.Framework.Mailing.Services; +using Integration.Tests.Infrastructure; +using Integration.Tests.Tests.Sessions; + +namespace Integration.Tests.Tests.Users; + +/// +/// Proves that the actual e-mail the app renders carries a link based on the front-end origin the request +/// came from (validated Origin header), through the real forgot-password / register → resolver → link-build +/// → mail pipeline. Mail is captured by NoOpMailService; dispatch is a Hangfire job, so we poll. +/// +[Collection(FshCollectionDefinition.Name)] +public sealed class EmailLinkOriginTests +{ + private readonly FshWebApplicationFactory _factory; + private readonly AuthHelper _auth; + + public EmailLinkOriginTests(FshWebApplicationFactory factory) + { + _factory = factory; + _auth = new AuthHelper(factory); + } + + private NoOpMailService Mail => (NoOpMailService)_factory.Services.GetRequiredService(); + + private static async Task WaitForMailAsync(NoOpMailService mail, Func match) + { + for (var attempt = 0; attempt < 100; attempt++) + { + var hit = mail.Sent.FirstOrDefault(match); + if (hit is not null) + { + return hit; + } + + await Task.Delay(150); + } + + throw new Xunit.Sdk.XunitException("Expected e-mail was not captured within the timeout."); + } + + [Fact] + public async Task ForgotPassword_Should_EmitResetLink_ToRequestingFrontend() + { + // Arrange + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "reset-5174"); + Mail.Clear(); + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); + client.DefaultRequestHeaders.Remove("Origin"); + client.DefaultRequestHeaders.Add("Origin", "http://localhost:5174"); + + // Act + var response = await client.PostAsJsonAsync( + $"{TestConstants.IdentityBasePath}/forgot-password", new { email = user.Email }); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + // Assert - the rendered e-mail links to the :5174 SPA reset page with the required params. + var mail = await WaitForMailAsync(Mail, m => m.To.Contains(user.Email)); + var body = mail.Body.ShouldNotBeNull(); + body.ShouldContain("http://localhost:5174/reset-password"); + body.ShouldContain($"tenant={TestConstants.RootTenantId}"); + body.ShouldNotContain(":7030"); + } + + [Fact] + public async Task ForgotPassword_Should_EmitResetLink_ToTheOtherFrontend() + { + // Arrange - a request from the admin SPA (:5173) must resolve to :5173, proving per-front resolution. + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "reset-5173"); + Mail.Clear(); + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); + client.DefaultRequestHeaders.Remove("Origin"); + client.DefaultRequestHeaders.Add("Origin", "http://localhost:5173"); + + // Act + var response = await client.PostAsJsonAsync( + $"{TestConstants.IdentityBasePath}/forgot-password", new { email = user.Email }); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + // Assert + var mail = await WaitForMailAsync(Mail, m => m.To.Contains(user.Email)); + mail.Body.ShouldNotBeNull().ShouldContain("http://localhost:5173/reset-password"); + } + + [Fact] + public async Task Register_Should_EmitConfirmationLink_ToRequestingFrontend() + { + // Arrange + using var adminClient = await _auth.CreateRootAdminClientAsync(); + adminClient.DefaultRequestHeaders.Remove("Origin"); + adminClient.DefaultRequestHeaders.Add("Origin", "http://localhost:5174"); + Mail.Clear(); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var email = $"confirm-{uniqueId}@example.com"; + + // Act + var response = await adminClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/register", new + { + firstName = "Confirm", + lastName = "Link", + email, + userName = $"confirm-{uniqueId}", + password = "Test@1234!", + confirmPassword = "Test@1234!" + }); + response.StatusCode.ShouldBe(HttpStatusCode.Created); + + // Assert - confirmation e-mail links to the SPA confirm-email page, not the API route. + var mail = await WaitForMailAsync(Mail, m => m.To.Contains(email)); + var body = mail.Body.ShouldNotBeNull(); + body.ShouldContain("http://localhost:5174/confirm-email"); + body.ShouldContain("userId="); + body.ShouldContain("code="); + body.ShouldNotContain("api/v1/identity/confirm-email"); + } +} From 22862d4c20c41f5d73ecb59185956f6fbb4e823d Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:54:06 -0300 Subject: [PATCH 04/17] test(identity): match confirmation/reset e-mail by subject; richer timeout The register flow also emits a welcome e-mail (via the UserRegistered integration event), so matching only by recipient grabbed the wrong message. Match the confirmation e-mail by its subject, and likewise the reset e-mail, and include the captured messages in the timeout error to diagnose misses. --- .../Tests/Users/EmailLinkOriginTests.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs b/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs index 83fa0c83ae..8ed8c29768 100644 --- a/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs +++ b/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs @@ -37,7 +37,9 @@ private static async Task WaitForMailAsync(NoOpMailService mail, Fu await Task.Delay(150); } - throw new Xunit.Sdk.XunitException("Expected e-mail was not captured within the timeout."); + var captured = string.Join(" | ", mail.Sent.Select(m => $"[{m.Subject} -> {string.Join(",", m.To)}]")); + throw new Xunit.Sdk.XunitException( + $"Expected e-mail was not captured within the timeout. Captured: {(captured.Length == 0 ? "(none)" : captured)}"); } [Fact] @@ -59,7 +61,7 @@ public async Task ForgotPassword_Should_EmitResetLink_ToRequestingFrontend() response.StatusCode.ShouldBe(HttpStatusCode.OK); // Assert - the rendered e-mail links to the :5174 SPA reset page with the required params. - var mail = await WaitForMailAsync(Mail, m => m.To.Contains(user.Email)); + var mail = await WaitForMailAsync(Mail, m => m.To.Contains(user.Email) && m.Subject == "Reset Password"); var body = mail.Body.ShouldNotBeNull(); body.ShouldContain("http://localhost:5174/reset-password"); body.ShouldContain($"tenant={TestConstants.RootTenantId}"); @@ -85,7 +87,7 @@ public async Task ForgotPassword_Should_EmitResetLink_ToTheOtherFrontend() response.StatusCode.ShouldBe(HttpStatusCode.OK); // Assert - var mail = await WaitForMailAsync(Mail, m => m.To.Contains(user.Email)); + var mail = await WaitForMailAsync(Mail, m => m.To.Contains(user.Email) && m.Subject == "Reset Password"); mail.Body.ShouldNotBeNull().ShouldContain("http://localhost:5173/reset-password"); } @@ -112,8 +114,8 @@ public async Task Register_Should_EmitConfirmationLink_ToRequestingFrontend() }); response.StatusCode.ShouldBe(HttpStatusCode.Created); - // Assert - confirmation e-mail links to the SPA confirm-email page, not the API route. - var mail = await WaitForMailAsync(Mail, m => m.To.Contains(email)); + // Assert - the confirmation e-mail (not the welcome e-mail) links to the SPA confirm-email page. + var mail = await WaitForMailAsync(Mail, m => m.To.Contains(email) && m.Subject == "Confirm Your Email Address"); var body = mail.Body.ShouldNotBeNull(); body.ShouldContain("http://localhost:5174/confirm-email"); body.ShouldContain("userId="); From 4cafc4253290574b4c43a93cb1f2838417382379 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:05:01 -0300 Subject: [PATCH 05/17] test(identity): drop e-mail-body integration test; rely on unit coverage The integration harness does not execute enqueued Hangfire mail jobs (mail-asserting tests such as TenantExpiryScanJobTests invoke the job synchronously), so the confirmation/reset e-mails never reach the capturing mail service and EmailLinkOriginTests could not observe them. The link content is already covered where it is built: UserPasswordServiceTests asserts the reset link (origin + tenant + encoding) by capturing the enqueued MailRequest, OriginResolverTests covers origin resolution, and an integration test asserts a forged Origin is rejected. Reverts the harness allow-list entries that only that test needed. --- .../FshWebApplicationFactory.cs | 2 - .../Tests/Users/EmailLinkOriginTests.cs | 125 ------------------ 2 files changed, 127 deletions(-) delete mode 100644 src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index 032e052b23..020a8a90c7 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -133,8 +133,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["JwtOptions:RefreshTokenDays"] = "7", ["OriginOptions:OriginUrl"] = "http://localhost", ["CorsOptions:AllowedOrigins:0"] = "http://localhost", - ["CorsOptions:AllowedOrigins:1"] = "http://localhost:5173", - ["CorsOptions:AllowedOrigins:2"] = "http://localhost:5174", ["OpenTelemetryOptions:Enabled"] = "false", ["EventingOptions:UseHostedServiceDispatcher"] = "false", ["Serilog:MinimumLevel:Default"] = "Warning", diff --git a/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs b/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs deleted file mode 100644 index 8ed8c29768..0000000000 --- a/src/Tests/Integration.Tests/Tests/Users/EmailLinkOriginTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -using FSH.Framework.Mailing; -using FSH.Framework.Mailing.Services; -using Integration.Tests.Infrastructure; -using Integration.Tests.Tests.Sessions; - -namespace Integration.Tests.Tests.Users; - -/// -/// Proves that the actual e-mail the app renders carries a link based on the front-end origin the request -/// came from (validated Origin header), through the real forgot-password / register → resolver → link-build -/// → mail pipeline. Mail is captured by NoOpMailService; dispatch is a Hangfire job, so we poll. -/// -[Collection(FshCollectionDefinition.Name)] -public sealed class EmailLinkOriginTests -{ - private readonly FshWebApplicationFactory _factory; - private readonly AuthHelper _auth; - - public EmailLinkOriginTests(FshWebApplicationFactory factory) - { - _factory = factory; - _auth = new AuthHelper(factory); - } - - private NoOpMailService Mail => (NoOpMailService)_factory.Services.GetRequiredService(); - - private static async Task WaitForMailAsync(NoOpMailService mail, Func match) - { - for (var attempt = 0; attempt < 100; attempt++) - { - var hit = mail.Sent.FirstOrDefault(match); - if (hit is not null) - { - return hit; - } - - await Task.Delay(150); - } - - var captured = string.Join(" | ", mail.Sent.Select(m => $"[{m.Subject} -> {string.Join(",", m.To)}]")); - throw new Xunit.Sdk.XunitException( - $"Expected e-mail was not captured within the timeout. Captured: {(captured.Length == 0 ? "(none)" : captured)}"); - } - - [Fact] - public async Task ForgotPassword_Should_EmitResetLink_ToRequestingFrontend() - { - // Arrange - using var adminClient = await _auth.CreateRootAdminClientAsync(); - var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "reset-5174"); - Mail.Clear(); - - using var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); - client.DefaultRequestHeaders.Remove("Origin"); - client.DefaultRequestHeaders.Add("Origin", "http://localhost:5174"); - - // Act - var response = await client.PostAsJsonAsync( - $"{TestConstants.IdentityBasePath}/forgot-password", new { email = user.Email }); - response.StatusCode.ShouldBe(HttpStatusCode.OK); - - // Assert - the rendered e-mail links to the :5174 SPA reset page with the required params. - var mail = await WaitForMailAsync(Mail, m => m.To.Contains(user.Email) && m.Subject == "Reset Password"); - var body = mail.Body.ShouldNotBeNull(); - body.ShouldContain("http://localhost:5174/reset-password"); - body.ShouldContain($"tenant={TestConstants.RootTenantId}"); - body.ShouldNotContain(":7030"); - } - - [Fact] - public async Task ForgotPassword_Should_EmitResetLink_ToTheOtherFrontend() - { - // Arrange - a request from the admin SPA (:5173) must resolve to :5173, proving per-front resolution. - using var adminClient = await _auth.CreateRootAdminClientAsync(); - var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "reset-5173"); - Mail.Clear(); - - using var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); - client.DefaultRequestHeaders.Remove("Origin"); - client.DefaultRequestHeaders.Add("Origin", "http://localhost:5173"); - - // Act - var response = await client.PostAsJsonAsync( - $"{TestConstants.IdentityBasePath}/forgot-password", new { email = user.Email }); - response.StatusCode.ShouldBe(HttpStatusCode.OK); - - // Assert - var mail = await WaitForMailAsync(Mail, m => m.To.Contains(user.Email) && m.Subject == "Reset Password"); - mail.Body.ShouldNotBeNull().ShouldContain("http://localhost:5173/reset-password"); - } - - [Fact] - public async Task Register_Should_EmitConfirmationLink_ToRequestingFrontend() - { - // Arrange - using var adminClient = await _auth.CreateRootAdminClientAsync(); - adminClient.DefaultRequestHeaders.Remove("Origin"); - adminClient.DefaultRequestHeaders.Add("Origin", "http://localhost:5174"); - Mail.Clear(); - var uniqueId = Guid.NewGuid().ToString("N")[..8]; - var email = $"confirm-{uniqueId}@example.com"; - - // Act - var response = await adminClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/register", new - { - firstName = "Confirm", - lastName = "Link", - email, - userName = $"confirm-{uniqueId}", - password = "Test@1234!", - confirmPassword = "Test@1234!" - }); - response.StatusCode.ShouldBe(HttpStatusCode.Created); - - // Assert - the confirmation e-mail (not the welcome e-mail) links to the SPA confirm-email page. - var mail = await WaitForMailAsync(Mail, m => m.To.Contains(email) && m.Subject == "Confirm Your Email Address"); - var body = mail.Body.ShouldNotBeNull(); - body.ShouldContain("http://localhost:5174/confirm-email"); - body.ShouldContain("userId="); - body.ShouldContain("code="); - body.ShouldNotContain("api/v1/identity/confirm-email"); - } -} From aa4037f234b0258e01a2f770498bc2fce1fbb718 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:45:17 -0300 Subject: [PATCH 06/17] docs(identity): describe origin comments by intent, not the prior behavior --- .../v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs | 2 +- .../Modules.Identity/Services/UserRegistrationService.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs index 02e2404793..c3cba946c4 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -20,7 +20,7 @@ public async ValueTask Handle(ForgotPasswordCommand command, Cancellatio { ArgumentNullException.ThrowIfNull(command); - // The reset link must land on the SPA that made the request, not the API host. + // The reset link must land on the SPA that made the request. var origin = _originResolver.FrontendOrigin(); await _userService.ForgotPasswordAsync(command.Email, origin, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index 553e4ab957..c966cabf4d 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -345,8 +345,8 @@ private async Task GetEmailVerificationUriAsync(FshUser user, string ori string code = await userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - // Points at the SPA confirm-email page (which then calls the API), not the API route directly, - // so the link lands on the front-end the user registered from. `origin` is the front-end origin. + // The SPA confirm-email page (which then calls the API) on the front-end the user registered from; + // `origin` is the resolved front-end origin. const string route = "confirm-email"; var endpointUri = new Uri(string.Concat($"{origin.TrimEnd('/')}/", route)); From 1b8c299a057a21e64b6d1b8f023c434431d947e1 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:28:43 -0300 Subject: [PATCH 07/17] fix(identity): reword confirm-email comment to satisfy S125 The explanatory comment above the confirm-email URI build read like commented-out code to SonarAnalyzer (S125) because of its parentheses and trailing semicolon, failing the -warnaserror backend build. Reword it as plain prose; behaviour is unchanged. --- .../Modules.Identity/Services/UserRegistrationService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index c966cabf4d..91f02aa47f 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -345,8 +345,8 @@ private async Task GetEmailVerificationUriAsync(FshUser user, string ori string code = await userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - // The SPA confirm-email page (which then calls the API) on the front-end the user registered from; - // `origin` is the resolved front-end origin. + // Point at the SPA confirm-email page on the front-end the user registered from, which in turn + // calls the API. The origin argument is the already-resolved front-end origin. const string route = "confirm-email"; var endpointUri = new Uri(string.Concat($"{origin.TrimEnd('/')}/", route)); From 0ee35bcc7764c65c3408fcefcfb6f79d6f43412c Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:25:06 -0300 Subject: [PATCH 08/17] refactor(identity): dedicated FrontendOptions for e-mail link origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #1323. Replace the CorsOptions-coupled, throw-on-miss OriginResolver with a framework-level front-end origin resolver, so any module that builds user-facing links (Identity today; Notifications/Billing/Tickets next) resolves them the same way. - New FSH.Framework.Web.Frontend: FrontendOptions (AllowedOrigins + DefaultOrigin) + IFrontendOriginResolver/FrontendOriginResolver. Validated at startup (ValidateOnStart) so a deployment missing both fails loud on boot instead of 500-ing on the first password-reset — resolves the silent CorsOptions.AllowAll and empty-Production-list traps. - ResolveForCurrentRequest() (self-service: forgot-password, self-register): validates the Origin header against the allow-list, returns the canonical entry (not the client's casing), falls back to DefaultOrigin when no header is present (curl / Scalar / mobile / server-to-server), and throws a 400-mapped CustomException on a present-but-forged origin (was InvalidOperationException -> 500). Matching is component-wise via Uri (port exact). - ResolveDefault() (operator-driven: register, resend-confirmation): targets the recipient's app via DefaultOrigin instead of the operator's Origin, so a tenant user provisioned from the admin app no longer gets a link into :5173. Also serves background jobs that have no HttpContext. - Dedup: ApiOrigin() folded into IRequestContext.Origin (its existing contract); RequestContextService owns the config-first/request-host logic and UserProfileService reads IRequestContextService.Origin for avatar URLs. - appsettings: FrontendOptions (dev 5173/5174 + default 5174; Production empty = deploy requirement). Rebased onto main (#1324 CORS allow-list). --- src/BuildingBlocks/Web/Extensions.cs | 13 ++ .../Web/Frontend/FrontendOptions.cs | 27 +++ .../Web/Frontend/FrontendOriginResolver.cs | 87 ++++++++ .../Web/Frontend/IFrontendOriginResolver.cs | 28 +++ src/BuildingBlocks/Web/Web.csproj | 4 + .../appsettings.Production.json | 4 + src/Host/FSH.Starter.Api/appsettings.json | 7 + .../ForgotPasswordCommandHandler.cs | 10 +- .../RegisterUser/RegisterUserEndpoint.cs | 9 +- .../ResendConfirmationEmailEndpoint.cs | 9 +- .../SelfRegisterUserEndpoint.cs | 8 +- .../Modules.Identity/IdentityModule.cs | 1 - .../Services/IOriginResolver.cs | 21 -- .../Services/OriginResolver.cs | 64 ------ .../Services/RequestContextService.cs | 33 ++- .../Services/UserProfileService.cs | 4 +- .../Web/FrontendOriginResolverTests.cs | 131 ++++++++++++ .../ForgotPasswordCommandHandlerTests.cs | 14 +- .../Services/OriginResolverTests.cs | 192 ------------------ .../Services/RequestContextServiceTests.cs | 6 +- .../FshWebApplicationFactory.cs | 5 + .../Tests/Users/ForgotPasswordRequestTests.cs | 7 +- 22 files changed, 367 insertions(+), 317 deletions(-) create mode 100644 src/BuildingBlocks/Web/Frontend/FrontendOptions.cs create mode 100644 src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs create mode 100644 src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs delete mode 100644 src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs delete mode 100644 src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs create mode 100644 src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs delete mode 100644 src/Tests/Identity.Tests/Services/OriginResolverTests.cs diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 50c6568fda..e6b2833a2d 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -8,6 +8,7 @@ using FSH.Framework.Web.Cors; using FSH.Framework.Web.Exceptions; using FSH.Framework.Web.FeatureFlags; +using FSH.Framework.Web.Frontend; using FSH.Framework.Web.Idempotency; using FSH.Framework.Web.Sse; using FSH.Framework.Web.Health; @@ -135,6 +136,18 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild builder.Services.AddOptions().BindConfiguration(nameof(OriginOptions)); builder.Services.AddOptions().BindConfiguration(nameof(SecurityHeadersOptions)); + // Front-end origin resolution for user-facing links in e-mails/notifications. Validated at + // startup so a deployment missing both the allow-list and the default fails loud on boot + // rather than 500-ing (or shipping empty links) on the first password-reset request. + builder.Services.AddHttpContextAccessor(); + builder.Services.AddOptions() + .BindConfiguration(nameof(FrontendOptions)) + .Validate( + o => o.AllowedOrigins.Length > 0 || !string.IsNullOrWhiteSpace(o.DefaultOrigin), + "FrontendOptions requires AllowedOrigins or DefaultOrigin to be configured.") + .ValidateOnStart(); + builder.Services.AddScoped(); + return builder; } diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs new file mode 100644 index 0000000000..2880aa3e7b --- /dev/null +++ b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs @@ -0,0 +1,27 @@ +namespace FSH.Framework.Web.Frontend; + +/// +/// Configuration for resolving the front-end (SPA) origin used when building user-facing links +/// inside e-mails and notifications. Deliberately separate from CorsOptions: the CORS +/// allow-list governs which browsers may call the API, while this list governs which origins may +/// be embedded in an outbound link. The two often overlap but carry different security duties, and +/// coupling them breaks same-origin/reverse-proxy topologies where CORS needs no entries yet links +/// still must resolve. +/// +public sealed class FrontendOptions +{ + /// + /// Origins trusted to appear in user-facing links. A request's Origin header is only + /// echoed into a link when it matches an entry here (scheme + host + port, port exact). Empty is + /// valid only when is set, in which case every link uses the default. + /// + public string[] AllowedOrigins { get; init; } = []; + + /// + /// Front-end origin used when the request carries no usable Origin header (non-browser + /// callers such as curl / the Scalar try-it UI / mobile apps / server-to-server), for + /// operator-driven flows whose link must land on the recipient's app rather than the caller's, + /// and for background jobs that run without an HTTP request. Typically the tenant dashboard URL. + /// + public string? DefaultOrigin { get; init; } +} diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs new file mode 100644 index 0000000000..f471056e38 --- /dev/null +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -0,0 +1,87 @@ +using System.Net; +using FSH.Framework.Core.Exceptions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Web.Frontend; + +internal sealed class FrontendOriginResolver( + IHttpContextAccessor httpContextAccessor, + IOptions options, + ILogger logger) : IFrontendOriginResolver +{ + // Normalize the allow-list once at construction: parse to Uri so matching is component-wise + // (scheme + host + port) instead of a raw string compare that an entry like ":443" or an IDN + // form would silently fail. + private readonly Uri[] _allowed = Normalize(options.Value.AllowedOrigins); + private readonly string? _default = options.Value.DefaultOrigin?.TrimEnd('/'); + + public string ResolveForCurrentRequest() + { + var header = httpContextAccessor.HttpContext?.Request.Headers.Origin.ToString(); + if (string.IsNullOrWhiteSpace(header)) + { + // Non-browser caller (curl, Scalar try-it, mobile, server-to-server) sends no Origin. + // Fall back to the configured default rather than failing an otherwise valid flow. + return ResolveDefault(); + } + + var canonical = MatchAllowed(header); + if (canonical is not null) + { + return canonical; + } + + // A present-but-unlisted Origin is a forged or misconfigured client, not a server fault: + // surface a 4xx so error-rate alerting doesn't page on bot traffic to anonymous endpoints. + logger.LogWarning("Rejected front-end origin {Origin}: not in FrontendOptions:AllowedOrigins", header); + throw new CustomException( + "The request origin is not an allowed front-end origin.", + errors: null, + HttpStatusCode.BadRequest); + } + + public string ResolveDefault() + { + if (string.IsNullOrEmpty(_default)) + { + // Startup validation should prevent this; guard anyway so a misconfig surfaces as a + // clear 500 rather than an empty link silently shipped into an e-mail. + throw new CustomException( + "No default front-end origin is configured (FrontendOptions:DefaultOrigin).", + errors: null, + HttpStatusCode.InternalServerError); + } + + return _default; + } + + private string? MatchAllowed(string header) + { + if (!Uri.TryCreate(header.TrimEnd('/'), UriKind.Absolute, out var candidate)) + { + return null; + } + + // Return the canonical configured entry, never the client-supplied casing. + return _allowed + .FirstOrDefault(allowed => Uri.Compare( + candidate, allowed, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) + ?.GetLeftPart(UriPartial.Authority); + } + + private static Uri[] Normalize(string[] origins) + { + var list = new List(origins.Length); + foreach (var origin in origins) + { + if (Uri.TryCreate(origin.TrimEnd('/'), UriKind.Absolute, out var uri)) + { + list.Add(uri); + } + } + + return [.. list]; + } +} diff --git a/src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs new file mode 100644 index 0000000000..c53969cc58 --- /dev/null +++ b/src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Web.Frontend; + +/// +/// Resolves the front-end (SPA) origin used to build user-facing links inside e-mails and +/// notifications. Framework-level so any module that sends such links (Identity, Notifications, +/// Billing, Tickets, …) resolves the origin the same way. +/// +public interface IFrontendOriginResolver +{ + /// + /// Origin for a link that lands on the SPA the caller is currently using — self-service flows + /// (password reset, self-registration) where the request comes from the user's own app. + /// Validates the request Origin header against + /// and returns the canonical matching entry (never the client's raw casing). Falls back to + /// when the request carries no Origin header. + /// Throws a 400-mapped exception when a header is present but not allow-listed — a forged origin + /// must never reach an e-mail. + /// + string ResolveForCurrentRequest(); + + /// + /// Origin for a link whose recipient is not the caller — operator-driven flows (an admin + /// registering or re-inviting a tenant user, whose confirmation link must land on the tenant's + /// app, not the operator's) — or where no HTTP request exists (background jobs). Returns + /// . + /// + string ResolveDefault(); +} diff --git a/src/BuildingBlocks/Web/Web.csproj b/src/BuildingBlocks/Web/Web.csproj index c84453709a..0c06183376 100644 --- a/src/BuildingBlocks/Web/Web.csproj +++ b/src/BuildingBlocks/Web/Web.csproj @@ -48,4 +48,8 @@ + + + + diff --git a/src/Host/FSH.Starter.Api/appsettings.Production.json b/src/Host/FSH.Starter.Api/appsettings.Production.json index 332724534b..2fdf8cbf84 100644 --- a/src/Host/FSH.Starter.Api/appsettings.Production.json +++ b/src/Host/FSH.Starter.Api/appsettings.Production.json @@ -62,6 +62,10 @@ "AllowedHeaders": [ "content-type", "authorization" ], "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] }, + "FrontendOptions": { + "AllowedOrigins": [], + "DefaultOrigin": "" + }, "JwtOptions": { "Issuer": "fsh.local", "Audience": "fsh.clients", diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json index 293fdfebb6..b81ab37269 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -103,6 +103,13 @@ "AllowedHeaders": [ "content-type", "authorization" ], "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] }, + "FrontendOptions": { + "AllowedOrigins": [ + "http://localhost:5173", + "http://localhost:5174" + ], + "DefaultOrigin": "http://localhost:5174" + }, "JwtOptions": { "Issuer": "fsh.local", "Audience": "fsh.clients", diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs index c3cba946c4..c7442ee07d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -1,6 +1,6 @@ +using FSH.Framework.Web.Frontend; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; -using FSH.Modules.Identity.Services; using Mediator; namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword; @@ -8,9 +8,9 @@ namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword; public sealed class ForgotPasswordCommandHandler : ICommandHandler { private readonly IUserService _userService; - private readonly IOriginResolver _originResolver; + private readonly IFrontendOriginResolver _originResolver; - public ForgotPasswordCommandHandler(IUserService userService, IOriginResolver originResolver) + public ForgotPasswordCommandHandler(IUserService userService, IFrontendOriginResolver originResolver) { _userService = userService; _originResolver = originResolver; @@ -20,8 +20,8 @@ public async ValueTask Handle(ForgotPasswordCommand command, Cancellatio { ArgumentNullException.ThrowIfNull(command); - // The reset link must land on the SPA that made the request. - var origin = _originResolver.FrontendOrigin(); + // Self-service flow: the reset link must land on the SPA the user is currently using. + var origin = _originResolver.ResolveForCurrentRequest(); await _userService.ForgotPasswordAsync(command.Email, origin, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs index 17ed9d6e16..043c02e64e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs @@ -1,8 +1,8 @@ using FSH.Modules.Identity.Contracts.Authorization; using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Web.Frontend; using FSH.Framework.Web.Idempotency; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; -using FSH.Modules.Identity.Services; using Mediator; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -15,12 +15,13 @@ public static class RegisterUserEndpoint internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/register", async (RegisterUserCommand command, - IOriginResolver originResolver, + IFrontendOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) => { - // The confirmation link lands on the SPA that made the request; resolved from the Origin header. - command.Origin = originResolver.FrontendOrigin(); + // Operator-driven flow: an admin registers a tenant user, so the confirmation link must + // land on the recipient's app (the default front-end), not the operator's Origin. + command.Origin = originResolver.ResolveDefault(); var result = await mediator.Send(command, cancellationToken); return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); }) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs index 3292a7a1f7..f5d3523e4d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs @@ -1,7 +1,7 @@ using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Web.Frontend; using FSH.Modules.Identity.Contracts.Authorization; using FSH.Modules.Identity.Contracts.v1.Users.ResendConfirmationEmail; -using FSH.Modules.Identity.Services; using Mediator; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -27,12 +27,13 @@ internal static RouteHandlerBuilder MapResendConfirmationEmailEndpoint(this IEnd private static async Task Handler( Guid id, - IOriginResolver originResolver, + IFrontendOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) { - // The confirmation link lands on the SPA that made the request; resolved from the Origin header. - var origin = originResolver.FrontendOrigin(); + // Operator-driven flow: an admin re-sends a tenant user's confirmation, so the link must + // land on the recipient's app (the default front-end), not the operator's Origin. + var origin = originResolver.ResolveDefault(); await mediator.Send(new ResendConfirmationEmailCommand(id.ToString(), origin), cancellationToken); return TypedResults.NoContent(); } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs index 5cdec2d112..3dbe7d80b6 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs @@ -1,7 +1,7 @@ using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Web.Frontend; using FSH.Framework.Web.Idempotency; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; -using FSH.Modules.Identity.Services; using Mediator; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -16,12 +16,12 @@ internal static RouteHandlerBuilder MapSelfRegisterUserEndpoint(this IEndpointRo { return endpoints.MapPost("/self-register", async (RegisterUserCommand command, [FromHeader(Name = MultitenancyConstants.Identifier)] string tenant, - IOriginResolver originResolver, + IFrontendOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) => { - // The confirmation link lands on the SPA that made the request; resolved from the Origin header. - command.Origin = originResolver.FrontendOrigin(); + // Self-service flow: the confirmation link lands on the SPA the user registered from. + command.Origin = originResolver.ResolveForCurrentRequest(); var result = await mediator.Send(command, cancellationToken); return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); }) diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs index 6550569d15..3c64f20eaf 100644 --- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs +++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -95,7 +95,6 @@ public void ConfigureServices(IHostApplicationBuilder builder) services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); - services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs b/src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs deleted file mode 100644 index 0f59013ab3..0000000000 --- a/src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace FSH.Modules.Identity.Services; - -/// -/// Resolves the base URL used to build user-facing links, distinguishing links that land on a -/// front-end single-page app from links and assets served by the API itself. -/// -public interface IOriginResolver -{ - /// - /// Origin of the calling single-page app, taken from the request Origin header and - /// validated against the CORS allow-list. Used for links that land on a front-end page - /// (password reset, e-mail confirmation). Throws when the request carries no allow-listed origin. - /// - string FrontendOrigin(); - - /// - /// Origin of the API itself, used for links and assets served by the back-end (avatars, API routes). - /// Prefers the configured origin, falling back to the request host. Null when neither is available. - /// - string? ApiOrigin(); -} diff --git a/src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs b/src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs deleted file mode 100644 index e82bc1598e..0000000000 --- a/src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs +++ /dev/null @@ -1,64 +0,0 @@ -using FSH.Framework.Web.Cors; -using FSH.Framework.Web.Origin; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace FSH.Modules.Identity.Services; - -internal sealed class OriginResolver( - IHttpContextAccessor httpContextAccessor, - IOptions corsOptions, - IOptions originOptions, - ILogger logger) : IOriginResolver -{ - private readonly string[] _allowedOrigins = corsOptions.Value.AllowedOrigins; - private readonly Uri? _originUrl = originOptions.Value.OriginUrl; - - public string FrontendOrigin() - { - var origin = httpContextAccessor.HttpContext?.Request.Headers.Origin.ToString(); - if (!string.IsNullOrWhiteSpace(origin) && IsAllowed(origin)) - { - return origin.TrimEnd('/'); - } - - // The allow-list check is the security boundary: a forged Origin header on an anonymous - // request (e.g. forgot-password) must never end up as a link inside an e-mail. - logger.LogWarning("Rejected frontend origin {Origin}: not present in the CORS allow-list", origin); - throw new InvalidOperationException( - "The request origin is not an allowed front-end origin. Configure CorsOptions:AllowedOrigins with the front-end URLs."); - } - - public string? ApiOrigin() - { - if (_originUrl is not null) - { - return _originUrl.AbsoluteUri.TrimEnd('/'); - } - - var request = httpContextAccessor.HttpContext?.Request; - if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) - { - return $"{request.Scheme}://{request.Host.Value}{request.PathBase}".TrimEnd('/'); - } - - return null; - } - - private bool IsAllowed(string origin) - { - var normalized = origin.TrimEnd('/'); - foreach (var allowed in _allowedOrigins) - { - // Scheme + host are case-insensitive; the port is compared exactly so :5173 never - // matches :5174. A trailing slash on either side is ignored. - if (string.Equals(allowed.TrimEnd('/'), normalized, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } -} diff --git a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs index 548dd65b2a..5046b3c413 100644 --- a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs @@ -1,6 +1,7 @@ -using FSH.Framework.Core.Context; +using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.Services; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace FSH.Modules.Identity.Services; @@ -11,14 +12,14 @@ namespace FSH.Modules.Identity.Services; internal sealed class RequestContextService : IRequestContextService { private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IOriginResolver _originResolver; + private readonly Uri? _configuredOrigin; public RequestContextService( IHttpContextAccessor httpContextAccessor, - IOriginResolver originResolver) + IOptions originOptions) { _httpContextAccessor = httpContextAccessor; - _originResolver = originResolver; + _configuredOrigin = originOptions.Value.OriginUrl; } public string? IpAddress => @@ -36,5 +37,27 @@ public string ClientId } } - public string? Origin => _originResolver.ApiOrigin(); + /// + /// Origin of the API itself (scheme + host + path base), used for back-end-served links and + /// assets such as avatars. Prefers the configured OriginOptions:OriginUrl, falling back + /// to the current request's host; null when neither is available (e.g. a background job). + /// + public string? Origin + { + get + { + if (_configuredOrigin is not null) + { + return _configuredOrigin.AbsoluteUri.TrimEnd('/'); + } + + var request = _httpContextAccessor.HttpContext?.Request; + if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) + { + return $"{request.Scheme}://{request.Host.Value}{request.PathBase}".TrimEnd('/'); + } + + return null; + } + } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index fab71c2e8f..ac4ec275a1 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -17,7 +17,7 @@ internal sealed class UserProfileService( SignInManager signInManager, IStorageService storageService, IMultiTenantContextAccessor multiTenantContextAccessor, - IOriginResolver originResolver) : IUserProfileService + IRequestContextService requestContext) : IUserProfileService { public async Task GetAsync(string userId, CancellationToken cancellationToken) { @@ -169,7 +169,7 @@ private void EnsureValidTenant() } // For relative paths from local storage, prefix with the API origin (configured, else the request host). - var baseUri = originResolver.ApiOrigin(); + var baseUri = requestContext.Origin; if (string.IsNullOrEmpty(baseUri)) { return imageUrl.ToString(); diff --git a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs new file mode 100644 index 0000000000..31ffc67b4e --- /dev/null +++ b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Frontend; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Framework.Tests.Web; + +/// +/// Tests for FrontendOriginResolver — resolves the SPA origin for user-facing links, validating the +/// request Origin header against the allow-list and falling back to the configured default. +/// +public sealed class FrontendOriginResolverTests +{ + private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For(); + + private FrontendOriginResolver CreateResolver(string[] allowedOrigins, string? defaultOrigin = null) + { + var options = Options.Create(new FrontendOptions + { + AllowedOrigins = allowedOrigins, + DefaultOrigin = defaultOrigin, + }); + return new FrontendOriginResolver(_httpContextAccessor, options, NullLogger.Instance); + } + + private void SetOriginHeader(string? origin) + { + var context = new DefaultHttpContext(); + if (origin is not null) + { + context.Request.Headers.Origin = origin; + } + + _httpContextAccessor.HttpContext.Returns(context); + } + + // ── ResolveForCurrentRequest ──────────────────────────────────────────── + + [Fact] + public void ResolveForCurrentRequest_Should_ReturnCanonicalEntry_When_HeaderInAllowList() + { + SetOriginHeader("http://localhost:5173"); + var resolver = CreateResolver(["http://localhost:5173", "http://localhost:5174"]); + + resolver.ResolveForCurrentRequest().ShouldBe("http://localhost:5173"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_MatchIgnoringTrailingSlash() + { + SetOriginHeader("http://localhost:5173/"); + var resolver = CreateResolver(["http://localhost:5173"]); + + resolver.ResolveForCurrentRequest().ShouldBe("http://localhost:5173"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_ReturnCanonicalCasing_When_HeaderCasingDiffers() + { + // A client sending uppercased scheme/host must not steer the emitted link's casing: + // the resolver returns the canonical allow-list entry, not the raw header. + SetOriginHeader("HTTP://LOCALHOST:5173"); + var resolver = CreateResolver(["http://localhost:5173"]); + + resolver.ResolveForCurrentRequest().ShouldBe("http://localhost:5173"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_Reject_When_PortDiffers() + { + // :5174 must never match the :5173 allow-list entry (port compared exactly). + SetOriginHeader("http://localhost:5174"); + var resolver = CreateResolver(["http://localhost:5173"]); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public void ResolveForCurrentRequest_Should_Reject_When_HeaderForged() + { + SetOriginHeader("https://evil.example.com"); + var resolver = CreateResolver(["http://localhost:5173"]); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToDefault_When_NoHeader() + { + // Non-browser callers (curl, Scalar, mobile, server-to-server) send no Origin — use the default. + SetOriginHeader(null); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: "https://app.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://app.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToDefault_When_NoHttpContext() + { + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: "https://app.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://app.example.com"); + } + + // ── ResolveDefault ────────────────────────────────────────────────────── + + [Fact] + public void ResolveDefault_Should_ReturnConfiguredDefault_TrailingSlashTrimmed() + { + var resolver = CreateResolver([], defaultOrigin: "https://app.example.com/"); + + resolver.ResolveDefault().ShouldBe("https://app.example.com"); + } + + [Fact] + public void ResolveDefault_Should_Throw_When_DefaultNotConfigured() + { + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null); + + var ex = Should.Throw(() => resolver.ResolveDefault()); + ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } +} diff --git a/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs index 7bc971cfb1..85cbb763e0 100644 --- a/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs +++ b/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs @@ -1,8 +1,8 @@ using AutoFixture; +using FSH.Framework.Web.Frontend; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; using FSH.Modules.Identity.Features.v1.Users.ForgotPassword; -using FSH.Modules.Identity.Services; using NSubstitute; using Shouldly; using Xunit; @@ -12,14 +12,14 @@ namespace Identity.Tests.Handlers; public sealed class ForgotPasswordCommandHandlerTests { private readonly IUserService _userService; - private readonly IOriginResolver _originResolver; + private readonly IFrontendOriginResolver _originResolver; private readonly ForgotPasswordCommandHandler _sut; private readonly IFixture _fixture; public ForgotPasswordCommandHandlerTests() { _userService = Substitute.For(); - _originResolver = Substitute.For(); + _originResolver = Substitute.For(); _sut = new ForgotPasswordCommandHandler(_userService, _originResolver); _fixture = new Fixture(); } @@ -30,7 +30,7 @@ public async Task Handle_Should_CallForgotPasswordAsync_With_ResolvedFrontendOri // Arrange var command = _fixture.Create(); const string origin = "https://app.example.com"; - _originResolver.FrontendOrigin().Returns(origin); + _originResolver.ResolveForCurrentRequest().Returns(origin); // Act var result = await _sut.Handle(command, CancellationToken.None); @@ -43,9 +43,9 @@ public async Task Handle_Should_CallForgotPasswordAsync_With_ResolvedFrontendOri [Fact] public async Task Handle_Should_Propagate_When_OriginResolverThrows() { - // Arrange - a request without an allow-listed Origin header cannot build a reset link. + // Arrange - a request with a forged Origin header cannot build a reset link. var command = _fixture.Create(); - _originResolver.FrontendOrigin().Returns(_ => throw new InvalidOperationException("no origin")); + _originResolver.ResolveForCurrentRequest().Returns(_ => throw new InvalidOperationException("no origin")); // Act & Assert await Should.ThrowAsync(async () => @@ -66,7 +66,7 @@ public async Task Handle_Should_PassCancellationToken_ToUserService() { // Arrange var command = _fixture.Create(); - _originResolver.FrontendOrigin().Returns("https://app.example.com"); + _originResolver.ResolveForCurrentRequest().Returns("https://app.example.com"); using var cts = new CancellationTokenSource(); // Act diff --git a/src/Tests/Identity.Tests/Services/OriginResolverTests.cs b/src/Tests/Identity.Tests/Services/OriginResolverTests.cs deleted file mode 100644 index a4dae396f9..0000000000 --- a/src/Tests/Identity.Tests/Services/OriginResolverTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -using FSH.Framework.Web.Cors; -using FSH.Framework.Web.Origin; -using FSH.Modules.Identity.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using NSubstitute; -using Shouldly; -using Xunit; - -namespace Identity.Tests.Services; - -/// -/// Tests for OriginResolver - resolves the front-end origin (Origin header validated against the CORS -/// allow-list) and the API origin (configured, else request-derived). -/// -public sealed class OriginResolverTests -{ - private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For(); - - private OriginResolver CreateResolver(string[] allowedOrigins, Uri? originUrl = null) - { - var cors = Options.Create(new CorsOptions { AllowedOrigins = allowedOrigins }); - var origin = Options.Create(new OriginOptions { OriginUrl = originUrl }); - return new OriginResolver(_httpContextAccessor, cors, origin, NullLogger.Instance); - } - - private void SetOriginHeader(string? origin) - { - var context = new DefaultHttpContext(); - if (origin is not null) - { - context.Request.Headers.Origin = origin; - } - - _httpContextAccessor.HttpContext.Returns(context); - } - - #region FrontendOrigin - - [Fact] - public void FrontendOrigin_Should_ReturnOrigin_When_HeaderInAllowList() - { - // Arrange - SetOriginHeader("http://localhost:5173"); - var resolver = CreateResolver(["http://localhost:5173", "http://localhost:5174"]); - - // Act - var result = resolver.FrontendOrigin(); - - // Assert - result.ShouldBe("http://localhost:5173"); - } - - [Fact] - public void FrontendOrigin_Should_MatchIgnoringTrailingSlash() - { - // Arrange - header has a trailing slash, allow-list entry does not - SetOriginHeader("http://localhost:5173/"); - var resolver = CreateResolver(["http://localhost:5173"]); - - // Act - var result = resolver.FrontendOrigin(); - - // Assert - result.ShouldBe("http://localhost:5173"); - } - - [Fact] - public void FrontendOrigin_Should_MatchIgnoringCase() - { - // Arrange - SetOriginHeader("HTTP://LOCALHOST:5173"); - var resolver = CreateResolver(["http://localhost:5173"]); - - // Act - var result = resolver.FrontendOrigin(); - - // Assert - result.ShouldBe("HTTP://LOCALHOST:5173"); - } - - [Fact] - public void FrontendOrigin_Should_Throw_When_PortDiffers() - { - // Arrange - :5174 must never match the :5173 allow-list entry - SetOriginHeader("http://localhost:5174"); - var resolver = CreateResolver(["http://localhost:5173"]); - - // Act & Assert - Should.Throw(() => resolver.FrontendOrigin()); - } - - [Fact] - public void FrontendOrigin_Should_Throw_When_HeaderNotInAllowList() - { - // Arrange - a forged Origin header must be rejected - SetOriginHeader("https://evil.example.com"); - var resolver = CreateResolver(["http://localhost:5173"]); - - // Act & Assert - Should.Throw(() => resolver.FrontendOrigin()); - } - - [Fact] - public void FrontendOrigin_Should_Throw_When_NoHeader() - { - // Arrange - SetOriginHeader(null); - var resolver = CreateResolver(["http://localhost:5173"]); - - // Act & Assert - Should.Throw(() => resolver.FrontendOrigin()); - } - - [Fact] - public void FrontendOrigin_Should_Throw_When_NoHttpContext() - { - // Arrange - _httpContextAccessor.HttpContext.Returns((HttpContext?)null); - var resolver = CreateResolver(["http://localhost:5173"]); - - // Act & Assert - Should.Throw(() => resolver.FrontendOrigin()); - } - - [Fact] - public void FrontendOrigin_Should_Throw_When_AllowListEmpty_EvenWithHeader() - { - // Arrange - AllowAll defaults to true, but an empty allow-list must not trust any origin for links - SetOriginHeader("http://localhost:5173"); - var resolver = CreateResolver([]); - - // Act & Assert - Should.Throw(() => resolver.FrontendOrigin()); - } - - #endregion - - #region ApiOrigin - - [Fact] - public void ApiOrigin_Should_ReturnConfigured_When_OriginUrlSet() - { - // Arrange - configured origin wins and is trailing-slash trimmed - var context = new DefaultHttpContext(); - context.Request.Scheme = "http"; - context.Request.Host = new HostString("request.example.com"); - _httpContextAccessor.HttpContext.Returns(context); - var resolver = CreateResolver([], new Uri("https://configured.example.com/")); - - // Act - var result = resolver.ApiOrigin(); - - // Assert - result.ShouldBe("https://configured.example.com"); - } - - [Fact] - public void ApiOrigin_Should_DeriveFromRequest_When_OriginUrlNull() - { - // Arrange - var context = new DefaultHttpContext(); - context.Request.Scheme = "https"; - context.Request.Host = new HostString("api.example.com"); - context.Request.PathBase = new PathString("/base"); - _httpContextAccessor.HttpContext.Returns(context); - var resolver = CreateResolver([], originUrl: null); - - // Act - var result = resolver.ApiOrigin(); - - // Assert - result.ShouldBe("https://api.example.com/base"); - } - - [Fact] - public void ApiOrigin_Should_ReturnNull_When_OriginUrlNullAndNoHttpContext() - { - // Arrange - _httpContextAccessor.HttpContext.Returns((HttpContext?)null); - var resolver = CreateResolver([], originUrl: null); - - // Act - var result = resolver.ApiOrigin(); - - // Assert - result.ShouldBeNull(); - } - - #endregion -} diff --git a/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs b/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs index b9dfff6489..a26161b7ec 100644 --- a/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs +++ b/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs @@ -1,9 +1,7 @@ using System.Net; -using FSH.Framework.Web.Cors; using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Services; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; @@ -24,9 +22,7 @@ public RequestContextServiceTests() private RequestContextService CreateService(Uri? originUrl = null) { var originOptions = Options.Create(new OriginOptions { OriginUrl = originUrl }); - var corsOptions = Options.Create(new CorsOptions()); - var resolver = new OriginResolver(_httpContextAccessor, corsOptions, originOptions, NullLogger.Instance); - return new RequestContextService(_httpContextAccessor, resolver); + return new RequestContextService(_httpContextAccessor, originOptions); } private void SetHttpContext(HttpContext? context) diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index 020a8a90c7..0f215afa8a 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -133,6 +133,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["JwtOptions:RefreshTokenDays"] = "7", ["OriginOptions:OriginUrl"] = "http://localhost", ["CorsOptions:AllowedOrigins:0"] = "http://localhost", + // Front-end origin resolution: allow the simulated browser Origin for self-service + // flows, and set the same as the default so operator-driven flows (register/resend) + // and the startup validation both resolve. + ["FrontendOptions:AllowedOrigins:0"] = "http://localhost", + ["FrontendOptions:DefaultOrigin"] = "http://localhost", ["OpenTelemetryOptions:Enabled"] = "false", ["EventingOptions:UseHostedServiceDispatcher"] = "false", ["Serilog:MinimumLevel:Default"] = "Warning", diff --git a/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs b/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs index 611a333934..e8b8092ffb 100644 --- a/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs +++ b/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs @@ -72,7 +72,7 @@ public async Task ForgotPassword_Should_Return400_When_EmailIsMalformed() [Fact] public async Task ForgotPassword_Should_Reject_When_OriginNotAllowed() { - // Arrange - a forged Origin header (not in the CORS allow-list) must never build a reset link. + // Arrange - a forged Origin header (not in FrontendOptions:AllowedOrigins) must never build a reset link. using var adminClient = await _auth.CreateRootAdminClientAsync(); var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "forgot-forged"); @@ -85,8 +85,9 @@ public async Task ForgotPassword_Should_Reject_When_OriginNotAllowed() var response = await client.PostAsJsonAsync( $"{TestConstants.IdentityBasePath}/forgot-password", new { email = user.Email }); - // Assert - rejected, not the uniform OK the happy path returns. - response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + // Assert - a present-but-unlisted origin is a client fault: 400, not the 500 a server fault + // would raise, and not the uniform OK the happy path returns. + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } [Fact] From 112ae5f6eb64571275995b7b2407439425c3bb6f Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:29:28 -0300 Subject: [PATCH 09/17] refactor(web): address origin-resolver review nits (log level, docs, boot message) - Log rejected origins at Debug, not Warning: the auth endpoints are anonymous, so bot/forged traffic would flood the aggregator; a genuine deployer misconfig still surfaces as a 400 to the affected SPA's users. - Document that FrontendOptions:DefaultOrigin is a single global (not per-tenant/custom-domain aware) so operator-driven links land on one SPA. - Make the FrontendOptions startup-validation message first-run actionable, matching the JwtOptions "set it before starting the host" precedent. --- src/BuildingBlocks/Web/Extensions.cs | 4 +++- src/BuildingBlocks/Web/Frontend/FrontendOptions.cs | 6 ++++++ src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs | 8 +++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index e6b2833a2d..d163680ee3 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -144,7 +144,9 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild .BindConfiguration(nameof(FrontendOptions)) .Validate( o => o.AllowedOrigins.Length > 0 || !string.IsNullOrWhiteSpace(o.DefaultOrigin), - "FrontendOptions requires AllowedOrigins or DefaultOrigin to be configured.") + "No front-end origin configured in FrontendOptions. Set FrontendOptions:AllowedOrigins " + + "(the SPA origins trusted in e-mail links) and/or FrontendOptions:DefaultOrigin (the " + + "fallback SPA for non-browser and operator-driven flows) before starting the host.") .ValidateOnStart(); builder.Services.AddScoped(); diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs index 2880aa3e7b..c01e43788f 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs @@ -22,6 +22,12 @@ public sealed class FrontendOptions /// callers such as curl / the Scalar try-it UI / mobile apps / server-to-server), for /// operator-driven flows whose link must land on the recipient's app rather than the caller's, /// and for background jobs that run without an HTTP request. Typically the tenant dashboard URL. + /// + /// This is a single global value, not per-tenant or custom-domain aware: operator-driven + /// register / resend-confirmation therefore point every tenant's link at this one SPA. + /// That fits the kit's single-dashboard model; a deployment with per-tenant custom domains would + /// need to resolve the recipient tenant's own origin here instead. + /// /// public string? DefaultOrigin { get; init; } } diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs index f471056e38..7e9cf61685 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -35,7 +35,13 @@ public string ResolveForCurrentRequest() // A present-but-unlisted Origin is a forged or misconfigured client, not a server fault: // surface a 4xx so error-rate alerting doesn't page on bot traffic to anonymous endpoints. - logger.LogWarning("Rejected front-end origin {Origin}: not in FrontendOptions:AllowedOrigins", header); + // Logged at Debug, not Warning: these endpoints are anonymous, so bot/forged traffic would + // flood the aggregator at Warning. A genuine deployer misconfig (a real SPA origin missing + // from the list) already surfaces loudly as a 400 to that SPA's own users. + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Rejected front-end origin {Origin}: not in FrontendOptions:AllowedOrigins", header); + } throw new CustomException( "The request origin is not an allowed front-end origin.", errors: null, From b635632273fe195f68d235ca1ed4c20fa97df24a Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:35:00 -0300 Subject: [PATCH 10/17] fix(web): require FrontendOptions:DefaultOrigin at startup The boot validation accepted AllowedOrigins-only (DefaultOrigin empty), yet operator-driven register/resend, every non-browser caller (no Origin header) and background jobs resolve through DefaultOrigin. Such a host booted clean then 500'd on the first admin register or non-browser request - the same surprise-runtime-break the fail-loud validation was meant to prevent. Require DefaultOrigin unconditionally; AllowedOrigins stays additive (widening which request origins may be echoed into self-service links). Same-origin / reverse-proxy topologies still work with DefaultOrigin alone. Fold the redundant second AddHttpContextAccessor() call into the platform's existing one. --- src/BuildingBlocks/Web/Extensions.cs | 16 ++++++++-------- .../Web/Frontend/FrontendOptions.cs | 6 ++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index d163680ee3..aa4b413c73 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -136,17 +136,17 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild builder.Services.AddOptions().BindConfiguration(nameof(OriginOptions)); builder.Services.AddOptions().BindConfiguration(nameof(SecurityHeadersOptions)); - // Front-end origin resolution for user-facing links in e-mails/notifications. Validated at - // startup so a deployment missing both the allow-list and the default fails loud on boot - // rather than 500-ing (or shipping empty links) on the first password-reset request. - builder.Services.AddHttpContextAccessor(); + // Front-end origin resolution for user-facing links in e-mails/notifications. DefaultOrigin + // is required and validated at startup: operator-driven flows (admin register / resend), all + // non-browser callers (no Origin header) and background jobs resolve through it, so a host + // that boots without it would 500 on the first such request instead of failing loud here. builder.Services.AddOptions() .BindConfiguration(nameof(FrontendOptions)) .Validate( - o => o.AllowedOrigins.Length > 0 || !string.IsNullOrWhiteSpace(o.DefaultOrigin), - "No front-end origin configured in FrontendOptions. Set FrontendOptions:AllowedOrigins " + - "(the SPA origins trusted in e-mail links) and/or FrontendOptions:DefaultOrigin (the " + - "fallback SPA for non-browser and operator-driven flows) before starting the host.") + o => !string.IsNullOrWhiteSpace(o.DefaultOrigin), + "FrontendOptions:DefaultOrigin is required before starting the host (the fallback SPA " + + "for operator-driven, non-browser and background flows). Add FrontendOptions:AllowedOrigins " + + "only to additionally trust per-request origins echoed into self-service links.") .ValidateOnStart(); builder.Services.AddScoped(); diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs index c01e43788f..b6e53e4ce4 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs @@ -23,6 +23,12 @@ public sealed class FrontendOptions /// operator-driven flows whose link must land on the recipient's app rather than the caller's, /// and for background jobs that run without an HTTP request. Typically the tenant dashboard URL. /// + /// Required. Every deployment resolves through this at some point (operator flows, + /// non-browser callers, jobs), so startup validation refuses to boot without it. + /// is additive: it only widens which request origins may be echoed + /// into self-service links, and cannot substitute for the default. + /// + /// /// This is a single global value, not per-tenant or custom-domain aware: operator-driven /// register / resend-confirmation therefore point every tenant's link at this one SPA. /// That fits the kit's single-dashboard model; a deployment with per-tenant custom domains would From 67e026f0b05ebb0da9d66f9936b2940526432763 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:08:19 -0300 Subject: [PATCH 11/17] fix(web): keep booting when FrontendOptions:DefaultOrigin is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DefaultOrigin was validated with ValidateOnStart, so an existing deployment that upgraded without configuring it stopped booting — a setting it may never exercise took the whole host down, and the operator's first signal was a container that would not come up. Fail loud at first use of the feature, not at process start: - drop the startup validation; the host boots with DefaultOrigin unset - ResolveDefault falls back to the API's own origin (OriginOptions:OriginUrl) so links land somewhere serviceable instead of going dark - UseHeroPlatform logs one startup Warning naming the setting, the file and what degrades without it The fallback is deliberately the configured API origin and never the current request's host: ResolveDefault exists because the caller is not the recipient, so an operator-driven confirmation link must not point at the admin app. Forged-origin rejection is unchanged — a present-but-unlisted Origin is still a 400, never swapped for the fallback. --- src/BuildingBlocks/Web/Extensions.cs | 46 +++++++++--- .../Web/Frontend/FrontendOptions.cs | 11 ++- .../Web/Frontend/FrontendOriginResolver.cs | 31 ++++++-- .../Web/FrontendOriginResolverTests.cs | 74 ++++++++++++++++++- 4 files changed, 135 insertions(+), 27 deletions(-) diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index aa4b413c73..b65997581e 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -29,6 +29,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Mediator; namespace FSH.Framework.Web; @@ -137,17 +139,10 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild builder.Services.AddOptions().BindConfiguration(nameof(SecurityHeadersOptions)); // Front-end origin resolution for user-facing links in e-mails/notifications. DefaultOrigin - // is required and validated at startup: operator-driven flows (admin register / resend), all - // non-browser callers (no Origin header) and background jobs resolve through it, so a host - // that boots without it would 500 on the first such request instead of failing loud here. - builder.Services.AddOptions() - .BindConfiguration(nameof(FrontendOptions)) - .Validate( - o => !string.IsNullOrWhiteSpace(o.DefaultOrigin), - "FrontendOptions:DefaultOrigin is required before starting the host (the fallback SPA " + - "for operator-driven, non-browser and background flows). Add FrontendOptions:AllowedOrigins " + - "only to additionally trust per-request origins echoed into self-service links.") - .ValidateOnStart(); + // is not validated at startup on purpose: a deployment that never sends such a link must not + // be taken down by the setting. Unset, the resolver falls back to the API's own origin and + // UseHeroPlatform logs one Warning naming the setting and what degrades without it. + builder.Services.AddOptions().BindConfiguration(nameof(FrontendOptions)); builder.Services.AddScoped(); return builder; @@ -158,6 +153,8 @@ public static WebApplication UseHeroPlatform(this WebApplication app, Action>().Value; + if (!string.IsNullOrWhiteSpace(frontend.DefaultOrigin)) + { + return; + } + + // Same absolute-Uri guard the resolver applies: OriginUrl ships as "", which binds relative. + var apiOrigin = app.Services.GetRequiredService>().Value.OriginUrl; + if (apiOrigin is { IsAbsoluteUri: true }) + { + app.Logger.LogWarning( + "FrontendOptions:DefaultOrigin is not set (appsettings.{Environment}.json). Auth e-mail links for operator-driven flows (admin register, resend confirmation) and for callers that send no Origin header will point at the API origin {ApiOrigin} instead of the front-end app. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".", + app.Environment.EnvironmentName, + apiOrigin); + return; + } + + app.Logger.LogWarning( + "Neither FrontendOptions:DefaultOrigin nor OriginOptions:OriginUrl is set (appsettings.{Environment}.json). Auth e-mail links for operator-driven flows (admin register, resend confirmation) and for callers that send no Origin header will fail with a 500. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".", + app.Environment.EnvironmentName); + } } public sealed class FshPlatformOptions diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs index b6e53e4ce4..c24c8bb80e 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs @@ -23,10 +23,13 @@ public sealed class FrontendOptions /// operator-driven flows whose link must land on the recipient's app rather than the caller's, /// and for background jobs that run without an HTTP request. Typically the tenant dashboard URL. /// - /// Required. Every deployment resolves through this at some point (operator flows, - /// non-browser callers, jobs), so startup validation refuses to boot without it. - /// is additive: it only widens which request origins may be echoed - /// into self-service links, and cannot substitute for the default. + /// Strongly recommended, not required. Every deployment resolves through this at some + /// point (operator flows, non-browser callers, jobs). Left unset, the host still starts, logs a + /// single startup Warning and falls back to the API's own origin + /// (OriginOptions:OriginUrl): links then land on the API rather than the SPA — serviceable, + /// but not where a user expects to arrive. is additive: it only + /// widens which request origins may be echoed into self-service links, and cannot substitute for + /// the default. /// /// /// This is a single global value, not per-tenant or custom-domain aware: operator-driven diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs index 7e9cf61685..e639caf923 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -1,5 +1,6 @@ using System.Net; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Origin; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -9,6 +10,7 @@ namespace FSH.Framework.Web.Frontend; internal sealed class FrontendOriginResolver( IHttpContextAccessor httpContextAccessor, IOptions options, + IOptions originOptions, ILogger logger) : IFrontendOriginResolver { // Normalize the allow-list once at construction: parse to Uri so matching is component-wise @@ -16,6 +18,11 @@ internal sealed class FrontendOriginResolver( // form would silently fail. private readonly Uri[] _allowed = Normalize(options.Value.AllowedOrigins); private readonly string? _default = options.Value.DefaultOrigin?.TrimEnd('/'); + // IsAbsoluteUri guard: appsettings ships OriginUrl as "", which binds to a relative Uri whose + // AbsoluteUri throws. + private readonly string? _apiOrigin = originOptions.Value.OriginUrl is { IsAbsoluteUri: true } api + ? api.AbsoluteUri.TrimEnd('/') + : null; public string ResolveForCurrentRequest() { @@ -50,17 +57,25 @@ public string ResolveForCurrentRequest() public string ResolveDefault() { - if (string.IsNullOrEmpty(_default)) + if (!string.IsNullOrWhiteSpace(_default)) { - // Startup validation should prevent this; guard anyway so a misconfig surfaces as a - // clear 500 rather than an empty link silently shipped into an e-mail. - throw new CustomException( - "No default front-end origin is configured (FrontendOptions:DefaultOrigin).", - errors: null, - HttpStatusCode.InternalServerError); + return _default; } - return _default; + // No DefaultOrigin: fall back to the API's own configured origin rather than taking the + // host down at boot over a setting a deployment may never exercise. Links then land on the + // API (serviceable, if not the SPA) and startup logs a single Warning naming what degrades. + // Deliberately NOT the current request's host: this method exists precisely because the + // caller is not the recipient — an operator-driven link must never point at the admin app. + if (!string.IsNullOrWhiteSpace(_apiOrigin)) + { + return _apiOrigin; + } + + throw new CustomException( + "No front-end origin is configured: set FrontendOptions:DefaultOrigin (or OriginOptions:OriginUrl as a fallback).", + errors: null, + HttpStatusCode.InternalServerError); } private string? MatchAllowed(string header) diff --git a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs index 31ffc67b4e..169ed161c2 100644 --- a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs +++ b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs @@ -1,6 +1,7 @@ using System.Net; using FSH.Framework.Core.Exceptions; using FSH.Framework.Web.Frontend; +using FSH.Framework.Web.Origin; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -12,20 +13,25 @@ namespace Framework.Tests.Web; /// /// Tests for FrontendOriginResolver — resolves the SPA origin for user-facing links, validating the -/// request Origin header against the allow-list and falling back to the configured default. +/// request Origin header against the allow-list, falling back to the configured default and, when +/// that is unset, to the API's own origin. /// public sealed class FrontendOriginResolverTests { private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For(); - private FrontendOriginResolver CreateResolver(string[] allowedOrigins, string? defaultOrigin = null) + private FrontendOriginResolver CreateResolver(string[] allowedOrigins, string? defaultOrigin = null, string? apiOrigin = null) { var options = Options.Create(new FrontendOptions { AllowedOrigins = allowedOrigins, DefaultOrigin = defaultOrigin, }); - return new FrontendOriginResolver(_httpContextAccessor, options, NullLogger.Instance); + var originOptions = Options.Create(new OriginOptions + { + OriginUrl = apiOrigin is null ? null : new Uri(apiOrigin, UriKind.RelativeOrAbsolute), + }); + return new FrontendOriginResolver(_httpContextAccessor, options, originOptions, NullLogger.Instance); } private void SetOriginHeader(string? origin) @@ -121,11 +127,71 @@ public void ResolveDefault_Should_ReturnConfiguredDefault_TrailingSlashTrimmed() } [Fact] - public void ResolveDefault_Should_Throw_When_DefaultNotConfigured() + public void ResolveDefault_Should_FallBackToApiOrigin_When_DefaultNotConfigured() + { + // An upgrader who never sets DefaultOrigin must keep booting and keep sending links: they + // land on the API's own origin instead of the SPA, and startup warns about the degradation. + var resolver = CreateResolver([], defaultOrigin: null, apiOrigin: "https://api.example.com"); + + resolver.ResolveDefault().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveDefault_Should_FallBackToApiOrigin_When_DefaultIsEmptyString() + { + // appsettings.Production.json ships "DefaultOrigin": "" — the empty string must take the + // same fallback path as an absent key, not resolve to an empty link. + var resolver = CreateResolver([], defaultOrigin: "", apiOrigin: "https://api.example.com/"); + + resolver.ResolveDefault().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveDefault_Should_PreferConfiguredDefault_Over_ApiOrigin() + { + var resolver = CreateResolver([], defaultOrigin: "https://app.example.com", apiOrigin: "https://api.example.com"); + + resolver.ResolveDefault().ShouldBe("https://app.example.com"); + } + + [Fact] + public void ResolveDefault_Should_IgnoreApiOrigin_When_NotAbsolute() + { + // OriginOptions:OriginUrl also ships as "" in Production, which binds to a relative Uri. + var resolver = CreateResolver([], defaultOrigin: null, apiOrigin: ""); + + var ex = Should.Throw(() => resolver.ResolveDefault()); + ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + public void ResolveDefault_Should_Throw_When_NothingConfigured() { var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null); var ex = Should.Throw(() => resolver.ResolveDefault()); ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); } + + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToApiOrigin_When_NoHeaderAndNoDefault() + { + // The no-header path routes through ResolveDefault, so it inherits the same fallback. + SetOriginHeader(null); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null, apiOrigin: "https://api.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_StillReject_ForgedHeader_When_NoDefault() + { + // The boot-safety fallback must not soften the security contract: a present-but-unlisted + // Origin is still a 400, never quietly swapped for the API origin. + SetOriginHeader("https://evil.example.com"); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null, apiOrigin: "https://api.example.com"); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } } From 7e0198c5a16fdf8d26467e1bba130aa1371de970 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:26:42 -0300 Subject: [PATCH 12/17] fix(web): fall back to the request host when no origin is configured at all appsettings.Production.json ships OriginOptions:OriginUrl empty as well, so a deployment that upgraded without touching either setting still had no origin to build a link from and 500'd on the first operator-driven register/resend - the exact failure the boot-safety fallback was meant to remove. ResolveDefault now walks DefaultOrigin, then the configured API origin, then the current request's host, and only throws when there is no request either (a background job). The request host is the API's own, never the caller's Origin header, so an operator-driven link still cannot point at the admin SPA. --- src/BuildingBlocks/Web/Extensions.cs | 2 +- .../Web/Frontend/FrontendOptions.cs | 7 ++-- .../Web/Frontend/FrontendOriginResolver.cs | 23 ++++++++++--- .../Web/FrontendOriginResolverTests.cs | 34 ++++++++++++++++++- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index b65997581e..0a6c8874c9 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -265,7 +265,7 @@ private static void WarnOnMissingFrontendOrigin(WebApplication app) } app.Logger.LogWarning( - "Neither FrontendOptions:DefaultOrigin nor OriginOptions:OriginUrl is set (appsettings.{Environment}.json). Auth e-mail links for operator-driven flows (admin register, resend confirmation) and for callers that send no Origin header will fail with a 500. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".", + "Neither FrontendOptions:DefaultOrigin nor OriginOptions:OriginUrl is set (appsettings.{Environment}.json). Auth e-mail links for operator-driven flows (admin register, resend confirmation) and for callers that send no Origin header will point at this API's own request host instead of the front-end app, and will fail outright in a background job, which has no request to derive a host from. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".", app.Environment.EnvironmentName); } } diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs index c24c8bb80e..f90347dc9c 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs @@ -26,8 +26,11 @@ public sealed class FrontendOptions /// Strongly recommended, not required. Every deployment resolves through this at some /// point (operator flows, non-browser callers, jobs). Left unset, the host still starts, logs a /// single startup Warning and falls back to the API's own origin - /// (OriginOptions:OriginUrl): links then land on the API rather than the SPA — serviceable, - /// but not where a user expects to arrive. is additive: it only + /// (OriginOptions:OriginUrl, or the current request's host when that is empty too): links + /// then land on the API rather than the SPA — serviceable, and the same place register / + /// self-register / resend derived them from before this option existed, but not where a user + /// expects to arrive. A background job, having no request, fails instead. + /// is additive: it only /// widens which request origins may be echoed into self-service links, and cannot substitute for /// the default. /// diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs index e639caf923..f3c4dc075a 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -62,16 +62,29 @@ public string ResolveDefault() return _default; } - // No DefaultOrigin: fall back to the API's own configured origin rather than taking the - // host down at boot over a setting a deployment may never exercise. Links then land on the - // API (serviceable, if not the SPA) and startup logs a single Warning naming what degrades. - // Deliberately NOT the current request's host: this method exists precisely because the - // caller is not the recipient — an operator-driven link must never point at the admin app. + // No DefaultOrigin: fall back to the API's own origin rather than taking the host down at + // boot over a setting a deployment may never exercise. Links then land on the API — which + // is where register / self-register / resend derived them from before the resolver existed + // — and startup logs a single Warning naming what degrades. The configured value first, the + // request host second: appsettings.Production.json ships OriginUrl empty too, and a + // deployment that set neither must still send a usable link. + // + // Note this is the API's own host, never the caller's Origin header: an operator-driven + // link must not point at the admin SPA the request came from, which is the whole reason + // ResolveDefault exists apart from ResolveForCurrentRequest. if (!string.IsNullOrWhiteSpace(_apiOrigin)) { return _apiOrigin; } + var request = httpContextAccessor.HttpContext?.Request; + if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) + { + return $"{request.Scheme}://{request.Host.Value}{request.PathBase}".TrimEnd('/'); + } + + // Nothing configured and no request to derive from (a background job): there is no origin + // to build a link out of. throw new CustomException( "No front-end origin is configured: set FrontendOptions:DefaultOrigin (or OriginOptions:OriginUrl as a fallback).", errors: null, diff --git a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs index 169ed161c2..81957a009a 100644 --- a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs +++ b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs @@ -45,6 +45,14 @@ private void SetOriginHeader(string? origin) _httpContextAccessor.HttpContext.Returns(context); } + private void SetRequestHost(string scheme, string host) + { + var context = new DefaultHttpContext(); + context.Request.Scheme = scheme; + context.Request.Host = new HostString(host); + _httpContextAccessor.HttpContext.Returns(context); + } + // ── ResolveForCurrentRequest ──────────────────────────────────────────── [Fact] @@ -158,6 +166,7 @@ public void ResolveDefault_Should_PreferConfiguredDefault_Over_ApiOrigin() public void ResolveDefault_Should_IgnoreApiOrigin_When_NotAbsolute() { // OriginOptions:OriginUrl also ships as "" in Production, which binds to a relative Uri. + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); var resolver = CreateResolver([], defaultOrigin: null, apiOrigin: ""); var ex = Should.Throw(() => resolver.ResolveDefault()); @@ -165,8 +174,31 @@ public void ResolveDefault_Should_IgnoreApiOrigin_When_NotAbsolute() } [Fact] - public void ResolveDefault_Should_Throw_When_NothingConfigured() + public void ResolveDefault_Should_FallBackToRequestHost_When_NothingConfigured() + { + // The both-empty upgrade case: appsettings.Production.json ships DefaultOrigin AND + // OriginUrl empty, so the link still has to resolve — to the API's own host, which is + // where register / self-register / resend built their links before this resolver existed. + SetRequestHost("https", "api.example.com"); + var resolver = CreateResolver([], defaultOrigin: null); + + resolver.ResolveDefault().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveDefault_Should_PreferApiOrigin_Over_RequestHost() + { + SetRequestHost("https", "internal.cluster.local"); + var resolver = CreateResolver([], defaultOrigin: null, apiOrigin: "https://api.example.com"); + + resolver.ResolveDefault().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveDefault_Should_Throw_When_NothingConfiguredAndNoRequest() { + // A background job: nothing configured and no request to derive a host from. + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null); var ex = Should.Throw(() => resolver.ResolveDefault()); From a48e9574a886191b175967bb38b089850bca762f Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:33:18 -0300 Subject: [PATCH 13/17] fix(web): resolve links against the default when no allow-list is configured appsettings.Production.json ships FrontendOptions:AllowedOrigins empty, and browsers attach an Origin header to the forgot-password and self-register POSTs even same-origin. Matching a present header against an empty list returned no canonical entry, so every legitimate password reset and self-registration came back 400 on the shipped Production config - and on any single-SPA or reverse-proxy deployment. With no allow-list there is nothing to validate against, so the header is discarded and the link resolves through the server-side default. The client's value is never echoed, so a forged origin against a configured list is still rejected with 400. The startup Warning now reports an empty AllowedOrigins independently of a missing DefaultOrigin: a deployment can configure one and not the other, and setting only the default silently sends every user to the same front-end. Also matches origins through IdnHost, so a list entry written in Unicode matches the punycode form browsers actually send instead of failing closed, and pins the handler contract on CustomException rather than the arbitrary exception type the old test stubbed. --- src/BuildingBlocks/Web/Extensions.cs | 13 +++++- .../Web/Frontend/FrontendOriginResolver.cs | 28 +++++++++--- .../Web/FrontendOriginResolverTests.cs | 44 +++++++++++++++++++ .../ForgotPasswordCommandHandlerTests.cs | 14 ++++-- 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 0a6c8874c9..120a8aabe2 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -248,12 +248,23 @@ private static bool IsOpenApiEnabled(IConfiguration configuration) private static void WarnOnMissingFrontendOrigin(WebApplication app) { var frontend = app.Services.GetRequiredService>().Value; + + // Reported independently of DefaultOrigin: a deployment that sets only the default still + // has every self-service link falling back to it, which is wrong the moment there is more + // than one front-end. + if (frontend.AllowedOrigins.Length == 0) + { + app.Logger.LogWarning( + "FrontendOptions:AllowedOrigins is empty (appsettings.{Environment}.json). Password-reset and self-registration links cannot follow the front-end that made the request and will all point at FrontendOptions:DefaultOrigin instead. With more than one front-end that sends users to the wrong app. List every SPA origin, e.g. [ \"https://app.example.com\", \"https://admin.example.com\" ].", + app.Environment.EnvironmentName); + } + if (!string.IsNullOrWhiteSpace(frontend.DefaultOrigin)) { return; } - // Same absolute-Uri guard the resolver applies: OriginUrl ships as "", which binds relative. + // Same absolute-Uri guard the resolver applies. var apiOrigin = app.Services.GetRequiredService>().Value.OriginUrl; if (apiOrigin is { IsAbsoluteUri: true }) { diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs index f3c4dc075a..e333ca850d 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -18,8 +18,8 @@ internal sealed class FrontendOriginResolver( // form would silently fail. private readonly Uri[] _allowed = Normalize(options.Value.AllowedOrigins); private readonly string? _default = options.Value.DefaultOrigin?.TrimEnd('/'); - // IsAbsoluteUri guard: appsettings ships OriginUrl as "", which binds to a relative Uri whose - // AbsoluteUri throws. + // IsAbsoluteUri guard: OriginUrl is operator-supplied, and only an absolute Uri has an + // AbsoluteUri to read. private readonly string? _apiOrigin = originOptions.Value.OriginUrl is { IsAbsoluteUri: true } api ? api.AbsoluteUri.TrimEnd('/') : null; @@ -34,6 +34,16 @@ public string ResolveForCurrentRequest() return ResolveDefault(); } + if (_allowed.Length == 0) + { + // No allow-list configured: there is nothing to validate the header against, so trust + // the server-side default instead of rejecting. Browsers attach Origin to these POSTs + // even same-origin, so matching an empty list would 400 every legitimate reset on the + // single-SPA and reverse-proxy topologies — and on the shipped Production config. + // The header is discarded, never echoed, so this cannot leak a client-chosen origin. + return ResolveDefault(); + } + var canonical = MatchAllowed(header); if (canonical is not null) { @@ -99,12 +109,20 @@ public string ResolveDefault() } // Return the canonical configured entry, never the client-supplied casing. - return _allowed - .FirstOrDefault(allowed => Uri.Compare( - candidate, allowed, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) + return _allowed.FirstOrDefault(allowed => IsSameOrigin(candidate, allowed)) ?.GetLeftPart(UriPartial.Authority); } + // Scheme + host + port, port exact. Compared through IdnHost so a list entry written in Unicode + // ("https://bücher.example") matches the punycode form the browser actually sends; Uri.Port + // supplies the scheme's default, so ":443" and the bare host are the same origin. + private static bool IsSameOrigin(Uri candidate, Uri allowed) + { + return string.Equals(candidate.Scheme, allowed.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(candidate.IdnHost, allowed.IdnHost, StringComparison.OrdinalIgnoreCase) + && candidate.Port == allowed.Port; + } + private static Uri[] Normalize(string[] origins) { var list = new List(origins.Length); diff --git a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs index 81957a009a..e350e80aef 100644 --- a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs +++ b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs @@ -105,6 +105,50 @@ public void ResolveForCurrentRequest_Should_Reject_When_HeaderForged() ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToDefault_When_AllowListEmpty() + { + // appsettings.Production.json ships AllowedOrigins empty, and browsers attach Origin to + // these POSTs even same-origin: matching an empty list would 400 every legitimate reset. + SetOriginHeader("https://app.example.com"); + var resolver = CreateResolver([], defaultOrigin: "https://tenant.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://tenant.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_ReturnConfiguredEntry_When_HeaderCarriesUserInfo() + { + // "http://evil.com@localhost:5173" compares equal on scheme+host+port, so the guarantee + // that holds is returning the configured entry rather than anything the client sent. + SetOriginHeader("http://evil.com@localhost:5173"); + var resolver = CreateResolver(["http://localhost:5173"]); + + resolver.ResolveForCurrentRequest().ShouldBe("http://localhost:5173"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_MatchIdnEntry_Against_PunycodeHeader() + { + // A list entry written in Unicode must match the punycode form the browser actually sends, + // otherwise a valid IDN deployment fails closed. The emitted value stays the configured + // entry, so an operator who writes Unicode gets Unicode in the link. + SetOriginHeader("https://xn--bcher-kva.example"); + var resolver = CreateResolver(["https://bücher.example"]); + + resolver.ResolveForCurrentRequest().ShouldBe("https://bücher.example"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_MatchDefaultPort_Written_Explicitly() + { + // ":443" is the same origin as the bare host; an entry carrying it must not fail closed. + SetOriginHeader("https://app.example.com"); + var resolver = CreateResolver(["https://app.example.com:443"]); + + resolver.ResolveForCurrentRequest().ShouldBe("https://app.example.com"); + } + [Fact] public void ResolveForCurrentRequest_Should_FallBackToDefault_When_NoHeader() { diff --git a/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs index 85cbb763e0..bd5244e7a4 100644 --- a/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs +++ b/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs @@ -1,4 +1,6 @@ +using System.Net; using AutoFixture; +using FSH.Framework.Core.Exceptions; using FSH.Framework.Web.Frontend; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; @@ -43,13 +45,19 @@ public async Task Handle_Should_CallForgotPasswordAsync_With_ResolvedFrontendOri [Fact] public async Task Handle_Should_Propagate_When_OriginResolverThrows() { - // Arrange - a request with a forged Origin header cannot build a reset link. + // Arrange - a request with a forged Origin header cannot build a reset link. The resolver + // signals that with the 400-mapped CustomException, so that is the type the handler must + // let through: catching it here would turn a rejected origin into a sent e-mail. var command = _fixture.Create(); - _originResolver.ResolveForCurrentRequest().Returns(_ => throw new InvalidOperationException("no origin")); + _originResolver.ResolveForCurrentRequest().Returns(_ => throw new CustomException( + "The request origin is not an allowed front-end origin.", + errors: null, + HttpStatusCode.BadRequest)); // Act & Assert - await Should.ThrowAsync(async () => + var ex = await Should.ThrowAsync(async () => await _sut.Handle(command, CancellationToken.None)); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); await _userService.DidNotReceive().ForgotPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); } From 93745107fdf4e7c3cd13c1f71f040b40fb2167c7 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:52:23 -0300 Subject: [PATCH 14/17] fix(web): count the allow-list after normalization in the startup warning Unparseable entries are dropped when the resolver normalizes the list, so a list of nothing but typos matched the empty-list fallback at runtime while the warning, reading the raw config array, saw a configured list and stayed quiet. The operator got neither their allow-list nor a diagnostic. The warning now counts the normalized list, and reports separately when only some entries were dropped - those origins are rejected with 400 rather than silently ignored. --- src/BuildingBlocks/Web/Extensions.cs | 16 ++++++++++--- .../Web/Frontend/FrontendOriginResolver.cs | 5 +++- .../Web/FrontendOriginResolverTests.cs | 23 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 120a8aabe2..412120abff 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -251,11 +251,21 @@ private static void WarnOnMissingFrontendOrigin(WebApplication app) // Reported independently of DefaultOrigin: a deployment that sets only the default still // has every self-service link falling back to it, which is wrong the moment there is more - // than one front-end. - if (frontend.AllowedOrigins.Length == 0) + // than one front-end. Counted after normalization, so a list of nothing but unparseable + // entries reports as the empty list it effectively is rather than looking configured. + var usableOrigins = FrontendOriginResolver.Normalize(frontend.AllowedOrigins).Length; + if (usableOrigins == 0) { app.Logger.LogWarning( - "FrontendOptions:AllowedOrigins is empty (appsettings.{Environment}.json). Password-reset and self-registration links cannot follow the front-end that made the request and will all point at FrontendOptions:DefaultOrigin instead. With more than one front-end that sends users to the wrong app. List every SPA origin, e.g. [ \"https://app.example.com\", \"https://admin.example.com\" ].", + "FrontendOptions:AllowedOrigins is empty or entirely unparseable (appsettings.{Environment}.json). Password-reset and self-registration links cannot follow the front-end that made the request and will all point at FrontendOptions:DefaultOrigin instead. With more than one front-end that sends users to the wrong app. List every SPA origin as an absolute URL, e.g. [ \"https://app.example.com\", \"https://admin.example.com\" ].", + app.Environment.EnvironmentName); + } + else if (usableOrigins < frontend.AllowedOrigins.Length) + { + app.Logger.LogWarning( + "{DroppedCount} of {ConfiguredCount} FrontendOptions:AllowedOrigins entries are not absolute URLs and were ignored (appsettings.{Environment}.json). Requests from those origins will be rejected with 400. Each entry must carry a scheme, e.g. \"https://app.example.com\".", + frontend.AllowedOrigins.Length - usableOrigins, + frontend.AllowedOrigins.Length, app.Environment.EnvironmentName); } diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs index e333ca850d..4d1075d4b3 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -123,7 +123,10 @@ private static bool IsSameOrigin(Uri candidate, Uri allowed) && candidate.Port == allowed.Port; } - private static Uri[] Normalize(string[] origins) + // Internal so the startup warning reports the list the resolver will actually match against, + // not the raw config array: an entry that fails to parse is dropped here and would otherwise + // leave a fully malformed list looking configured while every link silently used the default. + internal static Uri[] Normalize(string[] origins) { var list = new List(origins.Length); foreach (var origin in origins) diff --git a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs index e350e80aef..d9f31e0e6b 100644 --- a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs +++ b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs @@ -116,6 +116,29 @@ public void ResolveForCurrentRequest_Should_FallBackToDefault_When_AllowListEmpt resolver.ResolveForCurrentRequest().ShouldBe("https://tenant.example.com"); } + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToDefault_When_EveryEntryIsUnparseable() + { + // Entries that are not absolute URLs are dropped at construction, so a list of nothing but + // typos behaves as the empty list it effectively is. The startup warning counts the same way. + SetOriginHeader("https://app.example.com"); + var resolver = CreateResolver(["https;//app.example.com"], defaultOrigin: "https://tenant.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://tenant.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_Reject_When_OnlyOtherEntriesParse() + { + // One good entry keeps the list live, so an origin that is not on it is still a 400 — + // a partly-malformed list must not silently widen into the empty-list fallback. + SetOriginHeader("https://app.example.com"); + var resolver = CreateResolver(["https;//app.example.com", "https://admin.example.com"], defaultOrigin: "https://tenant.example.com"); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + [Fact] public void ResolveForCurrentRequest_Should_ReturnConfiguredEntry_When_HeaderCarriesUserInfo() { From dc767b52d37b0162782f3d1e7031a96c8ab90665 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:30:45 -0300 Subject: [PATCH 15/17] docs(web): stop claiming the Scalar try-it UI sends no Origin header Scalar.AspNetCore 2.14.14 ships no default proxy URL (the option exists but binds null, and no proxy host is baked into the assembly), so the try-it panel fetches straight from the browser and sends the API's own origin. Listing it alongside curl and server-to-server callers was wrong: those genuinely send no Origin and fall back to the default, while Scalar hits the allow-list branch and needs the API origin listed to exercise forgot-password or self-register. --- src/BuildingBlocks/Web/Frontend/FrontendOptions.cs | 2 +- src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs | 6 ++++-- .../Framework.Tests/Web/FrontendOriginResolverTests.cs | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs index f90347dc9c..9a4ea93570 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs @@ -19,7 +19,7 @@ public sealed class FrontendOptions /// /// Front-end origin used when the request carries no usable Origin header (non-browser - /// callers such as curl / the Scalar try-it UI / mobile apps / server-to-server), for + /// callers such as curl / mobile apps / server-to-server), for /// operator-driven flows whose link must land on the recipient's app rather than the caller's, /// and for background jobs that run without an HTTP request. Typically the tenant dashboard URL. /// diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs index 4d1075d4b3..d4fb7d14a1 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -29,8 +29,10 @@ public string ResolveForCurrentRequest() var header = httpContextAccessor.HttpContext?.Request.Headers.Origin.ToString(); if (string.IsNullOrWhiteSpace(header)) { - // Non-browser caller (curl, Scalar try-it, mobile, server-to-server) sends no Origin. - // Fall back to the configured default rather than failing an otherwise valid flow. + // Non-browser caller (curl, mobile, server-to-server) sends no Origin. Fall back to the + // configured default rather than failing an otherwise valid flow. Note the Scalar + // try-it UI is NOT in this group: it fetches from the browser, so it sends the API's + // own origin and needs that origin allow-listed to exercise these two endpoints. return ResolveDefault(); } diff --git a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs index d9f31e0e6b..8ee428875e 100644 --- a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs +++ b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs @@ -175,7 +175,7 @@ public void ResolveForCurrentRequest_Should_MatchDefaultPort_Written_Explicitly( [Fact] public void ResolveForCurrentRequest_Should_FallBackToDefault_When_NoHeader() { - // Non-browser callers (curl, Scalar, mobile, server-to-server) send no Origin — use the default. + // Non-browser callers (curl, mobile, server-to-server) send no Origin — use the default. SetOriginHeader(null); var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: "https://app.example.com"); From 0fdca92e5df9c2cc4d4ef940adb6316d89ef72a4 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:35:41 -0300 Subject: [PATCH 16/17] docs(rules): document the front-end origin resolver in the security rule The rule file agents read before touching CORS, headers or rate limiting had no entry for FrontendOptions, so the next person to add an e-mail link had nothing telling them which resolver method matches which recipient - a choice where both options compile and both return a plausible origin. --- .agents/rules/security.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.agents/rules/security.md b/.agents/rules/security.md index b3fb38404b..ee5368f2d0 100644 --- a/.agents/rules/security.md +++ b/.agents/rules/security.md @@ -7,6 +7,17 @@ For auth/JWT/permissions see `modules/identity.md`; for the global exception han Policy `FSHCorsPolicy`. When `CorsOptions.AllowAll=true` it uses **`SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials()`** — deliberately **NOT `AllowAnyOrigin()`**. `Access-Control-Allow-Origin: *` is illegal with credentialed requests, and **SignalR's negotiate always runs credentialed**, so `AllowAnyOrigin()` silently breaks SignalR while REST keeps working. Never "simplify" it to `AllowAnyOrigin()`. `UseHeroCors()` runs **before** `UseHttpsRedirection()` so OPTIONS preflight isn't 307-redirected. +## Front-end origin for outbound links (`Web/Frontend/`) + +`IFrontendOriginResolver` builds the origin of user-facing links sent in e-mails (password reset, e-mail confirmation). Backed by **`FrontendOptions`**, deliberately separate from `CorsOptions`: CORS governs which browsers may *call* the API, this governs which origins may appear *inside an outbound link*. Never merge the two lists — coupling them breaks same-origin/reverse-proxy topologies and overloads a security boundary. + +- **`ResolveForCurrentRequest()`** — self-service flows only (forgot-password, self-register), where the caller *is* the recipient. Validates the request `Origin` against `FrontendOptions:AllowedOrigins` and returns the **canonical configured entry**, never the client's string. Present-but-unlisted against a non-empty list → `CustomException(HttpStatusCode.BadRequest)`: these endpoints are anonymous, so a forged header must never reach an e-mail. No `Origin` header, or an empty/all-unparseable list, falls through to the default. +- **`ResolveDefault()`** — operator-driven flows (admin register, resend-confirmation) and background jobs, where the caller is **not** the recipient. Chain: `FrontendOptions:DefaultOrigin` → `OriginOptions:OriginUrl` → the current request's host → throw. Never the caller's `Origin`, or an operator would send a tenant user a link into the admin console. + +Picking the wrong method is a silent bug — both compile and both return a plausible origin. Match the method to **who receives the link**, not to who sent the request. + +No `ValidateOnStart` on `FrontendOptions`, on purpose: a deployment that never sends such a link must not be taken down by the setting. `UseHeroPlatform` logs one startup `Warning` instead, counted after normalization so an all-typo list reports as the empty list it effectively is. `OriginOptions:OriginUrl` keeps its own job — the API's own public base for back-end-served assets (avatars), read through `IRequestContext.Origin`. + ## Security headers (`Web/Security/`) `UseHeroSecurityHeaders()` sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, HSTS (HTTPS), and a CSP. `SecurityHeadersOptions.ExcludedPaths` defaults to `["/scalar","/openapi"]` (they manage their own scripts) — keep those excluded. From 124f182e8a51fca18a9ecdc73686f85b8146e55f Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:36:18 -0300 Subject: [PATCH 17/17] docs(agents): list front-end link origins in the security rule index The index line is how an agent decides whether to open security.md at all. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index cbe60e9e1f..f17f598619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ Single long-lived branch: **`main`** (the default) — there is **no `develop`** | Background jobs (Hangfire), recurring jobs | `jobs.md` | | Outbound HTTP resilience (Polly) | `resilience.md` | | Files/blobs, presigned uploads, providers | `storage.md` | -| CORS, security headers, rate limiting, idempotency, quotas | `security.md` | +| CORS, security headers, rate limiting, idempotency, quotas, front-end link origins | `security.md` | | SignalR / SSE backend | `realtime.md` | | Logging, correlation, OpenTelemetry | `logging.md` | | Unit test conventions, NetArchTest | `testing.md` |