From 05ebd4029b09d1ab09cd1e2f7220e2672a29c7a5 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:57:17 -0300 Subject: [PATCH 1/2] docs(identity): per-request front-end origin for auth e-mail links Documents that CorsOptions.AllowedOrigins now doubles as the allowlist the Identity module validates the request Origin against to build password-reset and e-mail-confirmation links (per PR fullstackhero/dotnet-starter-kit#1323): - security/cors-and-headers: new section + common-mistake note - security/production-checklist: AllowedOrigins gates the auth e-mail flows - modules/identity: callout on where reset/confirmation links point - changelog: 2026-07-02 entry --- src/content/docs/changelog/index.mdx | 6 +++++- src/content/docs/modules/identity.mdx | 6 +++++- src/content/docs/security/cors-and-headers.mdx | 10 ++++++++-- src/content/docs/security/production-checklist.mdx | 4 ++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 180d8b52..28dabdce 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-02 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-02 + +- **Identity: password-reset and e-mail-confirmation links now point at the front-end that made the request.** The reset link was built from a single configured `OriginOptions.OriginUrl` — which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` — and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). The Identity module now resolves the link's base URL from the request `Origin` header, validated against `CorsOptions.AllowedOrigins`: each user gets a link back to the app they started from, and — because forgot-password is anonymous — a forged or unlisted `Origin` is rejected rather than turned into a link inside an e-mail. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** list your SPA origins in `CorsOptions:AllowedOrigins` — the dev config now ships `http://localhost:5173` and `http://localhost:5174`; with the list empty these flows are rejected. `OriginOptions:OriginUrl` keeps its role as the API's own public base (avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323). + ## 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..d582990e 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-02 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 @@ -149,6 +149,10 @@ endpoints.MapPost("/users", handler) All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table: + +The `forgot-password` and registration e-mails link back to the **front-end that made the request**: the Identity module resolves the link's base URL from the request `Origin` header, validated against `CorsOptions.AllowedOrigins`. With more than one SPA each user gets a link to the app they started from, and a forged or unlisted origin is rejected rather than e-mailed. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. List your SPA origins in `AllowedOrigins` for these flows to work — see [CORS & headers](/docs/security/cors-and-headers/). + + | Verb | Route | What it does | |---|---|---| | POST | `/token/issue` | Login | diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx index 2ec2f00b..e59d7a95 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-02 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 @@ -54,6 +54,12 @@ Pipeline order (relevant slice): 6. ... ``` +## Front-end origin for auth e-mail links + +`AllowedOrigins` does double duty. Besides the CORS policy, it is the allowlist the Identity module validates the request `Origin` header against when it builds a link that lands on a front-end — the password-reset and e-mail-confirmation e-mails. Each link is built from the origin the request actually came from, so when you serve more than one SPA (say an admin console on one origin and a tenant app on another) every user gets a link back to the app they started from, not a single hard-coded URL. + +Because forgot-password is anonymous, this validation is a security boundary: a forged `Origin` header that is not in `AllowedOrigins` is rejected rather than turned into a link inside an e-mail. The check always runs against `AllowedOrigins`, independently of `AllowAll` — so for these links to resolve you must list your SPA origins explicitly, even in dev. A request whose `Origin` isn't allow-listed fails instead of e-mailing a link to the wrong host. (`OriginOptions:OriginUrl` remains the API's own public base, used for API-served assets such as avatar URLs — it is not the front-end link base.) + ## Why not AllowAnyOrigin for SignalR CORS spec says: when a response has `Access-Control-Allow-Credentials: true`, the `Access-Control-Allow-Origin` must be an explicit origin, not `*`. SignalR's negotiate request is credentialed (it carries `Cookie` or the JWT via `accessTokenFactory`'s query-param fallback). With `AllowAnyOrigin()`, the server emits `Allow-Origin: *`, which violates the spec — the browser silently refuses to use the response, and SignalR's `HubConnection` fails to start with a confusing CORS error. @@ -129,7 +135,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 also breaks password-reset and e-mail-confirmation links: the Identity module validates the request `Origin` against this same list to pick which front-end the e-mailed link points to, so an origin that isn't listed makes forgot-password and registration reject the request instead of sending the e-mail. - **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. diff --git a/src/content/docs/security/production-checklist.mdx b/src/content/docs/security/production-checklist.mdx index df6176ba..f52db75d 100644 --- a/src/content/docs/security/production-checklist.mdx +++ b/src/content/docs/security/production-checklist.mdx @@ -1,6 +1,6 @@ --- title: Production security checklist -lastUpdated: 2026-06-11 +lastUpdated: 2026-07-02 description: Ten configuration items you must check before shipping fullstackhero to production. Skip none. sidebar: label: Production checklist @@ -57,7 +57,7 @@ Adjust for your industry. Healthcare (HIPAA) and finance (PCI-DSS) tend to requi ## 3. Allowlist CORS origins -`CorsOptions:AllowAll = true` (and the `SetIsOriginAllowed(_ => true)` policy it enables) is **dev only**. Production needs the explicit lists — and note that `appsettings.Production.json` ships `AllowedOrigins` empty, which means **no CORS middleware mounts at all** until you fill it in; your front-ends on other origins will be blocked by the browser. See [CORS & security headers](/docs/security/cors-and-headers/). +`CorsOptions:AllowAll = true` (and the `SetIsOriginAllowed(_ => true)` policy it enables) is **dev only**. Production needs the explicit lists — and note that `appsettings.Production.json` ships `AllowedOrigins` empty, which means **no CORS middleware mounts at all** until you fill it in; your front-ends on other origins will be blocked by the browser. `AllowedOrigins` is also the allowlist the Identity module validates the request `Origin` against to build password-reset and e-mail-confirmation links, so if it doesn't list your SPA origins those flows are rejected and no e-mail is sent. See [CORS & security headers](/docs/security/cors-and-headers/). ```jsonc { From c8a894a55ea0cd10fef1defc9a6c24ab2ea9cdf9 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:21:01 -0300 Subject: [PATCH 2/2] docs(identity): dedicated FrontendOptions for auth e-mail link origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the origin docs to the reworked #1323 model: link resolution moved off CorsOptions onto a dedicated FrontendOptions (AllowedOrigins + DefaultOrigin). - cors-and-headers: rewrite the front-end-origin section — decoupled from CORS, self-service vs operator-driven flows, no-header fallback to DefaultOrigin, forged origin -> 400, startup validation, component-wise matching. - modules/identity: callout reflects FrontendOptions + recipient-app targeting for register/resend. - production-checklist: FrontendOptions is a separate required setting whose startup validation fails the boot until configured. - changelog: describe the FrontendOptions model and the updated deploy action. --- src/content/docs/changelog/index.mdx | 2 +- src/content/docs/modules/identity.mdx | 2 +- src/content/docs/security/cors-and-headers.mdx | 17 ++++++++++++++--- .../docs/security/production-checklist.mdx | 4 +++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 28dabdce..05402742 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -13,7 +13,7 @@ Notable changes to the kit, newest first. ## 2026-07-02 -- **Identity: password-reset and e-mail-confirmation links now point at the front-end that made the request.** The reset link was built from a single configured `OriginOptions.OriginUrl` — which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` — and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). The Identity module now resolves the link's base URL from the request `Origin` header, validated against `CorsOptions.AllowedOrigins`: each user gets a link back to the app they started from, and — because forgot-password is anonymous — a forged or unlisted `Origin` is rejected rather than turned into a link inside an e-mail. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** list your SPA origins in `CorsOptions:AllowedOrigins` — the dev config now ships `http://localhost:5173` and `http://localhost:5174`; with the list empty these flows are rejected. `OriginOptions:OriginUrl` keeps its role as the API's own public base (avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323). +- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request.** The reset link was built from a single configured `OriginOptions.OriginUrl` — which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` — and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry — so each user gets a link back to the app they started from; because forgot-password is anonymous a forged/unlisted `Origin` is rejected with **`400`**, and a request with no `Origin` (curl, Scalar, mobile, server-to-server) falls back to `DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` — the recipient's app — so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** set `FrontendOptions:AllowedOrigins` + `FrontendOptions:DefaultOrigin` — the dev config ships `http://localhost:5173`/`5174` with `DefaultOrigin` the tenant app; both ship empty in production and startup validation now **fails the boot** until you configure them. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323). ## 2026-06-20 diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx index d582990e..b3ceaff1 100644 --- a/src/content/docs/modules/identity.mdx +++ b/src/content/docs/modules/identity.mdx @@ -150,7 +150,7 @@ endpoints.MapPost("/users", handler) All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table: -The `forgot-password` and registration e-mails link back to the **front-end that made the request**: the Identity module resolves the link's base URL from the request `Origin` header, validated against `CorsOptions.AllowedOrigins`. With more than one SPA each user gets a link to the app they started from, and a forged or unlisted origin is rejected rather than e-mailed. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. List your SPA origins in `AllowedOrigins` for these flows to work — see [CORS & headers](/docs/security/cors-and-headers/). +Auth e-mail links resolve through `FrontendOptions` (a dedicated config, separate from CORS). **Self-service** flows (`forgot-password`, `self-register`) link back to the front-end that made the request — the base URL comes from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` — so with more than one SPA each user gets a link to the app they started from; a forged/unlisted origin is rejected with `400`, and a request with no `Origin` (non-browser callers) falls back to `FrontendOptions:DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` (the recipient's app), so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. Configure `FrontendOptions` for these flows to work — see [CORS & headers](/docs/security/cors-and-headers/). | Verb | Route | What it does | diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx index e59d7a95..c12d2cd3 100644 --- a/src/content/docs/security/cors-and-headers.mdx +++ b/src/content/docs/security/cors-and-headers.mdx @@ -56,9 +56,20 @@ Pipeline order (relevant slice): ## Front-end origin for auth e-mail links -`AllowedOrigins` does double duty. Besides the CORS policy, it is the allowlist the Identity module validates the request `Origin` header against when it builds a link that lands on a front-end — the password-reset and e-mail-confirmation e-mails. Each link is built from the origin the request actually came from, so when you serve more than one SPA (say an admin console on one origin and a tenant app on another) every user gets a link back to the app they started from, not a single hard-coded URL. +Links that land on a front-end SPA — the password-reset and e-mail-confirmation e-mails — are **not** built from the CORS list. They resolve through a dedicated `FrontendOptions`, kept separate from CORS on purpose: the CORS allowlist governs which browsers may *call* the API, while this list governs which origins may appear *inside an outbound link*. The two often overlap but carry different duties, and coupling them breaks same-origin / reverse-proxy topologies (SPA + API on one domain need no CORS entries, yet the browser still sends `Origin` on the POST). -Because forgot-password is anonymous, this validation is a security boundary: a forged `Origin` header that is not in `AllowedOrigins` is rejected rather than turned into a link inside an e-mail. The check always runs against `AllowedOrigins`, independently of `AllowAll` — so for these links to resolve you must list your SPA origins explicitly, even in dev. A request whose `Origin` isn't allow-listed fails instead of e-mailing a link to the wrong host. (`OriginOptions:OriginUrl` remains the API's own public base, used for API-served assets such as avatar URLs — it is not the front-end link base.) +```jsonc + "FrontendOptions": { + "AllowedOrigins": [ "http://localhost:5173", "http://localhost:5174" ], + "DefaultOrigin": "http://localhost:5174" // the tenant SPA + } +``` + +- **Self-service flows** (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `AllowedOrigins` and returned as the canonical list entry — so with more than one SPA each user gets a link back to the app they started from. Because forgot-password is anonymous this is a security boundary: a **forged/unlisted `Origin` is rejected with `400`** rather than turned into a link. A request with **no** `Origin` header (curl, the Scalar try-it UI, mobile, server-to-server) falls back to `DefaultOrigin` instead of failing. +- **Operator-driven flows** (`register`, `resend-confirmation-email`) target `DefaultOrigin` — the recipient's app — not the calling operator's origin, so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. +- Matching is component-wise (scheme + host + port, port exact). Startup validation **fails the boot** if neither `AllowedOrigins` nor `DefaultOrigin` is set, so a missing config surfaces loudly instead of 500-ing on the first reset. `appsettings.Production.json` ships both empty — you must configure them (see the [production checklist](/docs/security/production-checklist/)). + +(`OriginOptions:OriginUrl` is unrelated: it's the API's own public base for back-end-served assets such as avatar URLs, exposed via `IRequestContext.Origin`.) ## Why not AllowAnyOrigin for SignalR @@ -135,7 +146,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". It also breaks password-reset and e-mail-confirmation links: the Identity module validates the request `Origin` against this same list to pick which front-end the e-mailed link points to, so an origin that isn't listed makes forgot-password and registration reject the request instead of sending the e-mail. +- **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". (Auth e-mail links are a *separate* concern now — they use `FrontendOptions`, see above — but that list ships empty in production too, and its startup validation fails the boot until you set it.) - **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. diff --git a/src/content/docs/security/production-checklist.mdx b/src/content/docs/security/production-checklist.mdx index f52db75d..8160224f 100644 --- a/src/content/docs/security/production-checklist.mdx +++ b/src/content/docs/security/production-checklist.mdx @@ -57,7 +57,9 @@ Adjust for your industry. Healthcare (HIPAA) and finance (PCI-DSS) tend to requi ## 3. Allowlist CORS origins -`CorsOptions:AllowAll = true` (and the `SetIsOriginAllowed(_ => true)` policy it enables) is **dev only**. Production needs the explicit lists — and note that `appsettings.Production.json` ships `AllowedOrigins` empty, which means **no CORS middleware mounts at all** until you fill it in; your front-ends on other origins will be blocked by the browser. `AllowedOrigins` is also the allowlist the Identity module validates the request `Origin` against to build password-reset and e-mail-confirmation links, so if it doesn't list your SPA origins those flows are rejected and no e-mail is sent. See [CORS & security headers](/docs/security/cors-and-headers/). +`CorsOptions:AllowAll = true` (and the `SetIsOriginAllowed(_ => true)` policy it enables) is **dev only**. Production needs the explicit lists — and note that `appsettings.Production.json` ships `AllowedOrigins` empty, which means **no CORS middleware mounts at all** until you fill it in; your front-ends on other origins will be blocked by the browser. See [CORS & security headers](/docs/security/cors-and-headers/). + +Separately, set **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`) — the allowlist and fallback the Identity module uses to build password-reset and e-mail-confirmation links. It also ships empty in production, and its startup validation **fails the boot** if neither is set, so a missing config surfaces immediately rather than 500-ing on the first reset. `DefaultOrigin` is the tenant SPA; it's the fallback for non-browser callers and the target for operator-driven register/resend links. ```jsonc {