Skip to content

feat(auth): route all backend-v2 calls through the authed fetch chokepoint (#2879)#2880

Open
piyalbasu wants to merge 28 commits into
feat/2769-derive-auth-keypairfrom
feat/2879-backend-v2-authed-chokepoint
Open

feat(auth): route all backend-v2 calls through the authed fetch chokepoint (#2879)#2880
piyalbasu wants to merge 28 commits into
feat/2769-derive-auth-keypairfrom
feat/2879-backend-v2-authed-chokepoint

Conversation

@piyalbasu

@piyalbasu piyalbasu commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Routes every Freighter-extension call to freighter-backend-v2 through a single background "chokepoint" that attaches the user's per-request auth token when the wallet is unlocked, and sends anonymously otherwise (#2879). This is the client groundwork so the backend can attribute requests to a user and, eventually, require authentication.

It's non-breaking: the backend stays permissive, so old clients (and locked/onboarding states) keep working — updated clients just start including the token, which shows up in the backend's auth metrics as adoption signal. No user-facing change.

Invariant: a backend-v2 request carries the per-request JWT only when the wallet is unlocked and goes out anonymously otherwise. The mnemonic / derived auth keypair never leaves the background (only { status, body } returns to the popup) and never appears in a request body or the JWT payload beyond the pubkey sub claim.

Stacked on #2770 (PR #2877) — its base is feat/2770-per-request-backend-jwt, so this diff shows only the #2879 work. Merge order: #2876#2877 → this.

Design doc lives in wallet-eng-monorepo (design-docs/contact-lists/Freighter Backend-V2 Authed Fetch Chokepoint Design Doc.md).

Draft for review of the chokepoint architecture + the message-routing of the migrated call sites.

Implementation details (for agents/reviewers)

The chokepointextension/src/background/helpers/callBackendV2.ts: derives the auth keypair on-demand from the unlocked session mnemonic (else null → anonymous), and either delegates to authedFetch (#2770, JWT + retry-once-on-401) or does a plain fetch. Returns { status, body }.

  • Signed path correctness: INDEXER_V2_URL already includes /api/v1, so the JWT's methodAndPath is derived from the resolved URL's pathname + search (full server request-target incl. query, e.g. POST /api/v1/ledger-key/accounts?network=PUBLIC) — matching the server's r.URL.RequestURI(). Unit-tested by decoding the JWT claim.

Wiring (option A — route through the background): popup callers reach the chokepoint via a new FETCH_BACKEND_V2 message (SERVICE_TYPES + message-request.ts union + a popupMessageListener case gated by isFromExtensionPage so content scripts can't trigger authed calls). The seed never leaves the background; only { status, body } returns to the popup; the base URL is server-fixed (not an open proxy).

Migrated call sites:

  • getDiscoverDataGET /protocols (popup → message)
  • getLedgerKeyAccountsPOST /ledger-key/accounts?network= (popup → message)
  • fetchCollectiblesPOST /collectibles?network= (popup → message)
  • getTokenPrices (v2 path only) → POST /token-prices?network= (popup → message). The useV2=false path stays a direct fetch to the legacy v1 indexer. This site was added to master by feat(token-prices): migrate getTokenPrices to v2 indexer endpoint #2870 after this branch diverged, so it wasn't in the original migration; routing it fixed a TS2552 (stray INDEXER_V2_URL) and closed the "no call site builds its own fetch to INDEXER_V2_URL" gap.
  • rpc-health (account.ts) → GET /rpc-health?network= (background → direct callBackendV2 with skipAuth: true — this endpoint is never auth-gated, so it sends anonymously and skips the keypair/PBKDF2 derivation a JWT would trigger on every health check)

Notable: routing rpc-health introduced a circular import (ducks/session → account.ts → callBackendV2 → helpers/session → store → ducks/session); fixed by lazy-importing callBackendV2 inside getIsRpcHealthy. npx madge --circular confirms zero cycles remain.

Verification: yarn jest @shared/api 76/76; yarn jest extension/src/background 33 suites / 0 failed. New unit tests cover the chokepoint (unlocked/locked, POST body+Content-Type, full signed query path, non-2xx) and each migrated wrapper's message shape + mapping. Reviewed per-task + a whole-branch review (verdict: ready to merge).

Scope / follow-ups (out of scope here):

  • Backend permissive→required flip (freighter-backend-v2#114), gated on extension+mobile adoption metrics + a pre-flip audit that none of these endpoints is hit by a locked/watch-only account.
  • Mobile parity (freighter-mobile#865).
  • Perf: getTokenPrices is the first high-frequency chokepoint caller (the Account view polls it every 30s, uncached), so it now runs deriveAuthKeypair → synchronous PBKDF2 (~18ms block) per poll. Correct but worth a follow-up (short-lived in-background keypair memo, or let the poll honor cache). Tracked separately.
  • The two translation.json keys in the diff are the husky i18next-scanner hook backfilling a pre-existing master gap — unrelated.

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-2e703723f10b9da48e1f (SDF collaborators only — install instructions in the release description)

@piyalbasu piyalbasu force-pushed the feat/2770-per-request-backend-jwt branch from 65143fd to 9594e94 Compare June 29, 2026 20:29
@piyalbasu piyalbasu force-pushed the feat/2879-backend-v2-authed-chokepoint branch from 00c2a9e to 4e522b0 Compare June 29, 2026 20:30
piyalbasu and others added 11 commits June 29, 2026 21:27
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n script (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ndPath

buildAuthJwt bakes method.toUpperCase() into the methodAndPath claim, but
authedFetch sent the raw-case method on the wire. fetch only auto-uppercases
the standard verbs (GET/POST/...), not PATCH or custom methods — so a
lower-case non-standard method would leave the server's r.Method mismatching
the signed claim and yield a silent 401. Normalize the method once and use it
for both the JWT and the request. Adds a regression test.

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

The backend verifies methodAndPath against r.URL.RequestURI() (the full
path+query). authedFetch signed the caller's `path` fragment alone, but the
backend base URL (INDEXER_V2_URL) carries an "/api/v1" prefix and helpers
append the endpoint suffix — so base "<host>/api/v1" + path "/contacts" fetched
"/api/v1/contacts" while signing "/contacts", a guaranteed 401 once wired into
the real path. Derive the signed target from the final URL's pathname+search so
it always matches the wire request regardless of where the prefix lives. Adds
prefix + query-string regression tests.

Addresses Codex review (P2) on PR #2877.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@piyalbasu piyalbasu force-pushed the feat/2770-per-request-backend-jwt branch from 9594e94 to ac942ce Compare June 30, 2026 01:27
piyalbasu and others added 10 commits June 29, 2026 21:27
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…KEND_V2 (#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ccounts

Resolves TS2339 errors from the webpack pre-commit hook — the generic
defaults to the web Response type; pass { status: number; body: unknown }
so TypeScript resolves the destructure correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Thread sessionStore into getIsRpcHealthy / loadBackendSettings and swap
the direct fetch for callBackendV2 so the JWT chokepoint covers /rpc-health.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…est types (#2879)

Remove the top-level `import { callBackendV2 }` from account.ts and replace it
with a deferred dynamic import() inside getIsRpcHealthy. This breaks the
module-eval-time cycle: ducks/session → account → callBackendV2 → session.ts
→ store → ducks/session, which caused sessionSlice to resolve as undefined in
~11 background test suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@piyalbasu piyalbasu force-pushed the feat/2879-backend-v2-authed-chokepoint branch from 4e522b0 to 88c629a Compare June 30, 2026 01:27
Base automatically changed from feat/2770-per-request-backend-jwt to feat/2769-derive-auth-keypair June 30, 2026 20:26
getTokenPrices' v2 branch still did a raw fetch to ${INDEXER_V2_URL}/token-prices
(added to master by #2870 after this branch diverged), while this branch had
already dropped the INDEXER_V2_URL import from internal.ts — breaking the build
(TS2552: Cannot find name 'INDEXER_V2_URL') and leaving a backend-v2 call site
outside the chokepoint, contrary to the ticket's acceptance criteria.

Route the v2 path through the FETCH_BACKEND_V2 message (query-in-path so the
signed JWT's methodAndPath matches the server's request-target), matching the
other migrated call sites. The v1 (useV2=false) path stays a direct fetch to
the legacy indexer.

Contract parity with getDiscoverData: treat a 200 without a `data` payload as a
failure (throw) rather than returning undefined, and keep the response body in
the Sentry message. Rewrite the v2 tests to assert on the background message
instead of a fetch spy, and add coverage for the return value and the error
path (non-200 and 200-without-data both throw).

INDEXER_V2_URL is now referenced only by the chokepoint (callBackendV2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@piyalbasu piyalbasu marked this pull request as ready for review July 10, 2026 16:54
piyalbasu and others added 5 commits July 10, 2026 15:31
AssetDetail.test.tsx rendered an issued asset, whose domain gate awaits
getAssetDomains → getLedgerKeyAccounts. That call was migrated to the
FETCH_BACKEND_V2 background message earlier in this branch, and the test env
has no listener — so the message never resolves *or rejects* and the view is
stuck on <Loading /> (a plain fetch failure used to reject and clear the gate).

These two failures were latent on the branch, masked by the TS2552 compile
error that killed the test job before it ran; fixing the compile error
surfaced them. Stub getAssetDomains (and getTokenPrices, same message-hang
class) to resolve empty, mirroring Send.test.tsx. Full suite: 137 suites / 0
failed.

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

fetchCollectibles was migrated to the FETCH_BACKEND_V2 background message
earlier in this branch, so the /collectibles request is now made by the MV3
service worker. The collectibles e2e specs mocked it with page.route, which
only intercepts the popup page — so the mock never fired, no collectibles
rendered, and the tests timed out (addCollectible failed all 5 CI retries).

Switch the /collectibles interception to context.route (intercepts the SW),
matching how token-prices and account-history are already mocked. Fixed in the
shared stubCollectibles / stubCollectiblesUnsuccessfulMetadata helpers (now take
a context param) plus the inline overrides in addCollectible/hideCollectible.
tokenMetadata stays on page.route — it's a genuine popup fetch.

Like the AssetDetail unit failure, these were latent on the branch, masked by
the TS2552 compile error that killed the test job before e2e ran. Verified
locally: 19/19 collectible-tagged e2e tests pass (addCollectible, hideCollectible,
loadAccount, sendCollectible, reviewTxFees, sendPayment).

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

Same background-service-worker mocking gap as the collectibles fix, for the
other endpoints migrated to the FETCH_BACKEND_V2 chokepoint:

- /protocols (getDiscoverData): stubDiscoverProtocols / stubDiscoverProtocolsError
  and discover.test.ts's unroute now use page.context().route/unroute so the SW
  request is intercepted. Fixes the 3 discover CI failures.
- /token-prices (getTokenPrices, migrated in this PR): stubTokenPrices normalizes
  its Page|BrowserContext arg to the context before routing, and loadAccount's
  batching test counts calls via context.on("request") instead of page.on — the
  request is now issued by the service worker, not the popup.

Verified locally: discover.test.ts 7/7, loadAccount token-prices batching test,
and the full collectibles set all pass. (Unrelated: the freighterApiIntegration
signing tests fail only against a real backend URL locally; they pass in CI and
don't touch these endpoints.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rpc-health went through callBackendV2's authed path, so an unlocked wallet
derived the auth keypair (PBKDF2) and signed a JWT on every health check. That
endpoint will never be auth-gated, so the token is pointless work.

Add a skipAuth option to callBackendV2 that bypasses keypair derivation and
always sends an anonymous fetch, and set it on the rpc-health call. Keeps
rpc-health flowing through the single chokepoint (per the ticket) while avoiding
the JWT and its PBKDF2 cost. Test proves skipAuth never reads the session or
derives a keypair even when unlocked.

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

- getTokenPrices: the v2 comment said the chokepoint "sends anonymously
  otherwise". token-prices is only ever fetched from an unlocked wallet (a
  locked wallet shows the login screen), so it always carries the JWT — never
  anonymous. Reword to match.
- callBackendV2 skipAuth test: give the deriveAuthKeypair spy a mock impl so a
  regression fails fast instead of running real PBKDF2 before the assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread extension/src/background/messageListener/popupMessageListener.ts Outdated
Comment thread extension/src/background/helpers/callBackendV2.ts Outdated
Comment thread extension/src/background/helpers/callBackendV2.ts Outdated
Comment thread extension/src/background/helpers/callBackendV2.ts
Comment thread @shared/api/internal.ts Outdated
Resolves review feedback on #2880:

- popupMessageListener: DEV_SERVER carve-out on the FETCH_BACKEND_V2 gate. Under
  the webpack dev server the popup relays through the content script, so
  isFromExtensionPage is false and every v2 call (Discover, prices, collectibles,
  ledger-key import) returned Unauthorized in local dev. Gate stays intact in
  production (DEV_SERVER=false).
- callBackendV2: parse the response body on non-2xx too, so a server error
  payload reaches Sentry instead of being flattened to null.
- callBackendV2: captureException on unexpected key-derivation failures
  (corrupted temporaryStoreExtra / WebCrypto error) instead of silently
  downgrading to anonymous with no telemetry; corrected the catch comment.
- Extract a shared fetchBackendV2() popup helper owning the FETCH_BACKEND_V2
  message shape + typed result (incl. the { error } reply, normalized to a 401),
  replacing the ~10-line block copy-pasted across getDiscoverData,
  getTokenPrices, fetchCollectibles, and getLedgerKeyAccounts.

PBKDF2-per-request (deriveAuthKeypair on the SW thread) is deferred to #2897 as
a deliberate follow-up: derivation is deterministic, so a session-scoped
in-memory memo would be equally secure — kept out of this routing PR to avoid a
rushed keypair-lifecycle change.

New tests: JSON error-body preserved, capture-on-derive-failure, and
fetchBackendV2 error-reply normalization. Full suite 138 suites / 0 failed.

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

2 participants