Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
60 changes: 60 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 All @@ -28,6 +29,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mediator;

namespace FSH.Framework.Web;
Expand Down Expand Up @@ -135,6 +138,13 @@ 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. DefaultOrigin
// is not validated at startup on purpose: a deployment that never sends such a link must not
// be taken down by the setting. Unset, the resolver falls back to the API's own origin and
// UseHeroPlatform logs one Warning naming the setting and what degrades without it.
builder.Services.AddOptions<FrontendOptions>().BindConfiguration(nameof(FrontendOptions));
builder.Services.AddScoped<IFrontendOriginResolver, FrontendOriginResolver>();

return builder;
}

Expand All @@ -143,6 +153,8 @@ public static WebApplication UseHeroPlatform(this WebApplication app, Action<Fsh
{
ArgumentNullException.ThrowIfNull(app);

WarnOnMissingFrontendOrigin(app);

var options = new FshPipelineOptions();
configure?.Invoke(options);

Expand Down Expand Up @@ -229,6 +241,54 @@ private static bool IsOpenApiEnabled(IConfiguration configuration)
{
return configuration.GetValue("OpenApiOptions:Enabled", true);
}

// One Warning at boot, never per request: the resolver is scoped, so logging there would either
// flood the aggregator or stay silent on a host that simply never sends a link. An operator who
// upgrades into this change reads it once, in the startup banner, with the fix in the message.
private static void WarnOnMissingFrontendOrigin(WebApplication app)
{
var frontend = app.Services.GetRequiredService<IOptions<FrontendOptions>>().Value;

// Reported independently of DefaultOrigin: a deployment that sets only the default still
// has every self-service link falling back to it, which is wrong the moment there is more
// than one front-end. Counted after normalization, so a list of nothing but unparseable
// entries reports as the empty list it effectively is rather than looking configured.
var usableOrigins = FrontendOriginResolver.Normalize(frontend.AllowedOrigins).Length;
if (usableOrigins == 0)
{
app.Logger.LogWarning(
"FrontendOptions:AllowedOrigins is empty or entirely unparseable (appsettings.{Environment}.json). Password-reset and self-registration links cannot follow the front-end that made the request and will all point at FrontendOptions:DefaultOrigin instead. With more than one front-end that sends users to the wrong app. List every SPA origin as an absolute URL, e.g. [ \"https://app.example.com\", \"https://admin.example.com\" ].",
app.Environment.EnvironmentName);
}
else if (usableOrigins < frontend.AllowedOrigins.Length)
{
app.Logger.LogWarning(
"{DroppedCount} of {ConfiguredCount} FrontendOptions:AllowedOrigins entries are not absolute URLs and were ignored (appsettings.{Environment}.json). Requests from those origins will be rejected with 400. Each entry must carry a scheme, e.g. \"https://app.example.com\".",
frontend.AllowedOrigins.Length - usableOrigins,
frontend.AllowedOrigins.Length,
app.Environment.EnvironmentName);
}

if (!string.IsNullOrWhiteSpace(frontend.DefaultOrigin))
{
return;
}

// Same absolute-Uri guard the resolver applies.
var apiOrigin = app.Services.GetRequiredService<IOptions<OriginOptions>>().Value.OriginUrl;
if (apiOrigin is { IsAbsoluteUri: true })
{
app.Logger.LogWarning(
"FrontendOptions:DefaultOrigin is not set (appsettings.{Environment}.json). Auth e-mail links for operator-driven flows (admin register, resend confirmation) and for callers that send no Origin header will point at the API origin {ApiOrigin} instead of the front-end app. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".",
app.Environment.EnvironmentName,
apiOrigin);
return;
}

app.Logger.LogWarning(
"Neither FrontendOptions:DefaultOrigin nor OriginOptions:OriginUrl is set (appsettings.{Environment}.json). Auth e-mail links for operator-driven flows (admin register, resend confirmation) and for callers that send no Origin header will point at this API's own request host instead of the front-end app, and will fail outright in a background job, which has no request to derive a host from. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".",
app.Environment.EnvironmentName);
}
}

public sealed class FshPlatformOptions
Expand Down
45 changes: 45 additions & 0 deletions src/BuildingBlocks/Web/Frontend/FrontendOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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.
/// <para>
/// <b>Strongly recommended, not required.</b> Every deployment resolves through this at some
/// point (operator flows, non-browser callers, jobs). Left unset, the host still starts, logs a
/// single startup <c>Warning</c> and falls back to the API's own origin
/// (<c>OriginOptions:OriginUrl</c>, or the current request's host when that is empty too): links
/// then land on the API rather than the SPA — serviceable, and the same place register /
/// self-register / resend derived them from before this option existed, but not where a user
/// expects to arrive. A background job, having no request, fails instead.
/// <see cref="AllowedOrigins"/> is additive: it only
/// widens which request origins may be echoed into self-service links, and cannot substitute for
/// the default.
/// </para>
/// <para>
/// This is a single global value, not per-tenant or custom-domain aware: operator-driven
/// register / resend-confirmation therefore point <em>every</em> tenant's link at this one SPA.
/// That fits the kit's single-dashboard model; a deployment with per-tenant custom domains would
/// need to resolve the recipient tenant's own origin here instead.
/// </para>
/// </summary>
public string? DefaultOrigin { get; init; }
}
142 changes: 142 additions & 0 deletions src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Net;
using FSH.Framework.Core.Exceptions;
using FSH.Framework.Web.Origin;
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,
IOptions<OriginOptions> originOptions,
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('/');
// IsAbsoluteUri guard: OriginUrl is operator-supplied, and only an absolute Uri has an
// AbsoluteUri to read.
private readonly string? _apiOrigin = originOptions.Value.OriginUrl is { IsAbsoluteUri: true } api
? api.AbsoluteUri.TrimEnd('/')
: null;

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();
}

