Skip to content
Open
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-07-13
lastUpdated: 2026-08-10
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-08-10

- **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 or unlisted `Origin` is rejected with **`400`** once the list is non-empty, and a request with no `Origin` (curl, mobile, server-to-server) falls back to `DefaultOrigin` - as does every request while the list is empty, since there is then nothing to validate against. **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:DefaultOrigin` to your tenant SPA, and list every SPA origin in `FrontendOptions:AllowedOrigins`. Both ship empty in production; **the host still starts without them** and logs a startup `Warning`, falling back to the API's own origin for these links - `OriginOptions:OriginUrl`, or the request host when that is empty too - which is serviceable, and where confirmation links pointed before, but not where your users expect to land. `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-07-13

- **Dashboard: tenants can now edit their own branding from Settings.** A new **Settings → Branding** tab lets a tenant admin holding `Tenants.UpdateTheme` customise their **light and dark palettes** and **brand asset URLs** (logo, dark-mode logo, favicon) with a live preview - mirroring the operator's existing tenant-branding card, but self-service and with no `tenant:` header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API's `403`, surfaced as an error band. Editing is draft-based - a **Reset to defaults** action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin's concurrent change appears on a manual refresh rather than overwriting your form).
Expand Down
6 changes: 5 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-08-10
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 @@ -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:

<Callout type="note" title="Where reset & confirmation e-mails point">
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 or unlisted origin is rejected with `400`, and a request with no `Origin` (non-browser callers) falls back to `FrontendOptions:DefaultOrigin` - as does every request when the allowlist is empty, since there is then nothing to validate against. **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. Left unset, the host still boots and these links fall back to the API's own origin (`OriginOptions:OriginUrl`, or the request host), which is rarely what you want - configure it, see [CORS & headers](/docs/security/cors-and-headers/).
</Callout>

| Verb | Route | What it does |
|---|---|---|
| POST | `/token/issue` | Login |
Expand Down
23 changes: 21 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-08-10
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 @@ -54,6 +54,25 @@ Pipeline order (relevant slice):
6. ...
```

## Front-end origin for auth e-mail links

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).

```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: once the list is non-empty, a **forged or unlisted `Origin` is rejected with `400`** rather than turned into a link. A request with **no** `Origin` header (curl, mobile, server-to-server) falls back to `DefaultOrigin` instead of failing. The Scalar try-it UI is not in that group - it fetches from the browser, so it sends the API's own origin; add that origin to `AllowedOrigins` if you want to exercise these two endpoints from the docs UI.
- With `AllowedOrigins` **empty** there is nothing to validate against, so the header is discarded and the link uses `DefaultOrigin`. That keeps the single-SPA and reverse-proxy setups working on `DefaultOrigin` alone - browsers attach `Origin` to these POSTs even same-origin, so matching an empty list would otherwise reject every legitimate reset. The client's value is never echoed either way. List your origins as soon as you serve more than one front-end, or every user lands on the same app.
- **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). `appsettings.Production.json` ships both settings empty. The host **still starts** without them: it logs one startup `Warning` naming `FrontendOptions:DefaultOrigin`, and link resolution falls back to the API's own origin - `OriginOptions:OriginUrl`, or the request's own host when that is empty too. E-mails keep going out, but their links point at the API rather than your SPA (which is where confirmation links pointed before this resolver existed). A background job, having no request to derive a host from, fails instead. Configure both (see the [production checklist](/docs/security/production-checklist/)) to send users where they expect to land.
- `DefaultOrigin` is a **single global**, not per-tenant or custom-domain aware, so operator-driven `register` / `resend-confirmation-email` point every tenant's link at that one SPA. That fits the kit's single-dashboard model; a deployment with per-tenant custom domains would need to resolve the recipient tenant's own origin instead.

(`OriginOptions:OriginUrl` has a second, unrelated role: 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

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.
Expand Down Expand Up @@ -129,7 +148,7 @@ The cookie should be HttpOnly (no JS access - limits XSS impact), Secure (HTTPS
## 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". (Auth e-mail links are a *separate* concern - they use `FrontendOptions`, see above - which ships empty in production too, and silently falls back to the API origin 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.

Expand Down
4 changes: 3 additions & 1 deletion src/content/docs/security/production-checklist.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Production security checklist
lastUpdated: 2026-06-11
lastUpdated: 2026-08-10
description: Ten configuration items you must check before shipping fullstackhero to production. Skip none.
sidebar:
label: Production checklist
Expand Down Expand Up @@ -59,6 +59,8 @@ Adjust for your industry. Healthcare (HIPAA) and finance (PCI-DSS) tend to requi

`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 ships empty in production too. The host still boots without it, logging a startup `Warning` and falling back to the API's own origin (`OriginOptions:OriginUrl`, or the request host when that is empty as well), so auth e-mails keep arriving but their links point at the API instead of your SPA. `DefaultOrigin` is the tenant SPA: it's the fallback for non-browser callers and the target for operator-driven register/resend links.

```jsonc
{
"CorsOptions": {
Expand Down