From cb7273bb4b3389add21c5e13d1268220cb69cf48 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 1/8] 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 f7f4da64ed..c55e52c0cf 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 a1478fec82591aac2c7ac83786a181b213b5140a 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 2/8] 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 766db27894856c237bfd9219d39640d31802d91b 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 3/8] 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 483a5b7236278fee7d4ec629ec3446ec3150bc71 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 4/8] 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 fa0a6f22ceb806c012b12255933db8a2bfb4bc21 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 5/8] 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 f36f2652db48538467e40897752d484d38a69090 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 6/8] 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 9497b963f0320cada303d71d434a610aba85f510 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 7/8] 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 618bef4284e07233edd1ef3fd44a6dfe5a99e12d 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 8/8] 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 c55e52c0cf..f7f4da64ed 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]