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..1e7937a3e --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-idempotent-refresh-design.md @@ -0,0 +1,117 @@ +# #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. + +## 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 + +- 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 835419bd7..1aa35b2b3 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -443,7 +443,17 @@ 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, 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 3f859a827..e73179b23 100644 --- a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs +++ b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs @@ -94,12 +94,28 @@ 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. + var viaGrace = false; 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; + viaGrace = true; } if (stored.ExpiresAt <= now) @@ -109,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. @@ -225,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) @@ -249,17 +281,35 @@ 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; + 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 + || 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 + .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 + // 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/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/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 175935308..7d32980a4 100644 --- a/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/RefreshTokenFlowTests.cs @@ -2,7 +2,9 @@ namespace Cluckwork.Api.IntegrationTests; using System.Net; 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. @@ -38,44 +40,120 @@ 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 and NO family revocation happens. [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); + // 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); // 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 works, AND the pre-existing sibling session is + // untouched — proving no family revocation occurred. + var handed = await TestHarness.ReadTokensAsync(replay); + 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); } - // #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 +185,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.