Skip to content

fix(identity): resolve front-end origin per-request for auth e-mail links#1323

Open
marcelo-maciel wants to merge 8 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/identity-origin-multifront
Open

fix(identity): resolve front-end origin per-request for auth e-mail links#1323
marcelo-maciel wants to merge 8 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/identity-origin-multifront

Conversation

@marcelo-maciel

Copy link
Copy Markdown
Contributor

Problem

The kit ships two front-ends (admin on :5173, dashboard on :5174), but the back-end had no way to build a user-facing link that targets the front-end a request actually came from:

  • forgot-password built the reset link from a single configured OriginOptions.OriginUrl. In appsettings.json that value is the API URL (https://localhost:7030), and in appsettings.Production.json it is empty, so the handler throws "Origin URL is not configured.".
  • register / self-register / resend-confirmation built the confirmation link from the raw request host ({scheme}://{host}{pathbase}), i.e. the API, and pointed it at the API route api/v1/identity/confirm-email (which returns JSON) rather than a front-end page.
  • The HTTP Origin header was never consulted, so with more than one SPA there was no way to send the link to the correct one.

This is the structural follow-up to #1302, which fixed only the reset-link string format (trailing slash, tenant param, URL-encoding).

Solution

A small IOriginResolver (Identity module, no changes to BuildingBlocks) with two notions of origin:

  • FrontendOrigin() — takes the request Origin header and validates it against CorsOptions.AllowedOrigins (normalising trailing slash and case, matching scheme+host+port exactly). Used for links that land on a SPA (reset-password, e-mail confirmation). It throws when the request carries no allow-listed origin, rather than guessing a front-end.
  • ApiOrigin() — configured origin, else the request host (the previous behaviour, unchanged). Used for assets/links served by the API itself (avatar URLs, RequestContextService).

The confirmation e-mail now points at the SPA /confirm-email page (which already exists in both clients/admin and clients/dashboard and calls the API) instead of the API route directly. forgot-password, register, self-register and resend-confirmation all resolve the front-end origin through the resolver.

Security

The allow-list check is the security boundary: because forgot-password is anonymous, a forged Origin header must never be turned into a link inside an e-mail. The resolver always checks AllowedOrigins — independently of CorsOptions.AllowAll — and rejects anything not on the list.

Config / migration note

  • appsettings.json now lists the dev SPA origins (http://localhost:5173, http://localhost:5174) under CorsOptions.AllowedOrigins.
  • Deployments must populate CorsOptions.AllowedOrigins with their real SPA URLs, otherwise forgot-password/register will reject requests (no allow-listed front-end origin). appsettings.Production.json intentionally leaves it empty.
  • OriginOptions.OriginUrl keeps its meaning as the API public base (avatars / ApiOrigin); it is no longer overloaded as the reset-link base.

Tests

  • OriginResolverTests — allow-listed origin returns normalised; trailing-slash/case match; differing port does not match; forged origin, missing header and missing HttpContext all throw; ApiOrigin config-first then request fallback.
  • ForgotPasswordCommandHandlerTests updated to the resolver.
  • IntegrationForgotPassword_Should_Reject_When_OriginNotAllowed drives a forged Origin end-to-end (rejected, no reset link). EmailLinkOriginTests inspects the captured e-mail body and asserts the reset link resolves to the requesting front-end (:5174 vs :5173) and the confirmation link targets the SPA /confirm-email page rather than the API route. The integration harness sends an Origin header like a browser.
  • Verified locally: dotnet build clean (0 warnings), Identity unit suite green, full integration suite green, and the SPA reset/confirm pages render the emitted link URLs in a real browser.

Follow-up

Docs (the separate fullstackhero/docs site) and a changelog entry to describe the per-front origin resolution and the AllowedOrigins requirement — happy to open that PR separately.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@iammukeshm iammukeshm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the problem statement is exactly right (the kit had no way to target the correct SPA per request, and forgot-password was broken out of the box), the writeup is excellent, and the test coverage is thorough. The direction (validate the Origin header against an allow-list before it can end up in an e-mail) is the right security posture. That said, a deep review surfaced several issues that make this unsafe to merge as-is. Requesting changes.

Blocking

1. FrontendOrigin() ignores CorsOptions.AllowAll — the default config bricks all four auth flows (OriginResolver.cs:20)

CorsOptions defaults to AllowAll = true with AllowedOrigins = [], and the options validation in AddHeroCors only requires AllowedOrigins when AllowAll is false — so an AllowAll deployment (including appsettings.Development.json as shipped, and any deployment that omits the section) boots cleanly, passes CORS for every origin, and then 500s on every register / self-register / forgot-password / resend-confirmation call because the resolver consults only the empty static list. The unit test FrontendOrigin_Should_Throw_When_AllowListEmpty_EvenWithHeader codifies the inconsistency rather than resolving it. Coupling the e-mail-link trust list to the CORS list also breaks the same-origin/reverse-proxy topology (SPA + API on one domain: no CORS entries needed, but the browser still sends Origin on the POST and it's never in the list). I think the deeper fix is a dedicated FrontendOptions:AllowedOrigins (or similar) with its own startup validation, rather than overloading CORS policy — that also documents the list's second duty instead of leaving it implicit.

2. Every non-browser client is hard-broken, with no fallback (OriginResolver.cs:29, all four endpoints)

register/self-register/resend previously derived the link base from the request host; forgot-password used the configured OriginUrl. Now all four hard-require an allow-listed Origin header. That breaks: the Scalar try-it UI (same-origin fetch sends Origin: https://localhost:7030, the API's own origin, never in the list), curl/Postman, mobile apps, and server-to-server provisioning. A documented fallback (e.g. a configured default frontend origin used when no header is present) preserves those callers while keeping the forged-header rejection for mismatches.

3. Silent breaking change for existing production deployments (appsettings.Production.json)

Production ships AllowedOrigins: [] and this PR doesn't touch it. A deployment that satisfied the old contract by configuring OriginOptions:OriginUrl upgrades and finds forgot-password/register dead with 500s, with the previously-required setting now silently ignored for these flows. There's no startup validation or migration note. At minimum this needs the docs/changelog treatment (see below) plus ideally a startup check.

4. Client-caused rejection surfaces as HTTP 500 (OriginResolver.cs:30)

A missing/forged Origin is a request condition, not a server fault, but InvalidOperationException falls into GlobalExceptionHandler's 500 bucket — and ForgotPassword_Should_Reject_When_OriginNotAllowed pins InternalServerError as the contract. Per .agents/rules/api-conventions.md this should be a framework exception type (e.g. CustomException(msg, null, HttpStatusCode.BadRequest)) so callers get a 4xx ProblemDetails and error-rate alerting doesn't page on bot traffic to anonymous endpoints.

5. The link targets the caller's SPA, not the recipient's (RegisterUserEndpoint.cs:22, ResendConfirmationEmailEndpoint.cs:36)

/register and resend-confirmation are invoked from the admin app, so a tenant dashboard user provisioned (or re-sent) by an operator gets a confirmation link into the admin SPA at :5173 — confirmation succeeds, then "continue to sign in" lands them on the operator login where their credentials don't work; if the admin app is network-restricted, the link is dead entirely. The old API-route link was ugly (raw JSON) but client-agnostic. The origin needs to be derived from the target user's app, not the request's Origin — which probably reinforces the case for explicit frontend config over header sniffing for these two operator-driven flows.

Non-blocking

  • Return the matched allow-list entry rather than the raw header (OriginResolver.cs:22): today attacker/client casing (HTTP://LOCALHOST:5173) is embedded verbatim into e-mails (the FrontendOrigin_Should_MatchIgnoringCase test asserts this). Returning the canonical entry costs nothing and removes client influence over the emailed URL.
  • Matcher footguns (OriginResolver.cs:42): exact string compare means an entry like https://app.example.com:443 or an IDN form never matches what browsers actually send — a fail-closed config trap with no startup signal. Consider component-wise Uri comparison or normalizing the list once at startup.
  • ApiOrigin() duplicates the existing IRequestContext.Origin contract; consider keeping that logic in one place so X-Forwarded-* handling etc. only ever needs one change.
  • AGENTS.md golden rule 10: this changes config semantics and e-mail-link behavior, so the docs-repo update + changelog entry need to land with the change, not in a follow-up.
  • Heads-up: #1324 (merged) replaced the stale :4200/:7140 entries in the same AllowedOrigins block, so this branch needs a rebase — which also resolves the leftover stale entries this PR kept (with the header-validation role, dead entries in that list are no longer harmless cruft).

Happy to re-review quickly — the core of this (allow-list-validated per-request origin + SPA confirm page) is something the kit genuinely needs.

…inks

Password-reset and e-mail-confirmation links were built from a single
configured OriginUrl (which pointed at the API and was empty in
Production, throwing "Origin URL is not configured") or from the raw
request host (the API), so neither could target the correct SPA when
more than one front-end is served (admin :5173, dashboard :5174).

Introduce IOriginResolver:
- FrontendOrigin(): takes the request Origin header and validates it
  against CorsOptions.AllowedOrigins, so the reset/confirmation link
  lands on the SPA the request came from. The allow-list check is the
  security boundary: a forged Origin on the anonymous forgot-password
  flow can never be injected into an e-mail. Throws when no allow-listed
  origin is present.
- ApiOrigin(): configured origin, else request host (unchanged
  behaviour) for API-served assets (avatars) and RequestContextService.

The confirmation e-mail now points at the SPA `/confirm-email` page
(which already exists in both clients and calls the API) instead of the
API route directly.

- forgot-password, register, self-register and resend-confirmation now
  resolve the front-end origin via the resolver.
- avatar URL building and RequestContextService delegate to ApiOrigin().
- appsettings: add the dev SPA origins to CorsOptions.AllowedOrigins.
  Production deployments must list their SPA URLs there.
- tests: OriginResolverTests (allow-list, case/slash/port, forged origin,
  missing header), updated ForgotPassword handler + RequestContext tests,
  and the integration harness now sends an Origin header like a browser.
…-end

Drives the failure path through the real HTTP pipeline: a forgot-password
request carrying an Origin header outside CorsOptions.AllowedOrigins is
rejected (500) instead of returning the uniform OK, proving a spoofed
origin can never be turned into a reset link.
Adds EmailLinkOriginTests: drives forgot-password and register through
the real pipeline and inspects the captured MailRequest body, asserting
the reset link points at the SPA origin from the request's Origin header
(:5174 vs :5173, proving per-front resolution) and that the confirmation
link targets the SPA /confirm-email page rather than the API route.

Adds the two dev SPA origins to the integration harness allow-list so
per-front resolution can be exercised.

Not yet executed locally: Windows Smart App Control blocks the freshly
rebuilt unsigned test DLLs (0x800711C7); runs in CI (Linux).
…meout

The register flow also emits a welcome e-mail (via the UserRegistered
integration event), so matching only by recipient grabbed the wrong
message. Match the confirmation e-mail by its subject, and likewise the
reset e-mail, and include the captured messages in the timeout error to
diagnose misses.
The integration harness does not execute enqueued Hangfire mail jobs
(mail-asserting tests such as TenantExpiryScanJobTests invoke the job
synchronously), so the confirmation/reset e-mails never reach the
capturing mail service and EmailLinkOriginTests could not observe them.

The link content is already covered where it is built: UserPasswordServiceTests
asserts the reset link (origin + tenant + encoding) by capturing the enqueued
MailRequest, OriginResolverTests covers origin resolution, and an integration
test asserts a forged Origin is rejected. Reverts the harness allow-list
entries that only that test needed.
The explanatory comment above the confirm-email URI build read like
commented-out code to SonarAnalyzer (S125) because of its parentheses
and trailing semicolon, failing the -warnaserror backend build. Reword
it as plain prose; behaviour is unchanged.
Address review on fullstackhero#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 (fullstackhero#1324 CORS allow-list).
@marcelo-maciel marcelo-maciel force-pushed the fix/identity-origin-multifront branch from 0ddc3d1 to 618bef4 Compare July 4, 2026 21:25
@marcelo-maciel

Copy link
Copy Markdown
Contributor Author

Thanks for the deep review — this reshaped the change for the better. Reworked around a dedicated, framework-level resolver; all five blocking points plus the non-blocking ones are addressed.

New shape: FSH.Framework.Web.FrontendFrontendOptions (AllowedOrigins + DefaultOrigin) and IFrontendOriginResolver/FrontendOriginResolver. It lives in BuildingBlocks (alongside CorsOptions/OriginOptions) rather than Identity because building user-facing e-mail links is cross-cutting — Notifications, Billing, Tickets and the localized e-mail-template work will all need the same resolution.

Blocking

  1. CorsOptions coupling / AllowAll trap — gone. A dedicated FrontendOptions:AllowedOrigins carries the link-trust duty explicitly, decoupled from CORS. Validated with .ValidateOnStart(): a deployment with neither AllowedOrigins nor DefaultOrigin now fails loud on boot instead of passing CORS and 500-ing on the first request. Same-origin/reverse-proxy topologies work via DefaultOrigin with an empty allow-list.
  2. Non-browser clients hard-brokenResolveForCurrentRequest() falls back to DefaultOrigin when the request carries no Origin header (curl, Scalar try-it, mobile, server-to-server), while still rejecting a present-but-forged origin.
  3. Silent prod breaking change — now a loud startup failure (ValidateOnStart) plus the docs/changelog treatment; appsettings.Production.json ships FrontendOptions empty as an explicit deploy requirement.
  4. Client-caused rejection was 500 — a forged/missing-from-list origin now throws CustomException(..., HttpStatusCode.BadRequest), so callers get a 4xx ProblemDetails and alerting doesn't page on bot traffic. The integration test now pins BadRequest.
  5. Link targeted the caller's SPA/register and resend-confirmation now call ResolveDefault() (the recipient's app via DefaultOrigin), not the operator's Origin. A tenant user provisioned from the admin app gets a link into the tenant dashboard. Self-service flows (forgot-password, self-register) still use the request origin.

Non-blocking

  • Returns the canonical allow-list entry, never the client's raw casing (test: uppercased header resolves to the configured entry).
  • Matcher now uses component-wise Uri.Compare(SchemeAndServer) (port exact, case-insensitive scheme/host), normalized once at construction — no :443/IDN footgun.
  • ApiOrigin() deduped: folded into the existing IRequestContext.Origin contract. RequestContextService owns the config-first/request-host logic; UserProfileService reads IRequestContextService.Origin for avatar URLs. The separate ApiOrigin() method is gone.
  • Rebased onto main (picks up chore(host): point default CORS allow-list at the React client origins #1324's allow-list; dropped the stale :4200/:7140 entries).

Docs-repo companion (#232) will be updated to the FrontendOptions model before merge.

Verified locally: full solution build 0 warnings / 0 errors (-warnaserror); Framework.Tests 121/121 (incl. new FrontendOriginResolverTests), Identity.Tests 312/312; origin-flow integration tests 15/15 against real Postgres (forgot / self-register / register / confirm-email happy paths + the forged-origin → 400 adversarial case).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants