fix(identity): resolve front-end origin per-request for auth e-mail links#1323
fix(identity): resolve front-end origin per-request for auth e-mail links#1323marcelo-maciel wants to merge 8 commits into
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
iammukeshm
left a comment
There was a problem hiding this comment.
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 (theFrontendOrigin_Should_MatchIgnoringCasetest 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 likehttps://app.example.com:443or an IDN form never matches what browsers actually send — a fail-closed config trap with no startup signal. Consider component-wiseUricomparison or normalizing the list once at startup. ApiOrigin()duplicates the existingIRequestContext.Origincontract; consider keeping that logic in one place soX-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/:7140entries in the sameAllowedOriginsblock, 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).
0ddc3d1 to
618bef4
Compare
|
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: Blocking
Non-blocking
Docs-repo companion (#232) will be updated to the Verified locally: full solution build 0 warnings / 0 errors ( |
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:OriginOptions.OriginUrl. Inappsettings.jsonthat value is the API URL (https://localhost:7030), and inappsettings.Production.jsonit is empty, so the handler throws"Origin URL is not configured.".{scheme}://{host}{pathbase}), i.e. the API, and pointed it at the API routeapi/v1/identity/confirm-email(which returns JSON) rather than a front-end page.Originheader 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,
tenantparam, URL-encoding).Solution
A small
IOriginResolver(Identity module, no changes toBuildingBlocks) with two notions of origin:FrontendOrigin()— takes the requestOriginheader and validates it againstCorsOptions.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-emailpage (which already exists in bothclients/adminandclients/dashboardand 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
Originheader must never be turned into a link inside an e-mail. The resolver always checksAllowedOrigins— independently ofCorsOptions.AllowAll— and rejects anything not on the list.Config / migration note
appsettings.jsonnow lists the dev SPA origins (http://localhost:5173,http://localhost:5174) underCorsOptions.AllowedOrigins.CorsOptions.AllowedOriginswith their real SPA URLs, otherwise forgot-password/register will reject requests (no allow-listed front-end origin).appsettings.Production.jsonintentionally leaves it empty.OriginOptions.OriginUrlkeeps 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 missingHttpContextall throw;ApiOriginconfig-first then request fallback.ForgotPasswordCommandHandlerTestsupdated to the resolver.ForgotPassword_Should_Reject_When_OriginNotAlloweddrives a forgedOriginend-to-end (rejected, no reset link).EmailLinkOriginTestsinspects the captured e-mail body and asserts the reset link resolves to the requesting front-end (:5174vs:5173) and the confirmation link targets the SPA/confirm-emailpage rather than the API route. The integration harness sends anOriginheader like a browser.dotnet buildclean (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/docssite) and a changelog entry to describe the per-front origin resolution and theAllowedOriginsrequirement — happy to open that PR separately.