feat(auth): per-request backend JWT + apiFactory wiring (#865)#925
feat(auth): per-request backend JWT + apiFactory wiring (#865)#925piyalbasu wants to merge 11 commits into
Conversation
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-4d0ae7733dc155d1c256 (SDF collaborators only — install instructions in the release description) |
|
Extension parity: this ports two extension PRs:
|
f5cb136 to
e169e8e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e169e8e05d
ℹ️ 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".
342d60a to
9f4ec75
Compare
e169e8e to
f911796
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f911796684
ℹ️ 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".
80f48f7 to
814c2a4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b0e1e3fbdd
ℹ️ 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".
…ckendV2 (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…401 retry fires (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…for correct JWT signing (#865)
… retry for anonymous 401s (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…JWT bodyHash always matches (#865)
…path contract doc (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
b0e1e3f to
2cd79e6
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0c5e5851dc
ℹ️ 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".
| const entries = Object.entries(params as Record<string, unknown>); | ||
| if (entries.length === 0) return url; | ||
| qs = new URLSearchParams( | ||
| entries.map(([k, v]) => [k, String(v)] as [string, string]), |
There was a problem hiding this comment.
Skip nullish params before folding
When an unlocked request supplies optional Axios params, this conversion serializes null and undefined as literal strings before deleting config.params; Axios normally omits those values. For a call such as freighterBackendV2.get('/items', { params: { cursor: undefined } }), the authenticated path now sends and signs ?cursor=undefined, while the locked path still omits it, so backend filters/pagination can change depending on auth state. Filter nullish entries before building the merged query.
Useful? React with 👍 / 👎.
TL;DR
Closes #865. Builds on #864: attaches a short-lived, per-request auth token to every Freighter Mobile call to
freighter-backend-v2, so the backend can attribute requests to a user. When the wallet is unlocked the request carries the token; when locked it goes out anonymously (the backend is permissive today, so nothing breaks). If a token is rejected as expired, the request is retried once with a fresh one. This mirrors the extension's per-request JWT work and produces the same token contract (only the issuer differs).Stacked on #920 (#864) — review/merge that first. This PR's base is the
feat/864branch.Implementation details (for agents)
What changed:
src/services/auth/buildAuthJwt.ts(new) — pure, synchronous EdDSA (alg:"EdDSA") compact JWS. Claims:sub=hex pubkey,iss="freighter-mobile",iat,exp=iat+15,bodyHash=hex SHA-256 of the raw body string (empty-input hash for GET),methodAndPath="METHOD /full/path?query". base64url, unpadded. Parity port of the extension's builder; onlyissdiffers.src/services/auth/attachAuth.ts(new) —attachAuthInterceptors(instance). Request interceptor: ifgetAuthKeypair()(from [Mobile] Derive auth keypair from seed for backend authentication #864) returns a keypair, derive the full server path frombaseURL + url(incl./api/v1+ query, matching the server'sr.URL.RequestURI()), build the JWT over(method, path, string body), setAuthorization: Bearer …; locked → no header. Response interceptor: on 401, retry exactly once with a freshly built token (guarded by__isAuthRetry), then reject.src/services/apiFactory.ts— new optionalconfigureInstancehook, invoked on the raw axios instance before the error-normalizer interceptor is registered. This is load-bearing: axios's normalizer converts errors to a plainApiError(droppingconfig/response), so the auth interceptors must register first to see a real 401 and retry. Optional + no-op for other consumers (freighterBackendV1unaffected).src/services/backend.ts—freighterBackendV2created withconfigureInstance: attachAuthInterceptors. Migrated the two V2 POST call sites (fetchTokenPrices,fetchCollectibles) to the mandated contract: pre-serialized string body + query in the URL (not axiosparams) + explicitContent-Type. Without this, the interceptor (which runs before axios serializes) would hash an empty body and omit the query → a signed-but-wrong token that silently 401s once the backend requires auth.__tests__/services/auth/authJwt.integration.test.ts(new) — gated E2E vs stagingwhoami(skipped unlessBACKEND_V2_URL/IS_INTEGRATION_MODEset): valid→200+userId, tampered-body→401, flipped-sig→401, expired→401.Verification: full suite 2593 passing / 6 skipped (186 suites);
tsc --noEmitclean.attachAuthhas 13 tests including 3 that drive a real 401 through the full apiFactory chain (proving the retry actually fires and re-signs with a freshiat). Backend call-site tests strengthened to lock the string-body/query-in-url contract. Reviewed task-by-task plus a whole-branch review that caught the retry-ordering bug and the call-site contract gap (both fixed here).Follow-ups / out of scope: Contacts UI/CRUD (separate ticket); the backend permissive→required flip (
freighter-backend-v2#114), gated on extension + mobile adoption. The two migrated call sites are the only V2 POSTs today; a future audit should confirm any new v2 caller also passes a string body + query-in-url.