Skip to content

Commit 618bef4

Browse files
refactor(identity): dedicated FrontendOptions for e-mail link origins
Address review on #1323. Replace the CorsOptions-coupled, throw-on-miss OriginResolver with a framework-level front-end origin resolver, so any module that builds user-facing links (Identity today; Notifications/Billing/Tickets next) resolves them the same way. - New FSH.Framework.Web.Frontend: FrontendOptions (AllowedOrigins + DefaultOrigin) + IFrontendOriginResolver/FrontendOriginResolver. Validated at startup (ValidateOnStart) so a deployment missing both fails loud on boot instead of 500-ing on the first password-reset — resolves the silent CorsOptions.AllowAll and empty-Production-list traps. - ResolveForCurrentRequest() (self-service: forgot-password, self-register): validates the Origin header against the allow-list, returns the canonical entry (not the client's casing), falls back to DefaultOrigin when no header is present (curl / Scalar / mobile / server-to-server), and throws a 400-mapped CustomException on a present-but-forged origin (was InvalidOperationException -> 500). Matching is component-wise via Uri (port exact). - ResolveDefault() (operator-driven: register, resend-confirmation): targets the recipient's app via DefaultOrigin instead of the operator's Origin, so a tenant user provisioned from the admin app no longer gets a link into :5173. Also serves background jobs that have no HttpContext. - Dedup: ApiOrigin() folded into IRequestContext.Origin (its existing contract); RequestContextService owns the config-first/request-host logic and UserProfileService reads IRequestContextService.Origin for avatar URLs. - appsettings: FrontendOptions (dev 5173/5174 + default 5174; Production empty = deploy requirement). Rebased onto main (#1324 CORS allow-list).
1 parent 9497b96 commit 618bef4

22 files changed

Lines changed: 367 additions & 317 deletions

File tree

src/BuildingBlocks/Web/Extensions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using FSH.Framework.Web.Cors;
99
using FSH.Framework.Web.Exceptions;
1010
using FSH.Framework.Web.FeatureFlags;
11+
using FSH.Framework.Web.Frontend;
1112
using FSH.Framework.Web.Idempotency;
1213
using FSH.Framework.Web.Sse;
1314
using FSH.Framework.Web.Health;
@@ -135,6 +136,18 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild
135136
builder.Services.AddOptions<OriginOptions>().BindConfiguration(nameof(OriginOptions));
136137
builder.Services.AddOptions<SecurityHeadersOptions>().BindConfiguration(nameof(SecurityHeadersOptions));
137138

