feat(api): prod-gated smoke-auth bypass for post-deploy smoke (#227) - #217
Conversation
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>
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
TrySeedProductionSmokeCodeonly writesverify:{email}. Auth still flows through the unchangedVerifyCodeCommandHandler.ValidateCode, which compares the submitted code toentry.CodewithCryptographicOperations.FixedTimeEqualsand only then succeeds. An attacker must still produceSMOKE_TEST_CODE. TestProduction_PinnedEmail_WrongSubmittedCode_VerifyFailslocks this in. - Fail-safe-closed.
SendCodeCommand.cs:78-79returns false ifSMOKE_TEST_EMAILORSMOKE_TEST_CODEis empty (prod's current state → pure no-op);:81-82returns false on email mismatch. Only env==Production AND both secrets set AND email==pinned seeds and short-circuits. TestProduction_UnsetSecret_NoBypass_SendsEmailasserts the default-deny. - Seeded
Attempts:0is inert. The verify path reads onlyentry.Code; the failed-attempt counter lives in a separateverify-attempts:{email}key — so re-calling send-code can't reset the brute-force counter. No amplification. - Non-prod
TEST_ACCOUNTSpreserved 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. TestNonProduction_PinnedEmail_NoBypass_SendsEmailconfirms. - 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>
There was a problem hiding this comment.
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.
|
#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>



Part of #227 (backend). Pairs with the frontend PR thomasluizon/orbit-ui-mobile#253.
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
ASPNETCORE_ENVIRONMENT == "Production", ANDSMOKE_TEST_EMAIL(env), ANDSMOKE_TEST_EMAILandSMOKE_TEST_CODE(env) are set (non-empty).When it fires,
SendCodeCommandHandlerseeds the verification cache withSMOKE_TEST_CODEand skips sending email. The submitted code is still validated againstSMOKE_TEST_CODEby the unchangedVerifyCodeCommandHandler(FixedTimeEquals), so verify still enforces submitted==code, the existing[DistributedRateLimit("auth")]onverify-code/send-codestill applies, and attempt-counting is unchanged.Safety properties
SMOKE_TEST_EMAILorSMOKE_TEST_CODEis 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.)TEST_ACCOUNTSseam andResendEmailService.IsTestAccountare unchanged (theTEST_ACCOUNTSblock just moved verbatim into the non-prodelse).Why a prod bypass at all
Prod login is passwordless email OTP; the existing
TEST_ACCOUNTSdeterministic-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-entropySMOKE_TEST_CODEsecret. 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 +TrySeedProductionSmokeCodehelper.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