if (_allowed.Length == 0)
{
// No allow-list configured: there is nothing to validate the header against, so trust
// the server-side default instead of rejecting. Browsers attach Origin to these POSTs
// even same-origin, so matching an empty list would 400 every legitimate reset on the
// single-SPA and reverse-proxy topologies — and on the shipped Production config.
// The header is discarded, never echoed, so this cannot leak a client-chosen origin.
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.
// Logged at Debug, not Warning: these endpoints are anonymous, so bot/forged traffic would
// flood the aggregator at Warning. A genuine deployer misconfig (a real SPA origin missing
// from the list) already surfaces loudly as a 400 to that SPA's own users.
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("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.IsNullOrWhiteSpace(_default))
{
return _default;
}

// No DefaultOrigin: fall back to the API's own origin rather than taking the host down at
// boot over a setting a deployment may never exercise. Links then land on the API — which
// is where register / self-register / resend derived them from before the resolver existed
// — and startup logs a single Warning naming what degrades. The configured value first, the
// request host second: appsettings.Production.json ships OriginUrl empty too, and a
// deployment that set neither must still send a usable link.
//
// Note this is the API's own host, never the caller's Origin header: an operator-driven
// link must not point at the admin SPA the request came from, which is the whole reason
// ResolveDefault exists apart from ResolveForCurrentRequest.
if (!string.IsNullOrWhiteSpace(_apiOrigin))
{
return _apiOrigin;
}

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('/');
}

// Nothing configured and no request to derive from (a background job): there is no origin
// to build a link out of.
throw new CustomException(
"No front-end origin is configured: set FrontendOptions:DefaultOrigin (or OriginOptions:OriginUrl as a fallback).",
errors: null,
HttpStatusCode.InternalServerError);
}

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 => IsSameOrigin(candidate, allowed))
?.GetLeftPart(UriPartial.Authority);
}

// Scheme + host + port, port exact. Compared through IdnHost so a list entry written in Unicode
// ("https://bücher.example") matches the punycode form the browser actually sends; Uri.Port
// supplies the scheme's default, so ":443" and the bare host are the same origin.
private static bool IsSameOrigin(Uri candidate, Uri allowed)
{
return string.Equals(candidate.Scheme, allowed.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(candidate.IdnHost, allowed.IdnHost, StringComparison.OrdinalIgnoreCase)
&& candidate.Port == allowed.Port;
}

// Internal so the startup warning reports the list the resolver will actually match against,
// not the raw config array: an entry that fails to parse is dropped here and would otherwise
// leave a fully malformed list looking configured while every link silently used the default.
internal 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
Loading
Loading