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-11-03

- **Identity: the two-factor issuer name is configurable (fix).** `EnrollTwoFactorCommandHandler` hardcoded `FullStackHero` as the issuer of the `otpauth://` URI, so every product built on the kit showed up under that name in the user's authenticator app. A new **`TwoFactorOptions:Issuer`** setting chooses it; it defaults to `FullStackHero`, so existing deployments behave exactly as before, and a blank value fails options validation at startup. The issuer is percent-encoded in both the label and the `issuer=` parameter, so names with spaces or accents produce a valid URI. Only new enrolments get the new name: existing enrolments keep working unchanged, because the shared secret does not change, and no migration is needed. See [Two-factor authentication](/docs/security/two-factor/#issuer-name), [#1440](https://github.com/fullstackhero/dotnet-starter-kit/issues/1440) and [#1443](https://github.com/fullstackhero/dotnet-starter-kit/pull/1443).
- **Dashboard: no more `403` polls for users without the permission (fix).** The topbar notification bell (`GET /notifications/unread-count`), the topbar chat unread pill (`GET /chat/channels`) and the overview usage card (`GET /billing/usage`) queried their endpoints for every user, so a role without `Permissions.Notifications.Inbox.View`, `Permissions.Chat.Channels.View` or `Permissions.Billing.View` got a `403` on each poll and the API logged an error line for each one. Each query now runs only when the user holds that permission. Without it, the bell and the chat pill are hidden, and the usage card and the Resources stat card say there is no access. The `/chat` and `/subscription` pages are unchanged, because the nav already hides them from users without the permission. See [Add a dashboard page](/docs/guides/add-a-dashboard-page/#step-5---permission-gating-nav--actions-not-routes) and [#1445](https://github.com/fullstackhero/dotnet-starter-kit/pull/1445).
- **Frontend containers: the inactivity timeout is configurable from the environment (fix).** Both apps read `inactivityIdleMs` and `inactivityWarningMs` from `/config.json`, but the container's `config.json.template` never carried them, so an operator could not change the auto sign-out time without rebuilding the image. The template now renders them from `FSH_INACTIVITY_IDLE_MS` and `FSH_INACTIVITY_WARNING_MS`. Unset or empty keeps the built-in timings (admin 10 minutes, dashboard 20, each with a 60-second warning), so existing deployments behave as before; a value that is not a positive integer stops the container at start with a clear error rather than rendering invalid JSON. `deploy/docker` exposes them as `FSH_ADMIN_INACTIVITY_IDLE_MS`, `FSH_DASHBOARD_INACTIVITY_IDLE_MS` and `FSH_INACTIVITY_WARNING_MS` in `.env`. The Terraform static-site `runtime_config` and the Aspire dev setup are unchanged. See [Frontend development](/docs/frontend/development/#the-container-pattern) and [#1442](https://github.com/fullstackhero/dotnet-starter-kit/pull/1442).
- **Dashboard: the overview welcomes the tenant by its name (fix).** The first-run card on a tenant with no subscription read "Welcome to" followed by the tenant identifier (for example `qpb2edshbj`), and the header caption showed the same identifier, because both came from the token's `tenant` claim. Both now use the tenant name from `GET /api/v1/tenants/me/status`, which the overview already loads for its "Valid for" card, and fall back to the identifier while that call is loading or has failed. No backend change. See [Tenant dashboard](/docs/frontend/dashboard/#page-inventory) and [#1437](https://github.com/fullstackhero/dotnet-starter-kit/pull/1437).
Expand Down
24 changes: 20 additions & 4 deletions src/content/docs/security/two-factor.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Two-factor authentication (TOTP)
lastUpdated: 2026-06-11
lastUpdated: 2026-10-14
description: Opt-in TOTP enrolment for any user - shared secret + otpauth URI, verify, password-confirmed disable, with TOTP-required token issuance once enrolled.
sidebar:
label: Two-factor (TOTP)
Expand All @@ -25,7 +25,7 @@ The kit doesn't force 2FA. A reasonable production posture is to make TOTP **man
↓
2. Identity module:
- Generates (or rotates) the user's authenticator shared secret
- Builds the otpauth:// URI (issuer "FullStackHero")
- Builds the otpauth:// URI (issuer from TwoFactorOptions:Issuer)
- Returns { sharedKey, authenticatorUri }
↓
3. Client renders authenticatorUri as a QR code; user scans it,
Expand Down Expand Up @@ -78,15 +78,31 @@ public async ValueTask<TwoFactorEnrollmentResponse> Handle(EnrollTwoFactorComman
await _userManager.ResetAuthenticatorKeyAsync(user);
var sharedKey = await _userManager.GetAuthenticatorKeyAsync(user);

var authenticatorUri = $"otpauth://totp/FullStackHero:{user.Email}" +
$"?secret={sharedKey}&issuer=FullStackHero&digits=6";
// _options is IOptions<TwoFactorOptions>.Value; the issuer is percent-encoded in both places
var issuer = UrlEncoder.Default.Encode(_options.Issuer);
var authenticatorUri = $"otpauth://totp/{issuer}:{UrlEncoder.Default.Encode(user.Email)}" +
$"?secret={sharedKey}&issuer={issuer}&digits=6";

return new TwoFactorEnrollmentResponse(sharedKey, authenticatorUri);
}
```

The `otpauth://` URI is the RFC 6238 standard format that every authenticator app understands. The client (not the server) renders it as a QR code; the raw `sharedKey` is returned too, for users who can't scan.

### Issuer name

The issuer is the name the authenticator app shows next to the account. It comes from `TwoFactorOptions:Issuer` and defaults to `FullStackHero` (a project created with `fsh new` or `dotnet new fsh` gets its own display name instead), so set it to your product name:

```json
{
"TwoFactorOptions": {
"Issuer": "Acme"
}
}
```

Or through the environment: `TwoFactorOptions__Issuer=Acme`. The value is percent-encoded in both the label and the `issuer=` parameter, so names with spaces or accents (`Canal Correto`, `Ação`) produce a valid URI. A blank issuer fails options validation at startup. Changing it only affects new enrolments: users who enrolled earlier keep the old name in their app, and their codes keep working because the shared secret does not change. They see the new name after they disable and enrol again.

## Validating during login

During `token/issue`, after the password check passes:
Expand Down