Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/BuildingBlocks/Web/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -135,6 +136,18 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild
builder.Services.AddOptions<OriginOptions>().BindConfiguration(nameof(OriginOptions));
builder.Services.AddOptions<SecurityHeadersOptions>().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<FrontendOptions>()
.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<IFrontendOriginResolver, FrontendOriginResolver>();

return builder;
}

Expand Down
27 changes: 27 additions & 0 deletions src/BuildingBlocks/Web/Frontend/FrontendOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace FSH.Framework.Web.Frontend;

/// <summary>
/// Configuration for resolving the front-end (SPA) origin used when building user-facing links
/// inside e-mails and notifications. Deliberately separate from <c>CorsOptions</c>: 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.
/// </summary>
public sealed class FrontendOptions
{
/// <summary>
/// Origins trusted to appear in user-facing links. A request's <c>Origin</c> header is only
/// echoed into a link when it matches an entry here (scheme + host + port, port exact). Empty is
/// valid only when <see cref="DefaultOrigin"/> is set, in which case every link uses the default.
/// </summary>
public string[] AllowedOrigins { get; init; } = [];

/// <summary>
/// Front-end origin used when the request carries no usable <c>Origin</c> 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.
/// </summary>
public string? DefaultOrigin { get; init; }
}
87 changes: 87 additions & 0 deletions src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs
Original file line number Diff line number Diff line change
@@ -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<FrontendOptions> options,
ILogger<FrontendOriginResolver> 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<Uri>(origins.Length);
foreach (var origin in origins)
{
if (Uri.TryCreate(origin.TrimEnd('/'), UriKind.Absolute, out var uri))
{
list.Add(uri);
}
}

return [.. list];
}
}
28 changes: 28 additions & 0 deletions src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace FSH.Framework.Web.Frontend;

/// <summary>
/// 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.
/// </summary>
public interface IFrontendOriginResolver
{
/// <summary>
/// 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 <c>Origin</c> header against <see cref="FrontendOptions.AllowedOrigins"/>
/// and returns the canonical matching entry (never the client's raw casing). Falls back to
/// <see cref="FrontendOptions.DefaultOrigin"/> when the request carries no <c>Origin</c> header.
/// Throws a 400-mapped exception when a header is present but not allow-listed — a forged origin
/// must never reach an e-mail.
/// </summary>
string ResolveForCurrentRequest();

/// <summary>
/// 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
/// <see cref="FrontendOptions.DefaultOrigin"/>.
/// </summary>
string ResolveDefault();
}
4 changes: 4 additions & 0 deletions src/BuildingBlocks/Web/Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@
<ProjectReference Include="..\Quota\Quota.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Framework.Tests" />
</ItemGroup>

</Project>
4 changes: 4 additions & 0 deletions src/Host/FSH.Starter.Api/appsettings.Production.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
"AllowedHeaders": [ "content-type", "authorization" ],
"AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ]
},
"FrontendOptions": {
"AllowedOrigins": [],
"DefaultOrigin": ""
},
"JwtOptions": {
"Issuer": "fsh.local",
"Audience": "fsh.clients",
Expand Down
7 changes: 7 additions & 0 deletions src/Host/FSH.Starter.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ForgotPasswordCommand, string>
{
private readonly IUserService _userService;
private readonly IOptions<OriginOptions> _originOptions;
private readonly IFrontendOriginResolver _originResolver;

public ForgotPasswordCommandHandler(IUserService userService, IOptions<OriginOptions> originOptions)
public ForgotPasswordCommandHandler(IUserService userService, IFrontendOriginResolver 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.");
}
// 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);

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

private static async Task<NoContent> 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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
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,
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);
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using FSH.Framework.Core.Context;
using FSH.Framework.Web.Origin;
using FSH.Modules.Identity.Contracts.Services;
using Microsoft.AspNetCore.Http;
Expand All @@ -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> originOptions)
{
_httpContextAccessor = httpContextAccessor;
_originUrl = originOptions.Value.OriginUrl;
_configuredOrigin = originOptions.Value.OriginUrl;
}

public string? IpAddress =>
Expand All @@ -38,13 +37,18 @@ public string ClientId
}
}

/// <summary>
/// Origin of the API itself (scheme + host + path base), used for back-end-served links and
/// assets such as avatars. Prefers the configured <c>OriginOptions:OriginUrl</c>, falling back
/// to the current request's host; null when neither is available (e.g. a background job).
/// </summary>
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;
Expand Down
Loading
Loading