From 0fee24eb228c93da0d2335e4d51e1f530020644f 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/7] 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. --- src/Host/FSH.Starter.Api/appsettings.json | 4 +- .../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 + 15 files changed, 343 insertions(+), 81 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/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json index 5a9628a5f8..e094b606aa 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -98,7 +98,9 @@ "AllowAll": false, "AllowedOrigins": [ "https://localhost:4200", - "https://localhost:7140" + "https://localhost:7140", + "http://localhost:5173", + "http://localhost:5174" ], "AllowedHeaders": [ "content-type", "authorization" ], "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] 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 700959dc0001bbff89225715f2394692168b2b72 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/7] 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 582fbbfe0e3db45f5ff57cd1f3504acb8ad9ae96 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/7] 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 7472887673ea980cadd48e10cbade203b333e286 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/7] 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 52f3628a2a1bc43c828b7436dd5db9b61fe5aafe 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/7] 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 456e402a56a8776c09feb13ac1e440a793ff2ae6 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/7] 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 0ddc3d18a3fcdb2453c5f86d481aee8ba652e14d 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/7] 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));