From b68c615f7804803ce19a9cda59c054b065b540d7 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:52:43 -0300 Subject: [PATCH] docs(identity): document per-request origin resolution for auth e-mail links Covers the IOriginResolver change (FrontendOrigin vs ApiOrigin), that confirmation/reset links now target the SPA /confirm-email page, and that CorsOptions.AllowedOrigins is the allow-list gating anonymous auth e-mail links independently of AllowAll. Adds a changelog entry and cross-links the Identity and CORS pages. Accompanies fullstackhero/dotnet-starter-kit PRs #1323 and #1324. --- src/content/docs/changelog/index.mdx | 6 +++++- src/content/docs/modules/identity.mdx | 15 ++++++++++++++- src/content/docs/security/cors-and-headers.mdx | 8 ++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 180d8b52..01660e80 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastUpdated: 2026-06-20 +lastUpdated: 2026-07-03 description: Release notes and version history for fullstackhero. sidebar: order: 1 @@ -11,6 +11,10 @@ seo: Notable changes to the kit, newest first. +## 2026-07-03 + +- **Identity: auth e-mail links now resolve to the front-end the request came from, and `CorsOptions.AllowedOrigins` is their allow-list (fix).** The kit ships two SPAs (admin on `:5173`, dashboard on `:5174`), but the back-end had no way to build a user-facing link that targets the app a request actually came from. `forgot-password` built the reset link from a single configured `OriginOptions.OriginUrl` — the API URL in `appsettings.json`, empty in `appsettings.Production.json`, so the handler threw `"Origin URL is not configured."` — and register / self-register / resend-confirmation built the confirmation link from the raw request host and pointed it at the API's `confirm-email` route (which returns JSON) rather than a front-end page. A small `IOriginResolver` (Identity module, no `BuildingBlocks` change) now resolves this per request: **`FrontendOrigin()`** validates the request `Origin` header against `CorsOptions.AllowedOrigins` (normalising trailing slash and case, matching scheme + host + port exactly) and is used for links that land on a SPA, while **`ApiOrigin()`** keeps the previous configured-origin-then-request-host behaviour for API-served assets like avatar URLs. The confirmation e-mail now points at the SPA `/confirm-email` page (present in both apps, which then calls the API). **Action required for deployments:** populate `CorsOptions.AllowedOrigins` with your real SPA URLs — the resolver validates against it **independently of `CorsOptions.AllowAll`**, so an empty list makes `forgot-password`, `register`, `self-register`, and `resend-confirmation-email` reject the request rather than turn a forged `Origin` into a link inside an e-mail. `appsettings.json` now lists the dev origins (`http://localhost:5173`, `http://localhost:5174`); `appsettings.Production.json` intentionally leaves the list empty. This is the structural follow-up to the earlier reset-link string-format fix. See [Identity → origin resolution](/docs/modules/identity/) and [CORS & headers](/docs/security/cors-and-headers/). + ## 2026-06-20 - **The `fsh` CLI and `dotnet new` template are now on NuGet as stable `10.0.0`.** The two distribution packages that 10.0.0 had been waiting on have shipped: `FullStackHero.CLI` (install with `dotnet tool install -g FullStackHero.CLI` — no more `--prerelease`) and `FullStackHero.NET.StarterKit` (`dotnet new install FullStackHero.NET.StarterKit`). Because `fsh new` scaffolds *from* that template, the one-command flow is now end-to-end: `dotnet tool install -g FullStackHero.CLI && fsh new MyApp` produces a fully renamed project — unique JWT signing key, generated Docker secrets, `npm install` run, initial commit on `main`. The [Install](/docs/getting-started/install/) and [CLI](/docs/cli/) pages now lead with the CLI as the recommended path; `git clone` and the GitHub template remain available for reading the source or zero-install runs. See the [10.0.0 release](https://github.com/fullstackhero/dotnet-starter-kit/releases/tag/10.0.0). diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx index f2620fe7..744bb204 100644 --- a/src/content/docs/modules/identity.mdx +++ b/src/content/docs/modules/identity.mdx @@ -1,6 +1,6 @@ --- title: Identity module -lastUpdated: 2026-06-11 +lastUpdated: 2026-07-03 description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement. sidebar: label: Identity @@ -228,6 +228,19 @@ Yes, the role-permission routes really are `/{id}/permissions` directly under `/ Lockout is configured by `IdentityOptions` and defaults to 5 failed attempts → 15-minute lock. Outbox events are dispatched by the framework's `OutboxDispatcherHostedService` (on by default) — the module deliberately registers no dispatcher of its own, since a second one would race the same rows. +### Where auth e-mail links point (origin resolution) + +Confirmation and password-reset e-mails link to a **front-end** page, not to the API. Because the kit ships two SPAs (admin on `:5173`, dashboard on `:5174`), each link has to target the app the request actually came from. A small `IOriginResolver` in the Identity module resolves this per request, with two distinct notions of origin: + +- **`FrontendOrigin()`** — reads the request's `Origin` header and validates it against `CorsOptions.AllowedOrigins`, normalising trailing slash and case and matching scheme + host + port exactly. Used for links that land on a SPA (reset-password and e-mail confirmation). It **throws** when the request carries no allow-listed origin rather than guessing a front-end. +- **`ApiOrigin()`** — the configured `OriginOptions.OriginUrl`, or the request host when that is empty. Used for links and assets served by the API itself, such as avatar URLs and `RequestContextService`. `OriginUrl` is the API's public base — it is no longer overloaded as the reset-link base. + +The e-mail-confirmation link now points at the SPA `/confirm-email` page — present in both `clients/admin` and `clients/dashboard`, which then calls the API — instead of the API route directly. + + +The allow-list check is a security boundary, not just a CORS convenience. `forgot-password` is anonymous, so a forged `Origin` header must never be turned into a link inside an e-mail. The resolver always validates against `CorsOptions.AllowedOrigins` — **independently of `CorsOptions.AllowAll`** — and rejects anything not on the list. A deployment that leaves `AllowedOrigins` empty makes `forgot-password`, `register`, `self-register`, and `resend-confirmation-email` **reject the request** (no allow-listed front-end origin), not merely fail browser CORS. Populate it with your real SPA URLs. See [CORS & headers](/docs/security/cors-and-headers/). + + ## How to extend ### Add a new permission diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx index 2ec2f00b..bdc6fb9d 100644 --- a/src/content/docs/security/cors-and-headers.mdx +++ b/src/content/docs/security/cors-and-headers.mdx @@ -1,6 +1,6 @@ --- title: CORS & security headers -lastUpdated: 2026-06-11 +lastUpdated: 2026-07-03 description: CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, and the production security headers the kit emits by default. sidebar: label: CORS & headers @@ -41,6 +41,10 @@ CORS in fullstackhero has two non-default conventions: **CORS middleware runs be - Credentials are always allowed by the policy — there is no `AllowCredentials` config key. - If `AllowAll` is false **and** `AllowedOrigins` is empty, CORS isn't mounted at all — cross-origin browser calls will simply fail. (`appsettings.Production.json` ships with an empty list precisely so you have to fill it in.) + +`AllowedOrigins` is no longer only the browser CORS allow-list. Identity's origin resolver validates the request `Origin` header against it — **independently of `AllowAll`** — to decide which front-end a password-reset or e-mail-confirmation link points at. Because `forgot-password` is anonymous, this is the security boundary that stops a forged `Origin` from being turned into a link inside an e-mail. A deployment with an empty `AllowedOrigins` makes `forgot-password`, `register`, `self-register`, and `resend-confirmation-email` **reject the request**, not just fail browser CORS. See [Identity → origin resolution](/docs/modules/identity/). + + `UseHeroPlatform` mounts the middleware **before HTTPS redirect**, because OPTIONS preflight requests cannot follow HTTP→HTTPS redirects per the Fetch spec — the redirect breaks the preflight and the actual request never goes out. Pipeline order (relevant slice): @@ -129,7 +133,7 @@ The cookie should be HttpOnly (no JS access — limits XSS impact), Secure (HTTP ## Common mistakes - **Setting `AllowAll = true` in production.** CORS exists to give browsers a sanity check on cross-origin calls. Opening to the world removes the check (it doesn't directly compromise auth — auth still gates the request — but it removes the browser-enforced "is this site allowed to call you?" layer). -- **Forgetting to fill `AllowedOrigins` in production.** With `AllowAll: false` and no origins, CORS isn't mounted — your React apps on other origins will get blocked by the browser. The symptom is "works in Postman, fails in the browser". +- **Forgetting to fill `AllowedOrigins` in production.** With `AllowAll: false` and no origins, CORS isn't mounted — your React apps on other origins will get blocked by the browser. The symptom is "works in Postman, fails in the browser". It now also breaks auth e-mail links: `forgot-password` and `register` reject the request because the origin resolver finds no allow-listed front-end (see the callout above). - **Missing HSTS.** Without HSTS, an attacker on the network can downgrade to HTTP for the first request. The kit emits it on HTTPS responses automatically; verify your proxy doesn't strip it. - **CSP that breaks the UI.** If a third-party widget breaks after tightening CSP, look at the browser console — CSP violations are logged. Add the needed origins to `ScriptSources`/`StyleSources`, don't disable the middleware.