Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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).
Expand Down
15 changes: 14 additions & 1 deletion src/content/docs/modules/identity.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.

<Callout type="warning" title="AllowedOrigins gates anonymous e-mail links">
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/).
</Callout>

## How to extend

### Add a new permission
Expand Down
8 changes: 6 additions & 2 deletions src/content/docs/security/cors-and-headers.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.)

<Callout type="warning" title="AllowedOrigins also gates auth e-mail links">
`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/).
</Callout>

`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):
Expand Down Expand Up @@ -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.

Expand Down