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-08-07
lastUpdated: 2026-09-17
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 `appsettings.Production.json`, but the two shipped deployment paths now fill them in from the SPA URLs they already know - `deploy/docker/docker-compose.yml` from `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL`, and the AWS Terraform stack from the resolved site URLs - so this action is for deployments that roll their own hosting. **The host still starts without them** and logs a startup `Error`, but link building has no fallback: it resolves `FrontendOptions:DefaultOrigin` or fails the request with a `500`. The two fallback tiers an earlier revision carried were removed on review - the request host is caller-supplied, so a password-reset link built from it delivers a working token to a domain the attacker named, and the API's own origin returns `404` for the SPA pages these links now target. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1377](https://github.com/fullstackhero/dotnet-starter-kit/pull/1377).

## 2026-08-07

The transactional outbox was rebuilt so that any module can publish, every tenant's events actually get dispatched, and the kit is safe to scale past one API instance.
Expand Down
8 changes: 6 additions & 2 deletions src/content/docs/deployment/aws-terraform.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Deploy to AWS with Terraform
lastUpdated: 2026-06-06
lastUpdated: 2026-09-14
description: End-to-end AWS deployment - prerequisites, bootstrapping the state backend, configuring an environment, and the one-command deploy that ships the API and both React apps.
sidebar:
label: AWS (Terraform)
Expand Down Expand Up @@ -223,7 +223,11 @@ terraform output admin_site
```

Open the CloudFront `url` from each site output to reach the apps. The API's
CORS allow-list is wired automatically to include both SPA origins.
CORS allow-list is wired automatically to include both SPA origins, and so is
`FrontendOptions` - the separate allow-list that decides which origin a
password-reset or e-mail-confirmation link may point at, with the dashboard as
`DefaultOrigin`. Extra origins passed through `api_extra_cors_origins` land on
both lists.

## Layout reference

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-09-17
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. `DefaultOrigin` is required in practice: link building has no fallback. Left unset, the host still boots but logs a startup `Error`, and the four flows that build a user-facing link - confirm-email, resend-confirmation, forgot-password, reset-password - answer `500` until you configure it. That is deliberate: the request host is whatever the caller puts in the `Host` header, so a password-reset link derived from it delivers a working token to an attacker-chosen domain, and the API's own origin returns `404` for the SPA pages these links now target. 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-09-17
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 a startup `Error` naming `FrontendOptions:DefaultOrigin`, the config file, and what breaks. Link resolution has no fallback - it returns `DefaultOrigin` or throws - so confirm-email, resend-confirmation, forgot-password and reset-password answer `500` until it is set. Failing loudly is the deliberate choice: the request host is whatever the caller puts in the `Host` header, so a password-reset link derived from it delivers a working token to a domain the attacker picked, and the API's own origin returns `404` for the SPA pages these links target. Configure both (see the [production checklist](/docs/security/production-checklist/)) before the first user asks for a reset.
- `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` plays no part in building these links. Its role is 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 there the failure is loud: those flows answer `500` 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-09-17
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 default origin 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 `Error`, but link building has no fallback: confirm-email, resend-confirmation, forgot-password and reset-password answer `500` until `DefaultOrigin` is set. The two candidates an earlier revision fell back to are both unacceptable - the request host is caller-supplied, so a reset link built from it hands a live token to an attacker-chosen domain, and the API's own origin returns `404` for the SPA pages these links target. `DefaultOrigin` is the tenant SPA: it's the fallback for non-browser callers and the target for operator-driven register/resend links. The two shipped deployment paths fill this in for you - `deploy/docker/docker-compose.yml` from `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL`, and the AWS Terraform stack from the resolved SPA URLs - so this item is about deployments that roll their own hosting.

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