139+
// Front-end origin resolution for user-facing links in e-mails/notifications. Validated at
140+
// startup so a deployment missing both the allow-list and the default fails loud on boot
141+
// rather than 500-ing (or shipping empty links) on the first password-reset request.
142+
builder.Services.AddHttpContextAccessor();
143+
builder.Services.AddOptions<FrontendOptions>()
144+
.BindConfiguration(nameof(FrontendOptions))
145+
.Validate(
146+
o => o.AllowedOrigins.Length > 0 || !string.IsNullOrWhiteSpace(o.DefaultOrigin),
147+
"FrontendOptions requires AllowedOrigins or DefaultOrigin to be configured.")
148+
.ValidateOnStart();
149+
builder.Services.AddScoped<IFrontendOriginResolver, FrontendOriginResolver>();
150+
138151
return builder;
139152
}
140153

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace FSH.Framework.Web.Frontend;
2+
3+
/// <summary>
4+
/// Configuration for resolving the front-end (SPA) origin used when building user-facing links
5+
/// inside e-mails and notifications. Deliberately separate from <c>CorsOptions</c>: the CORS
6+
/// allow-list governs which browsers may call the API, while this list governs which origins may
7+
/// be embedded in an outbound link. The two often overlap but carry different security duties, and
8+
/// coupling them breaks same-origin/reverse-proxy topologies where CORS needs no entries yet links
9+
/// still must resolve.
10+
/// </summary>
11+
public sealed class FrontendOptions
12+
{
13+
/// <summary>
14+
/// Origins trusted to appear in user-facing links. A request's <c>Origin</c> header is only
15+
/// echoed into a link when it matches an entry here (scheme + host + port, port exact). Empty is
16+
/// valid only when <see cref="DefaultOrigin"/> is set, in which case every link uses the default.
17+
/// </summary>
18+
public string[] AllowedOrigins { get; init; } = [];
19+
20+
/// <summary>
21+
/// Front-end origin used when the request carries no usable <c>Origin</c> header (non-browser
22+
/// callers such as curl / the Scalar try-it UI / mobile apps / server-to-server), for
23+
/// operator-driven flows whose link must land on the recipient's app rather than the caller's,
24+
/// and for background jobs that run without an HTTP request. Typically the tenant dashboard URL.
25+
/// </summary>
26+
public string? DefaultOrigin { get; init; }
27+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Net;
2+
using FSH.Framework.Core.Exceptions;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Logging;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace FSH.Framework.Web.Frontend;
8+
9+
internal sealed class FrontendOriginResolver(
10+
IHttpContextAccessor httpContextAccessor,
11+
IOptions<FrontendOptions> options,
12+
ILogger<FrontendOriginResolver> logger) : IFrontendOriginResolver
13+
{
14+
// Normalize the allow-list once at construction: parse to Uri so matching is component-wise
15+
// (scheme + host + port) instead of a raw string compare that an entry like ":443" or an IDN
16+
// form would silently fail.
17+
private readonly Uri[] _allowed = Normalize(options.Value.AllowedOrigins);
18+
private readonly string? _default = options.Value.DefaultOrigin?.TrimEnd('/');
19+
20+
public string ResolveForCurrentRequest()
21+
{
22+
var header = httpContextAccessor.HttpContext?.Request.Headers.Origin.ToString();
23+
if (string.IsNullOrWhiteSpace(header))
24+
{
25+
// Non-browser caller (curl, Scalar try-it, mobile, server-to-server) sends no Origin.
26+
// Fall back to the configured default rather than failing an otherwise valid flow.
27+
return ResolveDefault();
28+
}
29+
30+
var canonical = MatchAllowed(header);
31+
if (canonical is not null)
32+
{
33+
return canonical;
34+
}
35+
36+
// A present-but-unlisted Origin is a forged or misconfigured client, not a server fault:
37+
// surface a 4xx so error-rate alerting doesn't page on bot traffic to anonymous endpoints.
38+
logger.LogWarning("Rejected front-end origin {Origin}: not in FrontendOptions:AllowedOrigins", header);
39+
throw new CustomException(
40+
"The request origin is not an allowed front-end origin.",
41+
errors: null,
42+
HttpStatusCode.BadRequest);
43+
}
44+
45+
public string ResolveDefault()
46+
{
47+
if (string.IsNullOrEmpty(_default))
48+
{
49+
// Startup validation should prevent this; guard anyway so a misconfig surfaces as a
50+
// clear 500 rather than an empty link silently shipped into an e-mail.
51+
throw new CustomException(
52+
"No default front-end origin is configured (FrontendOptions:DefaultOrigin).",
53+
errors: null,
54+
HttpStatusCode.InternalServerError);
55+
}
56+
57+
return _default;
58+
}
59+
60+
private string? MatchAllowed(string header)
61+
{
62+
if (!Uri.TryCreate(header.TrimEnd('/'), UriKind.Absolute, out var candidate))
63+
{
64+
return null;
65+
}
66+
67+
// Return the canonical configured entry, never the client-supplied casing.
68+
return _allowed
69+
.FirstOrDefault(allowed => Uri.Compare(
70+
candidate, allowed, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0)
71+
?.GetLeftPart(UriPartial.Authority);
72+
}
73+
74+
private static Uri[] Normalize(string[] origins)
75+
{
76+
var list = new List<Uri>(origins.Length);
77+
foreach (var origin in origins)
78+
{
79+
if (Uri.TryCreate(origin.TrimEnd('/'), UriKind.Absolute, out var uri))
80+
{
81+
list.Add(uri);
82+
}
83+
}
84+
85+
return [.. list];
86+
}
87+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace FSH.Framework.Web.Frontend;
2+
3+
/// <summary>
4+
/// Resolves the front-end (SPA) origin used to build user-facing links inside e-mails and
5+
/// notifications. Framework-level so any module that sends such links (Identity, Notifications,
6+
/// Billing, Tickets, …) resolves the origin the same way.
7+
/// </summary>
8+
public interface IFrontendOriginResolver
9+
{
10+
/// <summary>
11+
/// Origin for a link that lands on the SPA the caller is currently using — self-service flows
12+
/// (password reset, self-registration) where the request comes from the user's own app.
13+
/// Validates the request <c>Origin</c> header against <see cref="FrontendOptions.AllowedOrigins"/>
14+
/// and returns the canonical matching entry (never the client's raw casing). Falls back to
15+
/// <see cref="FrontendOptions.DefaultOrigin"/> when the request carries no <c>Origin</c> header.
16+
/// Throws a 400-mapped exception when a header is present but not allow-listed — a forged origin
17+
/// must never reach an e-mail.
18+
/// </summary>
19+
string ResolveForCurrentRequest();
20+
21+
/// <summary>
22+
/// Origin for a link whose recipient is not the caller — operator-driven flows (an admin
23+
/// registering or re-inviting a tenant user, whose confirmation link must land on the tenant's
24+
/// app, not the operator's) — or where no HTTP request exists (background jobs). Returns
25+
/// <see cref="FrontendOptions.DefaultOrigin"/>.
26+
/// </summary>
27+
string ResolveDefault();
28+
}

src/BuildingBlocks/Web/Web.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,8 @@
4848
<ProjectReference Include="..\Quota\Quota.csproj" />
4949
</ItemGroup>
5050

51+
<ItemGroup>
52+
<InternalsVisibleTo Include="Framework.Tests" />
53+
</ItemGroup>
54+
5155
</Project>

src/Host/FSH.Starter.Api/appsettings.Production.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
"AllowedHeaders": [ "content-type", "authorization" ],
6363
"AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ]
6464
},
65+
"FrontendOptions": {
66+
"AllowedOrigins": [],
67+
"DefaultOrigin": ""
68+
},
6569
"JwtOptions": {
6670
"Issuer": "fsh.local",
6771
"Audience": "fsh.clients",

src/Host/FSH.Starter.Api/appsettings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@
103103
"AllowedHeaders": [ "content-type", "authorization" ],
104104
"AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ]
105105
},
106+
"FrontendOptions": {
107+
"AllowedOrigins": [
108+
"http://localhost:5173",
109+
"http://localhost:5174"
110+
],
111+
"DefaultOrigin": "http://localhost:5174"
112+
},
106113
"JwtOptions": {
107114
"Issuer": "fsh.local",
108115
"Audience": "fsh.clients",

src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1+
using FSH.Framework.Web.Frontend;
12
using FSH.Modules.Identity.Contracts.Services;
23
using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword;
3-
using FSH.Modules.Identity.Services;
44
using Mediator;
55

66
namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword;
77

88
public sealed class ForgotPasswordCommandHandler : ICommandHandler<ForgotPasswordCommand, string>
99
{
1010
private readonly IUserService _userService;
11-
private readonly IOriginResolver _originResolver;
11+
private readonly IFrontendOriginResolver _originResolver;
1212

13-
public ForgotPasswordCommandHandler(IUserService userService, IOriginResolver originResolver)
13+
public ForgotPasswordCommandHandler(IUserService userService, IFrontendOriginResolver originResolver)
1414
{
1515
_userService = userService;
1616
_originResolver = originResolver;
@@ -20,8 +20,8 @@ public async ValueTask<string> Handle(ForgotPasswordCommand command, Cancellatio
2020
{
2121
ArgumentNullException.ThrowIfNull(command);
2222

23-
// The reset link must land on the SPA that made the request.
24-
var origin = _originResolver.FrontendOrigin();
23+
// Self-service flow: the reset link must land on the SPA the user is currently using.
24+
var origin = _originResolver.ResolveForCurrentRequest();
2525

2626
await _userService.ForgotPasswordAsync(command.Email, origin, cancellationToken).ConfigureAwait(false);
2727

src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
using FSH.Modules.Identity.Contracts.Authorization;
22
using FSH.Framework.Shared.Identity.Authorization;
3+
using FSH.Framework.Web.Frontend;
34
using FSH.Framework.Web.Idempotency;
45
using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser;
5-
using FSH.Modules.Identity.Services;
66
using Mediator;
77
using Microsoft.AspNetCore.Builder;
88
using Microsoft.AspNetCore.Http;
@@ -15,12 +15,13 @@ public static class RegisterUserEndpoint
1515
internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints)
1616
{
1717
return endpoints.MapPost("/register", async (RegisterUserCommand command,
18-
IOriginResolver originResolver,
18+
IFrontendOriginResolver originResolver,
1919
IMediator mediator,
2020
CancellationToken cancellationToken) =>
2121
{
22-
// The confirmation link lands on the SPA that made the request; resolved from the Origin header.
23-
command.Origin = originResolver.FrontendOrigin();
22+
// Operator-driven flow: an admin registers a tenant user, so the confirmation link must
23+
// land on the recipient's app (the default front-end), not the operator's Origin.
24+
command.Origin = originResolver.ResolveDefault();
2425
var result = await mediator.Send(command, cancellationToken);
2526
return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result);
2627
})

src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using FSH.Framework.Shared.Identity.Authorization;
2+
using FSH.Framework.Web.Frontend;
23
using FSH.Modules.Identity.Contracts.Authorization;
34
using FSH.Modules.Identity.Contracts.v1.Users.ResendConfirmationEmail;
4-
using FSH.Modules.Identity.Services;
55
using Mediator;
66
using Microsoft.AspNetCore.Builder;
77
using Microsoft.AspNetCore.Http;
@@ -27,12 +27,13 @@ internal static RouteHandlerBuilder MapResendConfirmationEmailEndpoint(this IEnd
2727

2828
private static async Task<NoContent> Handler(
2929
Guid id,
30-
IOriginResolver originResolver,
30+
IFrontendOriginResolver originResolver,
3131
IMediator mediator,
3232
CancellationToken cancellationToken)
3333
{
34-
// The confirmation link lands on the SPA that made the request; resolved from the Origin header.
35-
var origin = originResolver.FrontendOrigin();
34+
// Operator-driven flow: an admin re-sends a tenant user's confirmation, so the link must
35+
// land on the recipient's app (the default front-end), not the operator's Origin.
36+
var origin = originResolver.ResolveDefault();
3637
await mediator.Send(new ResendConfirmationEmailCommand(id.ToString(), origin), cancellationToken);
3738
return TypedResults.NoContent();
3839
}

0 commit comments

Comments
 (0)