From 9a7fbf5ebc2523c877e5371b010bfd70b2734dc7 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Thu, 23 Jul 2026 16:25:29 -0700 Subject: [PATCH 1/3] F176 spec: idempotent refresh (grace-window mint-on-retry) design --- .../2026-07-23-idempotent-refresh-design.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md diff --git a/docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md b/docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md new file mode 100644 index 000000000..0a95ea731 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md @@ -0,0 +1,90 @@ +# #176 — Idempotent refresh (grace-window mint-on-retry) + +## Problem + +#169 serialised refresh across browser tabs with the Web Locks API, closing the +common multi-tab race. One residual a page-owned lock cannot close remains: if a +tab dies in the sub-second between **sending** a refresh and **receiving** the +rotated cookie, the lock auto-releases while the cookie still holds the *old* +token. The next tab presents that already-rotated token, and today's +reuse-detection reads it as a replay → revokes the whole token family → both +tabs are logged out. + +## Goal & the unavoidable trade-off + +Make refresh **idempotent** for the immediately-previous rotation so a benign +dead-tab / concurrent retry succeeds instead of nuking the session — while +keeping theft-detection strict for every realistic replay. + +Reuse-detection works by catching *divergence* (someone presents a token derived +from an already-rotated ancestor). Any server grace window that lets the racing +tab succeed must accept a just-revoked token, so **within that window a stolen +token is also accepted**. This is inherent and unavoidable; the mitigation is to +keep the window tiny (default 10s, vs the ~15-min refresh cadence) and to fail +strict everywhere else. Chosen deliberately (design decision, 2026-07-23). + +## Design — mint-on-retry grace + +In `IdentityProvider.RefreshAsync`, replace the "revoked token → immediately +revoke family" branch with: + +``` +present token T; stored = lookup(hash(T)) +if stored is null -> invalid (unchanged) +if stored.RevokedAt is not null: + # #176 grace: a token rotated within GraceWindow whose replacement is still + # the live tip is a benign retry (the #169 residual), not a replay. + if stored.ReplacedByTokenHash is not null + and (now - stored.RevokedAt) <= GraceWindow + and replacement = lookup(stored.ReplacedByTokenHash) + and replacement.IsActive(now): + stored = replacement # advance the LIVE token; fall through to normal rotation + else: + RevokeAllActiveForUserAsync(...) # genuine theft (unchanged) + return invalid +if stored.ExpiresAt <= now -> expired (unchanged) +rotate stored -> new pair (unchanged) +``` + +- For the actual tab-death case the replacement was never delivered to anyone, + so advancing it does **not** fork the chain — there is one live token after. +- The grace path re-enters the *normal* rotation of the still-active replacement, + so single-use + expiry semantics are preserved unchanged. + +## Config + +Add `JwtOptions.RefreshReuseGraceSeconds` (default **10**). Bound by config so a +deployment can tune or disable it; tests set it to `0` to exercise the strict +path deterministically. + +## Theft-detection remains strict (all deterministic, no time-mocking) + +- **Replacement already consumed:** rotate twice (`R0→R1→R2`), then replay `R0`. + `R0`'s replacement `R1` is now revoked (not the live tip) → grace fails → + revoke family. Catches an attacker whose stolen token's chain has moved on. +- **Grace disabled (factory `RefreshReuseGraceSeconds=0`):** immediate replay → + `now - RevokedAt > 0` → expired → revoke family. Proves the grace gate is + load-bearing. +- **Benign retry (default grace):** rotate `R0→R1`, immediately replay `R0` → + within grace, `R1` active → succeeds with a fresh token; family NOT revoked. + +## Test changes + +- Update `Refresh_WithRotatedToken_IsRejected` and + `Refresh_ReuseDetection_RevokesTheWholeFamily` (both assert *immediate* replay + → 401, which is now the benign grace path) to the consumed-replacement / + grace-0 theft scenarios. +- Add the benign-retry success test and a grace-0 factory theft test. + +## Docs + +- GLOSSARY "Session tokens": note the short idempotency grace on reuse-detection + that closes the #169 residual. +- `web/README` auth section: the residual note now points here. + +## Out of scope + +- Refresh-token optimistic concurrency (pre-existing; two truly-concurrent + rotations of the same active token already race — separate hardening). +- The client Web Lock (#169) stays as the first line of defence; this grace is + the server-side safety net for the tab-death residual only. From 315defc86bcd1c61bf30133afef92dc591cd64ed Mon Sep 17 00:00:00 2001 From: mforce <> Date: Thu, 23 Jul 2026 16:31:44 -0700 Subject: [PATCH 2/3] F176: idempotent refresh grace closes the #169 tab-death residual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #169's client Web Lock can't cover a tab dying in the sub-second between sending a refresh and receiving the rotated cookie: the next tab presents the old token and reuse-detection revokes the whole family. Close it server-side. RefreshAsync now treats a revoked token as a BENIGN retry — not a replay — when it was rotated within a short grace window AND its replacement is still the live tip: it advances that live replacement and hands the caller a fresh token instead of revoking the family. For the actual tab-death case the replacement was never delivered, so the chain does not fork. The grace is inherent-cost-bounded: any window that lets the racing tab succeed must accept a just-revoked token, so within it a stolen token is also accepted. Kept tiny (JwtOptions.RefreshReuseGraceSeconds, default 10s) vs the ~15-min refresh cadence; set 0 to disable. Everywhere else theft-detection is unchanged: a stale token, an expired grace, or a replacement already rotated away still burns the whole family down. - JwtOptions.RefreshReuseGraceSeconds (default 10). - TryGraceReplacementAsync gates the grace path (grace<=0 short-circuits strict). - Tests: benign immediate-replay succeeds without tearing down the session; replay after the chain moved on revokes the family; a grace-disabled (0s) factory proves the gate is load-bearing (immediate replay = strict theft). - GLOSSARY + web/README document the grace as the server-side safety net. Closes #176 --- specs/product/GLOSSARY.md | 8 +- .../Identity/IdentityProvider.cs | 40 +++++++++- .../Identity/JwtOptions.cs | 8 ++ .../RefreshTokenFlowTests.cs | 80 +++++++++++++++---- web/README.md | 10 ++- 5 files changed, 121 insertions(+), 25 deletions(-) diff --git a/specs/product/GLOSSARY.md b/specs/product/GLOSSARY.md index 835419bd7..6a7576fb6 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -443,7 +443,13 @@ second would 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 -coordination only. +coordination only. As the server-side safety net for the case a lock can't cover +(a tab dying between sending a refresh and receiving the rotated cookie), +reuse-detection carries a short **idempotency grace** (#176): a token rotated in +the last few seconds whose replacement is still the live tip is treated as a +benign retry — the caller gets a fresh token instead of the family being revoked. +The grace is deliberately tiny (default 10s) vs the ~15-min refresh cadence, so a +genuinely replayed/stale token is still caught and revokes the whole family. **Version (concurrency token)** — every mutable aggregate carries a `Version` that each mutation bumps. Two concurrent edits: first save wins, second gets diff --git a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs index 3f859a827..3e8912b74 100644 --- a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs +++ b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs @@ -94,12 +94,26 @@ public async Task> RefreshAsync(string refreshToken, Cancellat if (stored is null) return Result.Failure(Error.Validation("Identity.InvalidRefreshToken", "Refresh token is invalid.")); - // Presenting an already-rotated/revoked token means it was replayed — treat as a - // possible theft and revoke every active token for the user (breaks the chain). + // Presenting an already-rotated/revoked token normally means it was replayed — + // treat as a possible theft and revoke every active token for the user. if (stored.RevokedAt is not null) { - await RevokeAllActiveForUserAsync(stored.UserId, now, ct); - return Result.Failure(Error.Validation("Identity.InvalidRefreshToken", "Refresh token is invalid.")); + // #176 — idempotency grace: a token rotated within the last + // RefreshReuseGraceSeconds whose replacement is still the live tip is a + // benign concurrent/dead-tab retry (the #169 residual), not a replay. + // Advance the still-active replacement (fall through to the normal + // rotation below) and hand the caller a fresh token instead of revoking + // the family. For the actual tab-death case the replacement was never + // delivered, so this does not fork the chain. Anything else — a stale + // token, an expired grace, or a replacement already gone — is a genuine + // replay and still burns the family down. + var graced = await TryGraceReplacementAsync(stored, now, ct); + if (graced is null) + { + await RevokeAllActiveForUserAsync(stored.UserId, now, ct); + return Result.Failure(Error.Validation("Identity.InvalidRefreshToken", "Refresh token is invalid.")); + } + stored = graced; } if (stored.ExpiresAt <= now) @@ -249,6 +263,24 @@ private RefreshToken NewToken(ApplicationUser user, string tokenHash) }; } + // #176 — returns the live replacement to rotate when `revoked` is a benign + // grace retry (rotated within the grace window and its replacement is still + // active), or null when it is a genuine replay that must revoke the family. + private async Task TryGraceReplacementAsync( + RefreshToken revoked, DateTimeOffset now, CancellationToken ct) + { + var graceSeconds = jwtOptions.Value.RefreshReuseGraceSeconds; + if (graceSeconds <= 0 // grace disabled → strict replay + || revoked.ReplacedByTokenHash is null + || revoked.RevokedAt is null + || now - revoked.RevokedAt.Value > TimeSpan.FromSeconds(graceSeconds)) + return null; + + var replacement = await db.RefreshTokens + .FirstOrDefaultAsync(t => t.TokenHash == revoked.ReplacedByTokenHash, ct); + return replacement is not null && replacement.IsActive(now) ? replacement : null; + } + private async Task RevokeAllActiveForUserAsync(Guid userId, DateTimeOffset now, CancellationToken ct) { var active = await db.RefreshTokens diff --git a/src/Cluckwork.Infrastructure/Identity/JwtOptions.cs b/src/Cluckwork.Infrastructure/Identity/JwtOptions.cs index fa4f70f39..4aa7b13bf 100644 --- a/src/Cluckwork.Infrastructure/Identity/JwtOptions.cs +++ b/src/Cluckwork.Infrastructure/Identity/JwtOptions.cs @@ -10,4 +10,12 @@ public sealed class JwtOptions public string PrivateKeyPem { get; init; } = string.Empty; public int AccessTokenMinutes { get; init; } = 15; public int RefreshTokenDays { get; init; } = 30; + + // #176 — idempotency grace for refresh-token reuse-detection. A token rotated + // within this many seconds whose replacement is still the live tip is treated + // as a benign concurrent/dead-tab retry (the #169 residual), not a replay, so + // the racing tab is handed a fresh token instead of the whole family being + // revoked. Kept short vs the ~15-min refresh cadence to bound the inherent + // in-window relaxation of theft-detection; set 0 to disable (strict replay). + public int RefreshReuseGraceSeconds { get; init; } = 10; } diff --git a/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs b/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs index 175935308..5a76dccb3 100644 --- a/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs @@ -2,6 +2,7 @@ namespace Cluckwork.Api.IntegrationTests; using System.Net; using Cluckwork.Api.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; // tech spec §7.4: refresh tokens are durable and rotating. Refresh issues a new @@ -38,44 +39,51 @@ public async Task Refresh_RotatesToken_AndIssuesNewPair() Assert.NotEqual(rotated.RefreshToken, rotatedAgain.RefreshToken); } + // #176 — a token rotated moments ago whose replacement is still the live tip + // is a BENIGN concurrent/dead-tab retry (the #169 residual), not a replay: the + // caller is handed a fresh token instead of the session being torn down. [Fact] - public async Task Refresh_WithRotatedToken_IsRejected() + public async Task Refresh_ImmediateReplayWithinGrace_Succeeds_WithoutRevokingTheSession() { var email = $"u-{Guid.NewGuid():N}@test.local"; await factory.SeedAccountWithUserAsync(email); var client = factory.CreateClient(Cookieless); var initial = await factory.LoginAsync(email); - await RefreshAsync(client, initial.RefreshToken); // rotates initial → revoked + await RefreshAsync(client, initial.RefreshToken); // initial → live (delivered) - // Replaying the now-revoked original token must fail. + // The dead tab's retry: it still holds `initial` (never saw the rotation). + // Within the grace window this is honoured rather than read as theft. var replay = await client.PostRefreshAsync(initial.RefreshToken); - Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode); + Assert.Equal(HttpStatusCode.OK, replay.StatusCode); + + // The handed-back token is a working member of a live session — nothing + // was revoked (a family revocation would make this refresh 401). + var handed = await TestHarness.ReadTokensAsync(replay); + var next = await client.PostRefreshAsync(handed.RefreshToken); + Assert.Equal(HttpStatusCode.OK, next.StatusCode); } - // #169 — reuse-detection stays strict: replaying a revoked token revokes the - // WHOLE family, not just the replayed one. This is exactly the "both tabs - // logged out" cascade the client-side Web Lock (#169) exists to avoid ever - // triggering — but a genuinely stolen/replayed token must still burn the - // family down. The server behaviour is deliberately unchanged by #169. + // #176 — theft-detection stays strict once the chain has moved on: replaying a + // token whose replacement is itself already rotated away (not the live tip) is + // a genuine replay and revokes the WHOLE family, not just the replayed token. [Fact] - public async Task Refresh_ReuseDetection_RevokesTheWholeFamily() + public async Task Refresh_ReplayAfterChainMovedOn_RevokesTheWholeFamily() { var email = $"u-{Guid.NewGuid():N}@test.local"; await factory.SeedAccountWithUserAsync(email); var client = factory.CreateClient(Cookieless); var initial = await factory.LoginAsync(email); - // The "legit tab" rotates once: `initial` → `live` (the current token). - var live = await RefreshAsync(client, initial.RefreshToken); + var r1 = await RefreshAsync(client, initial.RefreshToken); // initial → r1 + var live = await RefreshAsync(client, r1.RefreshToken); // r1 → r2 (r1 no longer the tip) - // A replay of the already-rotated `initial` is read as theft → the family - // is revoked. + // Replaying `initial` now: its replacement r1 is already revoked, so this + // is not a benign grace retry — it is a replay → revoke the family. var replay = await client.PostRefreshAsync(initial.RefreshToken); Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode); - // The still-"live" sibling token is now dead too — the whole session is - // torn down, which is precisely the multi-tab pain #169 addresses. + // The still-live tip r2 is dead too — the whole session is torn down. var afterCascade = await client.PostRefreshAsync(live.RefreshToken); Assert.Equal(HttpStatusCode.Unauthorized, afterCascade.StatusCode); } @@ -107,3 +115,43 @@ public async Task Logout_RevokesRefreshToken_CookieAuthenticated_NoBearerOrKey() Assert.Equal(HttpStatusCode.Unauthorized, afterLogout.StatusCode); } } + +// #176 — the idempotency grace is configurable; with it DISABLED (0s) even an +// immediate replay is strict theft-detection. Proves the grace gate is +// load-bearing (not that every revoked token is always accepted). +public sealed class RefreshGraceDisabledFactory : CluckworkWebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.UseSetting("Jwt:RefreshReuseGraceSeconds", "0"); + } +} + +public sealed class RefreshGraceDisabledTests(RefreshGraceDisabledFactory factory) + : IClassFixture +{ + private static readonly WebApplicationFactoryClientOptions Cookieless = + new() { HandleCookies = false }; + + [Fact] + public async Task Refresh_ImmediateReplay_WithGraceDisabled_RevokesTheWholeFamily() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + await factory.SeedAccountWithUserAsync(email); + var client = factory.CreateClient(Cookieless); + + var initial = await factory.LoginAsync(email); + var live = await client.PostRefreshAsync(initial.RefreshToken); // initial → live + live.EnsureSuccessStatusCode(); + var liveTokens = await TestHarness.ReadTokensAsync(live); + + // Grace off → an immediate replay is strict theft, no benign window. + var replay = await client.PostRefreshAsync(initial.RefreshToken); + Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode); + + // Family revoked: the live tip is dead too. + var afterCascade = await client.PostRefreshAsync(liveTokens.RefreshToken); + Assert.Equal(HttpStatusCode.Unauthorized, afterCascade.StatusCode); + } +} diff --git a/web/README.md b/web/README.md index d9dc4e710..77a497780 100644 --- a/web/README.md +++ b/web/README.md @@ -89,7 +89,9 @@ refresh can't park the lock forever — it's aborted after a bounded timeout so other tabs recover. Server reuse-detection stays strict; browsers without `navigator.locks` fall back to the per-tab single-flight only. -One residual a page-owned lock can't close: if a tab is closed in the sub-second -between sending a refresh and receiving the rotated cookie, the lock releases -while the cookie is still stale, so the next tab can still trip reuse-detection. -Narrow; the full fix needs an idempotent server refresh (tracked in #176). +One residual a page-owned lock can't close on its own: if a tab is closed in the +sub-second between sending a refresh and receiving the rotated cookie, the lock +releases while the cookie is still stale. The server covers this with a short +**idempotency grace** (#176) — reuse-detection treats a just-rotated token whose +replacement is still live as a benign retry (fresh token instead of family +revocation), while any genuinely stale/replayed token still revokes the family. From d880b21f778ac38a5647d4f64919deb41baba08e Mon Sep 17 00:00:00 2001 From: mforce <> Date: Thu, 23 Jul 2026 17:05:14 -0700 Subject: [PATCH 3/3] F176 review: bound grace to one hop + atomic token consume (fix 2 HIGH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4-way review (codex + pi + 2 agents) + a reproduction test found the naive grace had two HIGH holes. User chose full hardening; both now closed. 1. Leap-frog / chain extension (pi + codex HIGH, reproduced): the grace revoked the replacement, and that revoked link was itself grace-eligible, so a stolen token could be stepped down the chain (T0->grace->T2, then T1->grace->T3, ...) to extend a session indefinitely within the window. Fix: RefreshToken. RevokedByGrace marks a link revoked BY a grace-advance; TryGraceReplacementAsync refuses to grace such a link, bounding grace to a SINGLE hop off a normal rotation. A second hop hits the strict theft path -> family revoked. 2. Concurrent fork + backstop suppression (caveman + codex + code-reviewer HIGH): RefreshToken had no concurrency control, so two parallel presentations of one token each minted a live child — a fork that on the grace path spawned multiple sessions AND skipped the family-revoke backstop. Fix: a regenerated ConcurrencyStamp optimistic-concurrency token makes consuming a token an atomic compare-and-swap; the losing writer's SaveChanges throws DbUpdateConcurrencyException and RefreshAsync fails it closed. The two revoke paths became bulk ExecuteUpdateAsync so they don't fight the stamp and are safe from the rotation fail path. Also: clock-skew guard (elapsed >= 0) so a future RevokedAt can't widen the window (codex Low). - Migration AddRefreshTokenGraceAndConcurrency (RevokedByGrace + ConcurrencyStamp). - Tests: leap-frog down the chain -> family revoked; two concurrent presentations never fork (exactly one live tip); benign now proves a pre-existing sibling session survives (stronger no-family-revoke check). - Design spec + GLOSSARY document the one-hop bound + concurrency guarantee. Tests: 313/313 integration, 272 domain, 70 application; solution builds clean. --- .../2026-07-23-idempotent-refresh-design.md | 31 +- specs/product/GLOSSARY.md | 8 +- .../Identity/IdentityProvider.cs | 54 +- .../Identity/RefreshToken.cs | 23 + ...efreshTokenGraceAndConcurrency.Designer.cs | 1985 +++++++++++++++++ ...5611_AddRefreshTokenGraceAndConcurrency.cs | 41 + .../Migrations/AppDbContextModelSnapshot.cs | 11 + .../RefreshTokenFlowTests.cs | 80 +- 8 files changed, 2206 insertions(+), 27 deletions(-) create mode 100644 src/Cluckwork.Infrastructure/Persistence/Migrations/20260723235611_AddRefreshTokenGraceAndConcurrency.Designer.cs create mode 100644 src/Cluckwork.Infrastructure/Persistence/Migrations/20260723235611_AddRefreshTokenGraceAndConcurrency.cs diff --git a/docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md b/docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md index 0a95ea731..1e7937a3e 100644 --- a/docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md +++ b/docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md @@ -82,9 +82,36 @@ path deterministically. that closes the #169 residual. - `web/README` auth section: the residual note now points here. +## Hardening (post 4-way review) + +The 4-way review (codex + pi + 2 agents) + a reproduction test found the naive +grace above has two HIGH holes; both are now closed (user chose "full hardening"): + +1. **Leap-frog / chain extension.** The grace advanced the replacement by minting + a new token, which revoked the replacement — and that revoked link was itself + grace-eligible, so a stolen token could be stepped down the chain (`T0→grace→T2`, + then `T1→grace→T3`, …) to extend a session indefinitely within the window. + Fix: `RefreshToken.RevokedByGrace` marks a link revoked BY a grace-advance; + `TryGraceReplacementAsync` refuses to grace such a link, bounding the grace to + a SINGLE hop off a normal rotation. A second-hop attempt hits the strict + theft path → family revoked. + +2. **Concurrent fork + backstop suppression.** `RefreshToken` had no concurrency + control, so two parallel presentations of the same token both read it active + and each minted a live child — a fork that, on the grace path, spawned + multiple sessions AND skipped the family-revoke backstop entirely. Fix: a + regenerated `ConcurrencyStamp` optimistic-concurrency token makes consuming a + token an atomic compare-and-swap; the losing writer's `SaveChanges` throws + `DbUpdateConcurrencyException` and `RefreshAsync` fails it closed. The two + revoke paths (`RevokeRefreshTokenAsync`, `RevokeAllActiveForUserAsync`) became + bulk `ExecuteUpdateAsync` so they don't fight the stamp and are safe to call + from the rotation fail path. Invariant: two concurrent presentations of one + token never leave more than one live tip. + +Also: a clock-skew guard (`elapsed >= 0`) so a future `RevokedAt` from a lagging +node can't widen the window. + ## Out of scope -- Refresh-token optimistic concurrency (pre-existing; two truly-concurrent - rotations of the same active token already race — separate hardening). - The client Web Lock (#169) stays as the first line of defence; this grace is the server-side safety net for the tab-death residual only. diff --git a/specs/product/GLOSSARY.md b/specs/product/GLOSSARY.md index 6a7576fb6..1aa35b2b3 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -448,8 +448,12 @@ coordination only. As the server-side safety net for the case a lock can't cover reuse-detection carries a short **idempotency grace** (#176): a token rotated in the last few seconds whose replacement is still the live tip is treated as a benign retry — the caller gets a fresh token instead of the family being revoked. -The grace is deliberately tiny (default 10s) vs the ~15-min refresh cadence, so a -genuinely replayed/stale token is still caught and revokes the whole family. +The grace is deliberately tiny (default 10s) vs the ~15-min refresh cadence, and +**bounded to a single hop** (the link revoked by a grace-advance can't itself be +graced), so a stolen token can't be leap-frogged down the chain and a genuinely +replayed/stale token is still caught and revokes the whole family. Consuming a +token is an atomic compare-and-swap (a per-token concurrency stamp), so concurrent +replays can never fork one token into two live sessions. **Version (concurrency token)** — every mutable aggregate carries a `Version` that each mutation bumps. Two concurrent edits: first save wins, second gets diff --git a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs index 3e8912b74..e73179b23 100644 --- a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs +++ b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs @@ -96,6 +96,7 @@ public async Task> RefreshAsync(string refreshToken, Cancellat // Presenting an already-rotated/revoked token normally means it was replayed — // treat as a possible theft and revoke every active token for the user. + var viaGrace = false; if (stored.RevokedAt is not null) { // #176 — idempotency grace: a token rotated within the last @@ -114,6 +115,7 @@ public async Task> RefreshAsync(string refreshToken, Cancellat return Result.Failure(Error.Validation("Identity.InvalidRefreshToken", "Refresh token is invalid.")); } stored = graced; + viaGrace = true; } if (stored.ExpiresAt <= now) @@ -123,12 +125,28 @@ public async Task> RefreshAsync(string refreshToken, Cancellat if (user is null) return Result.Failure(Error.Validation("Identity.InvalidRefreshToken", "Refresh token is invalid.")); - // Rotate: revoke the presented token and issue a fresh one. + // Rotate: revoke the presented token and issue a fresh one. Marking the + // revocation as grace-sourced (#176) bounds the grace to a single hop: the + // token minted here can be rotated normally, but this just-revoked link + // can never itself be grace-advanced, so a stolen token can't be + // leap-frogged down the chain. var (rawToken, newHash) = GenerateRefreshToken(); stored.RevokedAt = now; stored.ReplacedByTokenHash = newHash; + stored.RevokedByGrace = viaGrace; + stored.ConcurrencyStamp = Guid.NewGuid().ToString(); // rotate the CAS token (#176) db.RefreshTokens.Add(NewToken(user, newHash)); - await db.SaveChangesAsync(ct); + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + // #176 — another request consumed this exact token first (concurrent + // presentation of the same token). The winner already minted the one + // live child; fail this one closed rather than fork a second session. + return Result.Failure(Error.Validation("Identity.InvalidRefreshToken", "Refresh token is invalid.")); + } // Roles re-read on every refresh so a demotion takes effect within one // access-token lifetime, not at next login. @@ -239,14 +257,14 @@ private static string Describe(IdentityResult result) => public async Task RevokeRefreshTokenAsync(string refreshToken, CancellationToken ct = default) { + // Bulk conditional update, not a tracked read-modify-save: the #176 xmin + // concurrency token would otherwise make this throw if the token was + // rotated concurrently. WHERE RevokedAt == null makes it idempotent. var presentedHash = Hash(refreshToken); - var stored = await db.RefreshTokens - .FirstOrDefaultAsync(t => t.TokenHash == presentedHash && t.RevokedAt == null, ct); - - if (stored is null) return; - - stored.RevokedAt = timeProvider.GetUtcNow(); - await db.SaveChangesAsync(ct); + var now = timeProvider.GetUtcNow(); + await db.RefreshTokens + .Where(t => t.TokenHash == presentedHash && t.RevokedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, now), ct); } private RefreshToken NewToken(ApplicationUser user, string tokenHash) @@ -270,10 +288,13 @@ private RefreshToken NewToken(ApplicationUser user, string tokenHash) RefreshToken revoked, DateTimeOffset now, CancellationToken ct) { var graceSeconds = jwtOptions.Value.RefreshReuseGraceSeconds; + var elapsed = now - (revoked.RevokedAt ?? now); if (graceSeconds <= 0 // grace disabled → strict replay + || revoked.RevokedByGrace // already a grace hop → don't chain (one-hop bound) || revoked.ReplacedByTokenHash is null || revoked.RevokedAt is null - || now - revoked.RevokedAt.Value > TimeSpan.FromSeconds(graceSeconds)) + || elapsed < TimeSpan.Zero // clock-skew guard: a future RevokedAt must not widen the window + || elapsed > TimeSpan.FromSeconds(graceSeconds)) return null; var replacement = await db.RefreshTokens @@ -283,15 +304,12 @@ private RefreshToken NewToken(ApplicationUser user, string tokenHash) private async Task RevokeAllActiveForUserAsync(Guid userId, DateTimeOffset now, CancellationToken ct) { - var active = await db.RefreshTokens + // Bulk update rather than tracked read-modify-save: it never trips the + // #176 xmin concurrency token (so it is safe to call from the rotation + // fail path) and revokes the whole family in one statement. + await db.RefreshTokens .Where(t => t.UserId == userId && t.RevokedAt == null) - .ToListAsync(ct); - - foreach (var token in active) - token.RevokedAt = now; - - if (active.Count > 0) - await db.SaveChangesAsync(ct); + .ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, now), ct); } // 256-bit random token; only the SHA-256 hash is persisted. diff --git a/src/Cluckwork.Infrastructure/Identity/RefreshToken.cs b/src/Cluckwork.Infrastructure/Identity/RefreshToken.cs index 541df0732..14f21d00b 100644 --- a/src/Cluckwork.Infrastructure/Identity/RefreshToken.cs +++ b/src/Cluckwork.Infrastructure/Identity/RefreshToken.cs @@ -20,6 +20,19 @@ public sealed class RefreshToken public DateTimeOffset? RevokedAt { get; set; } public string? ReplacedByTokenHash { get; set; } + // #176 — set when this token was revoked BY a grace-advance (not a normal + // rotation). Reuse-detection refuses to grace such a token again, so the + // idempotency grace is bounded to a SINGLE hop off a normal rotation and + // cannot be leap-frogged down the chain to extend a stolen session. + public bool RevokedByGrace { get; set; } + + // #176 — optimistic-concurrency token, regenerated on every rotation. Two + // concurrent presentations of the same token both load the same stamp; only + // the first UPDATE (WHERE ConcurrencyStamp = old) matches a row, so the loser + // throws DbUpdateConcurrencyException and RefreshAsync fails it closed — + // preventing a fork into two live sessions from one token. + public string ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); + public bool IsActive(DateTimeOffset now) => RevokedAt is null && ExpiresAt > now; } @@ -33,7 +46,17 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.AccountId).IsRequired(); builder.Property(e => e.TokenHash).HasMaxLength(64).IsRequired(); builder.Property(e => e.ReplacedByTokenHash).HasMaxLength(64); + builder.Property(e => e.RevokedByGrace).HasDefaultValue(false); builder.HasIndex(e => e.TokenHash).IsUnique(); builder.HasIndex(e => e.UserId); + + // #176 — consuming a refresh token (revoke-and-rotate) must be an atomic + // compare-and-swap: two concurrent presentations of the same token would + // otherwise both read it active and each mint a live child (a fork that, + // on the grace path, spawns multiple sessions AND skips theft-detection). + // The stamp is regenerated on every rotation, so a concurrent writer's + // UPDATE matches no row and throws DbUpdateConcurrencyException, which + // RefreshAsync fails closed. + builder.Property(e => e.ConcurrencyStamp).IsConcurrencyToken().HasMaxLength(36); } } diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260723235611_AddRefreshTokenGraceAndConcurrency.Designer.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260723235611_AddRefreshTokenGraceAndConcurrency.Designer.cs new file mode 100644 index 000000000..c833cb42e --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260723235611_AddRefreshTokenGraceAndConcurrency.Designer.cs @@ -0,0 +1,1985 @@ +// +using System; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260723235611_AddRefreshTokenGraceAndConcurrency")] + partial class AddRefreshTokenGraceAndConcurrency + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DateFormatOverride") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DefaultCurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("DefaultCurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("DefaultCurrencySymbol") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("FirstDayOfWeek") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TimeFormatOverride") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UnitSystem") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.FarmLogo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ByteLength") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId") + .IsUnique(); + + b.ToTable("FarmLogos", t => + { + t.HasCheckConstraint("ck_farm_logos_content_length", "octet_length(\"Content\") > 0 AND octet_length(\"Content\") <= 1048576"); + }); + }); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.UserRoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "UserId"); + + b.HasIndex("UserId", "FlockId") + .IsUnique(); + + b.ToTable("UserRoleAssignments"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Auditing.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("DetailsJson") + .HasColumnType("text"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EntityId"); + + b.HasIndex("AccountId", "OccurredAtUtc"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Catalog.EggUnitConversion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("EggsPerUnit") + .HasColumnType("integer"); + + b.Property("UnitCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "UnitCode") + .IsUnique(); + + b.ToTable("EggUnitConversions"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("DefaultPriceMinorUnits") + .HasColumnType("bigint"); + + b.Property("DefaultUnit") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProductType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Catalog.ProductEggGradeMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("ProductId") + .IsUnique(); + + b.ToTable("ProductEggGradeMappings"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AdjustReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AdjustedFromJson") + .HasColumnType("text"); + + b.Property("CrackedEggs") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DirtyEggs") + .HasColumnType("integer"); + + b.Property("DiscardedEggs") + .HasColumnType("integer"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("LockedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MortalityCount") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TotalEggs") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId", "FlockId", "Date") + .IsUnique() + .HasDatabaseName("IX_DailyEntries_NaturalKey") + .HasFilter("\"Status\" <> 'Voided'"); + + b.ToTable("DailyEntries"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("DailyEntryId", "EggGradeId") + .IsUnique(); + + b.ToTable("DailyEntryGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("GradeType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IsSaleable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EggGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggInventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EggLotId") + .HasColumnType("uuid"); + + b.Property("MovementType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("QuantityDelta") + .HasColumnType("integer"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("ReferenceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("EggLotId"); + + b.HasIndex("AccountId", "EggLotId", "CreatedAtUtc"); + + b.ToTable("EggInventoryMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("ProductionDate") + .HasColumnType("date"); + + b.Property("QuantityAvailable") + .HasColumnType("integer"); + + b.Property("QuantityProduced") + .HasColumnType("integer"); + + b.Property("RestrictedUntil") + .HasColumnType("date"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId") + .HasDatabaseName("IX_EggLots_DailyEntryId"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("AccountId", "EggGradeId", "ProductionDate", "QuantityAvailable") + .HasDatabaseName("IX_EggLots_Allocation"); + + b.ToTable("EggLots"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Expenses.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AmountMinorUnits") + .HasColumnType("bigint"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpenseCategoryId") + .HasColumnType("uuid"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExpenseCategoryId"); + + b.HasIndex("FlockId"); + + b.HasIndex("AccountId", "Date"); + + b.HasIndex("AccountId", "ExpenseCategoryId"); + + b.ToTable("Expenses"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Expenses.ExpenseCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ExpenseCategories"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("FlockId"); + + b.HasIndex("AccountId", "FlockId", "Date"); + + b.ToTable("BirdMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.Flock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ArchivedOn") + .HasColumnType("date"); + + b.Property("Breed") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DepletedOn") + .HasColumnType("date"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("InitialCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PlacementDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId"); + + b.ToTable("Flocks"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.FeedUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("FlockId", "Date"); + + b.HasIndex("InventoryItemId", "Date"); + + b.ToTable("FeedUsages"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId"); + + b.ToTable("InventoryItems"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("LotNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("QuantityAvailable") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("QuantityReceived") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ReceivedDate") + .HasColumnType("date"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InventoryItemId", "ReceivedDate"); + + b.ToTable("InventoryLots"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("InventoryLotId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuantityDelta") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("ReferenceType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("FlockId"); + + b.HasIndex("InventoryLotId"); + + b.HasIndex("InventoryItemId", "Date"); + + b.ToTable("InventoryMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.WaterUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("MeterEnd") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("MeterStart") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("FlockId", "Date"); + + b.ToTable("WaterUsages"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "Name"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AmountMinorUnits") + .HasColumnType("bigint"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PaymentDate") + .HasColumnType("date"); + + b.Property("ReferenceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Voided") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("SalesOrderId"); + + b.HasIndex("AccountId", "CustomerId"); + + b.HasIndex("AccountId", "SalesOrderId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("OrderDate") + .HasColumnType("date"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("AccountId", "ReferenceNumber") + .IsUnique(); + + b.ToTable("SalesOrders"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderAllocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EggLotId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("ReleasedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.Property("SalesOrderItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EggLotId"); + + b.HasIndex("SalesOrderId"); + + b.HasIndex("SalesOrderItemId"); + + b.ToTable("SalesOrderAllocations"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("BaseUnitFactor") + .HasColumnType("integer"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductTypeSnapshot") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("QuantityBase") + .HasColumnType("integer"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("ProductId"); + + b.HasIndex("SalesOrderId"); + + b.ToTable("SalesOrderItems"); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByGrace") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Jobs.DurableJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "RunAfter"); + + b.ToTable("durable_jobs", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Persistence.IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndpointHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IdempotencyKeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EndpointHash", "IdempotencyKeyHash") + .IsUnique(); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Domain.Catalog.ProductEggGradeMapping", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Catalog.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany("Grades") + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggInventoryMovement", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggLot", null) + .WithMany() + .HasForeignKey("EggLotId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Expenses.Expense", b => + { + b.HasOne("Cluckwork.Domain.Expenses.ExpenseCategory", null) + .WithMany() + .HasForeignKey("ExpenseCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany() + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.FeedUsage", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany() + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "EstimatedCost", b1 => + { + b1.Property("FeedUsageId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("EstimatedCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("EstimatedCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("EstimatedCostMinorUnits"); + + b1.HasKey("FeedUsageId"); + + b1.ToTable("FeedUsages"); + + b1.WithOwner() + .HasForeignKey("FeedUsageId"); + }); + + b.Navigation("EstimatedCost") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryItem", b => + { + b.OwnsOne("Cluckwork.Domain.Common.Money", "DefaultUnitCost", b1 => + { + b1.Property("InventoryItemId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("DefaultCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("DefaultCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("DefaultCostMinorUnits"); + + b1.HasKey("InventoryItemId"); + + b1.ToTable("InventoryItems"); + + b1.WithOwner() + .HasForeignKey("InventoryItemId"); + }); + + b.Navigation("DefaultUnitCost"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryLot", b => + { + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "UnitCost", b1 => + { + b1.Property("InventoryLotId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("UnitCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("UnitCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("UnitCostMinorUnits"); + + b1.HasKey("InventoryLotId"); + + b1.ToTable("InventoryLots"); + + b1.WithOwner() + .HasForeignKey("InventoryLotId"); + }); + + b.Navigation("UnitCost") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryMovement", b => + { + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryLot", null) + .WithMany() + .HasForeignKey("InventoryLotId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.WaterUsage", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany() + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Payment", b => + { + b.HasOne("Cluckwork.Domain.Sales.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany() + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.HasOne("Cluckwork.Domain.Sales.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "TotalAmount", b1 => + { + b1.Property("SalesOrderId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("TotalCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("TotalCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("TotalMinorUnits"); + + b1.HasKey("SalesOrderId"); + + b1.ToTable("SalesOrders"); + + b1.WithOwner() + .HasForeignKey("SalesOrderId"); + }); + + b.Navigation("TotalAmount") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderAllocation", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggLot", null) + .WithMany() + .HasForeignKey("EggLotId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany() + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrderItem", null) + .WithMany() + .HasForeignKey("SalesOrderItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Catalog.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany("Items") + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "UnitPrice", b1 => + { + b1.Property("SalesOrderItemId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("UnitPriceCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("UnitPriceCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("UnitPriceMinorUnits"); + + b1.HasKey("SalesOrderItemId"); + + b1.ToTable("SalesOrderItems"); + + b1.WithOwner() + .HasForeignKey("SalesOrderItemId"); + }); + + b.Navigation("UnitPrice") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260723235611_AddRefreshTokenGraceAndConcurrency.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260723235611_AddRefreshTokenGraceAndConcurrency.cs new file mode 100644 index 000000000..1df7f1a7c --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260723235611_AddRefreshTokenGraceAndConcurrency.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRefreshTokenGraceAndConcurrency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ConcurrencyStamp", + table: "refresh_tokens", + type: "character varying(36)", + maxLength: 36, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "RevokedByGrace", + table: "refresh_tokens", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ConcurrencyStamp", + table: "refresh_tokens"); + + migrationBuilder.DropColumn( + name: "RevokedByGrace", + table: "refresh_tokens"); + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index ca9c0d442..672e0f216 100644 --- a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -1333,6 +1333,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AccountId") .HasColumnType("uuid"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -1346,6 +1352,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RevokedAt") .HasColumnType("timestamp with time zone"); + b.Property("RevokedByGrace") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("TokenHash") .IsRequired() .HasMaxLength(64) diff --git a/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs b/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs index 5a76dccb3..7d32980a4 100644 --- a/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs @@ -4,6 +4,7 @@ namespace Cluckwork.Api.IntegrationTests; using Cluckwork.Api.IntegrationTests.Infrastructure; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; // tech spec §7.4: refresh tokens are durable and rotating. Refresh issues a new // pair, the old token is single-use, reuse is rejected, and logout revokes. @@ -41,7 +42,7 @@ public async Task Refresh_RotatesToken_AndIssuesNewPair() // #176 — a token rotated moments ago whose replacement is still the live tip // is a BENIGN concurrent/dead-tab retry (the #169 residual), not a replay: the - // caller is handed a fresh token instead of the session being torn down. + // caller is handed a fresh token and NO family revocation happens. [Fact] public async Task Refresh_ImmediateReplayWithinGrace_Succeeds_WithoutRevokingTheSession() { @@ -49,6 +50,11 @@ public async Task Refresh_ImmediateReplayWithinGrace_Succeeds_WithoutRevokingThe await factory.SeedAccountWithUserAsync(email); var client = factory.CreateClient(Cookieless); + // A SECOND, independent session for the same user (another device), logged + // in BEFORE the grace retry — so it would die if grace wrongly revoked the + // family (a "revoke-all-then-mint" bug that a self-check couldn't catch). + var sibling = await factory.LoginAsync(email); + var initial = await factory.LoginAsync(email); await RefreshAsync(client, initial.RefreshToken); // initial → live (delivered) @@ -57,11 +63,75 @@ public async Task Refresh_ImmediateReplayWithinGrace_Succeeds_WithoutRevokingThe var replay = await client.PostRefreshAsync(initial.RefreshToken); Assert.Equal(HttpStatusCode.OK, replay.StatusCode); - // The handed-back token is a working member of a live session — nothing - // was revoked (a family revocation would make this refresh 401). + // The handed-back token works, AND the pre-existing sibling session is + // untouched — proving no family revocation occurred. var handed = await TestHarness.ReadTokensAsync(replay); - var next = await client.PostRefreshAsync(handed.RefreshToken); - Assert.Equal(HttpStatusCode.OK, next.StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.PostRefreshAsync(handed.RefreshToken)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.PostRefreshAsync(sibling.RefreshToken)).StatusCode); + } + + // #176 — the grace is bounded to ONE hop off a normal rotation: the token + // revoked BY a grace-advance is marked and can never itself be graced, so a + // stolen token cannot be leap-frogged down the chain to extend a session + // (the exact HIGH the 4-way review + a reproduction test caught). + [Fact] + public async Task Refresh_GraceCannotBeLeapFroggedDownTheChain_RevokesTheFamily() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + await factory.SeedAccountWithUserAsync(email); + var client = factory.CreateClient(Cookieless); + + var t0 = await factory.LoginAsync(email); + var t1 = await RefreshAsync(client, t0.RefreshToken); // t0 → t1 (normal) + + // Dead-tab grace retry: replay t0 → t2. This marks t1 as revoked-by-grace. + var graceResp = await client.PostRefreshAsync(t0.RefreshToken); + Assert.Equal(HttpStatusCode.OK, graceResp.StatusCode); + var t2 = await TestHarness.ReadTokensAsync(graceResp); + + // The leap-frog: present t1 (the link revoked by the grace advance). It + // must NOT grace a second time — it is theft → the family is revoked. + var leap = await client.PostRefreshAsync(t1.RefreshToken); + Assert.Equal(HttpStatusCode.Unauthorized, leap.StatusCode); + + // Cascade: the live tip t2 is dead too — the whole session is torn down. + var afterCascade = await client.PostRefreshAsync(t2.RefreshToken); + Assert.Equal(HttpStatusCode.Unauthorized, afterCascade.StatusCode); + } + + // #176 — two concurrent presentations of the SAME token must never fork the + // chain into two live sessions. The per-token optimistic-concurrency stamp + // makes consumption an atomic compare-and-swap: a true-overlap loser fails + // closed (401, never a 500), a read-after-commit loser advances the single + // chain via grace — either way exactly ONE live tip remains, never two. + // (With 3+ concurrent, a late replay can legitimately find the replacement + // already consumed and trip the theft response to 0 live — also non-forking; + // two is the deterministic boundary that isolates the anti-fork guarantee.) + [Fact] + public async Task Refresh_ConcurrentPresentationsOfSameToken_NeverForkIntoTwoSessions() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var client = factory.CreateClient(Cookieless); + + var t0 = await factory.LoginAsync(email); + + var responses = await Task.WhenAll( + client.PostRefreshAsync(t0.RefreshToken), + client.PostRefreshAsync(t0.RefreshToken)); + + // A concurrency conflict must fail closed (401), never surface as a 500. + Assert.All(responses, r => Assert.True( + r.StatusCode is HttpStatusCode.OK or HttpStatusCode.Unauthorized, + $"unexpected {(int)r.StatusCode} — a concurrency conflict must fail closed, not 500")); + Assert.Contains(responses, r => r.StatusCode == HttpStatusCode.OK); + + // The invariant: exactly one live (non-revoked) token remains — the race + // never minted a second, forked session. + var active = 0; + await factory.WithTenantScopeAsync(accountId, async db => + active = await db.RefreshTokens.CountAsync(t => t.AccountId == accountId && t.RevokedAt == null)); + Assert.Equal(1, active); } // #176 — theft-detection stays strict once the chain has moved on: replaying a