feat(auth): per-request backend JWT + E2E round-trip (#2770)#2877
Conversation
|
PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-f40a2047c614474fe4b0 (SDF collaborators only — install instructions in the release description) |
6245cdd to
b6e0804
Compare
65143fd to
9594e94
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9594e947b3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
JakeUrban
left a comment
There was a problem hiding this comment.
I think codex found a bug, and I'm skeptical of the need for the retry logic in authedFetch, but other than that this LGTM.
| // Strip a trailing slash so the fetched URL can't diverge from the `path` | ||
| // baked into the JWT's methodAndPath claim (a "//api/..." vs "/api/..." split | ||
| // would be a silent 401). | ||
| const url = `${baseUrl.replace(/\/+$/, "")}${path}`; |
There was a problem hiding this comment.
Is the example provided in the comment correct? The "trailing slash" description makes it sound like what we're normalizing is /api/ vs /api.
There was a problem hiding this comment.
Good point — the original wording was muddled. Clarified in ac942ce/fae6da34. The trailing-slash strip only prevents a // when baseUrl ends in / and path starts with /; it isn't what guarantees the signed path. The actual correctness now comes from deriving the signed methodAndPath from new URL(url).pathname + search, so the signed claim and the wire request can't diverge regardless of slashes or where the /api/v1 prefix sits.
| const first = await send(); | ||
| if (first.status !== 401) return first; | ||
| return send(); |
There was a problem hiding this comment.
What are we guarding against with this retry? My understanding is that 401 is returned either because the token is invalid or the user is truly unauthorized. If the first token is invalid, why would the second not be?
There was a problem hiding this comment.
It guards the clock-skew / in-flight-expiry case from the design doc's Failure Modes table ("Client clock skew → JWT expires before reaching server → Client retries with fresh JWT on 401 → Transient; retry succeeds"). The JWT has a 15s lifetime, so a token that's valid when signed can age out between signing and the server validating it (latency, or client clock drift beyond the server's ±5s leeway). Regenerating produces a fresh iat/exp that recovers that transient case.
You're right that a token invalid for a structural reason (wrong key, bad bodyHash/methodAndPath) will 401 again on retry — that's expected, and we just surface the error after the single retry. The retry isn't meant to fix those.
The reason we retry automatically rather than rely on the caller: many of these requests will be non-interactive — e.g. the background polling of /account-balances — so there's no user in the loop to manually re-trigger after a transient clock-skew 401. The auto-retry handles it transparently. Clock skew should be rare, so the cost is an occasional extra request, capped at one.
There was a problem hiding this comment.
That being said - we could also add this as a per-endpoint configuration if we'd rather limit how often we do this
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>
…ace-script commit) (#2770)
…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>
9594e94 to
ac942ce
Compare
* docs(auth): spec for deriving backend auth keypair from seed (#2769) Design doc for the extension-side derivation primitive: HMAC-SHA256(seedBytes, "freighter-auth-v1") -> Ed25519 keypair, hex pubkey = anonymous backend user ID. Covers scope, threat model, crypto choices (crypto.subtle + stellar-sdk, zero new deps), exact algorithm, session-timeout lifecycle, verified cross-platform test vectors, and a reworded acceptance #2 (cryptographic independence, not "invalid G addr"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): HMAC auth-seed derivation + cross-platform vectors (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): declare bip39 dep, drop unused stellar-hd-wallet in @shared/api (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(auth): align spec/plan with bip39-direct derivation (jest interop) (#2769) * docs(auth): align plan Task 1 with bip39-direct implementation (#2769) * feat(auth): derive Ed25519 auth keypair + userId from seed (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(auth): mark deriveAuthSeed @internal; clarify purity test comment (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): move authKeypairVectors fixture out of __tests__ (CI test collection) (#2769) * chore(auth): keep PR code-only — spec moved to wallet-eng-monorepo, plan untracked (#2769) * fix(auth): correct fixture import path after moving it out of __tests__ (#2769) * docs(auth): note authKeypairVectors is a test fixture (PR #2876 review) * feat(auth): per-request backend JWT + E2E round-trip (#2770) (#2877) * feat(auth): per-request EdDSA JWT builder in @shared/api (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): authedFetch wrapper with retry-once-on-401 (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(auth): cover authedFetch Content-Type default + override (#2770) * feat(auth): runnable E2E script for backend JWT round-trip (#2770) * fix(auth): E2E script records per-case failures instead of aborting (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): uppercase signed method, normalize baseUrl join, pin tsx in script (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(auth): narrow JWT body to string, drop unused authedFetch headers override (#2770) * test(auth): replace E2E script with gated Playwright integration test (#2770) * test(auth): add gated Playwright auth e2e test (dropped from the replace-script commit) (#2770) * fix(auth): upper-case authedFetch wire method to match signed methodAndPath 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> * fix(auth): sign the full request target in authedFetch, not the bare 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> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): pin english wordlist, drop redundant Buffer wrap Address review feedback on deriveAuthSeed: - Pin validateMnemonic to wordlists.english instead of relying on bip39's implicit require-order default, matching the rest of the wallet's mnemonic paths (StellarHDWallet.validateMnemonic(m, "english")). - Drop the redundant Buffer.from() around mnemonicToSeedSync, which already returns a Buffer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 gated Playwright integration test that proves the whole round-trip works against a live 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 BE v2 requests — that's a later ticket. Nothing user-facing changes yet.
Implementation details (for agents/reviewers)
What changed (all new, under
@shared/api/helpers/+ a gated integration test):buildAuthJwt.ts— mints a compact EdDSA JWS per request. Header{alg:"EdDSA",typ:"JWT"}; claimssub(lowercase-hex auth pubkey = userId),iss("freighter-extension"),iat,exp(iat+15s),bodyHash(hex SHA-256 of raw body viacrypto.subtle; empty-bytes hash for no body),methodAndPath("<METHOD> <path>", method upper-cased, path includes query — bound to the server'sr.URL.RequestURI()). Signsb64url(header).b64url(payload)withKeypair.sign(); url-safe base64, no padding.nowis injectable for deterministic tests.authedFetch.ts— attachesAuthorization: Bearer <jwt>, builds a fresh JWT per call, and on401rebuilds a fresh JWT and retries exactly once. DefaultsContent-Type: application/jsonfor non-GET. Normalizes the method to upper-case on the wire so it always matches the upper-cased method baked into the JWT'smethodAndPathclaim (fetchonly auto-uppercases the standard verbs, not PATCH/custom — the mismatch would be a silent401).baseUrltrailing slash normalized so the fetched URL can't diverge from the signedmethodAndPath. (No custom-header passthrough yet — the auth middleware only readsAuthorizationand the backend enforces no requestContent-Type, so it's unneeded until a real endpoint requires it.)extension/e2e-tests/integration-tests/authJwt.test.ts— a gated Playwright integration test (request/APIRequestContextfixture, pure HTTP — no browser/extension launch). Derives a keypair from the [Extension] Derive auth keypair from seed for backend authentication #2769 vector mnemonic (known userId) and hitsGET /api/v1/auth/whoami, asserting: valid→200+matching userId; tampered bodyHash→401; flipped-signature→401; expired→401. Gated behindIS_INTEGRATION_MODE=trueviatest.skip, so it never runs in the normal stubbed e2e/CI pass.@shared/api/helpers/__tests__/buildAuthJwt.test.ts(7),authedFetch.test.ts(6) — unit tests.Why a gated Playwright test (not a jest integration test): it lives beside the other
integration-tests/, reuses therequestfixture for a real HTTP round-trip against a locally-run or staging backend, and stays skipped unlessIS_INTEGRATION_MODE=trueso CI never depends on a live backend.whoamiis GET-only, so the four cases are mapped onto it: tampered body = a JWT carrying a non-emptybodyHashwhile the GET body is empty; wrong key = a flipped signature byte; expired = a backdatednow.How to run the E2E round-trip:
Env:
BACKEND_V2_URL(defaulthttps://freighter-backend-v2-stg.stellar.org),AUTH_E2E_MNEMONIC(default: the #2769 vector mnemonic, known userId).Verification:
yarn jest @shared/api→ 10 suites / 84 tests pass underjest-fixed-jsdom(real WebCrypto). The integration test istest.skip-gated, so withoutIS_INTEGRATION_MODEit reports as skipped rather than failing. Reviewed per-task + a whole-branch review.Notes:
iatis 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 (buildAuthJwtis rebuilt inside the retry).translation.jsonkeys in the diff are the huskyi18next-scannerhook backfilling a pre-existingmastergap — unrelated to this feature.Follow-ups (out of scope): wire
authedFetchinto the real background contacts path; optionally run the gated integration test in CI against staging once stable;freighter-mobilebuilds the equivalent against the same contract.