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..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 @@ -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. + 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..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/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", 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() {