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 267f49887b..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,31 +1,27 @@ -using FSH.Framework.Web.Origin; +using FSH.Framework.Web.Frontend; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; 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 IFrontendOriginResolver _originResolver; - public ForgotPasswordCommandHandler(IUserService userService, IOptions originOptions) + public ForgotPasswordCommandHandler(IUserService userService, IFrontendOriginResolver 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."); - } + // 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 e045edfb2b..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,5 +1,6 @@ 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 Mediator; @@ -14,12 +15,13 @@ public static class RegisterUserEndpoint internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/register", async (RegisterUserCommand command, - HttpContext context, + IFrontendOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) => { - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - command.Origin = origin; + // 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 f6d2548325..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,4 +1,5 @@ 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 Mediator; @@ -26,12 +27,13 @@ internal static RouteHandlerBuilder MapResendConfirmationEmailEndpoint(this IEnd private static async Task Handler( Guid id, - HttpContext context, + IFrontendOriginResolver 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}"; + // 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 022936da7c..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,4 +1,5 @@ using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Web.Frontend; using FSH.Framework.Web.Idempotency; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; using Mediator; @@ -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, + IFrontendOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) => { - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - command.Origin = origin; + // 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/Services/RequestContextService.cs b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs index 691e3e44d6..5046b3c413 100644 --- a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs @@ -1,4 +1,3 @@ -using FSH.Framework.Core.Context; using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.Services; using Microsoft.AspNetCore.Http; @@ -13,14 +12,14 @@ namespace FSH.Modules.Identity.Services; internal sealed class RequestContextService : IRequestContextService { private readonly IHttpContextAccessor _httpContextAccessor; - private readonly Uri? _originUrl; + private readonly Uri? _configuredOrigin; public RequestContextService( IHttpContextAccessor httpContextAccessor, IOptions originOptions) { _httpContextAccessor = httpContextAccessor; - _originUrl = originOptions.Value.OriginUrl; + _configuredOrigin = originOptions.Value.OriginUrl; } public string? IpAddress => @@ -38,13 +37,18 @@ public string ClientId } } + /// + /// 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 (_originUrl is not null) + if (_configuredOrigin is not null) { - return _originUrl.AbsoluteUri.TrimEnd('/'); + return _configuredOrigin.AbsoluteUri.TrimEnd('/'); } var request = _httpContextAccessor.HttpContext?.Request; diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index c96c90384b..ac4ec275a1 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 + IRequestContextService requestContext) : 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 = requestContext.Origin; + 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..91f02aa47f 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)); + // 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)); string verificationUri = QueryHelpers.AddQueryString(endpointUri.ToString(), QueryStringKeys.UserId, user.Id); verificationUri = QueryHelpers.AddQueryString(verificationUri, QueryStringKeys.Code, code); 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 eb5e1ae0bd..85cbb763e0 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.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 Microsoft.Extensions.Options; 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 IFrontendOriginResolver _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.ResolveForCurrentRequest().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 with a forged Origin header cannot build a reset link. var command = _fixture.Create(); - _originOptions.Value.Returns(new OriginOptions { OriginUrl = null }); + _originResolver.ResolveForCurrentRequest().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.ResolveForCurrentRequest().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/RequestContextServiceTests.cs b/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs index ee800ef1e9..a26161b7ec 100644 --- a/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs +++ b/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs @@ -21,8 +21,8 @@ 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 }); + 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 ab8cfe3c65..0f215afa8a 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,12 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["JwtOptions:AccessTokenMinutes"] = "30", ["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 244b8a28bc..e8b8092ffb 100644 --- a/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs +++ b/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs @@ -69,6 +69,27 @@ 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 FrontendOptions:AllowedOrigins) 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 - 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] public async Task ForgotPassword_Should_ReturnUniformOk_When_EmailIsUnknown() {