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
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ Components/
Pages/Login.razor — The sign-in form (posts to /auth/login)
Pages/Error.razor — Unhandled-error page, localized
Pages/NotFound.razor — /not-found, localized
Security/
RevalidatingUserAuthenticationStateProvider.cs
— Re-checks a circuit's principal on a timer and signs it out when the
account is gone; the circuit-side half of OnValidatePrincipal
wwwroot/
theme.css — Semantic tokens, the muted-ink ramp and the gradients (see docs/theming.md)
app.css — Global styles: MudBlazor overrides, badges, .action-btn, .stacked-table,
Expand Down
6 changes: 5 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ custom domains at the lowest price point (~$3–5/month, less with scale-to-zero
| `Program.cs` | `APP_DATA_DIR` env var overrides the data folder (DB, logs, data-protection keys); maps `/health` |

On Fly, `APP_DATA_DIR=/data` points at a 1 GB persistent volume, so the SQLite DB,
Serilog logs, and data-protection keys all survive deploys and restarts.
Serilog logs, and data-protection keys all survive deploys and restarts. Surviving on disk is only
half of what a key ring needs, though: `AddDataProtection().SetApplicationName("FootballFormation")`
pins the purpose the keys are derived for, which otherwise defaults to the content root path and
would change with the Dockerfile's `WORKDIR`. Both halves have to hold or a deploy signs everyone
out with nothing in the log to say why.
`ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` (set in the Dockerfile) makes the app trust
Fly's `X-Forwarded-Proto` header — without it `UseHttpsRedirection` would loop, because
Fly terminates TLS at the edge and forwards plain HTTP to port 8080.
Expand Down
43 changes: 43 additions & 0 deletions docs/known_issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,49 @@ Avoid repeating these mistakes:
not backfill, because nothing left in the row says whether a 37 was stoppage time or typed in by
hand.

## Authentication
- **`ExpireTimeSpan` does not keep anyone signed in — `IsPersistent` does.** `SignInAsync` without
`AuthenticationProperties` sets a *session* cookie: no `Expires` on the header, so the browser is
free to drop it whenever it decides the session ended. An eight-hour `ExpireTimeSpan` sat right
above it and looked like the answer, but it bounds the ticket *inside* the cookie and has no say
in whether the browser keeps the container. The symptom is phone-shaped and so reads as flaky
rather than broken: a desktop tab holds the cookie for days, while iOS Safari and an installed PWA
drop it every time the OS reclaims the backgrounded tab — which is a coach putting their phone
away at half time. Both sign-in routes now pass `PersistentSession()`, and it returns a fresh
instance per call because the cookie handler writes `IssuedUtc`/`ExpiresUtc` onto the object it is
handed; one shared static would pin every later sign-in to the first one's expiry.
- **`SameSite=Strict` makes an ordinary link look like a logged-out session.** Strict withholds the
cookie on *every* cross-site navigation, a plain top-level link click included — so opening the
site from WhatsApp, an email or a search result arrives anonymous and bounces to `/login`, and
then a reload puts it right because that navigation is same-site. Coming back on its own is what
makes it hard to report and easy to dismiss. `Lax` is the setting; it still withholds the cookie
on the cross-site POST that CSRF actually needs, and nothing here is reached by one.
- **Persisting data-protection keys is only half of surviving a deploy.** The keys are on the
volume, but the purpose they are derived for defaults to the content root path — `/app` only
because the Dockerfile says `WORKDIR /app`. Keys present on disk and derived for a different
string open nothing, and the failure is silent: no exception, no log line, just every cookie
rejected at once after a deploy that changed nothing about authentication.
`SetApplicationName("FootballFormation")` is what stops it.
- **These three are browser decisions, so no C# test can see them.** All three are pinned in
`tests/ui/specs/session.spec.js`, which reads the cookie's attributes after a real form sign-in
and follows a link into the app from another site.
- **`OnValidatePrincipal` is not what revokes a Blazor Server session.** It runs per HTTP request,
and a circuit makes almost none after its first page load — the rest of the session is SignalR.
The stock `ServerAuthenticationStateProvider` reads the principal once when the circuit is created
and never asks again, so deleting an account left the owner's open tab fully working. Measured,
not assumed: with revalidation off, an account deleted while its owner sat idle on `/users` still
rendered the Add User button. And this is not only a markup problem — `CircuitCurrentUser` reads
that same provider, so `RunAdminAsync` was consulting the stale principal too.
`RevalidatingUserAuthenticationStateProvider` closes it on a timer.
- **A rejoin does not carry stale authority through, so the retained-circuit window does not widen
the gap.** Worth knowing before reasoning about `DisconnectedCircuitRetentionPeriod` as if it did.
With a revoked cookie, a dropped circuit does not come back: the reconnect fails and Blazor's
client falls back to a full page reload, which is an HTTP request, which is
`OnValidatePrincipal` — landing on `/login`. Probed both ways round, blocking `_blazor/negotiate`
to force the give-up path and leaving it open for a clean rejoin; both reloaded, while
`reconnect.spec.js` shows a *valid* cookie rejoining cleanly and staying live. So the stale window
is the revalidation interval on a connected idle circuit, and nothing more.

## General
- **Port already in use**: Kill orphaned process with `taskkill //PID <pid> //F`.
- **File locked during build**: Stop the running app before rebuilding.
Expand Down
13 changes: 10 additions & 3 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,14 +336,21 @@ is `SetNull`, so deleting a user leaves their comments in place, unattributed.
enum member name — so renaming a `UserRole` member breaks the build rather than quietly
unauthorizing everyone. Anonymous (not signed in) is not a role and needs no member.

**SecurityStamp is what makes a change take effect now.** The cookie lasts eight hours and is
**SecurityStamp is what makes a change take effect now.** The cookie lasts fourteen days and is
sliding, so without it, deleting an account or changing its role would leave the old session working
until it lapsed. The stamp is copied into the cookie at sign-in and re-checked on every authenticated
request by `OnValidatePrincipal` (Program.cs) via `UserService.FindForSessionAsync`; a mismatch
rejects the principal and signs the browser out. `UserService` regenerates it on password change and
role change — but deliberately **not** on a rename, which changes nothing about what the account may
do. Note that a live Blazor circuit is not re-validated per SignalR message: revocation lands on the
next HTTP request.
do.

A live Blazor circuit is still not re-validated per SignalR message — that would be a database read
per keystroke. It is re-validated **on a timer** instead, by
`RevalidatingUserAuthenticationStateProvider` (Web/Security), which asks `FindForSessionAsync` the
same question `OnValidatePrincipal` asks and signs the circuit out when the answer is no. Five
minutes by default, `Auth:RevalidationIntervalSeconds` to change it. Without it a tab open since
before the change kept its authority until someone reloaded — and because `CircuitCurrentUser` reads
that same provider, so did the write guard on every service.

`UserService.DeleteAsync` and `UpdateAsync` both refuse to remove or demote the **last** Admin —
the one operation with no way back short of editing the database by hand. `EnsureAdminSeededAsync`
Expand Down
43 changes: 43 additions & 0 deletions docs/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,49 @@ appears, it belongs in this paragraph.
`CircuitCurrentUser` answers false for an account still on its seeded password, so the first-login
gate is a real restriction rather than a redirect that could be navigated around.

## The sign-in cookie has three settings that are easy to get wrong
All three live in `Program.cs`, and each one failed in a way that looks like "it logged me out
again" rather than like a bug with a cause.

- **`IsPersistent`, on the sign-in, is what makes the cookie outlive the browser session.** Both
routes that sign anyone in — `/auth/login` and the dev-only `/dev/login` — pass
`PersistentSession()`, which is a *new* `AuthenticationProperties` each time because the cookie
handler writes `IssuedUtc`/`ExpiresUtc` onto the instance it is given. `ExpireTimeSpan` does not
substitute for it: that bounds the ticket the cookie carries, while `IsPersistent` is the only
thing that puts an `Expires` on the header at all. Without it the browser holds a session cookie
and drops it whenever it decides the session ended — on a phone, every time the OS reclaims the
backgrounded tab.
- **`SameSite` is `Lax`, and must not go back to `Strict`.** Strict withholds the cookie on every
cross-site navigation including an ordinary link click, so arriving from WhatsApp, an email or a
search result renders the page signed out until a reload. Lax still withholds it on the cross-site
POST that CSRF needs.
- **Data protection sets an application name.** Left unset the purpose string defaults to the
content root path — `/app` only because the Dockerfile says so — so pinning it is what stops a
change to where the app is unpacked from invalidating every issued cookie at once.

`tests/ui/specs/session.spec.js` holds all three, by reading the cookie's own attributes and by
following a link into the app from another site.

## Revoking authority takes two halves, because a circuit barely makes requests
`OnValidatePrincipal` re-checks the security stamp on every HTTP request — and a Blazor Server tab
makes almost none after the page loads. `RevalidatingUserAuthenticationStateProvider`
(Web/Security) is the other half: it re-asks `UserService.FindForSessionAsync` on a timer for the
life of the circuit and signs the circuit out when the account is gone or its stamp has moved.
Five minutes by default; `Auth:RevalidationIntervalSeconds` sets it, and `0` leaves the stock
provider in place so the UI test can be run against the old behaviour.

Both halves call the same `FindForSessionAsync(ClaimsPrincipal)` overload on purpose — two places
deciding separately what a valid session looks like is how they drift.

The provider takes an `IServiceScopeFactory` rather than a `UserService`, and **not** for the usual
short-lived-context reason. It *is* the circuit's `AuthenticationStateProvider`; `UserService`
depends on `ICurrentUser`, which depends on the `AuthenticationStateProvider`. Injecting it directly
closes the loop and the container refuses to build.

A failed check makes the circuit anonymous. It cannot clear the cookie — a circuit has no HTTP
response to set a header on — so `[Authorize]` renders `NotAuthorized`, `RedirectToLogin`
force-loads, and *that* request is where `OnValidatePrincipal` finally drops the cookie.

## Blazor Rendering
- Entire app is Interactive Server (set on `<Routes>` and `<HeadOutlet>` in App.razor)
- UI assembly discovered via `AddAdditionalAssemblies(typeof(FootballFormation.UI._Imports).Assembly)`
Expand Down
1 change: 1 addition & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ real SignalR circuit.
| `localization.spec.js` | Dutch by default, the switcher moving the whole app to English, and the choice surviving a navigation |
| `mobile.touchline.spec.js` | The phone layout — the drawer, the full-screen match sheet, the stacked squad — in the `mobile` project on a Pixel 7 |
| `reconnect.spec.js` | Losing the circuit and getting it back: the retry schedule a suspended phone rejoins on, and the rejoined page still being interactive |
| `session.spec.js` | Staying signed in: the auth cookie carrying a real expiry rather than being a session cookie, surviving a link followed in from another site, and a deleted account losing its authority on an open circuit without anyone reloading |
| `selectors.spec.js` | A test for the tests — see below |

### The test that guards the tests
Expand Down
24 changes: 22 additions & 2 deletions src/FootballFormation.Core/Services/UserService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Claims;
using FootballFormation.Core.Data;
using FootballFormation.Core.Models;
using FootballFormation.Core.Security;
Expand Down Expand Up @@ -36,10 +37,29 @@ public class UserService(
return await VerifyAsync(db, username, password, cancellationToken);
}

/// <summary>
/// The account behind a signed-in principal, or null when the session it stands for is no longer
/// good. Two callers need exactly this question answered and must not drift on the answer:
/// <c>OnValidatePrincipal</c>, on every authenticated HTTP request, and the circuit's
/// revalidation loop, which is the only thing asking it between one page load and the next.
/// </summary>
public Task<AppUser?> FindForSessionAsync(
ClaimsPrincipal? principal, CancellationToken cancellationToken = default)
{
var stamp = principal?.FindFirst(AppClaims.SecurityStamp)?.Value;
var userId = principal?.FindFirst(AppClaims.UserId)?.Value;

// Cookies issued before the security stamp shipped carry neither claim. Rejected rather
// than trusted — the only cost is one extra sign-in.
return stamp is not null && int.TryParse(userId, out var id)
? FindForSessionAsync(id, stamp, cancellationToken)
: Task.FromResult<AppUser?>(null);
}

/// <summary>
/// The account behind a live cookie, or null when it has been deleted or its authority changed
/// since the cookie was issued. Called on every authenticated request — see OnValidatePrincipal
/// in Program.cs — so it reads no-tracking and touches one row by key.
/// since the cookie was issued. Called on every authenticated request, so it reads no-tracking
/// and touches one row by key.
/// </summary>
public async Task<AppUser?> FindForSessionAsync(
int userId, string securityStamp, CancellationToken cancellationToken = default)
Expand Down
Loading