Skip to content

feat(api): prod-gated smoke-auth bypass for post-deploy smoke (#227) - #217

Merged
thomasluizon merged 2 commits into
mainfrom
feature/phase2-prod-smoke
Jun 23, 2026
Merged

feat(api): prod-gated smoke-auth bypass for post-deploy smoke (#227)#217
thomasluizon merged 2 commits into
mainfrom
feature/phase2-prod-smoke

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Part of #227 (backend). Pairs with the frontend PR thomasluizon/orbit-ui-mobile#253.

⚠️ REVIEW CAREFULLY — this is an intentional, scoped PRODUCTION auth bypass

This deliberately introduces a way to authenticate one pinned account in prod without receiving the email OTP, so the post-deploy Playwright smoke suite (#227) can sign in. It is gated tightly and fail-safe closed, but it IS prod auth surface — please scrutinize the gating.

Exact gating — the bypass fires ONLY when ALL of these hold

  1. ASPNETCORE_ENVIRONMENT == "Production", AND
  2. the request email == SMOKE_TEST_EMAIL (env), AND
  3. both SMOKE_TEST_EMAIL and SMOKE_TEST_CODE (env) are set (non-empty).

When it fires, SendCodeCommandHandler seeds the verification cache with SMOKE_TEST_CODE and skips sending email. The submitted code is still validated against SMOKE_TEST_CODE by the unchanged VerifyCodeCommandHandler (FixedTimeEquals), so verify still enforces submitted==code, the existing [DistributedRateLimit("auth")] on verify-code/send-code still applies, and attempt-counting is unchanged.

Safety properties

  • Fail-safe closed: if SMOKE_TEST_EMAIL or SMOKE_TEST_CODE is unset/empty, the branch is fully inert → normal random-code + email flow. (Prod today has neither set, so this is a no-op until an operator opts in.)
  • Single pinned account — one env value, no list, no wildcard.
  • No secrets logged — the bypass adds no log lines; email/code are never emitted.
  • Existing paths untouched — the non-prod TEST_ACCOUNTS seam and ResendEmailService.IsTestAccount are unchanged (the TEST_ACCOUNTS block just moved verbatim into the non-prod else).

Why a prod bypass at all

Prod login is passwordless email OTP; the existing TEST_ACCOUNTS deterministic-code path is hard-gated to non-prod (and the email service suppresses mail for those accounts), so no automated client can obtain the code in prod. Owner-approved Option 1: a prod-only, single-pinned-email bypass behind a high-entropy SMOKE_TEST_CODE secret. Alternatives (direct cookie mint / mailbox polling) were considered and rejected (lost real-UI coverage / added flake).

Files changed

  • src/Orbit.Application/Auth/Commands/SendCodeCommand.cs — prod-smoke seed branch + TrySeedProductionSmokeCode helper.
  • tests/Orbit.Application.Tests/Commands/Auth/SendCodeCommandHandlerTests.cs — NEW. prod+pinned+correct→success; wrong code / wrong email / non-prod env / unset secret → no bypass (env vars set+restored per test).

Operator secrets to set in prod (only when enabling the smoke suite)

  • SMOKE_TEST_EMAIL — the dedicated disposable smoke account's email.
  • SMOKE_TEST_CODE — a long, high-entropy fixed code (the OTP the bypass accepts). Rotate by changing this env value.

Validation

  • dotnet build tests/Orbit.Application.Tests → succeeded.
  • dotnet test (full Orbit.Application.Tests) → 2007 passed, 0 failed (incl. the 6 new bypass tests; existing 33 auth tests green; no env leakage).

🤖 Generated with Claude Code

Intentional, narrowly-scoped production auth bypass so the post-deploy
Playwright smoke suite (#227) can authenticate as one pinned, disposable
account without reading email.

The bypass seeds the verification cache with a fixed code and skips the email
ONLY when ALL hold: ASPNETCORE_ENVIRONMENT == "Production" AND the request
email == SMOKE_TEST_EMAIL AND both SMOKE_TEST_EMAIL and SMOKE_TEST_CODE are
set. The submitted code is still validated against SMOKE_TEST_CODE by the
unchanged verify-code path (FixedTimeEquals), which keeps the existing "auth"
distributed rate limit and attempt-counting.

- Fail-safe closed: if either secret is unset/empty, the bypass is fully inert
  and login falls back to the normal random-code + email flow.
- Single pinned account only (no list, no wildcard); email/code are never logged.
- The existing non-prod TEST_ACCOUNTS path and ResendEmailService.IsTestAccount
  are unchanged.
- Adds SendCodeCommandHandlerTests covering: prod + pinned + correct code -> success;
  wrong code / wrong email / non-prod env / unset secret -> no bypass.

Part of #227 (backend). Operator secrets: SMOKE_TEST_EMAIL, SMOKE_TEST_CODE.

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

This PR adds a tightly-scoped production smoke-auth bypass so the post-deploy Playwright suite can sign in as one fixed account without email polling. The implementation is correct and the security properties hold: all three conditions must be simultaneously true (Production env + matching email + both secrets non-empty), the bypass only seeds the cache — the verify path is unchanged so FixedTimeEquals, attempt counting, and HTTP-level distributed rate limiting all remain in force. Fail-safe closed when secrets are unset, no secrets logged, and the 6 new test cases cover both the bypass path and all four ways the bypass must stay inert (wrong code, wrong email, non-production env, missing secret).

@thomasluizon thomasluizon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code Review: PR #217 (orbit-api)

Scope: PR #217 — prod-gated smoke-auth bypass for post-deploy smoke (#227)
Recommendation: APPROVE

Summary

Adds a Production-only sibling to the existing non-prod TEST_ACCOUNTS mechanism: when
SendCode is called for the pinned SMOKE_TEST_EMAIL in Production with both smoke secrets
set, it seeds SMOKE_TEST_CODE into the verification cache (and skips the email) so the
smoke suite can authenticate without a real inbox. The diff is two files (the handler + 6
tests). The gating is tight and fail-safe-closed; the submitted-code proof-of-knowledge
check is unchanged. No Critical/High/Medium. (This is the most security-sensitive PR in the
batch — every gate was verified line-by-line and independently by the security subagent.)

Findings

Critical / High / Medium

None.

Low / Info

[LOW] Bypass safety is delegated entirely to SMOKE_TEST_CODE entropy — code imposes no minimum strength.
· dimension: Security (Insecure design / defense-in-depth)
· location: src/Orbit.Application/Auth/Commands/SendCodeCommand.cs:73-91
· issue: The verify path clamps brute force to 3 guesses / 15 min per account
  (MaxVerificationAttempts, dominating the 10/min auth rate limit). Safe IF SMOKE_TEST_CODE is
  high-entropy. Nothing in code rejects a weak value — a 6-digit OTP look-alike would be
  guessable, a targeted low-entropy value crackable.
· risk: A fat-fingered/weak SMOKE_TEST_CODE in prod env collapses the bypass to a guessable seam.
· fix (load-bearing, operational): set SMOKE_TEST_CODE as a ≥128-bit random string (GUID /
  `openssl rand`, NOT an OTP look-alike), store in the prod secret manager, rotate per run/short
  schedule; scope SMOKE_TEST_EMAIL to a dedicated throwaway account with no elevated entitlements.
· fix (optional code hardening, defense-in-depth): in TrySeedProductionSmokeCode, also
  `return false` when smokeCode.Length is below a threshold (e.g. 16), so a weak value fails
  closed rather than shipping a weak bypass. Offered, not applied — see note below.
[INFO] Send-code latency distinguishes the pinned email (seeded path skips RNG + SMTP). Reveals WHICH
account is pinned, not the code; the auth-guarding verify comparison is constant-time (FixedTimeEquals)
and unchanged. No auth impact.
[INFO] This introduces a standing prod-bypass primitive that lies dormant until SMOKE_TEST_* env vars
are set — so the security boundary becomes "who can write prod env vars." Acceptable given the gating,
worth noting as a blast-radius shift.

Why it's sound (verified line-by-line + by the security subagent)

  • Seeding ≠ bypassing the code check. TrySeedProductionSmokeCode only writes verify:{email}. Auth still flows through the unchanged VerifyCodeCommandHandler.ValidateCode, which compares the submitted code to entry.Code with CryptographicOperations.FixedTimeEquals and only then succeeds. An attacker must still produce SMOKE_TEST_CODE. Test Production_PinnedEmail_WrongSubmittedCode_VerifyFails locks this in.
  • Fail-safe-closed. SendCodeCommand.cs:78-79 returns false if SMOKE_TEST_EMAIL OR SMOKE_TEST_CODE is empty (prod's current state → pure no-op); :81-82 returns false on email mismatch. Only env==Production AND both secrets set AND email==pinned seeds and short-circuits. Test Production_UnsetSecret_NoBypass_SendsEmail asserts the default-deny.
  • Seeded Attempts:0 is inert. The verify path reads only entry.Code; the failed-attempt counter lives in a separate verify-attempts:{email} key — so re-calling send-code can't reset the brute-force counter. No amplification.
  • Non-prod TEST_ACCOUNTS preserved byte-identical — only the guard was inverted (if (!Production){…}if (Production){TrySeed} else {…TEST_ACCOUNTS…}); the inner block is unchanged. In Production, TEST_ACCOUNTS is never consulted. Test NonProduction_PinnedEmail_NoBypass_SendsEmail confirms.
  • No secret leakage. The handler logs nothing; downstream auth logging records only the email, never the code.
  • Reachable via AuthController.SendCode/SendCodeOperation + OAuthController.SendCode — all carry [DistributedRateLimit("auth")] and converge on the same code-gated verify; no seed-and-skip shortcut.

Subagents

Agent Verdict
security-reviewer PASS (worktree-accurate) — gating fail-safe-closed; submitted-code check unchanged + constant-time; brute force clamped to 3/15-min; no secret leak; TEST_ACCOUNTS move byte-identical. R1 Low + R2/R3 Info above.
contract-aligner / parity / i18n N/A (backend-only; no DTO/shared/UI change)

Validation

Check Result
Build (api) PASS (0 errors)
Tests PASS — 50/50 auth, incl. the 6 new bypass tests covering fires / wrong-code-fails / correct-code-works / different-email / non-prod / unset-secret
Comment policy (ORBIT0001) PASS
Backward-compat N/A (no DTO/shared change → no old-client risk)

Recommendation

APPROVE. The in-code gating is correct and fail-safe-closed; the load-bearing control is
operational — set a high-entropy SMOKE_TEST_CODE + SMOKE_TEST_EMAIL in the prod secret
manager (in the manual-infra checklist). The optional length-guard (R1) is offered but not
applied
, since this bypass is yours to scrutinize — say the word and I'll add it. Deploy this
PR before pointing the smoke suite (ui#253) at prod, so the smoke account can authenticate.
Closes #227 (via the paired ui#253).

…closed

The prod smoke-auth bypass's safety is delegated to SMOKE_TEST_CODE entropy. A
fat-fingered or weak value (e.g. a 6-digit OTP look-alike) would collapse the
bypass to the 3-guess/15-min brute-force ceiling. Require >= 16 chars; below that,
seeding is skipped and the normal random-code OTP flow runs (fail closed). Test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

The second commit adds exactly the hardening the owner's prior review surfaced as recommended-but-unapplied: a MinSmokeCodeLength = 16 guard that makes TrySeedProductionSmokeCode return false (fail-closed) if the operator sets a weak code. Guard ordering is correct — the null/empty check at line 80 fires before .Length is called at line 83, so no null-deref is possible. The bypass path for a valid (≥16 char) code is unchanged and confirmed by the existing tests. The new test Production_ShortSmokeCode_NoBypass_SendsEmail correctly asserts that a 5-char smoke code causes the normal random-OTP + email flow to run. No high-signal issues found in either commit.

@sonarqubecloud

Copy link
Copy Markdown

thomasluizon added a commit that referenced this pull request Jun 23, 2026
#218) (#214)

* feat(api): Redis-backed IDistributedCache for user-date preferences (#217)

Move the user-date-preferences cache in UserDateService off the per-instance
IMemoryCache and onto the framework IDistributedCache seam, so a second API
instance never serves a stale timezone or week-start preference. The backing
store is selected by a rollout flag: with Redis disabled (default) an in-process
IDistributedCache is registered and behavior is unchanged; with it enabled the
same seam is backed by Redis (Render Key-Value). Preferences serialize as JSON
with the existing 15-minute TTL, and a preference change still invalidates the
entry. Only the user-prefs cache moves; the AI-content IMemoryCache caches are
unrelated and stay as-is.

Closes thomasluizon/orbit-ui-mobile#217

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): durable Hangfire job queue for recurring schedulers (#218)

Add a durable, flag-gated path for the recurring background schedulers so a
restart no longer drops in-flight work, two instances no longer double-run a
scan, and a transient failure retries with backoff instead of waiting a full
interval. With BackgroundServices:UseDurableQueue off (default) every scheduler
runs as its existing in-process BackgroundService polling loop, unchanged. With
it on, the ten recurring scans are registered as Hangfire recurring jobs backed
by PostgreSQL (reusing ConnectionStrings:DefaultConnection, separate Hangfire
schema): occurrences persist across restarts, Hangfire's distributed lock keeps
a single instance per occurrence, and failed runs retry with exponential
backoff. The in-process loops are not registered in that mode, so the two paths
never run at once.

Each recurring scheduler now implements IScheduledJob (name + cron + RunAsync)
delegating to its existing scan; a single ScheduledJobRunner is the one Hangfire
entry point (storage persists only the job name), and HangfireRecurringJobRegistrar
reconciles the schedule on startup. The one-shot DataEncryptionMigrationService
stays a hosted service in both modes. PostgreSQL was chosen over Redis for the
queue store to keep job state transactional with the domain data already in
Postgres and avoid a second durability-critical dependency.

Storage/queue decision and chosen backing store are documented in the PR. Also
carries the #217 distributed-cache DI/config wiring in the shared bootstrap
files (ServiceCollectionExtensions, appsettings, csproj).

Closes thomasluizon/orbit-ui-mobile#218

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(health): record durable-mode health ticks in each scheduler RunAsync

In durable mode (UseDurableQueue=true) schedulers run as Hangfire jobs via
RunAsync, not the in-process ExecuteAsync loop, so BackgroundServiceHealthCheck
never recorded a tick and the check reported Healthy forever even if Hangfire was
dead (a never-ticked service is not flagged stale). Each RunAsync now records its
tick with the same PascalCase key its ExecuteAsync loop uses (matching
ExpectedIntervals); the bot-suggested Name is the kebab job id and would not match
the health-check keys.

Addresses the PR-review bot's change request. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): monitor CalendarAutoSync + make date-pref invalidation async

- Add CalendarAutoSync to BackgroundServiceHealthCheck.ExpectedIntervals: it records
  a tick in both modes but was never evaluated, so a stall went unnoticed (dead tick).
- IUserDateService.InvalidateUserDatePreferences was sync-over-async on IDistributedCache
  (Redis): IDistributedCache.Remove blocks a thread-pool thread on a network round-trip.
  Make it InvalidateUserDatePreferencesAsync returning Task, call RemoveAsync, and await
  it in SetTimezone/SetWeekStartDay.

Addresses the PR-review bot's two change requests. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon
thomasluizon merged commit b4cb35f into main Jun 23, 2026
6 checks passed
@thomasluizon
thomasluizon deleted the feature/phase2-prod-smoke branch June 23, 2026 18:34
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