feat(auth): multi-origin viewport OAuth (localhost tunnel + reverse-proxied host) #11
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/viewport-multi-origin-auth"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Prerequisite for exposing the viewport at
reviewer.hemoglobina.storewhile keeping the existing localhost tunnel as an independent login path (operator chose 'multi-origin' over single-origin).Problem
The viewport ties OAuth (
redirect_uri, session-cookie host, SSE Origin check) to a singleSTATUS_PUBLIC_URL. Flipping it to the public host breaks localhost-tunnel login; keeping localhost breaks remote login.Approach
Select the public origin per request from an operator-configured allow-list.
STATUS_PUBLIC_URLS(comma-separated allow-list); back-compat — a loneSTATUS_PUBLIC_URL→ single-entry list.statusPublicUrlstays as primary/fallback ([0]).src/auth/select-origin.ts(pure, security-critical): derive scheme (X-Forwarded-Protoonly whentrustProxy) + host (X-Forwarded-Host/Host), match exactly against the allow-list; unmatched/forged → primary fallback (no host-header injection / open redirect). Chosen origin carried in the OAuthstate.redirect_urifrom the selected origin + stores it in state; callback rebuilds the token-exchangeredirect_urifrom the state origin, re-validated against the allow-list (tamper-safe).originAllowed→ allow-list membership.__Host-cookie unchanged (host-scoped, works per-origin).Security notes
The allow-list match is the load-bearing boundary — a forged
Host/X-Forwarded-Hostcan only ever resolve to a pre-registered origin or fall back to the primary.X-Forwarded-*is honoured only underTRUST_PROXY. Tested: forged-Host fallback, spoofed-forwarded-when-untrusted fallback, scheme-mismatch fallback, state-tamper fallback.Tests
+17 (origin selection, login→callback origin carry, tamper fallback, config parse + back-compat, SSE multi-origin); full suite 2168; typecheck clean.
Not in this PR
The infra (DNS
reviewerA-record, DO Caddy block, viewport dual-bind +STATUS_PUBLIC_URLS/TRUST_PROXYenv, OAuth-app callback URI) lands after this merges + deploys.The viewport ties OAuth (redirect_uri, cookie host, CSRF/SSE origin) to a SINGLE STATUS_PUBLIC_URL, so a deployment can only fully log in on one origin. To expose the viewport publicly via a reverse proxy (e.g. reviewer.hemoglobina.store) while KEEPING the existing direct localhost tunnel as an independent login path, the viewport now selects its public origin per request from an allow-list. - config: new STATUS_PUBLIC_URLS (comma-separated allow-list); back-compat — a lone STATUS_PUBLIC_URL yields a single-entry list. statusPublicUrl stays as the primary (= statusPublicUrls[0]) + the fallback. - select-origin.ts: pure selectPublicOrigin(req, allowList, {trustProxy}) — derives scheme (X-Forwarded-Proto only when trustProxy) + host (X-Forwarded-Host/Host), matched EXACTLY against the allow-list; any unmatched/forged value falls back to the primary (no host-header injection / open redirect). The chosen origin is carried in the OAuth state. - routes: handleLogin builds redirect_uri from the selected origin + stores it in state; handleCallback rebuilds the token-exchange redirect_uri from the state origin, re-validated against the allow-list (tamper-safe fallback). - sse-producer: originAllowed now checks membership of the origin allow-list (the viewport may answer on several origins at once). - viewport wires selectOrigin into the auth ctx + expectedOrigins into the SSE producer. __Host- cookie is unchanged (host-scoped, works per-origin). +17 tests (origin selection incl. forged-Host/spoofed-forwarded fallbacks, login→callback origin carry, tamper fallback, config parsing, SSE allow-list); full suite 2168.Show previous round
hib-pr-reviewer review — PR #11 (hib/hib-pr-reviewer)
Round 1 — head
a8c1c51e09d9, basemain, triggeropenedTL;DR: CONDITIONAL_APPROVE — kept 2 agreed findings + 1 verified unique-to-A + 1 verified unique-to-B; all minor/info, no blocking issues.
Summary
Arbitration summary
3 total findings evaluated (2 agreed, 1 unique-to-A, 1 unique-to-B).
Agreed (A ∩ B)
routes.ts:1117as URLcast — both reviewers independently flagged the same line.Readat offset 1090 confirmsreturn config.statusPublicUrl as URL;at line 1117 with no local null assertion. Kept as minor.Unique-to-A — verified KEEP
config.ts:529dead null-guard.ReadofparseStatusPublicUrl(line 456-508) confirms: for a non-emptyrawthe function either throws (all validation paths) or returns aURL;nullis only returned for!raw(line 457). The loop already.filters out empty strings (line 527), souis nevernullthere. Theu &&guard is dead code and misleads readers. Kept as info.Unique-to-B — verified KEEP
routes.ts:263origin.toString()stores the full URL string (e.g.https://example.com/), not the bare origin string. Confirmed at line 263:origin: origin.toString().resolveAllowedOriginat line 1108 then re-parses it withnew URL(stored).origininside a try/catch — a round-trip that would be unnecessary iforigin.origin(the bare scheme+host+port) were stored instead. The code is functionally correct but the extra parse + try/catch is avoidable churn. Kept as minor.No findings were dropped. Total kept: 3.
Blast Radius
The diff spans the exported auth surface (
src/auth/config.ts,src/auth/routes.ts), a new module (src/auth/select-origin.ts), the viewport entry-point (bin/svelte-viewport.ts), and the SSE producer. Changes toAuthConfigandAuthContextinterfaces propagate to every consumer that constructs or reads those types. Impact is bounded to the auth subsystem but the interface changes are exported.BLAST_SCORE: 5/10
Risk Indicators
resolveAllowedOrigin,selectPublicOrigin,handleCallback,handleLogin,buildCallbackUri,parseStatusPublicUrls,parseStatusPublicUrlCI status (head
a8c1c51e09d9)Overall: ✓ success
3 checks: 3 pending
Findings (3)
[MINOR] Unchecked
as URLcast inresolveAllowedOrigin— relies on undocumented caller invariantsrc/auth/routes.ts:1117
config.statusPublicUrlis typedURL | null. The cast is safe at the current callsite becausehandleCallbackguardsstatusPublicUrl !== nullupstream, butresolveAllowedOrigincarries no local assertion, so TypeScript will type-check cleanly even if a future caller omits that guard — the runtime would then returnnulland cause aTypeErrorinsidebuildCallbackUri. Fix with an explicit guard:This removes the cast and makes TypeScript enforce the invariant rather than suppress it.
[MINOR] State field
originstores a full URL string, not a bare origin string — unnecessary parse round-trip in callbacksrc/auth/routes.ts:263
The field is named
originbut holds the full URL (trailing slash included).resolveAllowedOriginmust therefore recover the origin with a try/catchnew URL(stored).origincall before the allow-list lookup. Storing the bare origin string eliminates the round-trip and makes the intent unambiguous:With this change
resolveAllowedOriginsimplifies to a direct===comparison:No URL re-parse, no try/catch needed.
[INFO] Dead null-guard on
uinparseStatusPublicUrlsloopsrc/auth/config.ts:529
partis guaranteed non-empty by the preceding.filter((s) => s !== ""). For non-empty inputs,parseStatusPublicUrleither throws (invalid URL, wrong scheme, userinfo, query/fragment present) or returns aURL— the onlyreturn nullpath isif (!raw) return nullat line 457, which is unreachable here. Theu &&truthiness guard is therefore dead code and suggests to future readers that invalid entries might be silently skipped rather than surfacing a startup error. Remove it:Verdict
CONDITIONAL_APPROVE
hib-pr-reviewer • round 1 • 3 findings (2m/1i) • 2026-06-03T10:32:17.326Z → 2026-06-03T10:37:39.523Z • posted-as: pr-reviewer-bot • model: auto
hib-pr-reviewer review — PR #11 (hib/hib-pr-reviewer)
Round 2 — head
3ca1bbc35a90, basemain, triggersynchronizeTL;DR: CONDITIONAL_APPROVE — verified 1 unique-to-A minor finding (property-narrowing loss across await at line 257); Reviewer B returned 0 findings. No blocking issues.
Summary
Arbitration — Round 2
Reviewer A: 1 minor finding (null-guard pattern inconsistency in
handleLoginacrossawaitboundary).Reviewer B: 0 findings (empty JSON array, though prose notes two minor issues — the JSON governs).
Verification
Reviewer A's finding is unique-to-A and requires independent verification via
Read.What I checked (lines 219–282 of
src/auth/routes.tsat HEAD):if (ctx.config.statusPublicUrl === null)guard confirmed. TypeScript narrowsctx.config.statusPublicUrltoURLwithin this branch of control flow.await ctx.redis.eval(…)call confirmed between the guard and the later use. TypeScript does not preserve property-access narrowing acrossawaitsuspension points; onlyconst-local variables retain CFA narrowing past anawait. This is documented TypeScript behavior.const publicUrl = ctx.selectOrigin?.(req) ?? ctx.config.statusPublicUrl;confirmed.ctx.config.statusPublicUrlis read from the property after theawait. In strict TypeScript, the RHS of??is typed asURL | null, sopublicUrlisURL | null.origin: publicUrl.originconfirmed. UnderstrictNullChecks, accessing.originonURL | nullwithout a guard is a type error.handleCallback(lines 294–296) — confirmed uses the safe local-variable pattern:const primaryOrigin = ctx.config.statusPublicUrl; if (primaryOrigin === null) return;, soprimaryOriginisURLeverywhere downstream.Conclusion: Finding verified. At runtime, the null guard at line 219 does prevent execution when
statusPublicUrlis null, so there is no crash risk in the current call flow. However, TypeScript strict mode would flagpublicUrl.originas a potential null-dereference because property narrowing is lost across theawait. The inconsistency withhandleCallback's established pattern also creates a maintenance hazard — a future refactor that moves the guard or theawaitcould silently introduce a real NPE. Keeping as minor.Blast Radius
Changes are contained to
src/auth/(config parsing, routes, new select-origin module) plus wiring inbin/svelte-viewport.ts. The multi-origin allow-list is additive and back-compatible; existing single-origin deployments are unaffected. The blast radius is limited to the OAuth login/callback flow.BLAST_SCORE: 3/10
Risk Indicators
handleLogin,handleCallback,resolveAllowedOrigin,selectPublicOrigin,parsePublicUrl,parseStatusPublicUrlsCI status (head
3ca1bbc35a90)Overall: ✓ success
3 checks: 3 pending
Findings (1)
[MINOR] Property re-read after
awaitloses TypeScript narrowing; use local-variable pattern (matcheshandleCallback)src/auth/routes.ts:257
handleLoginchecksctx.config.statusPublicUrl === nullat line 219 (returning early on null), but then re-reads the same property at line 257 — after theawait ctx.redis.eval(…)suspension at lines 230-237:TypeScript's control-flow narrowing for property accesses is not preserved across
awaitboundaries (onlyconst-local variables retain narrowing past a suspension point). This means the RHS of??is still typed asURL | null, sopublicUrlisURL | null.publicUrl.originat line 264 is therefore astrictNullChecksviolation.handleCallbackalready uses the correct pattern (lines 294-296):Apply the same fix in
handleLogin, capturing before the rate-limitawait:Runtime behaviour is unchanged (the line-219 guard still prevents null execution), but TypeScript now correctly infers
publicUrl: URL, and the code matches the establishedhandleCallbackpattern, making future refactors safe.Verdict
CONDITIONAL_APPROVE
hib-pr-reviewer • round 2 • 1 finding (1m) • 2026-06-03T10:41:20.937Z → 2026-06-03T10:49:26.159Z • posted-as: pr-reviewer-bot • model: auto
Review cancelled by operator at 2026-06-03T10:52:55.453Z.