Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Notable changes to the kit, newest first.

## 2026-09-25

- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request (breaking for Production config).** **Upgrade note - set `FrontendOptions:DefaultOrigin` before you upgrade, or Production won't boot.** In `Production` the API now refuses to start when `FrontendOptions:DefaultOrigin` is missing or not an absolute `http(s)` URL (`Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…`), alongside the existing fail-fast on `DatabaseOptions:ConnectionString`, `CachingOptions:Redis` and `JwtOptions:SigningKey`. Point it at your tenant dashboard. The shipped deployment paths set it for you: `deploy/docker/docker-compose.yml` derives it from `FSH_DASHBOARD_URL`, and the AWS Terraform stack from `dashboard_url` (falling back to `admin_url`) - so the action is for deployments that roll their own hosting. The DbMigrator is unaffected. 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. Also list every SPA origin in `FrontendOptions:AllowedOrigins`; both settings ship empty in `appsettings.Production.json`, and the shipped deploys fill the list from the same SPA URLs (`FSH_ADMIN_URL` / `FSH_DASHBOARD_URL`, or the Terraform site URLs - `api_extra_cors_origins` deliberately stays off it). Outside Production the host still boots without `DefaultOrigin` and logs one startup `Error`, but link building has no fallback, so those flows answer `500` until it is set. 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).
- **Object storage: MinIO replaced with RustFS for local dev, Docker Compose, and integration tests (breaking for docker-compose).** The `minio/minio` and `minio/mc` images were removed from Docker Hub and `quay.io/minio` now refuses anonymous pulls, so fresh clones could no longer bring up the stack. The kit now ships [RustFS](https://rustfs.com) (`rustfs/rustfs:1.0.0`, S3-compatible, Apache-2.0) on the same ports - **9000** (S3 API) and **9001** (web console) - with bucket bootstrap done by a pinned `amazon/aws-cli:2.37.3` init container. Nothing changes in application code: the API still talks to it through the `s3` storage provider with `ForcePathStyle`, and production can keep pointing at AWS S3 or any other S3-compatible store. See PR [#1390](https://github.com/fullstackhero/dotnet-starter-kit/pull/1390).
- **Aspire:** the `minio` / `minio-init` resources are now `rustfs` / `rustfs-init`, and the AppHost parameters are renamed `minio-user` / `minio-password` → `rustfs-user` / `rustfs-password` (default `rustfsadmin`). If you set the old parameters in user secrets or config, rename them. The data volume is now `{appPrefix}-rustfs-data`, so local uploads start empty; delete the old `*-minio-data` volume when you no longer need it.
- **Breaking (docker-compose):** in `deploy/docker/.env`, rename `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` → `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` (compose refuses to start without them). The services are now `rustfs` / `rustfs-init` and the volume `minio_data` → `rustfs_data`, so existing objects are **not** carried over: before upgrading, copy them out of the old MinIO bucket and into the new `fsh` bucket with `aws s3 sync` (against each `--endpoint-url`), or have users re-upload. `fsh new` now generates `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` for new projects.
Expand Down
11 changes: 9 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-25
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,14 @@ 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` (falling back to the admin URL). Extra origins passed through
`api_extra_cors_origins` land on the CORS list only - never on `FrontendOptions`,
so a CORS grant can't become a grant to receive credential links. The API
refuses to start in Production without `FrontendOptions:DefaultOrigin`, so if
the stack hosts neither SPA, pass it yourself via `api_extra_environment_variables`.

## 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-25
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: link building has no fallback, so in `Production` the API **refuses to start** until it is set to an absolute `http(s)` URL. Outside Production 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
21 changes: 20 additions & 1 deletion src/content/docs/security/cors-and-headers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ Pipeline order (relevant slice):
7. ...
```

## 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. **In `Production` the API refuses to start** until `DefaultOrigin` is set to an absolute `http(s)` URL (`Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…`), the same fail-fast it applies to the database connection string, the Redis connection and the JWT signing key. Outside Production the host still boots and logs one startup `Error` naming `FrontendOptions:DefaultOrigin`; 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. (The DbMigrator never sends links and is unaffected.) 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 you deploy.
- `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`.)

## Reverse proxy & forwarded headers

The kit runs behind a reverse proxy in production (Cloudflare / cloudflared → Caddy / Nginx → app). Without `UseForwardedHeaders`, `Connection.RemoteIpAddress` is the proxy's IP and `Request.Scheme` is the internal `http`, which breaks two things: the **IP-partitioned rate limiters** (`auth` policy + global IP limiter) collapse into one shared bucket - losing per-origin brute-force protection - and audit / `UserSession` records log the proxy IP for every request. `UseHeroPlatform` mounts `UseForwardedHeaders` **first** (right after the exception handler), so `X-Forwarded-For` / `X-Forwarded-Proto` are applied before rate limiting, auth, HTTPS redirect, and audit read the client.
Expand All @@ -73,7 +92,7 @@ Blindly trusting `X-Forwarded-For` is itself a hole - any client that can reach
```

- Forwarded headers are honoured **only** when the immediate upstream is one of the configured proxies/networks; from any other source they're ignored and the connection IP/scheme stand.
- Only `X-Forwarded-For` and `X-Forwarded-Proto` are in the flag list. `X-Forwarded-Host` is deliberately left out: rewriting `Request.Host` from a header is a host-header injection primitive, and the registration / confirmation e-mail endpoints build their links straight from the request, so they would mail links pointing wherever the header said. The trade-off is that `Request.Host` keeps the internal host behind a proxy, and those links carry it.
- Only `X-Forwarded-For` and `X-Forwarded-Proto` are in the flag list. `X-Forwarded-Host` is deliberately left out: rewriting `Request.Host` from a header is a host-header injection primitive for anything that builds an absolute URL from the request. The trade-off is that `Request.Host` keeps the internal host behind a proxy. Auth e-mail links are not affected either way: they resolve through `FrontendOptions` (above), never the request host.
- `ForwardLimit` must match the real number of proxy hops, and must be at least `1`. The framework default of `1` reads only the rightmost hop, which in a multi-hop ingress yields the nearest proxy's IP (or an attacker-injected value) instead of the real client. Anything below `1` is rejected at startup for the same reason a malformed proxy entry is: `0` would leave forwarded headers unprocessed with no error at all, and a negative value would fail every request, including requests that carry no forwarded headers.
- **Secure by default:** with `KnownProxies` and `KnownNetworks` both empty (as `appsettings.json` / `appsettings.Production.json` ship them), the framework default - trust loopback only - stands, so forwarded headers from a real proxy are ignored until you configure the ingress. Set them as part of your deploy.
- A malformed entry fails the host build with a message naming the offending setting and value (for example ``TrustedProxyOptions:KnownNetworks contains "10.0.0.0/999", which is not a valid CIDR network``), rather than an opaque parse error. A typo can't silently degrade into "trust nobody".
Expand Down
2 changes: 2 additions & 0 deletions src/content/docs/security/production-checklist.mdx
Original file line number Diff line number Diff line change
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. Both ship empty in production too. **`DefaultOrigin` is required: in `Production` the API refuses to start without it** (or with anything but an absolute `http(s)` URL), failing with `Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…` - the same fail-fast the host applies to `DatabaseOptions:ConnectionString`, `CachingOptions:Redis` and `JwtOptions:SigningKey`. Link building has no fallback: 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` (`DefaultOrigin` = `FSH_DASHBOARD_URL`), and the AWS Terraform stack from `dashboard_url` / `admin_url` - so this item is about deployments that roll their own hosting.

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