Skip to content

feat(auth): per-request backend JWT + E2E round-trip (#2770)#2877

Draft
piyalbasu wants to merge 8 commits into
feat/2769-derive-auth-keypairfrom
feat/2770-per-request-backend-jwt
Draft

feat(auth): per-request backend JWT + E2E round-trip (#2770)#2877
piyalbasu wants to merge 8 commits into
feat/2769-derive-auth-keypairfrom
feat/2770-per-request-backend-jwt

Conversation

@piyalbasu

Copy link
Copy Markdown
Contributor

TL;DR

Builds the client side of Freighter's stateless backend auth: a function that mints a fresh, short-lived signed token for each request to freighter-backend-v2, signed with the anonymous auth key derived in #2769 — so the backend can verify who's calling without the wallet ever doing a signing prompt or revealing its Stellar address. Includes a tiny request wrapper that retries once if a token is rejected, and a runnable script to prove the whole round-trip works against a local backend.

This is the piece that lets you test the auth middleware end-to-end (#2770). It's still scoped narrowly: it does not yet wire auth into the real contacts requests — that's a later ticket. Nothing user-facing changes yet.

Stacked on #2769 (PR #2876) — review/merge that first; this PR's base is feat/2769-derive-auth-keypair, so the diff here is only the #2770 work.

Design doc lives in wallet-eng-monorepo (design-docs/contact-lists/Freighter Per-Request Backend JWT Design Doc.md).

Draft: opening for early review of the JWT contract and the E2E approach.

Implementation details (for agents/reviewers)

What changed (all new, under @shared/api/helpers/ + one script):

  • buildAuthJwt.ts — mints a compact EdDSA JWS per request. Header {alg:"EdDSA",typ:"JWT"}; claims sub (lowercase-hex auth pubkey = userId), iss ("freighter-extension"), iat, exp (iat+15s), bodyHash (hex SHA-256 of raw body via crypto.subtle; empty-bytes hash for no body), methodAndPath ("<METHOD> <path>", method upper-cased, path includes query — bound to the server's r.URL.RequestURI()). Signs b64url(header).b64url(payload) with Keypair.sign(); url-safe base64, no padding. now is injectable for deterministic tests.
  • authedFetch.ts — attaches Authorization: Bearer <jwt>, builds a fresh JWT per call, and on 401 rebuilds a fresh JWT and retries exactly once. Content-Type: application/json default for non-GET (caller can override); baseUrl trailing slash normalized so the fetched URL can't diverge from the signed methodAndPath.
  • scripts/auth-e2e.ts — standalone round-trip check. Derives a keypair from a test mnemonic ([Extension] Derive auth keypair from seed for backend authentication #2769 vector → known userId) and hits GET /api/v1/auth/whoami, asserting: valid→200+matching userId; tampered bodyHash→401; flipped-signature→401; expired→401. Per-case PASS/FAIL, non-zero exit on any failure.
  • @shared/api/helpers/__tests__/buildAuthJwt.test.ts (7), authedFetch.test.ts (6) — unit tests.

Why a script (not a jest integration test): chosen deliberately for a manual end-to-end check against a locally-run backend. whoami is GET-only, so the four failure cases are mapped onto it: tampered body = a JWT carrying a non-empty bodyHash while the GET body is empty; wrong key = a flipped signature byte; expired = a backdated now.

How to run the E2E round-trip:

# in freighter-backend-v2:
make run                              # serves :3002
# in this repo:
npx --yes tsx@4 scripts/auth-e2e.ts   # BACKEND_V2_URL overridable

Runs via npx tsx by design — no committed dependency (version pinned in the script's usage comment).

Verification: yarn jest @shared/api → 10 suites / 76 tests pass under jest-fixed-jsdom (real WebCrypto). The script runs end-to-end up to the network call without a backend (derives the expected userId, then reports four FAILs + a friendly hint, exit 1). Reviewed per-task + a whole-branch review.

Notes:

  • One prior review suggestion (assert the two retry JWTs differ) was rejected: iat is whole-seconds and Ed25519 is deterministic, so within-second retries are byte-identical — that assertion would be flaky and the property is behaviorally moot. The "fresh per request" guarantee is structural (buildAuthJwt is rebuilt inside the retry).
  • The two translation.json keys in the diff are the husky i18next-scanner hook backfilling a pre-existing master gap — unrelated to this feature.

Follow-ups (out of scope): wire authedFetch into the real background contacts path; optionally promote the script to a gated/CI integration test; freighter-mobile builds the equivalent against the same contract.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

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

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