Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ee3f95e
feat(auth): account-scoped Identity indexes, validator and farm-code …
Aug 19, 2026
6a1055c
feat(web): farm code on the sign-in form
Aug 19, 2026
7da5f97
chore(sim): send the farm code from the k6, Playwright and reset callers
Aug 19, 2026
a1f058a
docs: farm code glossary, help page, i18n example and the record amen…
Aug 19, 2026
04a9a6f
fix(auth): close the cross-farm lockout bypass and scope the remainin…
Aug 19, 2026
de68be1
test(auth): pin the account-scoped Identity indexes against a migrate…
Aug 19, 2026
1f86150
fix(sim): farm code on the two direct Playwright sign-ins, and dedupe…
Aug 19, 2026
590edf2
test(auth): pin the duplicate-email user to the requesting account, n…
Aug 19, 2026
8d8f125
test(auth): assert the orphan insert fails on the foreign key, not on…
Aug 19, 2026
e3cbd8c
feat(auth): immediate farm suspension across login, refresh and every…
Aug 20, 2026
7287231
fix(auth): compare raw values in the validator short-circuit, and gua…
Aug 20, 2026
bf42c47
test(auth): cover farm suspension end to end, and prove the identity-…
Aug 20, 2026
ccc857f
fix(auth): pin the suspension guarantee, and stop reactivation signin…
Aug 20, 2026
0c7e9dd
fix(auth): stop the SPA adopting another farm's session, and pin what…
Aug 20, 2026
b519d1b
fix(#532): round 8 — one-shot guard, FK-dropping test, decode pin, gu…
Aug 20, 2026
07d6ca7
feat(auth): bind refresh to the account the tab expects (#547)
Aug 20, 2026
54b1e68
feat(auth): name the refresh cookie per farm, so two farms cannot col…
Aug 20, 2026
a928557
fix(auth): restore the farm-scoped session safeguards the rename broke
Aug 20, 2026
b2429da
fix(auth): attribute every session action to the farm that owns it
Aug 20, 2026
5f466df
fix(auth): scope legacy logout revocation to the farm that owns it
Aug 20, 2026
fd91e41
Merge remote-tracking branch 'origin/main' into mforce/auth-login-by-…
Aug 20, 2026
3e26386
test(otlp): send a farm code from the subprocess exporter's login probe
Aug 20, 2026
7bfac85
test(auth): probe the legacy token first, so the same-farm guard is real
Aug 20, 2026
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
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ flowchart TD
EDGE["forwarded headers · security headers · cache defaults<br/>HSTS <i>(not in Development)</i> · exception handler<br/>HTTPS redirect · static files · request logging"]
EDGE --> LIMITS["rate limiter · per-endpoint body caps"]
LIMITS --> AUTHN["UseAuthentication<br/><i>JWT → HttpContext.User</i>"]
AUTHN --> TENANT["TenantResolutionMiddleware<br/><i>account_id claim → TenantContext</i>"]
AUTHN --> AMBIENT["AmbientPrincipalMiddleware<br/><i>blanks the ambient principal for endpoints that must ignore a bearer</i>"]
AMBIENT --> TENANT["TenantResolutionMiddleware<br/><i>account_id claim → TenantContext</i>"]
TENANT --> EPOCH["CredentialEpochMiddleware<br/><i>fresh DB read, every request</i>"]
EPOCH --> MCP["MustChangePasswordMiddleware<br/><i>403s everything but change-password + logout</i>"]
MCP --> AUTHZ["UseAuthorization"]
Expand Down
7 changes: 7 additions & 0 deletions docs/runbooks/break-glass-account-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ dotnet Cluckwork.Api.dll recover-admin \
dotnet Cluckwork.Api.dll recover-admin --email owner@thefarm.example --account <account-guid> --reason "..."
```

> **Ambiguous emails are now expected, not hypothetical (#532).** Since login is
> farm-scoped, one address can legitimately exist in several farms, and
> `recover-admin` looks across accounts. An ambiguous email is **refused**
> (`Recovery.Ambiguous`) — it never picks — so pass `--account <id>` to
> disambiguate. Find ids with the `list-accounts` command. The code already
> behaves this way; this note is documentation only.

The database connection comes from the same configuration the serving process
uses (`ConnectionStrings__Default` / `Database__Provider`, plus the `Jwt__*`
values the host reads at startup). In a container deployment, exec into the app
Expand Down
3 changes: 2 additions & 1 deletion docs/schema/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 28 additions & 1 deletion docs/schema/public.Accounts.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/schema/public.AspNetUserClaims.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/schema/public.AspNetUserLogins.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/schema/public.AspNetUserRoles.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/schema/public.AspNetUserTokens.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 33 additions & 8 deletions docs/schema/public.AspNetUsers.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 35 additions & 26 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,16 +592,20 @@ total — clients never sum pages.
isolation with EF global query filters. Single-farm login today, multi-tenant
infrastructure dormant.

**Farm code (account slug, #531)** — a short, stable, URL-safe identifier for an
account: lowercase letters, digits and hyphens, 3–32 characters, no leading or
trailing hyphen. Unlike the account's internal id (a GUID), it is meant to be
typed and read aloud. It is chosen once and **immutable** — a provisioning typo
has no in-app fix this phase — and a handful of words are reserved (`api`,
`admin`, `www`, `health`, `app`, `login`, `auth`, and similar). The default
farm's code is `default-farm`. Operators discover the codes with the
`list-accounts` command. The farm code becomes the way to disambiguate login
across farms in a later phase (#532); today it is recorded and discoverable but
not yet used at sign-in, and there is no SPA surface for it yet.
**Farm code (account slug, #531)** — the per-farm login identifier: a short,
stable, URL-safe slug (`Account.Slug`) for an account — lowercase letters,
digits and hyphens, 3–32 characters, no leading or trailing hyphen. Unlike the
account's internal id (a GUID), it is meant to be typed and read aloud. It is
chosen once and **immutable** — a provisioning typo has no in-app fix this
phase — and a handful of words are reserved (`api`, `admin`, `www`, `health`,
`app`, `login`, `auth`, and similar). The default farm's code is
`default-farm`. Operators discover the codes with the `list-accounts` command.
The farm code is the way to disambiguate login across farms (#532): the sign-in
form requires it before the email, because one email address can now exist in
several farms and only the farm code says which one is meant, and a wrong or
unrecognised one is refused with its own message. (Written earlier, before
#532 shipped: it was recorded and discoverable but not yet used at sign-in, and
there was no SPA surface for it.)

**Account status — active / suspended (#531)** — an account is *active* by
default. **Suspending** it takes the farm offline; **reactivating** brings it
Expand Down Expand Up @@ -753,15 +757,24 @@ it is never written to `localStorage`/`sessionStorage`, so an XSS payload has no
durable credential to steal and the 15-minute lifetime bounds any exposure. The
durable **refresh token** is an `HttpOnly; Secure; SameSite=Strict` cookie
path-scoped to `/api/v1/auth` — the browser attaches it automatically and JS
cannot read it. A page reload (memory cleared) silently refreshes against the
cookie to restore the session; an expired/absent cookie lands cleanly on login.
cannot read it. Its name includes the farm account ID (#532), so one browser can
hold independent sessions for several farms without one farm's login, refresh,
password change, or logout overwriting another's cookie. The SPA keeps the
non-secret farm ID in per-tab `sessionStorage`, while the access token remains
memory-only, so a reload names and silently restores that tab's farm even when
the browser holds several farm cookies. A fresh tab with several cookies and no
remembered farm still makes the server refuse to guess
(`Auth.FarmSelectionRequired`) and sends the user to login to choose a farm; it
rotates or clears none of them. Explicit logout removes the tab binding, and
closing the tab discards it with the rest of `sessionStorage`. An expired/absent
cookie lands cleanly on login.
CSRF is covered by SameSite=Strict plus a custom header (`X-Cluckwork-Auth`) that
a cross-site request cannot set. Rotation + theft-detection (single-use, revoke
the whole family on replay) are unchanged — this moved the storage, not the
hygiene. Deploying #145 forces one re-login (the old localStorage token is
purged on first load). Because the refresh token now lives only in the shared
cookie, two tabs refreshing at once would each present the same value and the
second would trip theft-detection, logging both out; refresh is therefore
purged on first load). Tabs on the same farm still share that farm's cookie, so
two same-farm tabs refreshing at once would each present the same value and the
second could trip theft-detection, logging both out; refresh is therefore
**serialised across tabs** via the browser Web Locks API (#169) so only one tab
refreshes at a time and the next presents the freshly-rotated cookie. Server
theft-detection stays strict; browsers without the API fall back to per-tab
Expand All @@ -779,18 +792,14 @@ replays can never fork one token into two live sessions.

**Superseded-flight cookie revocation (#393)** — a refresh (or sign-in, or
password change) that goes stale mid-flight — superseded by a newer sign-in
before its own response lands — still rotates the shared refresh cookie the
before its own response lands — still rotates its farm's refresh cookie the
instant the browser receives that response, before the app's own bookkeeping
ever runs. The stale flight's cookie is therefore always revoked, even when
the newer sign-in already has a token in hand: which of the two responses'
cookies the browser actually kept is real network timing, not something the
app can observe or infer from "is someone currently signed in." User-visible
consequence, narrow and rare: switching to a different sign-in in one browser
tab while another tab of the same browser is mid-refresh can occasionally
revoke the credential behind the *newer* sign-in too, surfacing as an
unexpected "please sign in again" shortly after. No work is lost — it is
exactly the same experience as any other session timeout, just sooner than
expected. See the Help page's "Signing in" section.
ever runs. The stale flight's cookie is therefore always revoked. Which of two
responses for the **same farm** the browser kept is real network timing, so a
same-farm account switch during an in-flight refresh can still surface as an
unexpected "please sign in again." A different farm's cookie is a different
name and cannot be touched by that race (#532). No work is lost; see the Help
page's "Signing in" section.

**Credential epoch (#364)** — a monotonically increasing per-user number carried
in every access token and stamped onto every refresh token. A request is valid
Expand Down
Loading
Loading