Skip to content

fix(api): stop concurrent refresh-token double-rotation + close three test gaps (#243) - #329

Merged
thomasluizon merged 4 commits into
mainfrom
fix/session-refresh-race-and-api-test-gaps
Jul 12, 2026
Merged

fix(api): stop concurrent refresh-token double-rotation + close three test gaps (#243)#329
thomasluizon merged 4 commits into
mainfrom
fix/session-refresh-race-and-api-test-gaps

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

What

Closes three test-coverage gaps and fixes the real bug the third one surfaced.

Source fix — refresh-token rotation race

UserSession was missing the optimistic-concurrency token that User/Goal/Referral already carry, so two parallel RefreshSessionAsync calls with the same refresh token both rotated the row (last-write-wins) — a refresh-token reuse race where both callers minted access tokens and the losing caller's issued refresh token was silently overwritten.

  • Map the Postgres xmin system-column concurrency token on UserSession (no DDL — the migration is a no-op at the SQL level, matching AddXminConcurrencyTokens).
  • Translate the losing writer's DbUpdateConcurrencyException into a clean INVALID_SESSION failure in RefreshSessionAsync. RefreshSessionCommand is not IConcurrencyRetryable, so resolving it in-service (rather than letting it surface as a 409) is correct: exactly one refresh rotates, the loser is rejected, no double-rotation, no retry.

Tests (behavior + edge + failure)

  • ValidationExceptionHandler — own focused test: 400 status, application/json content-type, { type, status, requestId, errors } envelope with messages grouped per property, request-id echo header, and the non-ValidationException delegation path.
  • XpAwardLogBackfillHostedService — startup-delay deferral (backfill does not run inside the delay window), idempotency skip when the flag is already set, clean-run flag write + completion log, and failure-path error logging.
  • AuthSessionService concurrent refresh — two refreshes racing on one token: exactly one rotates, the loser gets INVALID_SESSION, and the row is rotated once. The in-memory provider does not enforce xmin, so the conflict is injected with a save interceptor (the same shape Postgres raises on a stale token — the house pattern from ConcurrencyRetryTests).

Validation

Full api suite green locally: 4,642 passed, 0 failed (dotnet test Orbit.slnx).

Parity

Backend-internal only — no endpoint, DTO, or error-code contract change (INVALID_SESSION already exists and is already returned for other refresh failures), so no consumer-side edit is required.

Refs thomasluizon/orbit-ui-mobile#243

… test gaps (#243)

UserSession lacked the optimistic-concurrency token that User/Goal/Referral carry,
so two parallel RefreshSessionAsync calls with the same token both rotated the row
(last write wins) — a refresh-token reuse race. Map the Postgres xmin token on
UserSession and translate the losing writer's DbUpdateConcurrencyException into a
clean INVALID_SESSION failure so exactly one refresh rotates and the loser is
rejected (no retry, no double-rotation). The token is a system column, so the
migration emits no DDL; RefreshSessionCommand is not IConcurrencyRetryable, so the
conflict is resolved in-service rather than surfacing as a 409.

Intelligent unit tests for three coverage gaps:
- ValidationExceptionHandler: focused test for the 400 JSON envelope shape,
  application/json content-type, grouped-per-property errors, and request-id header.
- XpAwardLogBackfillHostedService: startup-delay deferral, idempotency skip when the
  flag is set, clean-run flag write + completion log, and failure-path logging.
- AuthSessionService concurrent refresh: exactly-one-wins with the loser getting
  INVALID_SESSION and the row rotated once (conflict injected via save interceptor,
  since the in-memory provider does not enforce xmin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #329 — fix(api): stop concurrent refresh-token double-rotation + close three test gaps

Recommendation: REQUEST CHANGES

Summary

The core fix (mapping the Postgres xmin system column onto UserSession and catching the losing racer's DbUpdateConcurrencyException as INVALID_SESSION) is architecturally sound and well-tested for the scenario it targets in isolation. However, the new catch block in AuthSessionService.RefreshSessionAsync deviates from an established, consistently-applied codebase convention — it never resets the DbContext's change tracker after catching the exception — and this leaves a poisoned tracked entity that breaks a real, reachable secondary code path (the audit-log write on POST /api/auth/operations/refresh). Verified directly against source (not just taken from subagent output): the DI scoping, the unconditional audit-write call site, and the precedent pattern all check out.

Findings

High

1. Concurrency-exception catch leaks a poisoned tracked entity into the shared per-request DbContext, breaking the next SaveChangesAsync in the same scope
src/Orbit.Infrastructure/Services/AuthSessionService.cs:70-77

try
{
    await unitOfWork.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
    return Result.Failure<SessionTokens>(ErrorMessages.InvalidSession);
}

When the losing racer's SaveChangesAsync throws, the UserSession entity mutated by session.Rotate(...) (line 65-68) stays tracked as EntityState.Modified with its stale xmin value — the catch returns without calling unitOfWork.DiscardChanges() or ResetTracking() (both exist on IUnitOfWork, UnitOfWork.cs:71-87).

OrbitDbContext (AddDbContext, scoped by default) and IUnitOfWork/IAgentAuditService (both explicit AddScoped, ServiceCollectionExtensions.cs:59,69, ServiceCollectionExtensions.AiServices.cs:39) resolve to the same instance within one HTTP request. AuthController.RefreshOperation (AuthController.cs:230-242) calls mediator.Send(new RefreshSessionCommand(...)) and then unconditionally, regardless of result.IsSuccess, calls RecordDirectAuthAuditAsync(...)AgentAuditService.RecordAsync (AgentAuditService.cs:8-15), which does await dbContext.SaveChangesAsync(...) on the same context. EF Core's SaveChangesAsync re-attempts every Modified entry in the tracker, not just newly touched ones — so this second save re-attempts the stale UserSession UPDATE and throws a second, uncaught DbUpdateConcurrencyException, which ConcurrencyExceptionHandler turns into an HTTP 409. Under the exact concurrent-refresh race this PR fixes, the endpoint returns the wrong response (409 instead of the intended Unauthorized/INVALID_SESSION) and silently drops the AgentAuditLog row for that operation — an audit-trail gap on a security-relevant surface.

This is the only catch (DbUpdateConcurrencyException) site in the codebase that swallows the exception and returns without resetting tracking — ConcurrencyRetry.ExecuteAsync (src/Orbit.Application/Common/ConcurrencyRetry.cs:79-83) calls unitOfWork.DiscardChanges() before proceeding, confirming this is a deviation from house convention rather than a one-off style choice. No existing test catches it: AuthSessionServiceConcurrentRefreshTests.cs only exercises RefreshSessionAsync in isolation, and AuthControllerTests.cs mocks IAgentAuditService, so neither touches a real DbContext after the caught exception.

Fix: add unitOfWork.DiscardChanges(); (or ResetTracking()) inside the catch (DbUpdateConcurrencyException) block before returning. Add a regression test that performs a second SaveChangesAsync on the same context afterward (ideally through the real audit-write path) to close the gap.

Medium / Low

None.

What's good

  • The xmin-token approach for UserSession correctly reuses the existing pattern used for User/Goal/Referral.
  • Correctly not marking RefreshSessionCommand as IConcurrencyRetryable — blindly retrying would just replay the same stale token.
  • The new test suites (ValidationExceptionHandlerTests, XpAwardLogBackfillHostedServiceTests, AuthSessionServiceConcurrentRefreshTests) close real gaps and correctly use the house "save interceptor" pattern to simulate xmin conflicts.
  • No secrets/PII logged, no narration comments, no dead code, migration correctly mirrors precedent.

Not verifiable in this environment

  • Cross-repo (orbit-ui-mobile / packages/shared) impact — N/A anyway, this PR touches no DTO or route surface.
  • Build/test run — skipped per CI (Build / Unit Tests / SonarCloud already required checks on this PR).

Recommendation

Add unitOfWork.DiscardChanges() (or ResetTracking()) to the catch block in AuthSessionService.RefreshSessionAsync, plus a test proving a subsequent save on the same context succeeds after the caught conflict.

Comment thread src/Orbit.Infrastructure/Services/AuthSessionService.cs
thomasluizon and others added 2 commits July 12, 2026 02:47
The catch (DbUpdateConcurrencyException) block in RefreshSessionAsync
returned without resetting the scoped DbContext's change tracker, leaving
the session.Rotate(...)-mutated UserSession stuck as EntityState.Modified
with a stale xmin. AuthController.RefreshOperation unconditionally writes
an AgentAuditLog on the same scoped context right after, so EF re-attempted
the stale UserSession UPDATE and threw a second, uncaught concurrency
exception (HTTP 409 instead of INVALID_SESSION, and the audit row dropped).

Add unitOfWork.DiscardChanges() before returning, matching the house
convention (ConcurrencyRetry.ExecuteAsync). Add a regression test that
routes a subsequent save through the real AgentAuditService audit path on
the same context and asserts no residual Modified UserSession remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Scope: PR #329 (thomasluizon/orbit-api) — focused on the diff since the last bot review
Recommendation: APPROVE

Severity Count
Critical (incl. ⚠️ old-client breaks) 0
High 0
Medium 0
Low / Info 0

Summary: A prior review from this bot (submitted against commit 1a0332f) flagged one High finding: the catch (DbUpdateConcurrencyException) in AuthSessionService.RefreshSessionAsync (src/Orbit.Infrastructure/Services/AuthSessionService.cs:70-77) left a poisoned Modified UserSession entity tracked on the shared scoped DbContext, which would make the very next SaveChangesAsync in the same request (the unconditional post-refresh AgentAuditLog write in AuthController.RefreshOperation) throw a second, uncaught DbUpdateConcurrencyException — turning the intended INVALID_SESSION failure into an HTTP 409 and silently dropping the audit row.

Commit 2c80e8581e614a4cbb944f78cc8bdc91861c1811 ("fix(api): reset change tracker after refresh concurrency conflict") fixes exactly this: it adds unitOfWork.DiscardChanges(); before the return (AuthSessionService.cs:76), matching the house convention already used in ConcurrencyRetry.ExecuteAsync (src/Orbit.Application/Common/ConcurrencyRetry.cs:81). Independently verified:

  • UnitOfWork.DiscardChanges() (src/Orbit.Infrastructure/Persistence/UnitOfWork.cs:71-82) correctly flips Modified/Deleted entries to Unchanged and Added to Detached, so the stale UserSession won't be re-submitted on the next SaveChanges in the same scope.
  • The new regression test RefreshSessionAsync_AfterConcurrencyConflict_LeavesContextCleanForSubsequentAuditWrite (tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceConcurrentRefreshTests.cs) exercises the real vulnerable path: it asserts no Modified UserSession remains, then calls the actual AgentAuditService.RecordAsync (src/Orbit.Infrastructure/Services/AgentAuditService.cs:8-15, confirmed scoped OrbitDbContext) on the same context and asserts it does not throw — precisely closing the gap the prior review identified.
  • Security review: PASS — the losing racer's stale refresh token is genuinely dead (its rotated hash is never persisted or returned), no distinguishing error signal between "raced and lost" vs. "already invalid" (both return the same generic INVALID_SESSION), no tokens/secrets logged, no [Authorize] posture change (no controller touched in this PR).
  • Contract/parity check: N/A — no DTO, route, or packages/shared surface changed; confirmed by the diff (only Infrastructure persistence/services + Infrastructure tests). Cross-repo verification against orbit-ui-mobile not performed (repo not checked out in this job) — not applicable here since no contract surface changed.
  • Comment policy: the migration's WHY comment (AddUserSessionXminConcurrencyToken.cs:13-14) is a URL-linked note (links to Npgsql docs), the one exception the narration-comment rule allows.
  • Backward-compat guard: N/A — no field add/remove/rename anywhere in this diff.
  • Migration .Designer.cs snapshot: standard auto-generated EF boilerplate, consistent with the hand-written migration.

What's good: The fix directly targets the exact reachable secondary code path the previous review traced through source (DI scoping, unconditional audit write, precedent pattern), and the new test proves it against the real AgentAuditService rather than a mock — closing the gap the earlier review noted.

Deferred (out of scope for this diff): ValidationExceptionHandlerTests.cs and XpAwardLogBackfillHostedServiceTests.cs are unchanged since the prior review, which already verdicted them clean. Build/test execution skipped — CI runs Build / Unit Tests / SonarCloud as separate required checks.

@sonarqubecloud

Copy link
Copy Markdown

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #329 — fix(api): stop concurrent refresh-token double-rotation + close three test gaps (#243)

Recommendation: APPROVE

Summary

This PR maps the Postgres xmin optimistic-concurrency token onto UserSession (mirroring the existing User/Goal/Referral pattern byte-for-byte) and translates the losing writer's DbUpdateConcurrencyException in AuthSessionService.RefreshSessionAsync into a clean INVALID_SESSION failure with DiscardChanges(), closing a refresh-token double-rotation race. It also adds three unit-test suites (validation-exception envelope, XP backfill hosted-service orchestration, concurrent-refresh semantics) that reproduce the exact production sequence via save interceptors and a real AgentAuditService write on the same scoped DbContext.

All 8 changed files were read in full: 2 migration files, OrbitDbContextModelSnapshot.cs, OrbitDbContext.cs, AuthSessionService.cs, and 3 new test files. No commits since the prior bot APPROVE (2026-07-12T05:56:50Z) touch this PR's own files — the branch's newest commit only merges unrelated changes in from main (e.g., the User deactivation query filter, #324); nothing new to re-review.

Findings

Critical / High / Medium: None.

Low / Info: None posted — one theoretical observation (UnitOfWork.DiscardChanges() resets every tracked entry on the shared DbContext, not just the conflicting entity, and doesn't reload CurrentValues from the DB) was independently raised by both my review and a dedicated security-reviewer subagent. Confirmed not concretely actionable for this PR: RefreshSessionCommand doesn't implement IIdempotentCommand, so it never runs through IdempotencyBehavior (the only other code path that layers pending writes onto a shared context), and the only other tracked entity in this call path (User, loaded read-only) is never mutated. Per the rubric's signal gate, this is future-proofing on code that already works today, not a finding.

Security notes

  • Token leak: on DbUpdateConcurrencyException, RefreshSessionAsync returns immediately before ever surfacing the losing writer's newRefreshToken to a caller — no plaintext token leak on the failure path, and the old (already-consumed) refresh token cannot be replayed by either party since the loser's TokenHash lookup no longer matches any row.
  • Authorization: no Controllers touched by this diff; AuthController's refresh endpoint and its [AllowAnonymous]/audit-logging shape are unchanged.
  • Migration safety: AddColumn<uint>("xmin", type: "xid", rowVersion: true) is a true no-op at the SQL level (Npgsql special-cases the xmin system column) — structurally identical to the two prior xmin migrations. Safe for rolling deploy; the only transitional effect is that old (pre-migration-model) instances won't apply the WHERE xmin = ... guard on their own writes until they redeploy, a self-resolving window, not a data-loss risk.

Validation

Build/Unit Tests run as separate required CI checks (Build, Unit Tests, SonarCloud) per this workflow's CI adaptation — not re-run here. PR description states 4,642/4,642 tests passing locally.

Deferred — N/A dimensions

  • Dimensions 8–10, 14 (DESIGN.md/AI-slop, web↔mobile parity, i18n, FEATURES.md parity): N/A — backend-only bugfix + test coverage, no UI or user-facing feature surface.
  • Dimension 11 (contract drift / backward-compat guard): N/A — no DTO, Controller route, or packages/shared type touched; not verifiable in this CI job regardless (sibling orbit-ui-mobile repo not checked out).

What's good

  • Minimal-risk copy of an already-proven house pattern (byte-for-byte structural match to AddXminToReferral), not a novel mechanism.
  • New tests reproduce the exact production sequence (save-interceptor-injected conflict, then a real AgentAuditService.RecordAsync write on the same DbContext) rather than mocking around it.
  • No dead code, no workarounds, no scope creep.

@thomasluizon
thomasluizon merged commit 6edbf3c into main Jul 12, 2026
19 checks passed
@thomasluizon
thomasluizon deleted the fix/session-refresh-race-and-api-test-gaps branch July 12, 2026 08:06
thomasluizon added a commit that referenced this pull request Jul 12, 2026
* fix(api): harden auth/session surface (#243)

Four targeted hardenings on the refresh/session flow, none regressing the
#329 double-rotation guard (its concurrency tests still pass):

- Account-wide revocation: add IAuthSessionService.RevokeAllSessionsAsync,
  which revokes every active session for a user (for password/security
  events), wired to a new authenticated POST /api/auth/logout-all endpoint.
  Single-session logout is unchanged.
- Pre-rotation validation: UserSession.Rotate now returns a Result and guards
  that the session is still usable (not revoked/expired) and the new hash is
  non-empty before mutating, so a rotation can never land on an inactive
  session. This complements the xmin concurrency token from #329 (DB-level
  loser rejection) with an explicit in-memory invariant.
- Refresh-token format validation: RefreshSessionCommandValidator (and the
  logout validator, which takes the same token) now enforce the exact
  server-issued shape — 128 uppercase-hex chars — via a shared RefreshTokenRules,
  rejecting malformed tokens with a 400 before any lookup.
- Refresh rate limit: the refresh endpoints move to a dedicated `refresh`
  policy partitioned by a SHA-256 hash of the refresh token (a stable
  per-session identity) instead of the caller IP, so a stolen/targeted token
  cannot be replayed faster by rotating source IPs. Falls back to IP when no
  token is present. The key is hashed so logs never carry the raw secret.

Intelligent tests for each: domain Rotate guards, RevokeAllSessions scoping
(only the target user's active sessions), validator format/length/case edges,
and refresh-token partition-key resolution + filter wiring.

The new endpoint is mapped in the agent catalog (AuthManage) and openapi.json
regenerated. orbit-api-only, additive/append-only contract; no client change
required.

Refs thomasluizon/orbit-ui-mobile#243

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(api): address review — inline refresh-token format gate + revoke-all concurrency (#243)

- Rate limit: TryResolveRefreshTokenPartitionKey now accepts a token only if it
  matches the exact server-issued shape (RefreshTokenRules.IsWellFormed, the
  single source of format truth); a malformed body (the trivial vary-per-request
  bypass) falls back to IP partitioning so the request is still throttled per IP
  and unvalidated input no longer reaches the rate-limit DB round-trip.
- RevokeAllSessionsAsync now catches DbUpdateConcurrencyException (DiscardChanges +
  INVALID_SESSION), matching RefreshSessionAsync, so a concurrent refresh during
  logout-all fails gracefully instead of 500.
- Tests: malformed/lowercase/wrong-length tokens fall back to IP; revoke-all
  concurrency conflict returns a controlled failure without throwing.

Refs thomasluizon/orbit-ui-mobile#243

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(api): gate refresh rate-limit partition on a real session lookup (#243)

The refresh policy partitioned by SHA256(RefreshToken) whenever the token
merely matched RefreshTokenRules.IsWellFormed (128 uppercase-hex). Minting a
fresh well-formed token per request is as cheap as the server's own token
generation, so a single IP could mint unlimited unthrottled partitions,
defeating the 10/min cap and forcing a Serializable rate-limit transaction per
request (DoS on the auth surface).

Format alone is now insufficient: the per-session (per-token) partition is
granted only after IAuthSessionService.HasSessionForTokenAsync confirms the
token maps to a real stored session (AnyAsync over the unique TokenHash index).
A forged or malformed token an attacker can mint for free never earns a private
bucket and falls back to per-IP throttling, while a genuine token keeps its
per-session bucket that survives source-IP rotation.

Split the pure partition helper into TryExtractRefreshToken +
BuildRefreshTokenPartitionKey; add filter, partition-key, and service-level
tests including the well-formed-but-forged token -> IP fallback path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(api): make logout-all concurrency-resilient + scope refresh partition to usable sessions (#243)

RevokeAllSessionsAsync batched every active session into one SaveChanges: a
single concurrent write to any one of them (e.g. another device auto-refreshing
at the same instant) threw DbUpdateConcurrencyException, discarded the whole
batch, and returned failure — so NONE of the user's sessions were revoked. That
silently defeats the account-wide "log out everywhere" security control and let
an attacker holding one stolen token keep looping refresh to block revocation.

Replace the discard-and-fail catch with the codebase's concurrency-retry idiom:
on conflict, ResetTracking and reload current state, then re-revoke and retry
(bounded, mirroring DistributedRateLimitService). A single conflicting row no
longer defeats the batch; genuine repeated conflicts still surface a controlled
failure without throwing.

Also scope HasSessionForTokenAsync to currently-usable sessions
(RevokedAtUtc == null and not expired, mirroring UserSession.CanUse), so a
revoked or expired token no longer earns a stable private rate-limit bucket and
falls back to per-IP throttling.

Tests: transient-conflict retry revokes every active session; revoked and
expired tokens are excluded from the per-token partition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
thomasluizon added a commit that referenced this pull request Jul 13, 2026
…243) (#370)

Close the high-severity mock-level gaps in the two AuthSessionServiceTests:

- Rotation atomicity: assert the persisted TokenHash is deterministically
  the SHA-256 of the new refresh token handed to the client (not just
  "!= original"), in both the Infrastructure and Application rotate tests.
- Concurrent refresh: model the race two ways. A DbUpdateConcurrencyException
  on save (stale #329 xmin token) is a controlled failure — INVALID_SESSION,
  DiscardChanges() runs, and no new token is issued. A loser replaying the
  already-consumed token is rejected while the session rotates exactly once
  to the winner's token (no double-rotation).

These complement the DB-integration coverage in
AuthSessionServiceConcurrentRefreshTests by asserting the service's own
contract (deterministic storage, recovery call, token non-issuance) at the
unit level.

Refs thomasluizon/orbit-ui-mobile#243

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant