Skip to content
4 changes: 3 additions & 1 deletion src/Host/FSH.Starter.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ForgotPasswordCommand, string>
{
private readonly IUserService _userService;
private readonly IOptions<OriginOptions> _originOptions;
private readonly IOriginResolver _originResolver;

public ForgotPasswordCommandHandler(IUserService userService, IOptions<OriginOptions> originOptions)
public ForgotPasswordCommandHandler(IUserService userService, IOriginResolver originResolver)
{
_userService = userService;
_originOptions = originOptions;
_originResolver = originResolver;
}

public async ValueTask<string> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,12 +27,12 @@ internal static RouteHandlerBuilder MapResendConfirmationEmailEndpoint(this IEnd

private static async Task<NoContent> 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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
})
Expand Down
1 change: 1 addition & 0 deletions src/Modules/Identity/Modules.Identity/IdentityModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public void ConfigureServices(IHostApplicationBuilder builder)
services.AddScoped<ICurrentUserInitializer>(sp => sp.GetRequiredService<ICurrentUserService>());
services.AddScoped<IRequestContextService, RequestContextService>();
services.AddScoped<IRequestContext>(sp => sp.GetRequiredService<IRequestContextService>());
services.AddScoped<IOriginResolver, OriginResolver>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IImpersonationGrantService, ImpersonationGrantService>();

Expand Down
21 changes: 21 additions & 0 deletions src/Modules/Identity/Modules.Identity/Services/IOriginResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace FSH.Modules.Identity.Services;

/// <summary>
/// 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.
/// </summary>
public interface IOriginResolver
{
/// <summary>
/// Origin of the calling single-page app, taken from the request <c>Origin</c> 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.
/// </summary>
string FrontendOrigin();

/// <summary>
/// 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.
/// </summary>
string? ApiOrigin();
}
64 changes: 64 additions & 0 deletions src/Modules/Identity/Modules.Identity/Services/OriginResolver.cs
Original file line number Diff line number Diff line change
@@ -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> corsOptions,
IOptions<OriginOptions> originOptions,
ILogger<OriginResolver> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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> originOptions)
IOriginResolver originResolver)
{
_httpContextAccessor = httpContextAccessor;
_originUrl = originOptions.Value.OriginUrl;
_originResolver = originResolver;
}

public string? IpAddress =>
Expand All @@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,11 +17,8 @@ internal sealed class UserProfileService(
SignInManager<FshUser> signInManager,
IStorageService storageService,
IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor,
IOptions<OriginOptions> originOptions,
IHttpContextAccessor httpContextAccessor) : IUserProfileService
IOriginResolver originResolver) : IUserProfileService
{
private readonly Uri? _originUrl = originOptions.Value.OriginUrl;

public async Task<UserDto> GetAsync(string userId, CancellationToken cancellationToken)
{
// Relies on Finbuckle's tenant filter — callers can only ever read
Expand Down Expand Up @@ -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}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,10 @@ private async Task<string> 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);
Expand Down
Loading
Loading