From 1ffc314c20574ebbad2bc2a02c9bf4432f89c5ef Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 6 Jul 2026 20:55:15 -0400 Subject: [PATCH 01/12] feat(auth): derive Ed25519 auth keypair from seed, cross-platform vectors (#864) Implements HMAC-SHA256(key=bip39-64-byte-seed, msg="freighter-auth-v1") -> Keypair.fromRawEd25519Seed -> lowercase-hex userId, matching the extension's committed vectors exactly. Adds bip39@3.1.0 as a direct dep (exact pin). Co-Authored-By: Claude Sonnet 4.6 --- .../services/auth/deriveAuthKeypair.test.ts | 54 +++++++++++++++++++ package.json | 1 + src/services/auth/authKeypairVectors.ts | 24 +++++++++ src/services/auth/deriveAuthKeypair.ts | 27 ++++++++++ yarn.lock | 1 + 5 files changed, 107 insertions(+) create mode 100644 __tests__/services/auth/deriveAuthKeypair.test.ts create mode 100644 src/services/auth/authKeypairVectors.ts create mode 100644 src/services/auth/deriveAuthKeypair.ts diff --git a/__tests__/services/auth/deriveAuthKeypair.test.ts b/__tests__/services/auth/deriveAuthKeypair.test.ts new file mode 100644 index 000000000..f494a4a98 --- /dev/null +++ b/__tests__/services/auth/deriveAuthKeypair.test.ts @@ -0,0 +1,54 @@ +import { StrKey } from "@stellar/stellar-sdk"; +import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; +import { + AUTH_SALT, + deriveAuthKeypair, + deriveAuthSeed, +} from "services/auth/deriveAuthKeypair"; +import StellarHDWallet from "stellar-hd-wallet"; + +describe("deriveAuthKeypair", () => { + it("uses the versioned salt", () => { + expect(AUTH_SALT).toBe("freighter-auth-v1"); + }); + + it.each(AUTH_KEYPAIR_VECTORS)( + "matches vector %#", + ({ mnemonic, authSeedHex, userId }) => { + expect(deriveAuthSeed(mnemonic).toString("hex")).toBe(authSeedHex); + expect(deriveAuthKeypair(mnemonic).userId).toBe(userId); + }, + ); + + it("is deterministic", () => { + const m = AUTH_KEYPAIR_VECTORS[0].mnemonic; + expect(deriveAuthKeypair(m).userId).toBe(deriveAuthKeypair(m).userId); + }); + + it("emits lowercase 64-char hex", () => { + expect(deriveAuthKeypair(AUTH_KEYPAIR_VECTORS[0].mnemonic).userId).toMatch( + /^[0-9a-f]{64}$/, + ); + }); + + it("is independent from the wallet account-0 key", () => { + const m = AUTH_KEYPAIR_VECTORS[0].mnemonic; + try { + const walletHex = StrKey.decodeEd25519PublicKey( + StellarHDWallet.fromMnemonic(m).getPublicKey(0), + ).toString("hex"); + expect(deriveAuthKeypair(m).userId).not.toBe(walletHex); + } catch { + // stellar-hd-wallet bip39 interop fallback: use verified extension value + expect(deriveAuthKeypair(m).userId).not.toBe( + "7691d85048acc4ed085d9061ce0948bbdf7de6a92b790aaf241d31b7dcaa4238", + ); + } + }); + + it("throws on an invalid mnemonic", () => { + expect(() => deriveAuthKeypair("not a valid mnemonic")).toThrow( + /invalid mnemonic/i, + ); + }); +}); diff --git a/package.json b/package.json index 4324419c1..c4a936af7 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "asyncstorage-down": "4.2.0", "axios": "1.13.2", "bignumber.js": "9.1.2", + "bip39": "3.1.0", "buffer": "4.9.2", "events": "3.3.0", "fast-text-encoding": "1.0.6", diff --git a/src/services/auth/authKeypairVectors.ts b/src/services/auth/authKeypairVectors.ts new file mode 100644 index 000000000..22d41aed7 --- /dev/null +++ b/src/services/auth/authKeypairVectors.ts @@ -0,0 +1,24 @@ +// Canonical cross-platform auth-keypair vectors — MUST match the extension's +// committed values (stellar/freighter @shared/api/helpers/authKeypairVectors.ts). +// SOURCE OF TRUTH: wallet-eng-monorepo design-docs/contact-lists/Freighter Auth Keypair Derivation Design Doc.md +export interface AuthKeypairVector { + mnemonic: string; + authSeedHex: string; + userId: string; +} +export const AUTH_KEYPAIR_VECTORS: AuthKeypairVector[] = [ + { + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + authSeedHex: + "cf8ef34afb730ffd0807ee8731f2378a4f6c702e2f14915976fac4afa711b52d", + userId: "ec57e5d04783b5ade776621ab171b3b197c3acc0a1cb5bad10786dc8e381e797", + }, + { + mnemonic: + "illness spike retreat truth genius clock brain pass fit cave bargain toe", + authSeedHex: + "882835b30a2c5b011f1b2424a84f4cd39f342f4d12be574e4233dbf9b98976d1", + userId: "bd9498475c7191c5e9a5e18edda2402ab0ae527580a6c38b2a32a77c65729cd7", + }, +]; diff --git a/src/services/auth/deriveAuthKeypair.ts b/src/services/auth/deriveAuthKeypair.ts new file mode 100644 index 000000000..0fcdf411c --- /dev/null +++ b/src/services/auth/deriveAuthKeypair.ts @@ -0,0 +1,27 @@ +// alias → react-native-crypto (pure JS; works in jest & RN) + +import { Keypair } from "@stellar/stellar-sdk"; +import { mnemonicToSeedSync, validateMnemonic } from "bip39"; +import { createHmac } from "crypto"; + +export const AUTH_SALT = "freighter-auth-v1"; + +/** 32-byte auth seed: HMAC-SHA256(key = 64-byte BIP39 seed, msg = AUTH_SALT). */ +export const deriveAuthSeed = (mnemonic: string): Buffer => { + if (!validateMnemonic(mnemonic)) { + throw new Error("Invalid mnemonic (see bip39)"); + } + const seed = mnemonicToSeedSync(mnemonic); // 64 bytes, empty passphrase + return createHmac("sha256", seed).update(AUTH_SALT).digest(); +}; + +/** + * Derives the backend auth keypair from the wallet mnemonic. Pure crypto — no + * logging, no keychain, no wallet-signing. The keypair signs per-request JWTs. + */ +export const deriveAuthKeypair = ( + mnemonic: string, +): { userId: string; keypair: Keypair } => { + const keypair = Keypair.fromRawEd25519Seed(deriveAuthSeed(mnemonic)); + return { userId: keypair.rawPublicKey().toString("hex"), keypair }; +}; diff --git a/yarn.lock b/yarn.lock index a16b73679..b9d23dee9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9980,6 +9980,7 @@ __metadata: babel-plugin-module-resolver: "npm:5.0.2" babel-plugin-styled-components: "npm:2.1.4" bignumber.js: "npm:9.1.2" + bip39: "npm:3.1.0" buffer: "npm:4.9.2" eslint: "npm:8.57.1" eslint-config-airbnb: "npm:19.0.4" From e9c0b3b61d8def6bd1ca0d45d5784047eb45f0f5 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 6 Jul 2026 21:07:57 -0400 Subject: [PATCH 02/12] test(auth): strengthen determinism + independence assertions (#864) --- .../services/auth/deriveAuthKeypair.test.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/__tests__/services/auth/deriveAuthKeypair.test.ts b/__tests__/services/auth/deriveAuthKeypair.test.ts index f494a4a98..13e3dcd9e 100644 --- a/__tests__/services/auth/deriveAuthKeypair.test.ts +++ b/__tests__/services/auth/deriveAuthKeypair.test.ts @@ -1,11 +1,9 @@ -import { StrKey } from "@stellar/stellar-sdk"; import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; import { AUTH_SALT, deriveAuthKeypair, deriveAuthSeed, } from "services/auth/deriveAuthKeypair"; -import StellarHDWallet from "stellar-hd-wallet"; describe("deriveAuthKeypair", () => { it("uses the versioned salt", () => { @@ -22,7 +20,10 @@ describe("deriveAuthKeypair", () => { it("is deterministic", () => { const m = AUTH_KEYPAIR_VECTORS[0].mnemonic; - expect(deriveAuthKeypair(m).userId).toBe(deriveAuthKeypair(m).userId); + const call1 = deriveAuthKeypair(m); + const call2 = deriveAuthKeypair(m); + expect(call1.userId).toBe(call2.userId); + expect(call1.publicKey).toBe(call2.publicKey); }); it("emits lowercase 64-char hex", () => { @@ -33,17 +34,10 @@ describe("deriveAuthKeypair", () => { it("is independent from the wallet account-0 key", () => { const m = AUTH_KEYPAIR_VECTORS[0].mnemonic; - try { - const walletHex = StrKey.decodeEd25519PublicKey( - StellarHDWallet.fromMnemonic(m).getPublicKey(0), - ).toString("hex"); - expect(deriveAuthKeypair(m).userId).not.toBe(walletHex); - } catch { - // stellar-hd-wallet bip39 interop fallback: use verified extension value - expect(deriveAuthKeypair(m).userId).not.toBe( - "7691d85048acc4ed085d9061ce0948bbdf7de6a92b790aaf241d31b7dcaa4238", - ); - } + // Verified wallet account-0 public key hex for this mnemonic + const walletAccountZeroHex = + "7691d85048acc4ed085d9061ce0948bbdf7de6a92b790aaf241d31b7dcaa4238"; + expect(deriveAuthKeypair(m).userId).not.toBe(walletAccountZeroHex); }); it("throws on an invalid mnemonic", () => { From 4379a3cc56698abaf0c4fa66cb832d903ba67cd3 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 6 Jul 2026 21:13:52 -0400 Subject: [PATCH 03/12] test(auth): fix determinism assertion to use keypair.publicKey() (#864) --- __tests__/services/auth/deriveAuthKeypair.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/services/auth/deriveAuthKeypair.test.ts b/__tests__/services/auth/deriveAuthKeypair.test.ts index 13e3dcd9e..86f5d1e50 100644 --- a/__tests__/services/auth/deriveAuthKeypair.test.ts +++ b/__tests__/services/auth/deriveAuthKeypair.test.ts @@ -23,7 +23,7 @@ describe("deriveAuthKeypair", () => { const call1 = deriveAuthKeypair(m); const call2 = deriveAuthKeypair(m); expect(call1.userId).toBe(call2.userId); - expect(call1.publicKey).toBe(call2.publicKey); + expect(call1.keypair.publicKey()).toBe(call2.keypair.publicKey()); }); it("emits lowercase 64-char hex", () => { From 56bdf0ccadccdb997363104129542b77ef249cc2 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 6 Jul 2026 21:15:39 -0400 Subject: [PATCH 04/12] feat(auth): export getActiveMnemonicPhrase accessor (#864) --- src/ducks/auth.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index a9ee1b416..405aac418 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -3106,3 +3106,18 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ signInMethod: method }); }, })); + +/** + * Returns the mnemonic phrase from the unlocked temporary store, + * or null when the wallet is locked or the store is unavailable. + */ +export const getActiveMnemonicPhrase = async (): Promise => { + try { + const store = await getTemporaryStore( + useAuthenticationStore.getState().authStatus, + ); + return store?.mnemonicPhrase ?? null; + } catch { + return null; + } +}; From ba445c29640a60be93bc695a10cefc900a5bd799 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 6 Jul 2026 21:19:41 -0400 Subject: [PATCH 05/12] feat(auth): in-memory memoized getAuthKeypair, cleared on logout (#864) Co-Authored-By: Claude Sonnet 4.6 --- .../services/auth/getAuthKeypair.test.ts | 44 +++++++++++++++++++ src/ducks/auth.ts | 3 ++ src/services/auth/getAuthKeypair.ts | 20 +++++++++ 3 files changed, 67 insertions(+) create mode 100644 __tests__/services/auth/getAuthKeypair.test.ts create mode 100644 src/services/auth/getAuthKeypair.ts diff --git a/__tests__/services/auth/getAuthKeypair.test.ts b/__tests__/services/auth/getAuthKeypair.test.ts new file mode 100644 index 000000000..139b6df09 --- /dev/null +++ b/__tests__/services/auth/getAuthKeypair.test.ts @@ -0,0 +1,44 @@ +import * as authDuck from "ducks/auth"; +import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; +import { + getAuthKeypair, + clearAuthKeypairCache, +} from "services/auth/getAuthKeypair"; + +jest.mock("ducks/auth", () => ({ getActiveMnemonicPhrase: jest.fn() })); +const mockMnemonic = authDuck.getActiveMnemonicPhrase as jest.Mock; +const M = AUTH_KEYPAIR_VECTORS[0]; + +describe("getAuthKeypair", () => { + beforeEach(() => { + clearAuthKeypairCache(); + jest.clearAllMocks(); + }); + + it("derives and memoizes (accessor called once for repeated calls)", async () => { + mockMnemonic.mockResolvedValue(M.mnemonic); + const a = await getAuthKeypair(); + const b = await getAuthKeypair(); + expect(a).not.toBeNull(); + expect(a!.rawPublicKey().toString("hex")).toBe(M.userId); + expect(b).toBe(a); // same cached instance + expect(mockMnemonic).toHaveBeenCalledTimes(1); + }); + + it("returns null when locked (no mnemonic)", async () => { + mockMnemonic.mockResolvedValue(null); + expect(await getAuthKeypair()).toBeNull(); + }); + + it("re-derives after cache clear, and on mnemonic change", async () => { + mockMnemonic.mockResolvedValue(M.mnemonic); + const a = await getAuthKeypair(); + clearAuthKeypairCache(); + mockMnemonic.mockResolvedValue(AUTH_KEYPAIR_VECTORS[1].mnemonic); + const c = await getAuthKeypair(); + expect(c!.rawPublicKey().toString("hex")).toBe( + AUTH_KEYPAIR_VECTORS[1].userId, + ); + expect(c).not.toBe(a); + }); +}); diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 405aac418..763c47dff 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -57,6 +57,7 @@ import { AppState, Keyboard } from "react-native"; import ReactNativeBiometrics from "react-native-biometrics"; import * as Keychain from "react-native-keychain"; import { analytics } from "services/analytics"; +import { clearAuthKeypairCache } from "services/auth/getAuthKeypair"; import { clearBackgroundedAt, getAutoLockTimer, @@ -2151,6 +2152,7 @@ export const useAuthenticationStore = create()((set, get) => ({ if (hasAccountList && !shouldWipeAllData) { // Don't expire hash key - preserve temporary store accessibility // Security comes from app being locked, not key expiration + clearAuthKeypairCache(); set({ account: null, @@ -2182,6 +2184,7 @@ export const useAuthenticationStore = create()((set, get) => ({ // Wipe path: clear derived key cache and use resetRoot so the auth screen is // visible before the long async cleanup begins. clearDerivedKeyCache(); + clearAuthKeypairCache(); // Capture navigationRef before ...initialState clears it to null const { navigationRef: navRef } = get(); diff --git a/src/services/auth/getAuthKeypair.ts b/src/services/auth/getAuthKeypair.ts new file mode 100644 index 000000000..0063a9679 --- /dev/null +++ b/src/services/auth/getAuthKeypair.ts @@ -0,0 +1,20 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import { getActiveMnemonicPhrase } from "ducks/auth"; +import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; + +// In-memory only — never persisted. Keyed to the mnemonic so an account switch +// / re-import misses the cache. Cleared on logout/lock (see ducks/auth logout). +let cache: { mnemonic: string; keypair: Keypair } | null = null; + +export const getAuthKeypair = async (): Promise => { + if (cache) return cache.keypair; + const mnemonic = await getActiveMnemonicPhrase(); + if (!mnemonic) return null; + const { keypair } = deriveAuthKeypair(mnemonic); + cache = { mnemonic, keypair }; + return keypair; +}; + +export const clearAuthKeypairCache = (): void => { + cache = null; +}; From 93943551a423400788d60c2a9f868dc977642bbb Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 6 Jul 2026 21:28:29 -0400 Subject: [PATCH 06/12] fix(auth): clear auth keypair cache on wallet import; simplify cache (#864) Co-Authored-By: Claude Sonnet 4.6 --- src/ducks/auth.ts | 1 + src/services/auth/getAuthKeypair.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 763c47dff..33fe0e3de 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -1103,6 +1103,7 @@ const deriveKeyPair = (params: DeriveKeypairParams) => { */ const clearAllData = async (): Promise => { clearDerivedKeyCache(); + clearAuthKeypairCache(); const allKeys = await keyManager.loadAllKeyIds(); diff --git a/src/services/auth/getAuthKeypair.ts b/src/services/auth/getAuthKeypair.ts index 0063a9679..c6bf2ce4c 100644 --- a/src/services/auth/getAuthKeypair.ts +++ b/src/services/auth/getAuthKeypair.ts @@ -2,16 +2,18 @@ import { Keypair } from "@stellar/stellar-sdk"; import { getActiveMnemonicPhrase } from "ducks/auth"; import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; -// In-memory only — never persisted. Keyed to the mnemonic so an account switch -// / re-import misses the cache. Cleared on logout/lock (see ducks/auth logout). -let cache: { mnemonic: string; keypair: Keypair } | null = null; +// In-memory only — never persisted. Freshness is guaranteed by explicit cache +// eviction: clearAuthKeypairCache() is called on logout, soft-lock, and wallet +// import/wipe (via clearAllData in ducks/auth). NOT mnemonic-keyed — sub-account +// switches share one mnemonic and do not clear the cache. +let cache: Keypair | null = null; export const getAuthKeypair = async (): Promise => { - if (cache) return cache.keypair; + if (cache) return cache; const mnemonic = await getActiveMnemonicPhrase(); if (!mnemonic) return null; const { keypair } = deriveAuthKeypair(mnemonic); - cache = { mnemonic, keypair }; + cache = keypair; return keypair; }; From 9f4ec75f6e418f7c9b75bc6b1a7241c700f2f6eb Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 8 Jul 2026 13:09:28 -0400 Subject: [PATCH 07/12] fix(auth): only derive auth keypair when AUTHENTICATED, never LOCKED/expired (#864) Co-Authored-By: Claude Sonnet 4.6 --- .../services/auth/getAuthKeypair.test.ts | 64 ++++++++++++++++++- src/services/auth/getAuthKeypair.ts | 16 ++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/__tests__/services/auth/getAuthKeypair.test.ts b/__tests__/services/auth/getAuthKeypair.test.ts index 139b6df09..47a968a0d 100644 --- a/__tests__/services/auth/getAuthKeypair.test.ts +++ b/__tests__/services/auth/getAuthKeypair.test.ts @@ -1,3 +1,4 @@ +import { AUTH_STATUS } from "config/types"; import * as authDuck from "ducks/auth"; import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; import { @@ -5,14 +6,28 @@ import { clearAuthKeypairCache, } from "services/auth/getAuthKeypair"; -jest.mock("ducks/auth", () => ({ getActiveMnemonicPhrase: jest.fn() })); +// Mock ducks/auth: expose both getActiveMnemonicPhrase and useAuthenticationStore +// with a controllable getState so we can drive the auth-status gate directly. +let mockAuthStatus: string = AUTH_STATUS.AUTHENTICATED; + +jest.mock("ducks/auth", () => ({ + getActiveMnemonicPhrase: jest.fn(), + useAuthenticationStore: { + getState: jest.fn(() => ({ authStatus: mockAuthStatus })), + }, +})); + const mockMnemonic = authDuck.getActiveMnemonicPhrase as jest.Mock; +const mockGetState = authDuck.useAuthenticationStore.getState as jest.Mock; const M = AUTH_KEYPAIR_VECTORS[0]; describe("getAuthKeypair", () => { beforeEach(() => { clearAuthKeypairCache(); jest.clearAllMocks(); + // Default: fully authenticated, so the status gate passes. + mockAuthStatus = AUTH_STATUS.AUTHENTICATED; + mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); }); it("derives and memoizes (accessor called once for repeated calls)", async () => { @@ -25,7 +40,52 @@ describe("getAuthKeypair", () => { expect(mockMnemonic).toHaveBeenCalledTimes(1); }); - it("returns null when locked (no mnemonic)", async () => { + it("returns null when LOCKED even if getActiveMnemonicPhrase would return a mnemonic (regression guard)", async () => { + // This is the real LOCKED path: fast-unlock preserved session. + // getActiveMnemonicPhrase deliberately allows LOCKED, so without the + // auth-status gate it would still yield a keypair — this test proves it + // no longer does. + mockAuthStatus = AUTH_STATUS.LOCKED; + mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + mockMnemonic.mockResolvedValue(M.mnemonic); // would succeed if gate absent + + expect(await getAuthKeypair()).toBeNull(); + // The mnemonic accessor must NOT have been called — gate fires first. + expect(mockMnemonic).not.toHaveBeenCalled(); + }); + + it("returns null when HASH_KEY_EXPIRED", async () => { + mockAuthStatus = AUTH_STATUS.HASH_KEY_EXPIRED; + mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + mockMnemonic.mockResolvedValue(M.mnemonic); + + expect(await getAuthKeypair()).toBeNull(); + expect(mockMnemonic).not.toHaveBeenCalled(); + }); + + it("returns null when NOT_AUTHENTICATED", async () => { + mockAuthStatus = AUTH_STATUS.NOT_AUTHENTICATED; + mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + mockMnemonic.mockResolvedValue(M.mnemonic); + + expect(await getAuthKeypair()).toBeNull(); + expect(mockMnemonic).not.toHaveBeenCalled(); + }); + + it("warm cache is NOT returned after transitioning to LOCKED", async () => { + // Populate cache while AUTHENTICATED. + mockMnemonic.mockResolvedValue(M.mnemonic); + const a = await getAuthKeypair(); + expect(a).not.toBeNull(); + + // Transition to LOCKED — warm cache must be bypassed. + mockAuthStatus = AUTH_STATUS.LOCKED; + mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + + expect(await getAuthKeypair()).toBeNull(); + }); + + it("returns null when AUTHENTICATED but no mnemonic available", async () => { mockMnemonic.mockResolvedValue(null); expect(await getAuthKeypair()).toBeNull(); }); diff --git a/src/services/auth/getAuthKeypair.ts b/src/services/auth/getAuthKeypair.ts index c6bf2ce4c..690602d11 100644 --- a/src/services/auth/getAuthKeypair.ts +++ b/src/services/auth/getAuthKeypair.ts @@ -1,14 +1,28 @@ import { Keypair } from "@stellar/stellar-sdk"; -import { getActiveMnemonicPhrase } from "ducks/auth"; +import { AUTH_STATUS } from "config/types"; +import { getActiveMnemonicPhrase, useAuthenticationStore } from "ducks/auth"; import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; // In-memory only — never persisted. Freshness is guaranteed by explicit cache // eviction: clearAuthKeypairCache() is called on logout, soft-lock, and wallet // import/wipe (via clearAllData in ducks/auth). NOT mnemonic-keyed — sub-account // switches share one mnemonic and do not clear the cache. +// +// Contract: returns null unless the wallet is fully AUTHENTICATED. LOCKED +// (preserved fast-unlock session) and expired/unauthenticated states always +// yield null — even when a warm cache exists — so the per-request JWT is never +// attached outside a fully-unlocked session. let cache: Keypair | null = null; export const getAuthKeypair = async (): Promise => { + // Only attach auth for a fully-unlocked (AUTHENTICATED) session. LOCKED + // (preserved fast-unlock session) and expired/unauthed states must not + // produce a keypair — and a warm cache must not be served in those states. + if ( + useAuthenticationStore.getState().authStatus !== AUTH_STATUS.AUTHENTICATED + ) { + return null; + } if (cache) return cache; const mnemonic = await getActiveMnemonicPhrase(); if (!mnemonic) return null; From 4d8e481011a15fe7b6eb47cff61a57635e190d33 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 8 Jul 2026 16:07:10 -0400 Subject: [PATCH 08/12] fix(auth): break cache cycle, gate mnemonic accessor + warm cache on live session validity, clear cache on soft-lock (#920) Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/ducks/auth.test.ts | 77 ++++++++++++++++++- .../services/auth/getAuthKeypair.test.ts | 52 +++++++------ src/ducks/auth.ts | 27 ++++++- src/services/auth/authKeypairCache.ts | 13 ++++ src/services/auth/getAuthKeypair.ts | 36 +++------ 5 files changed, 153 insertions(+), 52 deletions(-) create mode 100644 src/services/auth/authKeypairCache.ts diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 6e18cb0e0..fdda1b282 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -16,6 +16,7 @@ import { ActiveAccount, appendAccounts, clearAccountData, + isSessionAuthValid, } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useCollectiblesStore } from "ducks/collectibles"; @@ -2074,7 +2075,7 @@ describe("auth duck", () => { // Verify TTL was refreshed on the existing hash key (no re-encryption) expect(secureDataStorage.setItem).toHaveBeenCalledWith( SENSITIVE_STORAGE_KEYS.HASH_KEY, - expect.stringContaining("\"expiresAt\":"), + expect.stringContaining('"expiresAt":'), ); // Verify that re-encryption did NOT occur expect(decryptDataWithPassword).not.toHaveBeenCalled(); @@ -2356,4 +2357,78 @@ describe("auth duck", () => { expect(result.current.network).toBe(NETWORKS.PUBLIC); }); }); + + describe("isSessionAuthValid", () => { + it("returns true when AUTHENTICATED and hash key present and not expired", async () => { + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + }); + }); + (getHashKey as jest.Mock).mockResolvedValue(mockHashKeyObj); + + const result = await isSessionAuthValid(); + + expect(result).toBe(true); + }); + + it("returns false when not AUTHENTICATED (short-circuits before reading hash key)", async () => { + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.LOCKED, + }); + }); + (getHashKey as jest.Mock).mockClear(); + + const result = await isSessionAuthValid(); + + expect(result).toBe(false); + expect(getHashKey).not.toHaveBeenCalled(); + }); + + it("returns false when AUTHENTICATED but hash key absent", async () => { + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + }); + }); + (getHashKey as jest.Mock).mockResolvedValue(null); + + const result = await isSessionAuthValid(); + + expect(result).toBe(false); + }); + + it("returns false when AUTHENTICATED but hash key expired", async () => { + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + }); + }); + (getHashKey as jest.Mock).mockResolvedValue({ + ...mockHashKeyObj, + expiresAt: Date.now() - 1000, + }); + + const result = await isSessionAuthValid(); + + expect(result).toBe(false); + }); + + it("returns false when AUTHENTICATED but hash key has future generatedAt (clock rollback)", async () => { + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + }); + }); + (getHashKey as jest.Mock).mockResolvedValue({ + ...mockHashKeyObj, + generatedAt: Date.now() + 100_000, + }); + + const result = await isSessionAuthValid(); + + expect(result).toBe(false); + }); + }); }); diff --git a/__tests__/services/auth/getAuthKeypair.test.ts b/__tests__/services/auth/getAuthKeypair.test.ts index 47a968a0d..68e2f4745 100644 --- a/__tests__/services/auth/getAuthKeypair.test.ts +++ b/__tests__/services/auth/getAuthKeypair.test.ts @@ -1,4 +1,3 @@ -import { AUTH_STATUS } from "config/types"; import * as authDuck from "ducks/auth"; import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; import { @@ -6,28 +5,23 @@ import { clearAuthKeypairCache, } from "services/auth/getAuthKeypair"; -// Mock ducks/auth: expose both getActiveMnemonicPhrase and useAuthenticationStore -// with a controllable getState so we can drive the auth-status gate directly. -let mockAuthStatus: string = AUTH_STATUS.AUTHENTICATED; - +// Mock ducks/auth: expose both getActiveMnemonicPhrase and isSessionAuthValid +// so we can drive the auth-status gate directly. jest.mock("ducks/auth", () => ({ getActiveMnemonicPhrase: jest.fn(), - useAuthenticationStore: { - getState: jest.fn(() => ({ authStatus: mockAuthStatus })), - }, + isSessionAuthValid: jest.fn(), })); const mockMnemonic = authDuck.getActiveMnemonicPhrase as jest.Mock; -const mockGetState = authDuck.useAuthenticationStore.getState as jest.Mock; +const mockIsSessionAuthValid = authDuck.isSessionAuthValid as jest.Mock; const M = AUTH_KEYPAIR_VECTORS[0]; describe("getAuthKeypair", () => { beforeEach(() => { clearAuthKeypairCache(); jest.clearAllMocks(); - // Default: fully authenticated, so the status gate passes. - mockAuthStatus = AUTH_STATUS.AUTHENTICATED; - mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + // Default: session is fully valid. + mockIsSessionAuthValid.mockResolvedValue(true); }); it("derives and memoizes (accessor called once for repeated calls)", async () => { @@ -42,11 +36,10 @@ describe("getAuthKeypair", () => { it("returns null when LOCKED even if getActiveMnemonicPhrase would return a mnemonic (regression guard)", async () => { // This is the real LOCKED path: fast-unlock preserved session. - // getActiveMnemonicPhrase deliberately allows LOCKED, so without the - // auth-status gate it would still yield a keypair — this test proves it - // no longer does. - mockAuthStatus = AUTH_STATUS.LOCKED; - mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + // getActiveMnemonicPhrase now also returns null when not AUTHENTICATED + // (Fix 2), but the gate in getAuthKeypair fires first — this test proves + // the accessor is never reached when LOCKED. + mockIsSessionAuthValid.mockResolvedValue(false); mockMnemonic.mockResolvedValue(M.mnemonic); // would succeed if gate absent expect(await getAuthKeypair()).toBeNull(); @@ -55,8 +48,7 @@ describe("getAuthKeypair", () => { }); it("returns null when HASH_KEY_EXPIRED", async () => { - mockAuthStatus = AUTH_STATUS.HASH_KEY_EXPIRED; - mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + mockIsSessionAuthValid.mockResolvedValue(false); mockMnemonic.mockResolvedValue(M.mnemonic); expect(await getAuthKeypair()).toBeNull(); @@ -64,8 +56,7 @@ describe("getAuthKeypair", () => { }); it("returns null when NOT_AUTHENTICATED", async () => { - mockAuthStatus = AUTH_STATUS.NOT_AUTHENTICATED; - mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + mockIsSessionAuthValid.mockResolvedValue(false); mockMnemonic.mockResolvedValue(M.mnemonic); expect(await getAuthKeypair()).toBeNull(); @@ -79,8 +70,7 @@ describe("getAuthKeypair", () => { expect(a).not.toBeNull(); // Transition to LOCKED — warm cache must be bypassed. - mockAuthStatus = AUTH_STATUS.LOCKED; - mockGetState.mockImplementation(() => ({ authStatus: mockAuthStatus })); + mockIsSessionAuthValid.mockResolvedValue(false); expect(await getAuthKeypair()).toBeNull(); }); @@ -101,4 +91,20 @@ describe("getAuthKeypair", () => { ); expect(c).not.toBe(a); }); + + it("warm cache is NOT returned when hash key expired (session validity check fires even with warm cache)", async () => { + // Populate cache while session is valid. + mockIsSessionAuthValid.mockResolvedValue(true); + mockMnemonic.mockResolvedValue(M.mnemonic); + const a = await getAuthKeypair(); + expect(a).not.toBeNull(); + + // Simulate hash key expiry: isSessionAuthValid returns false even though + // authStatus is AUTHENTICATED in the store (the stale-authStatus window). + mockIsSessionAuthValid.mockResolvedValue(false); + + expect(await getAuthKeypair()).toBeNull(); + // Mnemonic must not have been called on the second invocation. + expect(mockMnemonic).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 33fe0e3de..cad21ee23 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -57,7 +57,7 @@ import { AppState, Keyboard } from "react-native"; import ReactNativeBiometrics from "react-native-biometrics"; import * as Keychain from "react-native-keychain"; import { analytics } from "services/analytics"; -import { clearAuthKeypairCache } from "services/auth/getAuthKeypair"; +import { clearAuthKeypairCache } from "services/auth/authKeypairCache"; import { clearBackgroundedAt, getAutoLockTimer, @@ -2272,6 +2272,7 @@ export const useAuthenticationStore = create()((set, get) => ({ suppressBiometricAutoPrompt: options?.suppressBiometricPrompt ?? false, isLoading: false, }); + clearAuthKeypairCache(); // Persist LOCKED (covers tampering + cold starts). Retry once and rethrow // on failure: a swallowed write would leave the wallet auto-unlockable on @@ -3113,9 +3114,15 @@ export const useAuthenticationStore = create()((set, get) => ({ /** * Returns the mnemonic phrase from the unlocked temporary store, - * or null when the wallet is locked or the store is unavailable. + * or null when the wallet is not fully authenticated (AUTHENTICATED), + * locked, or the store is unavailable. */ export const getActiveMnemonicPhrase = async (): Promise => { + if ( + useAuthenticationStore.getState().authStatus !== AUTH_STATUS.AUTHENTICATED + ) { + return null; + } try { const store = await getTemporaryStore( useAuthenticationStore.getState().authStatus, @@ -3125,3 +3132,19 @@ export const getActiveMnemonicPhrase = async (): Promise => { return null; } }; + +/** + * Cheap session-validity check: returns true only when the store reports + * AUTHENTICATED AND the persisted hash key is both present and not expired. + * Does NOT decrypt the temporary store — no PBKDF2 involved. + */ +export const isSessionAuthValid = async (): Promise => { + if ( + useAuthenticationStore.getState().authStatus !== AUTH_STATUS.AUTHENTICATED + ) { + return false; + } + const hashKey = await getHashKey(); + if (!hashKey) return false; + return !isHashKeyExpired(hashKey); +}; diff --git a/src/services/auth/authKeypairCache.ts b/src/services/auth/authKeypairCache.ts new file mode 100644 index 000000000..8f5b441b5 --- /dev/null +++ b/src/services/auth/authKeypairCache.ts @@ -0,0 +1,13 @@ +import { Keypair } from "@stellar/stellar-sdk"; + +// In-memory only — never persisted. Freshness enforced by explicit eviction +// (logout / soft-lock / wipe) plus the AUTHENTICATED+hash-TTL gate in getAuthKeypair. +let cache: Keypair | null = null; + +export const getCachedAuthKeypair = (): Keypair | null => cache; +export const setCachedAuthKeypair = (kp: Keypair): void => { + cache = kp; +}; +export const clearAuthKeypairCache = (): void => { + cache = null; +}; diff --git a/src/services/auth/getAuthKeypair.ts b/src/services/auth/getAuthKeypair.ts index 690602d11..cfbd68671 100644 --- a/src/services/auth/getAuthKeypair.ts +++ b/src/services/auth/getAuthKeypair.ts @@ -1,36 +1,20 @@ import { Keypair } from "@stellar/stellar-sdk"; -import { AUTH_STATUS } from "config/types"; -import { getActiveMnemonicPhrase, useAuthenticationStore } from "ducks/auth"; +import { getActiveMnemonicPhrase, isSessionAuthValid } from "ducks/auth"; +import { + getCachedAuthKeypair, + setCachedAuthKeypair, +} from "services/auth/authKeypairCache"; import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; -// In-memory only — never persisted. Freshness is guaranteed by explicit cache -// eviction: clearAuthKeypairCache() is called on logout, soft-lock, and wallet -// import/wipe (via clearAllData in ducks/auth). NOT mnemonic-keyed — sub-account -// switches share one mnemonic and do not clear the cache. -// -// Contract: returns null unless the wallet is fully AUTHENTICATED. LOCKED -// (preserved fast-unlock session) and expired/unauthenticated states always -// yield null — even when a warm cache exists — so the per-request JWT is never -// attached outside a fully-unlocked session. -let cache: Keypair | null = null; +export { clearAuthKeypairCache } from "services/auth/authKeypairCache"; export const getAuthKeypair = async (): Promise => { - // Only attach auth for a fully-unlocked (AUTHENTICATED) session. LOCKED - // (preserved fast-unlock session) and expired/unauthed states must not - // produce a keypair — and a warm cache must not be served in those states. - if ( - useAuthenticationStore.getState().authStatus !== AUTH_STATUS.AUTHENTICATED - ) { - return null; - } - if (cache) return cache; + if (!(await isSessionAuthValid())) return null; // AUTHENTICATED + hash key present & not expired (cheap) + const cached = getCachedAuthKeypair(); + if (cached) return cached; const mnemonic = await getActiveMnemonicPhrase(); if (!mnemonic) return null; const { keypair } = deriveAuthKeypair(mnemonic); - cache = keypair; + setCachedAuthKeypair(keypair); return keypair; }; - -export const clearAuthKeypairCache = (): void => { - cache = null; -}; From 994bb73589f6dc914238b08c622c71fed8fe0e58 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 8 Jul 2026 16:24:30 -0400 Subject: [PATCH 09/12] fix(auth): re-check authStatus after decrypt to close lock TOCTOU in getActiveMnemonicPhrase (#920) --- __tests__/ducks/auth.test.ts | 59 ++++++++++++++++++++++++++++++++++++ src/ducks/auth.ts | 9 ++++++ 2 files changed, 68 insertions(+) diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index fdda1b282..2942f8883 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -17,6 +17,7 @@ import { appendAccounts, clearAccountData, isSessionAuthValid, + getActiveMnemonicPhrase, } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useCollectiblesStore } from "ducks/collectibles"; @@ -2431,4 +2432,62 @@ describe("auth duck", () => { expect(result).toBe(false); }); }); + + describe("getActiveMnemonicPhrase lock race (TOCTOU)", () => { + beforeEach(() => { + // Working temporary-store decrypt (mirrors the "authentication + // mechanisms" setup) so the happy path actually yields the mnemonic — + // otherwise the race test's null would pass for the wrong reason. + (secureDataStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE) { + return Promise.resolve(mockEncryptedData); + } + if (key === SENSITIVE_STORAGE_KEYS.HASH_KEY) { + return Promise.resolve(JSON.stringify(mockHashKeyObj)); + } + return Promise.resolve(null); + }); + (decryptDataWithDerivedKey as jest.Mock).mockReturnValue( + mockTemporaryStore, + ); + }); + + it("returns the mnemonic when AUTHENTICATED throughout the decrypt", async () => { + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + }); + }); + (getHashKey as jest.Mock).mockResolvedValue(mockHashKeyObj); + + const result = await getActiveMnemonicPhrase(); + + expect(result).toBe(mockMnemonicPhrase); + }); + + it("returns null if a lock lands during the temporary-store decrypt", async () => { + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + }); + }); + // Simulate a soft-lock / auto-lock landing while getTemporaryStore is + // awaiting SecureStorage: flip the store to LOCKED during the decrypt + // path (getHashKey is awaited inside getTemporaryStore). The pre-lock + // AUTHENTICATED status was already captured, so the store still decrypts + // — but getActiveMnemonicPhrase must re-check afterward and refuse to + // return the pre-lock mnemonic (otherwise getAuthKeypair could re-cache a + // signing key after softLock cleared it). + (getHashKey as jest.Mock).mockImplementation(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.LOCKED, + }); + return Promise.resolve(mockHashKeyObj); + }); + + const result = await getActiveMnemonicPhrase(); + + expect(result).toBeNull(); + }); + }); }); diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index cad21ee23..a98c2d274 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -3127,6 +3127,15 @@ export const getActiveMnemonicPhrase = async (): Promise => { const store = await getTemporaryStore( useAuthenticationStore.getState().authStatus, ); + // Re-check after the async decrypt: a lock (soft-lock / auto-lock) may have + // landed while awaiting SecureStorage. If so, do NOT return the pre-lock + // mnemonic — otherwise getAuthKeypair could derive and re-cache a signing + // key after softLock() already cleared the cache (TOCTOU race). + if ( + useAuthenticationStore.getState().authStatus !== AUTH_STATUS.AUTHENTICATED + ) { + return null; + } return store?.mnemonicPhrase ?? null; } catch { return null; From 02693b1c66ca67c16af7d10bd7279bc74b013efd Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 9 Jul 2026 14:01:56 -0400 Subject: [PATCH 10/12] fix(auth): address review feedback on auth-keypair derivation (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getAuthKeypair: wrap body in try/catch so the accessor is total — log and return null on keychain / corrupt-hash / invalid-mnemonic errors instead of rejecting the Promise contract - getAuthKeypair: memoize the in-flight derivation so the unlock request burst (balances/prices/history) runs one PBKDF2, not one per caller - deriveAuth{Seed,Keypair}: use async mnemonicToSeed so the 2048-round PBKDF2 doesn't block the JS thread; document the crypto jest-vs-RN test gap and the deliberate direct bip39 dependency - getAuthStatus: evict the auth-keypair cache on HASH_KEY_EXPIRED transitions (retention, not just use, must end at hard-expiry — matches softLock) - getActiveMnemonicPhrase: log in the previously-silent catch - drop the clearAuthKeypairCache re-export (one canonical import path) - tests: collapse three identical gate tests into one, add concurrency and expiry-eviction regressions, update for the async API Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/ducks/auth.test.ts | 13 +++++ .../services/auth/deriveAuthKeypair.test.ts | 30 ++++++------ .../services/auth/getAuthKeypair.test.ts | 47 +++++++++---------- src/ducks/auth.ts | 17 ++++++- src/services/auth/deriveAuthKeypair.ts | 31 ++++++++---- src/services/auth/getAuthKeypair.ts | 47 +++++++++++++++---- 6 files changed, 125 insertions(+), 60 deletions(-) diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 2942f8883..65088c902 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -36,6 +36,7 @@ import { createKeyManager } from "helpers/keyManager/keyManager"; import { clearScreenshotDek } from "helpers/screenshotCrypto"; import { AppState } from "react-native"; import { getSupportedBiometryType, BIOMETRY_TYPE } from "react-native-keychain"; +import { clearAuthKeypairCache } from "services/auth/authKeypairCache"; import { clearNonSensitiveData, clearTemporaryData, @@ -149,6 +150,12 @@ jest.mock("config/logger", () => ({ }, })); +jest.mock("services/auth/authKeypairCache", () => ({ + clearAuthKeypairCache: jest.fn(), + getCachedAuthKeypair: jest.fn(() => null), + setCachedAuthKeypair: jest.fn(), +})); + jest.mock("i18next", () => ({ t: (key: string) => key, })); @@ -1785,11 +1792,17 @@ describe("auth duck", () => { expiresAt: Date.now() - ONE_HOUR_MS, }); + (clearAuthKeypairCache as jest.Mock).mockClear(); + await act(async () => { const status = await result.current.getAuthStatus(); expect(status).toBe(AUTH_STATUS.HASH_KEY_EXPIRED); }); + // Hard-expiry must evict the derived auth keypair — retention, not just + // use, ends at expiry (the eviction-coverage gap raised in review). + expect(clearAuthKeypairCache).toHaveBeenCalled(); + (AppState as { currentState: typeof previousAppState }).currentState = previousAppState; }); diff --git a/__tests__/services/auth/deriveAuthKeypair.test.ts b/__tests__/services/auth/deriveAuthKeypair.test.ts index 86f5d1e50..1d02f34fb 100644 --- a/__tests__/services/auth/deriveAuthKeypair.test.ts +++ b/__tests__/services/auth/deriveAuthKeypair.test.ts @@ -12,36 +12,38 @@ describe("deriveAuthKeypair", () => { it.each(AUTH_KEYPAIR_VECTORS)( "matches vector %#", - ({ mnemonic, authSeedHex, userId }) => { - expect(deriveAuthSeed(mnemonic).toString("hex")).toBe(authSeedHex); - expect(deriveAuthKeypair(mnemonic).userId).toBe(userId); + async ({ mnemonic, authSeedHex, userId }) => { + expect((await deriveAuthSeed(mnemonic)).toString("hex")).toBe( + authSeedHex, + ); + expect((await deriveAuthKeypair(mnemonic)).userId).toBe(userId); }, ); - it("is deterministic", () => { + it("is deterministic", async () => { const m = AUTH_KEYPAIR_VECTORS[0].mnemonic; - const call1 = deriveAuthKeypair(m); - const call2 = deriveAuthKeypair(m); + const call1 = await deriveAuthKeypair(m); + const call2 = await deriveAuthKeypair(m); expect(call1.userId).toBe(call2.userId); expect(call1.keypair.publicKey()).toBe(call2.keypair.publicKey()); }); - it("emits lowercase 64-char hex", () => { - expect(deriveAuthKeypair(AUTH_KEYPAIR_VECTORS[0].mnemonic).userId).toMatch( - /^[0-9a-f]{64}$/, - ); + it("emits lowercase 64-char hex", async () => { + expect( + (await deriveAuthKeypair(AUTH_KEYPAIR_VECTORS[0].mnemonic)).userId, + ).toMatch(/^[0-9a-f]{64}$/); }); - it("is independent from the wallet account-0 key", () => { + it("is independent from the wallet account-0 key", async () => { const m = AUTH_KEYPAIR_VECTORS[0].mnemonic; // Verified wallet account-0 public key hex for this mnemonic const walletAccountZeroHex = "7691d85048acc4ed085d9061ce0948bbdf7de6a92b790aaf241d31b7dcaa4238"; - expect(deriveAuthKeypair(m).userId).not.toBe(walletAccountZeroHex); + expect((await deriveAuthKeypair(m)).userId).not.toBe(walletAccountZeroHex); }); - it("throws on an invalid mnemonic", () => { - expect(() => deriveAuthKeypair("not a valid mnemonic")).toThrow( + it("rejects on an invalid mnemonic", async () => { + await expect(deriveAuthKeypair("not a valid mnemonic")).rejects.toThrow( /invalid mnemonic/i, ); }); diff --git a/__tests__/services/auth/getAuthKeypair.test.ts b/__tests__/services/auth/getAuthKeypair.test.ts index 68e2f4745..eba2352b7 100644 --- a/__tests__/services/auth/getAuthKeypair.test.ts +++ b/__tests__/services/auth/getAuthKeypair.test.ts @@ -1,9 +1,7 @@ import * as authDuck from "ducks/auth"; +import { clearAuthKeypairCache } from "services/auth/authKeypairCache"; import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; -import { - getAuthKeypair, - clearAuthKeypairCache, -} from "services/auth/getAuthKeypair"; +import { getAuthKeypair } from "services/auth/getAuthKeypair"; // Mock ducks/auth: expose both getActiveMnemonicPhrase and isSessionAuthValid // so we can drive the auth-status gate directly. @@ -34,31 +32,13 @@ describe("getAuthKeypair", () => { expect(mockMnemonic).toHaveBeenCalledTimes(1); }); - it("returns null when LOCKED even if getActiveMnemonicPhrase would return a mnemonic (regression guard)", async () => { - // This is the real LOCKED path: fast-unlock preserved session. - // getActiveMnemonicPhrase now also returns null when not AUTHENTICATED - // (Fix 2), but the gate in getAuthKeypair fires first — this test proves - // the accessor is never reached when LOCKED. + it("returns null when the session is invalid, before touching the mnemonic accessor", async () => { + // isSessionAuthValid folds LOCKED / HASH_KEY_EXPIRED / NOT_AUTHENTICATED + // into one boolean (the state→boolean mapping is covered in the duck tests). + // The gate must fire before getActiveMnemonicPhrase is ever reached. mockIsSessionAuthValid.mockResolvedValue(false); mockMnemonic.mockResolvedValue(M.mnemonic); // would succeed if gate absent - expect(await getAuthKeypair()).toBeNull(); - // The mnemonic accessor must NOT have been called — gate fires first. - expect(mockMnemonic).not.toHaveBeenCalled(); - }); - - it("returns null when HASH_KEY_EXPIRED", async () => { - mockIsSessionAuthValid.mockResolvedValue(false); - mockMnemonic.mockResolvedValue(M.mnemonic); - - expect(await getAuthKeypair()).toBeNull(); - expect(mockMnemonic).not.toHaveBeenCalled(); - }); - - it("returns null when NOT_AUTHENTICATED", async () => { - mockIsSessionAuthValid.mockResolvedValue(false); - mockMnemonic.mockResolvedValue(M.mnemonic); - expect(await getAuthKeypair()).toBeNull(); expect(mockMnemonic).not.toHaveBeenCalled(); }); @@ -107,4 +87,19 @@ describe("getAuthKeypair", () => { // Mnemonic must not have been called on the second invocation. expect(mockMnemonic).toHaveBeenCalledTimes(1); }); + + it("dedupes concurrent first-derivations (accessor called once under a stampede)", async () => { + mockMnemonic.mockResolvedValue(M.mnemonic); + // Several callers race before the first derivation resolves (the + // balances/prices/history burst on unlock). + const [a, b, c] = await Promise.all([ + getAuthKeypair(), + getAuthKeypair(), + getAuthKeypair(), + ]); + expect(a).not.toBeNull(); + expect(b).toBe(a); + expect(c).toBe(a); + expect(mockMnemonic).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index a98c2d274..330cd53ca 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2818,6 +2818,9 @@ export const useAuthenticationStore = create()((set, get) => ({ authStatus === AUTH_STATUS.HASH_KEY_EXPIRED) ) { set({ authStatus, isSoftLocked: false }); + // Session is no longer authenticated — evict the derived auth keypair so + // private-key material never outlives the session (as softLock does). + clearAuthKeypairCache(); if (authStatus === AUTH_STATUS.HASH_KEY_EXPIRED) { get().navigateToLockScreen(); } @@ -2826,8 +2829,10 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ authStatus }); - // If the hash key is expired, navigate to lock screen + // If the hash key is expired, evict the cached auth keypair (retention, not + // just use, must end at hard-expiry) and navigate to lock screen. if (authStatus === AUTH_STATUS.HASH_KEY_EXPIRED) { + clearAuthKeypairCache(); get().navigateToLockScreen(); } @@ -3137,7 +3142,15 @@ export const getActiveMnemonicPhrase = async (): Promise => { return null; } return store?.mnemonicPhrase ?? null; - } catch { + } catch (error) { + // Unreachable today (getTemporaryStore is itself total), but keep the guard + // so a future throwing edit surfaces in Sentry instead of masquerading as a + // normal locked session with an empty breadcrumb trail. + logger.error( + "getActiveMnemonicPhrase", + "Failed to read active mnemonic", + error, + ); return null; } }; diff --git a/src/services/auth/deriveAuthKeypair.ts b/src/services/auth/deriveAuthKeypair.ts index 0fcdf411c..ce21a1b6b 100644 --- a/src/services/auth/deriveAuthKeypair.ts +++ b/src/services/auth/deriveAuthKeypair.ts @@ -1,17 +1,30 @@ -// alias → react-native-crypto (pure JS; works in jest & RN) - +// `crypto` resolves to react-native-crypto on device (package.json +// browser/react-native field maps it) and to Node's built-in crypto under Jest +// (jest.config.js has no override). Both implement HMAC-SHA256 per RFC 2104, so +// the cross-platform vectors below pin the *algorithm*; they do NOT exercise the +// react-native-crypto code path. A device-side vector assertion (PR #865's gated +// E2E) is what proves the on-device library agrees byte-for-byte. +// +// bip39 is a direct dependency on purpose: (1) parity with the extension, which +// derives from `bip39` directly (stellar/freighter#2876), and (2) we need its +// async `mnemonicToSeed` + `validateMnemonic` here. It is pinned to the same +// exact version stellar-hd-wallet resolves transitively. import { Keypair } from "@stellar/stellar-sdk"; -import { mnemonicToSeedSync, validateMnemonic } from "bip39"; +import { mnemonicToSeed, validateMnemonic } from "bip39"; import { createHmac } from "crypto"; export const AUTH_SALT = "freighter-auth-v1"; -/** 32-byte auth seed: HMAC-SHA256(key = 64-byte BIP39 seed, msg = AUTH_SALT). */ -export const deriveAuthSeed = (mnemonic: string): Buffer => { +/** + * 32-byte auth seed: HMAC-SHA256(key = 64-byte BIP39 seed, msg = AUTH_SALT). + * Async so the PBKDF2-HMAC-SHA512 (2048-iteration) seed derivation does not + * block the JS thread. + */ +export const deriveAuthSeed = async (mnemonic: string): Promise => { if (!validateMnemonic(mnemonic)) { throw new Error("Invalid mnemonic (see bip39)"); } - const seed = mnemonicToSeedSync(mnemonic); // 64 bytes, empty passphrase + const seed = await mnemonicToSeed(mnemonic); // 64 bytes, empty passphrase return createHmac("sha256", seed).update(AUTH_SALT).digest(); }; @@ -19,9 +32,9 @@ export const deriveAuthSeed = (mnemonic: string): Buffer => { * Derives the backend auth keypair from the wallet mnemonic. Pure crypto — no * logging, no keychain, no wallet-signing. The keypair signs per-request JWTs. */ -export const deriveAuthKeypair = ( +export const deriveAuthKeypair = async ( mnemonic: string, -): { userId: string; keypair: Keypair } => { - const keypair = Keypair.fromRawEd25519Seed(deriveAuthSeed(mnemonic)); +): Promise<{ userId: string; keypair: Keypair }> => { + const keypair = Keypair.fromRawEd25519Seed(await deriveAuthSeed(mnemonic)); return { userId: keypair.rawPublicKey().toString("hex"), keypair }; }; diff --git a/src/services/auth/getAuthKeypair.ts b/src/services/auth/getAuthKeypair.ts index cfbd68671..386a41963 100644 --- a/src/services/auth/getAuthKeypair.ts +++ b/src/services/auth/getAuthKeypair.ts @@ -1,4 +1,5 @@ import { Keypair } from "@stellar/stellar-sdk"; +import { logger } from "config/logger"; import { getActiveMnemonicPhrase, isSessionAuthValid } from "ducks/auth"; import { getCachedAuthKeypair, @@ -6,15 +7,43 @@ import { } from "services/auth/authKeypairCache"; import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; -export { clearAuthKeypairCache } from "services/auth/authKeypairCache"; +// Dedupe concurrent first-derivations: the balances / prices / history calls +// that fire on unlock would otherwise each run a full PBKDF2 derivation. Shared +// while in flight, then dropped — the warm cache serves everything after. +let inFlight: Promise | null = null; +/** + * Returns the session's backend auth keypair, deriving and caching it on first + * use. Total: never throws — any failure (keychain read, corrupt hash key, + * invalid mnemonic) is logged and surfaced as `null` so request-layer callers + * can fall back to unauthenticated rather than face an unhandled rejection. + */ export const getAuthKeypair = async (): Promise => { - if (!(await isSessionAuthValid())) return null; // AUTHENTICATED + hash key present & not expired (cheap) - const cached = getCachedAuthKeypair(); - if (cached) return cached; - const mnemonic = await getActiveMnemonicPhrase(); - if (!mnemonic) return null; - const { keypair } = deriveAuthKeypair(mnemonic); - setCachedAuthKeypair(keypair); - return keypair; + try { + // Cheap live-session gate (AUTHENTICATED + hash key present & not expired). + // Runs on every path as defense-in-depth: it is the only guard on the warm + // cache, and on the cold path it fails fast before the temp-store decrypt. + if (!(await isSessionAuthValid())) return null; + + const cached = getCachedAuthKeypair(); + if (cached) return cached; + + if (!inFlight) { + inFlight = (async () => { + const mnemonic = await getActiveMnemonicPhrase(); + if (!mnemonic) return null; + const { keypair } = await deriveAuthKeypair(mnemonic); + setCachedAuthKeypair(keypair); + return keypair; + })(); + } + try { + return await inFlight; + } finally { + inFlight = null; + } + } catch (error) { + logger.error("getAuthKeypair", "Failed to derive auth keypair", error); + return null; + } }; From e0b74af34bb7f9eecf8d4b41538c97e7bf92f778 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 9 Jul 2026 16:49:52 -0400 Subject: [PATCH 11/12] fix(auth): route isSessionAuthValid through the getAuthStatus funnel (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isSessionAuthValid re-implemented a subset of the lock rules (in-memory authStatus + hash-key TTL) and omitted the auto-lock timer, so in the window after foreground but before the getAuthStatus() funnel runs it could report a session valid after auto-lock should have engaged — letting getAuthKeypair hand out the backend signing key. Delegate to the authoritative getAuthStatus() funnel instead, so the check evaluates account existence, persisted LOCKED, hash-key hard-expiry AND the auto-lock timer from one source of truth, removing the drift that caused the gap. Reworked the isSessionAuthValid tests to drive the real funnel via storage mocks and added an auto-lock-timer regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/ducks/auth.test.ts | 137 ++++++++++++++++++++--------------- src/ducks/auth.ts | 23 +++--- 2 files changed, 90 insertions(+), 70 deletions(-) diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 65088c902..6f877506f 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -2373,76 +2373,99 @@ describe("auth duck", () => { }); describe("isSessionAuthValid", () => { - it("returns true when AUTHENTICATED and hash key present and not expired", async () => { - act(() => { - useAuthenticationStore.setState({ - authStatus: AUTH_STATUS.AUTHENTICATED, - }); + // isSessionAuthValid now delegates to the authoritative getAuthStatus() + // funnel, so these drive the real computation via storage mocks (one source + // of truth) rather than the in-memory authStatus + hash key alone. + const ONE_HOUR = 3_600_000; + + const mockFunnelStorage = ({ + backgroundedAt = null, + autoLockTimer = null, + hashKey = { + hashKey: "mock-hash-key", + salt: "mock-salt", + expiresAt: Date.now() + ONE_HOUR, + } as unknown, + hasTempStore = true, + hasAccount = true, + persistedLocked = false, + }: { + backgroundedAt?: number | null; + autoLockTimer?: AUTO_LOCK_TIMER | null; + hashKey?: unknown; + hasTempStore?: boolean; + hasAccount?: boolean; + persistedLocked?: boolean; + } = {}) => { + (dataStorage.getItem as jest.Mock).mockImplementation((key) => + Promise.resolve( + key === STORAGE_KEYS.ACCOUNT_LIST && hasAccount + ? JSON.stringify([mockAccount]) + : null, + ), + ); + (secureDataStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE) { + return Promise.resolve(hasTempStore ? "encrypted-temp-store" : null); + } + if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT) { + return Promise.resolve( + backgroundedAt !== null ? String(backgroundedAt) : null, + ); + } + if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING) { + return Promise.resolve(autoLockTimer); + } + if (key === SENSITIVE_STORAGE_KEYS.AUTH_STATUS) { + return Promise.resolve(persistedLocked ? AUTH_STATUS.LOCKED : null); + } + return Promise.resolve(null); }); - (getHashKey as jest.Mock).mockResolvedValue(mockHashKeyObj); - - const result = await isSessionAuthValid(); + (getHashKey as jest.Mock).mockResolvedValue(hashKey); + (secureDataStorage.remove as jest.Mock).mockResolvedValue(undefined); + (secureDataStorage.setItem as jest.Mock).mockResolvedValue(undefined); + }; - expect(result).toBe(true); + it("returns true when the funnel reports AUTHENTICATED (accounts + valid hash + within timer)", async () => { + mockFunnelStorage(); + expect(await isSessionAuthValid()).toBe(true); }); - it("returns false when not AUTHENTICATED (short-circuits before reading hash key)", async () => { - act(() => { - useAuthenticationStore.setState({ - authStatus: AUTH_STATUS.LOCKED, - }); - }); - (getHashKey as jest.Mock).mockClear(); - - const result = await isSessionAuthValid(); - - expect(result).toBe(false); - expect(getHashKey).not.toHaveBeenCalled(); + it("returns false when no accounts exist (NOT_AUTHENTICATED)", async () => { + mockFunnelStorage({ hasAccount: false }); + expect(await isSessionAuthValid()).toBe(false); }); - it("returns false when AUTHENTICATED but hash key absent", async () => { - act(() => { - useAuthenticationStore.setState({ - authStatus: AUTH_STATUS.AUTHENTICATED, - }); - }); - (getHashKey as jest.Mock).mockResolvedValue(null); - - const result = await isSessionAuthValid(); - - expect(result).toBe(false); + it("returns false when the hash key is absent and no temp store (HASH_KEY_EXPIRED)", async () => { + mockFunnelStorage({ hashKey: null, hasTempStore: false }); + expect(await isSessionAuthValid()).toBe(false); }); - it("returns false when AUTHENTICATED but hash key expired", async () => { - act(() => { - useAuthenticationStore.setState({ - authStatus: AUTH_STATUS.AUTHENTICATED, - }); - }); - (getHashKey as jest.Mock).mockResolvedValue({ - ...mockHashKeyObj, - expiresAt: Date.now() - 1000, + it("returns false when the hash key is expired", async () => { + mockFunnelStorage({ + hashKey: { + hashKey: "mock-hash-key", + salt: "mock-salt", + expiresAt: Date.now() - 1000, + }, }); + expect(await isSessionAuthValid()).toBe(false); + }); - const result = await isSessionAuthValid(); - - expect(result).toBe(false); + it("returns false when the session is persisted LOCKED", async () => { + mockFunnelStorage({ persistedLocked: true }); + expect(await isSessionAuthValid()).toBe(false); }); - it("returns false when AUTHENTICATED but hash key has future generatedAt (clock rollback)", async () => { - act(() => { - useAuthenticationStore.setState({ - authStatus: AUTH_STATUS.AUTHENTICATED, - }); - }); - (getHashKey as jest.Mock).mockResolvedValue({ - ...mockHashKeyObj, - generatedAt: Date.now() + 100_000, + it("returns false once the auto-lock timer has elapsed even though the hash key is still within its TTL (foreground-before-funnel window)", async () => { + // The Codex regression: a stale in-memory AUTHENTICATED + within-TTL hash + // key would report valid, but the funnel soft-locks on the elapsed timer. + mockFunnelStorage({ + backgroundedAt: Date.now() - 2 * ONE_HOUR, // beyond the 1h timer + autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR, + // hashKey left at the default (expiresAt = +1h, i.e. still valid) }); - - const result = await isSessionAuthValid(); - - expect(result).toBe(false); + expect(await isSessionAuthValid()).toBe(false); }); }); diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 330cd53ca..810bd5ff8 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -3156,17 +3156,14 @@ export const getActiveMnemonicPhrase = async (): Promise => { }; /** - * Cheap session-validity check: returns true only when the store reports - * AUTHENTICATED AND the persisted hash key is both present and not expired. - * Does NOT decrypt the temporary store — no PBKDF2 involved. + * Session-validity gate for backend auth-key use. Delegates to the authoritative + * `getAuthStatus()` funnel — the single source of truth for lock transitions — + * so it evaluates account existence, persisted LOCKED, hash-key hard-expiry AND + * the auto-lock timer (`backgroundedAt` + `autoLockTimer`), rather than + * re-implementing a subset. This closes the foreground-before-funnel window + * where a stale in-memory AUTHENTICATED + within-TTL hash key would otherwise + * report valid after the auto-lock timer had already elapsed. Does not decrypt + * the temporary store (no PBKDF2); it reads persisted/secure state only. */ -export const isSessionAuthValid = async (): Promise => { - if ( - useAuthenticationStore.getState().authStatus !== AUTH_STATUS.AUTHENTICATED - ) { - return false; - } - const hashKey = await getHashKey(); - if (!hashKey) return false; - return !isHashKeyExpired(hashKey); -}; +export const isSessionAuthValid = async (): Promise => + (await getAuthStatus()) === AUTH_STATUS.AUTHENTICATED; From 3bccc8cafd5907c6e406de61820ef8c52958853c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Sat, 11 Jul 2026 15:21:20 -0400 Subject: [PATCH 12/12] feat(auth): per-request backend JWT + apiFactory wiring (#865) (#925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): per-request EdDSA JWT builder for mobile (#865) * feat(auth): attach per-request JWT + retry-once-on-401 to freighterBackendV2 (#865) Co-Authored-By: Claude Sonnet 4.6 * fix(auth): register JWT interceptors before apiFactory normalizer so 401 retry fires (#865) Co-Authored-By: Claude Sonnet 4.6 * test(auth): gated E2E round-trip vs staging whoami (#865) Co-Authored-By: Claude Sonnet 4.6 * feat(auth): migrate v2 POST call sites to string body + query-in-url for correct JWT signing (#865) * test(auth): read whoami { data } envelope; E2E verified green vs staging (#865) * fix(auth): idempotent error normalizer (preserve 401 on retry) + skip retry for anonymous 401s (#865) Co-Authored-By: Claude Sonnet 4.6 * feat(auth): enforce string-only V2 request bodies at compile time so JWT bodyHash always matches (#865) * fix(auth): fold config.params into the signed JWT path; refresh body/path contract doc (#865) Co-Authored-By: Claude Sonnet 4.6 * fix(auth): strip stale Authorization header on locked/anonymous requests (#865) * fix(auth): handle URLSearchParams when folding params into signed path (#865) * perf(auth): short-TTL memo + in-flight dedup for isSessionAuthValid (#865) isSessionAuthValid delegates to the getAuthStatus() funnel (~5 keychain reads per run), and the JWT interceptor calls it on every backend request. Add an in-flight promise to collapse the near-simultaneous unlock burst (balances + prices + history) into one run, and a short-TTL (1s) value memo for sequential calls. Tradeoff documented inline: a funnel-only transition (auto-lock timer elapse, hash hard-expiry) is reflected at the gate up to the TTL late — bounded and immaterial (auto-lock windows are minutes+), and it cannot leak key material because explicit lock/logout/wipe paths clear the keypair cache and flip in-memory authStatus. Exposes clearSessionAuthValidMemo() for eviction/tests. Also await the now-async deriveAuthKeypair in the gated JWT e2e test (#920 made it async). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(auth): address review — central body serialization, clock-skew fallback (#865) Body handling (aristides): serialize object bodies centrally in the request interceptor before hashing, so bodyHash always matches the wire bytes. Fixes the prod empty-bodyHash bug (non-string body silently signed an empty hash, warned only in __DEV__). Removes the string-only contract: drop the createApiService constraint, the backendV2TypeContract test, the dev warning, and the per-call JSON.stringify + redundant Content-Type overrides (createApiService already defaults application/json). Clock skew (aristides/codex): if the signed 401-retry also 401s (likely device clock skew — re-signing from the same bad clock fails identically), fall back to one anonymous request so these endpoints (anonymous pre-PR) keep working, and log it. The fallback rate is the signal for widening the backend clock-skew leeway; tracked as a follow-up. Also: hoist the constant JWT header segment to module scope (compute once); skip null/undefined params in mergeParamsIntoUrl to match axios; collapse duplicated call-shape assertions in backend.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(api): drop the now-inert TWriteBody generic from createApiService (#865) The TWriteBody generic existed only to support the string-only body contract (V2 was createApiService). Central body serialization removed that contract, leaving the generic always defaulting to unknown. Remove it and type post/put bodies as `unknown` directly. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(auth): derive signed JWT path from axios getUri, not hand-rolled helpers (#865) Replace mergeParamsIntoUrl + deriveServerPath (and the fold-then-delete of config.params) with instance.getUri(config): axios's own baseURL-join + param serializer. The signed methodAndPath is now pathname+search of exactly what axios will put on the wire, so signed path == wire path by construction — no manual param serialization that can drift from axios (the state-dependent divergence aristides flagged), and config.params is left untouched. Tests: fake instance gains a getUri stand-in; unit param tests assert the getUri-derived path + params-preserved; the real-serialization specifics (&-join, encoding) are covered by the integration tests against real axios. Co-Authored-By: Claude Opus 4.8 (1M context) * revert(auth): drop the anonymous 401 fallback; match the extension's retry-once (#865) The extension's authedFetch retries once on 401 and returns the second 401 (no anonymous fallback). The mobile fallback was a divergence added for clock-skew resilience; on review it breaks parity, masks genuine auth failures behind a permissive-backend success, and only helps during the permissive window. Remove the __authFellBackAnon path (forced-anon request branch + response-side fallback + the logger.warn) and return the second 401 to the caller. Clock-skew handling is deferred entirely to the backend leeway (#930), same as the extension. Retry-once-then-fail is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(auth): sign the path without RN's non-spec URL; clear memo on unlock (#865) Fable review (device-vs-jest) findings: BLOCKER — deriveServerPath used `new URL(getUri).pathname`. React Native's built-in URL (0.81, Libraries/Blob/URL.js) is not WHATWG-compliant: it appends a trailing "/" to any URL with no query/hash. So on device a query-less GET (e.g. /protocols) signed "/api/v1/protocols/" while the wire target was "/api/v1/protocols" → guaranteed 401 (no fallback). jest uses Node's compliant URL, so the suite was blind to it. Replace with an origin-strip on getUri's output — environment-independent, preserves the wire target verbatim. Added a query-less regression test. - signIn clears the isSessionAuthValid memo on unlock, so the post-unlock request burst isn't served a stale `false` (would go anonymous for ≤1s). - Central body serialization narrowed to plain objects/arrays, so a future FormData/Blob body isn't corrupted to "{}" on the wire. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): redact Authorization in the logRequests debug logger (#865) The logRequests request/response debug logger JSON.stringify'd the full config; on an instance carrying per-request JWT auth (via configureInstance) that would log the Bearer token (request interceptors run LIFO, so the token is attached before the logger runs). Mask any Authorization header at any depth via a stringify replacer. Not reachable today (V2 doesn't set logRequests) — closes the secret-leak foot-gun. Added a redaction test through the real chain. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(backend): pass v2 network via { params } now that getUri signs them (#865) The two v2 POST call sites embedded the network query directly in the URL string because the pre-getUri interceptor signed the path before axios serialized config.params — so { params } would have signed a query-less path (≠ wire → 401). The getUri switch removed that constraint: the signed path is derived from instance.getUri(config), which folds params in via axios's own serializer. So migrate both to the idiomatic { params: { network } }; wire + signed output are identical. Tests updated to lock the { params } call shape. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(api): explain the `unknown` body type on post/put (#865) Document why the body is `unknown` (the TWriteBody string-only contract was removed) and that an auth-wired instance serializes object bodies centrally so the signed bodyHash matches the wire. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- __tests__/ducks/auth.test.ts | 41 + __tests__/services/api.test.ts | 6 + __tests__/services/auth/attachAuth.test.ts | 969 ++++++++++++++++++ .../services/auth/authJwt.integration.test.ts | 136 +++ __tests__/services/auth/buildAuthJwt.test.ts | 122 +++ __tests__/services/backend.test.ts | 44 +- src/ducks/auth.ts | 59 +- src/services/apiFactory.ts | 85 +- src/services/auth/attachAuth.ts | 232 +++++ src/services/auth/buildAuthJwt.ts | 50 + src/services/backend.ts | 16 +- 11 files changed, 1740 insertions(+), 20 deletions(-) create mode 100644 __tests__/services/auth/attachAuth.test.ts create mode 100644 __tests__/services/auth/authJwt.integration.test.ts create mode 100644 __tests__/services/auth/buildAuthJwt.test.ts create mode 100644 src/services/auth/attachAuth.ts create mode 100644 src/services/auth/buildAuthJwt.ts diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 6f877506f..e8e065c4d 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -17,6 +17,8 @@ import { appendAccounts, clearAccountData, isSessionAuthValid, + clearSessionAuthValidMemo, + SESSION_AUTH_VALID_TTL_MS, getActiveMnemonicPhrase, } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; @@ -2426,6 +2428,11 @@ describe("auth duck", () => { (secureDataStorage.setItem as jest.Mock).mockResolvedValue(undefined); }; + // The memo is module-scoped state that would otherwise leak across tests. + beforeEach(() => { + clearSessionAuthValidMemo(); + }); + it("returns true when the funnel reports AUTHENTICATED (accounts + valid hash + within timer)", async () => { mockFunnelStorage(); expect(await isSessionAuthValid()).toBe(true); @@ -2467,6 +2474,40 @@ describe("auth duck", () => { }); expect(await isSessionAuthValid()).toBe(false); }); + + it("dedupes a concurrent burst into a single funnel run (in-flight promise shared)", async () => { + mockFunnelStorage(); + (getHashKey as jest.Mock).mockClear(); + + const results = await Promise.all([ + isSessionAuthValid(), + isSessionAuthValid(), + isSessionAuthValid(), + ]); + + expect(results).toEqual([true, true, true]); + // getAuthStatus (→ getHashKey once per run) ran a single time for all three. + expect(getHashKey).toHaveBeenCalledTimes(1); + }); + + it("memoizes within the TTL and re-runs the funnel once it expires", async () => { + const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_000_000); + mockFunnelStorage(); + (getHashKey as jest.Mock).mockClear(); + + await isSessionAuthValid(); + await isSessionAuthValid(); + await isSessionAuthValid(); + // Second and third calls are served from the memo — one funnel run. + expect(getHashKey).toHaveBeenCalledTimes(1); + + // Advance past the TTL: the memo expires and the funnel runs again. + nowSpy.mockReturnValue(1_000_000 + SESSION_AUTH_VALID_TTL_MS + 1); + await isSessionAuthValid(); + expect(getHashKey).toHaveBeenCalledTimes(2); + + nowSpy.mockRestore(); + }); }); describe("getActiveMnemonicPhrase lock race (TOCTOU)", () => { diff --git a/__tests__/services/api.test.ts b/__tests__/services/api.test.ts index 5681a8670..037e2dad7 100644 --- a/__tests__/services/api.test.ts +++ b/__tests__/services/api.test.ts @@ -14,6 +14,12 @@ jest.mock("services/apiFactory", () => ({ createApiService: jest.fn(() => ({ get: jest.fn(), post: jest.fn(), + getInstance: jest.fn(() => ({ + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + })), })), isRequestCanceled: jest.fn(), })); diff --git a/__tests__/services/auth/attachAuth.test.ts b/__tests__/services/auth/attachAuth.test.ts new file mode 100644 index 000000000..ee33ac587 --- /dev/null +++ b/__tests__/services/auth/attachAuth.test.ts @@ -0,0 +1,969 @@ +/* eslint-disable no-underscore-dangle */ +/** + * Tests for attachAuthInterceptors helper. + * + * Strategy A (isolated handler tests): build a minimal fake AxiosInstance that + * captures the interceptor functions registered via `interceptors.request.use` + * and `interceptors.response.use`. We then invoke those functions directly to + * exercise the logic without any real HTTP calls. + * + * Strategy B (integration tests): build a REAL instance via + * `createApiService({ configureInstance: attachAuthInterceptors })` with a + * custom axios adapter so requests never hit the network. These tests prove + * that the 401-retry fires through the full interceptor chain — including + * apiFactory's error-normalizing response interceptor — which the isolated + * handler tests cannot cover because they bypass the normalizer entirely. + * + * Modules under test: + * - services/auth/attachAuth (the helper being implemented) + * - services/apiFactory (createApiService, integration only) + * + * Dependencies mocked: + * - services/auth/getAuthKeypair (getAuthKeypair) + * - services/auth/buildAuthJwt (buildAuthJwt — mocked for isolated tests, + * real implementation used for integration) + */ +import { Keypair } from "@stellar/stellar-sdk"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { debug } from "helpers/debug"; +import { createApiService, isApiError } from "services/apiFactory"; +import { attachAuthInterceptors } from "services/auth/attachAuth"; +import { buildAuthJwt } from "services/auth/buildAuthJwt"; +import { getAuthKeypair } from "services/auth/getAuthKeypair"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +jest.mock("services/auth/getAuthKeypair", () => ({ + getAuthKeypair: jest.fn(), +})); + +// apiFactory's logRequests debug logger — spy so we can assert redaction. +jest.mock("helpers/debug", () => ({ + debug: jest.fn(), +})); + +jest.mock("services/auth/buildAuthJwt", () => ({ + buildAuthJwt: jest.fn(), +})); + +// apiFactory imports config/logger; silence it in the integration tests. +jest.mock("config/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const mockGetAuthKeypair = jest.mocked(getAuthKeypair); +const mockBuildAuthJwt = jest.mocked(buildAuthJwt); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Fixed test keypair — deterministic, never changes. */ +const SEED = Buffer.alloc(32, 7); +const TEST_KEYPAIR = Keypair.fromRawEd25519Seed(SEED); + +/** b64url → plain JSON */ +const decodeSegment = (seg: string): Record => + JSON.parse( + Buffer.from(seg.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8", + ), + ) as Record; + +/** Decode the payload segment from a JWT string. */ +const decodePayload = (jwt: string): Record => + decodeSegment(jwt.split(".")[1]); + +/** + * Build a minimal fake AxiosInstance that records every interceptor registered + * with it and exposes helpers for invoking them. + */ +function makeFakeInstance() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reqFulfilled: Array<(cfg: any) => any> = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resRejected: Array<(err: any) => any> = []; + + // Tracks calls to instance.request(...) for 401 retry assertions. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requestCalls: any[][] = []; + + const instance = { + interceptors: { + request: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + use(fulfilled: (cfg: any) => any) { + reqFulfilled.push(fulfilled); + }, + }, + response: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + use(_fulfilled: (res: any) => any, rejected?: (err: any) => any) { + if (rejected) resRejected.push(rejected); + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: jest.fn((...args: any[]) => { + requestCalls.push(args); + return Promise.resolve({ data: "retried" }); + }), + // Faithful stand-in for axios's getUri (baseURL join + param serialize) so + // the interceptor can derive the signed path. Overridable per test via + // mockReturnValue; the REAL serializer is exercised in the integration tests. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getUri: jest.fn((config: any) => { + const base = (config.baseURL ?? "").replace(/\/+$/, ""); + let uri = `${base}${config.url ?? ""}`; + const p = config.params; + if (p) { + const qs = + p instanceof URLSearchParams + ? p.toString() + : new URLSearchParams( + Object.entries(p) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => [k, String(v)] as [string, string]), + ).toString(); + if (qs) uri += (uri.includes("?") ? "&" : "?") + qs; + } + return uri; + }), + }; + + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instance: instance as unknown as import("axios").AxiosInstance, + + /** Run all registered request interceptors in order. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async runRequestInterceptors(cfg: any): Promise { + let current = cfg; + for (let i = 0; i < reqFulfilled.length; i += 1) { + // eslint-disable-next-line no-await-in-loop + current = await reqFulfilled[i](current); + } + return current; + }, + + /** Run the first registered response error handler with an error object. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runResponseError(err: any): Promise { + if (resRejected.length === 0) + throw new Error("No response error handler registered"); + return resRejected[0](err) as Promise; + }, + + requestCalls, + }; +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("attachAuthInterceptors", () => { + const BASE_URL = "https://mock-backend-v2-dev.example.com/api/v1"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // (a) Unlocked wallet — Authorization header present, path includes /api/v1 + // ------------------------------------------------------------------------- + + describe("when wallet is unlocked (getAuthKeypair returns a Keypair)", () => { + it("sets Authorization: Bearer on the config", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + // Use jest.requireActual to get the real buildAuthJwt without going + // through the mock (which would cause an infinite loop). + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { buildAuthJwt: realBuildAuthJwt } = jest.requireActual( + "services/auth/buildAuthJwt", + ); + mockBuildAuthJwt.mockImplementation(realBuildAuthJwt); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + }; + const result = await runRequestInterceptors(cfg); + + expect(result.headers?.Authorization).toMatch(/^Bearer /); + }); + + it("builds the JWT with methodAndPath including /api/v1 prefix and query", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols?network=PUBLIC", + method: "get", + headers: {}, + data: undefined, + }; + await runRequestInterceptors(cfg); + + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/api/v1/protocols?network=PUBLIC", + }), + ); + }); + + it("signs a query-less path with NO trailing slash (RN URL non-spec regression)", async () => { + // React Native's built-in URL appends "/" to a query-less URL; if the + // signed path used `new URL().pathname` it would be "/api/v1/protocols/" + // on device (≠ wire "/api/v1/protocols" → 401). The origin-strip must + // preserve getUri's output verbatim. + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + }; + await runRequestInterceptors(cfg); + + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ path: "/api/v1/protocols" }), + ); + }); + + it("passes method and body correctly to buildAuthJwt", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const bodyStr = '{"tokens":["XLM"]}'; + const cfg = { + baseURL: BASE_URL, + url: "/token-prices?network=PUBLIC", + method: "POST", + headers: {}, + data: bodyStr, + }; + await runRequestInterceptors(cfg); + + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ + keypair: TEST_KEYPAIR, + method: "POST", + path: "/api/v1/token-prices?network=PUBLIC", + body: bodyStr, + }), + ); + }); + + it("serializes an object body so the hashed bytes equal the wire bytes", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/token-prices", + method: "post", + headers: {}, + data: { tokens: ["XLM"] }, // plain object — interceptor serializes it + }; + const result = await runRequestInterceptors(cfg); + + const expected = JSON.stringify({ tokens: ["XLM"] }); + // config.data is rewritten to the serialized string axios will send... + expect(result.data).toBe(expected); + // ...and that exact string is what gets hashed. + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ body: expected }), + ); + }); + + it("leaves an already-serialized string body untouched (no double-encode)", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const raw = JSON.stringify({ tokens: ["XLM"] }); + const cfg = { + baseURL: BASE_URL, + url: "/token-prices", + method: "post", + headers: {}, + data: raw, + }; + const result = await runRequestInterceptors(cfg); + + expect(result.data).toBe(raw); + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ body: raw }), + ); + }); + + it("decoded JWT methodAndPath equals full path derived from baseURL + url", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + // Use jest.requireActual to get the real buildAuthJwt so we can decode the JWT. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { buildAuthJwt: realBuildAuthJwt } = jest.requireActual( + "services/auth/buildAuthJwt", + ); + mockBuildAuthJwt.mockImplementation(realBuildAuthJwt); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/collectibles?network=PUBLIC", + method: "get", + headers: {}, + data: undefined, + }; + const result = await runRequestInterceptors(cfg); + + const payload = decodePayload( + (result.headers?.Authorization as string).replace("Bearer ", ""), + ); + + expect(payload.methodAndPath).toBe( + "GET /api/v1/collectibles?network=PUBLIC", + ); + }); + }); + + // ------------------------------------------------------------------------- + // (a2) Unlocked wallet — config.params folding + // ------------------------------------------------------------------------- + + describe("when wallet is unlocked and config.params is supplied", () => { + it("signs the request-target from instance.getUri and leaves config.params for axios", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { buildAuthJwt: realBuildAuthJwt } = jest.requireActual( + "services/auth/buildAuthJwt", + ); + mockBuildAuthJwt.mockImplementation(realBuildAuthJwt); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + // getUri returns exactly what axios would put on the wire. + (instance.getUri as jest.Mock).mockReturnValue( + `${BASE_URL}/protocols?network=PUBLIC`, + ); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + params: { network: "PUBLIC" }, + }; + const result = await runRequestInterceptors(cfg); + + // Signed methodAndPath is the pathname+search of getUri's output. + const payload = decodePayload( + (result.headers?.Authorization as string).replace("Bearer ", ""), + ); + expect(payload.methodAndPath).toBe( + "GET /api/v1/protocols?network=PUBLIC", + ); + // getUri was consulted with the request config... + expect(instance.getUri).toHaveBeenCalledWith(cfg); + // ...and we do NOT fold/delete — params and url are left intact for axios + // to serialize identically on the wire. (Real serialization: integration.) + expect(result.params).toEqual({ network: "PUBLIC" }); + expect(result.url).toBe("/protocols"); + }); + }); + + // ------------------------------------------------------------------------- + // (b) Locked wallet — no Authorization header + // ------------------------------------------------------------------------- + + describe("when wallet is locked (getAuthKeypair returns null)", () => { + it("returns config unmodified (no Authorization header)", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + }; + const result = await runRequestInterceptors(cfg); + + expect(result.headers?.Authorization).toBeUndefined(); + expect(mockBuildAuthJwt).not.toHaveBeenCalled(); + }); + + it("does NOT touch config.params when wallet is locked (params flow through axios normally)", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + params: { network: "PUBLIC" }, + }; + const result = await runRequestInterceptors(cfg); + + // No Authorization header. + expect(result.headers?.Authorization).toBeUndefined(); + // config.params must be left intact for axios to append on send. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(result.params).toEqual({ network: "PUBLIC" }); + // config.url must be unchanged (no merging). + expect(result.url).toBe("/protocols"); + }); + + it("strips a stale Authorization header when locked (401-retry copies the pre-lock token)", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + // Simulates a 401-retry whose config was copied from the authed first + // attempt (carrying a Bearer token) after the wallet locked. The locked + // request must go out anonymously — the stale token must not be re-sent. + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: { Authorization: "Bearer stale.jwt.token" }, + data: undefined, + }; + const result = await runRequestInterceptors(cfg); + + expect(result.headers?.Authorization).toBeUndefined(); + expect(mockBuildAuthJwt).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // (c) 401 response — retry exactly once with a fresh token + // ------------------------------------------------------------------------- + + describe("on 401 response", () => { + it("retries exactly once via instance.request with __isAuthRetry=true", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runResponseError, requestCalls } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const originalConfig = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + // Simulate that the request interceptor already attached a JWT — + // hadAuth must be true for the retry guard to fire. + headers: { Authorization: "Bearer mock.jwt.token" }, + data: undefined, + __isAuthRetry: false, + }; + const err = { + response: { status: 401 }, + config: originalConfig, + }; + + await runResponseError(err); + + expect(requestCalls).toHaveLength(1); + expect(requestCalls[0][0].__isAuthRetry).toBe(true); + }); + + it("does NOT retry a second time when __isAuthRetry is already true (returns the second 401)", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runResponseError, requestCalls } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const retryConfig = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + // The signed retry carried a JWT (hadAuth) and just 401'd again. + headers: { Authorization: "Bearer mock.jwt.token" }, + data: undefined, + __isAuthRetry: true, + }; + const err = { response: { status: 401 }, config: retryConfig }; + + // No anonymous fallback (matches the extension): the 401 surfaces. + await expect(runResponseError(err)).rejects.toBe(err); + expect(requestCalls).toHaveLength(0); + }); + + it("does NOT retry on non-401 errors", async () => { + const { instance, runResponseError, requestCalls } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const err = { + response: { status: 500 }, + config: { headers: {}, __isAuthRetry: false }, + }; + + await expect(runResponseError(err)).rejects.toBe(err); + expect(requestCalls).toHaveLength(0); + }); + + it("does NOT retry when error has no response (network error)", async () => { + const { instance, runResponseError, requestCalls } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const err = { + response: undefined, + config: { headers: {}, __isAuthRetry: false }, + }; + + await expect(runResponseError(err)).rejects.toBe(err); + expect(requestCalls).toHaveLength(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests — real createApiService + real interceptor chain +// +// These tests prove the 401-retry fires through the FULL interceptor chain, +// including apiFactory's error-normalizing response interceptor. The isolated +// handler tests above bypass that normalizer; this suite covers the gap that +// hid the original bug (auth interceptors registered after the normalizer). +// --------------------------------------------------------------------------- + +/** + * Reject with a proper AxiosError so that axios response interceptors receive + * a real AxiosError (with `.response` and `.config` set) rather than a plain + * object. Custom adapters in axios 1.x do NOT run through `settle` + * automatically — the built-in http/xhr adapters call `settle` internally. + * For a custom adapter to trigger the error path the adapter must reject with + * an AxiosError itself. + */ +function make401AxiosError(config: InternalAxiosRequestConfig): AxiosError { + const response = { + data: { message: "Unauthorized" }, + status: 401, + statusText: "Unauthorized", + headers: {}, + config, + } as import("axios").AxiosResponse; + const err = new axios.AxiosError( + "Request failed with status code 401", + "ERR_BAD_REQUEST", + config, + null, + response, + ); + return err; +} + +/** + * Build an axios adapter that rejects with a 401 AxiosError on the first call + * and resolves with 200 on the second. Captures every config it receives so + * the caller can inspect the Authorization headers used on each attempt. + */ +function makeTwoCallAdapter(capturedConfigs: InternalAxiosRequestConfig[]) { + let callCount = 0; + return async function adapter( + config: InternalAxiosRequestConfig, + ): Promise { + capturedConfigs.push(config); + callCount += 1; + if (callCount === 1) { + // First call → 401 Unauthorized (reject with AxiosError so axios + // response error interceptors see .response/.config intact). + return Promise.reject(make401AxiosError(config)); + } + // Second call → 200 OK. + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; +} + +/** + * Build an adapter that always rejects with 401 (simulates a second + * consecutive 401 after the retry — terminal failure path). + */ +function makeAlways401Adapter(capturedConfigs: InternalAxiosRequestConfig[]) { + return async function adapter( + config: InternalAxiosRequestConfig, + ): Promise { + capturedConfigs.push(config); + return Promise.reject(make401AxiosError(config)); + }; +} + +describe("attachAuthInterceptors — integration through full apiFactory chain", () => { + const BASE_URL = "https://mock-backend-v2-dev.example.com/api/v1"; + + beforeEach(() => { + jest.clearAllMocks(); + // Use the real buildAuthJwt for all integration tests so we can inspect + // the actual JWT payloads and assert fresh tokens on retry. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { buildAuthJwt: realBuildAuthJwt } = jest.requireActual( + "services/auth/buildAuthJwt", + ); + mockBuildAuthJwt.mockImplementation(realBuildAuthJwt); + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + }); + + it("adapter is called exactly twice and request succeeds with 200 on the second call", async () => { + // This is the regression test for the ordering bug. Before the fix, + // the auth retry interceptor was registered AFTER apiFactory's normalizer. + // On a 401 the normalizer would convert the AxiosError to a plain ApiError + // (dropping .response), so err.response?.status === 401 was never true and + // the retry never fired — the request failed with ApiError{ status: 401 }. + // After the fix, auth interceptors are registered via configureInstance + // (BEFORE the normalizer), so they see the raw AxiosError and the retry + // fires correctly. + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + // Inject the adapter after service creation — this controls HTTP responses + // without changing the interceptor registration order. + api.getInstance().defaults.adapter = makeTwoCallAdapter(capturedConfigs); + + const result = await api.get<{ ok: boolean }>("/token-prices"); + + // Adapter was called twice: first 401, then 200. + expect(capturedConfigs).toHaveLength(2); + // The final response reaches the consumer as a 200. + expect(result.status).toBe(200); + expect(result.data).toEqual({ ok: true }); + }); + + it("second request carries a fresh JWT that differs from the first", async () => { + // The retry must re-run the request interceptor to get a new JWT. + // We use fake timers to advance the clock by 2 s between the first and + // second call so the `iat` claim differs deterministically — without this + // the two JWTs may be identical when both calls land in the same second. + jest.useFakeTimers(); + const baseNow = Date.now(); + jest.setSystemTime(baseNow); + + const capturedConfigs: InternalAxiosRequestConfig[] = []; + + // Wrap the real adapter so we can advance the clock between calls. + let callCount = 0; + const clockAdvancingAdapter = async ( + config: InternalAxiosRequestConfig, + ): Promise => { + capturedConfigs.push(config); + callCount += 1; + if (callCount === 1) { + // Advance clock BEFORE the retry so the second JWT has a later iat. + jest.advanceTimersByTime(2000); + // Reject with an AxiosError so interceptors see .response/.config. + return Promise.reject(make401AxiosError(config)); + } + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; + + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = clockAdvancingAdapter; + + await api.get<{ ok: boolean }>("/protocols"); + + jest.useRealTimers(); + + expect(capturedConfigs).toHaveLength(2); + + // Extract Authorization headers from both captured configs. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const token1 = (capturedConfigs[0].headers as Record) + .Authorization as string | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const token2 = (capturedConfigs[1].headers as Record) + .Authorization as string | undefined; + + expect(token1).toMatch(/^Bearer /); + expect(token2).toMatch(/^Bearer /); + + // The two tokens must differ — the second request must have triggered the + // request interceptor again to build a fresh JWT. + expect(token1).not.toBe(token2); + + // Verify the iat in the second JWT is later than in the first. + const payload1 = decodePayload(token1!.replace("Bearer ", "")); + const payload2 = decodePayload(token2!.replace("Bearer ", "")); + expect(payload2.iat as number).toBeGreaterThan(payload1.iat as number); + }); + + it("a second consecutive 401 rejects with ApiError{ status: 401 } and does NOT retry more than once", async () => { + // When both the initial request AND the retry return 401: + // - original (JWT) → 401 → signed retry (__isAuthRetry) → 401 → reject. + // No anonymous fallback (matches the extension's authedFetch). + // - The inner normalizer converts the second AxiosError → ApiError{ 401 }; + // the outer normalizer is idempotent (isApiError guard) and rethrows it + // unchanged, preserving status: 401 instead of clobbering it to 0. + // Key assertions: + // (a) no infinite retry loop (adapter called exactly twice), + // (b) the consumer receives ApiError{ status: 401 } — NOT status: 0. + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = makeAlways401Adapter(capturedConfigs); + + await expect(api.get("/protocols")).rejects.toMatchObject({ + status: 401, + isNetworkError: false, + message: expect.any(String), + }); + + // Exactly 2 adapter calls: original + one retry. A third would mean the + // guard flag is broken (or an anonymous fallback crept back in). + expect(capturedConfigs).toHaveLength(2); + }); + + // ------------------------------------------------------------------------- + // isApiError type guard + // ------------------------------------------------------------------------- + + it("isApiError returns true for an ApiError-shaped object", () => { + const apiErr = { + message: "Unauthorized", + status: 401, + isNetworkError: false, + }; + expect(isApiError(apiErr)).toBe(true); + }); + + it("isApiError returns false for a real AxiosError", () => { + const axiosErr = new axios.AxiosError("Request failed"); + expect(isApiError(axiosErr)).toBe(false); + }); + + it("isApiError returns false for a plain object missing required fields", () => { + expect(isApiError({ message: "oops" })).toBe(false); + expect(isApiError(null)).toBe(false); + expect(isApiError("string error")).toBe(false); + }); + + it("anonymous request (no JWT) that receives a 401 does NOT retry", async () => { + // When the wallet is locked, getAuthKeypair() returns null and no + // Authorization header is attached. Retrying such a request is pointless + // — nothing would change on the second attempt. The response interceptor + // must detect the absence of an Authorization header and skip the retry. + mockGetAuthKeypair.mockResolvedValue(null); + + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = makeAlways401Adapter(capturedConfigs); + + await expect(api.get("/protocols")).rejects.toMatchObject({ + status: 401, + isNetworkError: false, + message: expect.any(String), + }); + + // Exactly 1 adapter call — the retry must not have fired. + expect(capturedConfigs).toHaveLength(1); + }); + + // ------------------------------------------------------------------------- + // config.params folding — integration: signed path == wire path + // ------------------------------------------------------------------------- + + it("authed request with config.params: signed methodAndPath matches the wire query (params left for axios)", async () => { + // Prove it end-to-end with the REAL axios chain: the JWT claim equals the + // request-target axios will build, and we don't fold/delete params. + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + // Adapter: always 200 so we can inspect what it received. + api.getInstance().defaults.adapter = ( + config: InternalAxiosRequestConfig, + ) => { + capturedConfigs.push(config); + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; + + await api.get("/token-prices", { params: { network: "PUBLIC" } }); + + expect(capturedConfigs).toHaveLength(1); + const captured = capturedConfigs[0]; + + // The signed methodAndPath equals the request-target axios builds for the + // captured config (getUri shares axios's builder with the adapter). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const authHeader = (captured.headers as Record) + .Authorization as string; + expect(authHeader).toMatch(/^Bearer /); + const payload = decodePayload(authHeader.replace("Bearer ", "")); + expect(payload.methodAndPath).toBe( + "GET /api/v1/token-prices?network=PUBLIC", + ); + // The query reaches the wire (regardless of whether axios keeps it on + // url or params) — proving we didn't drop it by not folding. + expect(api.getInstance().getUri(captured)).toContain("?network=PUBLIC"); + }); + + it("config.url already has a query + config.params: joined with & not a second ?", async () => { + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = ( + config: InternalAxiosRequestConfig, + ) => { + capturedConfigs.push(config); + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; + + await api.get("/foo?a=1", { params: { b: "2" } }); + + expect(capturedConfigs).toHaveLength(1); + const captured = capturedConfigs[0]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const authHeader = (captured.headers as Record) + .Authorization as string; + const payload = decodePayload(authHeader.replace("Bearer ", "")); + // Signed path uses & (axios's builder joins the pre-existing query). + expect(payload.methodAndPath).toBe("GET /api/v1/foo?a=1&b=2"); + // And that's the URL axios will actually send. + expect(api.getInstance().getUri(captured)).toContain("/foo?a=1&b=2"); + }); + + it("anonymous request with config.params: interceptor leaves params for axios to handle", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = ( + config: InternalAxiosRequestConfig, + ) => { + capturedConfigs.push(config); + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; + + await api.get("/protocols", { params: { network: "PUBLIC" } }); + + expect(capturedConfigs).toHaveLength(1); + const captured = capturedConfigs[0]; + + // Anonymous path: interceptor must not strip params. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const authHeader = (captured.headers as Record) + .Authorization as string | undefined; + expect(authHeader).toBeUndefined(); + // axios serialises params onto the URL for the adapter, so by the time + // the adapter fires, params may or may not still be on the config — + // what matters is that no Authorization header was set. + }); + + it("logRequests never logs the Bearer token (redacted)", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("super.secret.jwt"); + (debug as jest.Mock).mockClear(); + + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + logRequests: true, + }); + api.getInstance().defaults.adapter = (config: InternalAxiosRequestConfig) => + Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + + await api.get("/token-prices"); + + // The request logger ran with the token attached, but redacted it. + const logged = (debug as jest.Mock).mock.calls + .map((args) => args.join(" ")) + .join("\n"); + expect(logged).toContain("[REDACTED]"); + expect(logged).not.toContain("super.secret.jwt"); + }); +}); diff --git a/__tests__/services/auth/authJwt.integration.test.ts b/__tests__/services/auth/authJwt.integration.test.ts new file mode 100644 index 000000000..935eff1ee --- /dev/null +++ b/__tests__/services/auth/authJwt.integration.test.ts @@ -0,0 +1,136 @@ +/** + * Gated E2E integration test: round-trips a real JWT against the staging + * backend's /api/v1/auth/whoami route. + * + * HOW TO RUN: + * BACKEND_V2_URL=https://freighter-backend-v2-stg.stellar.org \ + * yarn jest __tests__/services/auth/authJwt.integration.test.ts + * + * Or with the IS_INTEGRATION_MODE flag (uses the staging URL by default): + * IS_INTEGRATION_MODE=1 yarn jest __tests__/services/auth/authJwt.integration.test.ts + * + * Normal CI: suite is SKIPPED — set neither env var and all tests are omitted. + */ +import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; +import { buildAuthJwt } from "services/auth/buildAuthJwt"; +import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; + +// --------------------------------------------------------------------------- +// Gating: skip the whole suite unless an integration flag is present +// --------------------------------------------------------------------------- +const RUN = !!(process.env.BACKEND_V2_URL || process.env.IS_INTEGRATION_MODE); +const d = RUN ? describe : describe.skip; + +const BASE_URL = + process.env.BACKEND_V2_URL || "https://freighter-backend-v2-stg.stellar.org"; + +const WHOAMI_PATH = "/api/v1/auth/whoami"; +const WHOAMI_URL = `${BASE_URL}${WHOAMI_PATH}`; + +// Vector index 1 (0-based): +// mnemonic: "illness spike retreat truth genius clock brain pass fit cave bargain toe" +// userId: "bd9498475c7191c5e9a5e18edda2402ab0ae527580a6c38b2a32a77c65729cd7" +const VECTOR = AUTH_KEYPAIR_VECTORS[1]; + +d("auth JWT e2e", () => { + let keypair: Awaited>["keypair"]; + let userId: string; + + beforeAll(async () => { + // deriveAuthKeypair is async as of #920 (bip39 mnemonicToSeed off the JS thread). + const derived = await deriveAuthKeypair(VECTOR.mnemonic); + keypair = derived.keypair; + userId = derived.userId; + }); + + it("valid JWT → 200 with authenticated:true and correct userId", async () => { + const jwt = buildAuthJwt({ + keypair, + method: "GET", + path: WHOAMI_PATH, + }); + + const res = await fetch(WHOAMI_URL, { + method: "GET", + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status).toBe(200); + + // The backend wraps successful responses in its standard `{ data: ... }` + // envelope (verified against staging), so whoami is at body.data. + const body = (await res.json()) as { + data: { authenticated: boolean; userId: string }; + }; + expect(body.data.authenticated).toBe(true); + expect(body.data.userId).toBe(userId); + // Double-check against the hard-coded vector value for extra safety + expect(body.data.userId).toBe(VECTOR.userId); + }); + + it("tampered body: JWT built with body hash mismatch → 401", async () => { + // JWT claims a non-empty body but GET is sent with no body — server must + // reject because the bodyHash in the token won't match the empty-body hash. + const jwt = buildAuthJwt({ + keypair, + method: "GET", + path: WHOAMI_PATH, + body: '{"tampered":true}', + }); + + const res = await fetch(WHOAMI_URL, { + method: "GET", + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status).toBe(401); + }); + + it("flipped signature byte → 401", async () => { + const jwt = buildAuthJwt({ + keypair, + method: "GET", + path: WHOAMI_PATH, + }); + + const parts = jwt.split("."); + // Decode the signature segment, flip a byte, re-encode + const sigBytes = Buffer.from( + parts[2].replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ); + sigBytes[0] = sigBytes[0] === 0 ? 1 : 0; // flip the first byte to something different + const corruptedSig = sigBytes + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const corruptedJwt = `${parts[0]}.${parts[1]}.${corruptedSig}`; + + const res = await fetch(WHOAMI_URL, { + method: "GET", + headers: { Authorization: `Bearer ${corruptedJwt}` }, + }); + + expect(res.status).toBe(401); + }); + + it("expired JWT → 401", async () => { + // Build with now 60 seconds in the past so exp is already past + const expiredNow = Date.now() - 60_000; + const jwt = buildAuthJwt({ + keypair, + method: "GET", + path: WHOAMI_PATH, + now: expiredNow, + }); + + const res = await fetch(WHOAMI_URL, { + method: "GET", + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/__tests__/services/auth/buildAuthJwt.test.ts b/__tests__/services/auth/buildAuthJwt.test.ts new file mode 100644 index 000000000..cd28e2963 --- /dev/null +++ b/__tests__/services/auth/buildAuthJwt.test.ts @@ -0,0 +1,122 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import { + ISS, + JWT_LIFETIME_SECONDS, + buildAuthJwt, +} from "services/auth/buildAuthJwt"; + +// Fixed test fixtures +const SEED = Buffer.alloc(32, 7); +const keypair = Keypair.fromRawEd25519Seed(SEED); +const NOW = 1_700_000_000_000; + +const b64urlDecode = (s: string): Buffer => + Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64"); + +describe("buildAuthJwt", () => { + it("exports ISS = 'freighter-mobile'", () => { + expect(ISS).toBe("freighter-mobile"); + }); + + it("exports JWT_LIFETIME_SECONDS = 15", () => { + expect(JWT_LIFETIME_SECONDS).toBe(15); + }); + + describe("JWT structure", () => { + let token: string; + let headerJson: Record; + let payloadJson: Record; + let sigBytes: Buffer; + let headerSeg: string; + let payloadSeg: string; + + beforeEach(() => { + token = buildAuthJwt({ + keypair, + method: "get", + path: "/api/v1/auth/whoami", + now: NOW, + }); + const parts = token.split("."); + expect(parts).toHaveLength(3); + [headerSeg, payloadSeg] = parts; + headerJson = JSON.parse( + b64urlDecode(parts[0]).toString("utf8"), + ) as Record; + payloadJson = JSON.parse( + b64urlDecode(parts[1]).toString("utf8"), + ) as Record; + sigBytes = b64urlDecode(parts[2]); + }); + + it("has EdDSA header", () => { + expect(headerJson).toEqual({ alg: "EdDSA", typ: "JWT" }); + }); + + it("has sub = raw public key hex", () => { + expect(payloadJson.sub).toBe(keypair.rawPublicKey().toString("hex")); + }); + + it("has iss = 'freighter-mobile'", () => { + expect(payloadJson.iss).toBe("freighter-mobile"); + }); + + it("has iat derived from now", () => { + expect(payloadJson.iat).toBe(Math.floor(NOW / 1000)); + }); + + it("has exp - iat === 15", () => { + expect((payloadJson.exp as number) - (payloadJson.iat as number)).toBe( + 15, + ); + }); + + it("has methodAndPath = 'GET /api/v1/auth/whoami'", () => { + expect(payloadJson.methodAndPath).toBe("GET /api/v1/auth/whoami"); + }); + + it("upper-cases the method", () => { + const t = buildAuthJwt({ + keypair, + method: "post", + path: "/foo", + now: NOW, + }); + const p = JSON.parse( + b64urlDecode(t.split(".")[1]).toString("utf8"), + ) as Record; + expect(p.methodAndPath).toBe("POST /foo"); + }); + + it("has empty-body SHA-256 when no body is supplied", () => { + expect(payloadJson.bodyHash).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + }); + + it("has correct SHA-256 for body = 'hello'", () => { + const t = buildAuthJwt({ + keypair, + method: "POST", + path: "/foo", + body: "hello", + now: NOW, + }); + const p = JSON.parse( + b64urlDecode(t.split(".")[1]).toString("utf8"), + ) as Record; + expect(p.bodyHash).toBe( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ); + }); + + it("signature verifies against signing input", () => { + const signingInput = Buffer.from(`${headerSeg}.${payloadSeg}`, "utf8"); + expect(keypair.verify(signingInput, sigBytes)).toBe(true); + }); + + it("is url-safe: no +, /, or = characters", () => { + expect(token).not.toMatch(/[+/=]/); + }); + }); +}); diff --git a/__tests__/services/backend.test.ts b/__tests__/services/backend.test.ts index 435c661af..e765f4912 100644 --- a/__tests__/services/backend.test.ts +++ b/__tests__/services/backend.test.ts @@ -20,6 +20,12 @@ jest.mock("services/apiFactory", () => { createApiService: jest.fn(() => ({ get: jest.fn(), post: jest.fn(), + getInstance: jest.fn(() => ({ + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + })), })), isRequestCanceled: jest.fn(), }; @@ -705,6 +711,39 @@ describe("Backend Service - fetchCollectibles severity split", () => { expect.any(Error), ); }); + + it("sends a pre-serialized string body and query-in-URL so the JWT interceptor hashes the correct bytes", async () => { + // The JWT request interceptor hashes config.data ONLY when it is already a + // string. If the body is an object the interceptor would sign the empty-string + // hash, producing a token that never validates on the backend. This test locks + // the contract: body must be a string, query must be in the URL. + mockV2Post.mockResolvedValue({ + data: { + data: { + collections: [ + { + contract_id: "C...", + collection_name: "Test", + nfts: [], + }, + ], + }, + }, + status: 200, + statusText: "OK", + }); + + await fetchCollectibles(params); + + // Idiomatic axios: the network goes via `{ params }` (the JWT interceptor + // folds it into the signed path via getUri) and the body is a plain object + // (the interceptor serializes it so bodyHash matches the wire). + expect(mockV2Post).toHaveBeenCalledWith( + "/collectibles", + { owner: params.owner, contracts: params.contracts }, + { params: { network: params.network } }, + ); + }); }); describe("Backend Service - fetchTokenPrices v2 migration", () => { @@ -737,7 +776,10 @@ describe("Backend Service - fetchTokenPrices v2 migration", () => { it("hits the v2 client with a network query param and native id when useV2 is true", async () => { await fetchTokenPrices({ tokens, network: NETWORKS.PUBLIC, useV2: true }); - // Native "XLM" is sent to v2 as "native". + // Native "XLM" is sent to v2 as "native"; the network goes via `{ params }` + // (the JWT interceptor folds it into the signed path via getUri). The body + // is a plain object — the interceptor serializes it centrally so bodyHash + // matches the wire bytes. expect(mockV2Post).toHaveBeenCalledWith( "/token-prices", { tokens: v2Tokens }, diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 810bd5ff8..2308d4558 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2368,6 +2368,13 @@ export const useAuthenticationStore = create()((set, get) => ({ isLoadingAccount: true, }); + // Drop any stale isSessionAuthValid memo captured while locked — otherwise + // the post-unlock request burst (balances + prices + history) could read a + // cached `false` for up to the TTL and go out anonymous, defeating the auth + // this feature exists to attach. (Defined below; resolved at call time.) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + clearSessionAuthValidMemo(); + getActiveAccount(AUTH_STATUS.AUTHENTICATED) .then((activeAccount) => { if (!activeAccount) { @@ -3155,15 +3162,55 @@ export const getActiveMnemonicPhrase = async (): Promise => { } }; +// Short-TTL memo + in-flight dedup for the session-validity funnel result. +// getAuthKeypair runs isSessionAuthValid on every call — per backend request +// once the JWT interceptor is wired — and each funnel run is ~5 keychain reads. +// The in-flight promise collapses a near-simultaneous burst (balances + prices +// + history on unlock) to one run; the value memo collapses sequential calls +// within the window. +// +// Tradeoff: a transition detected ONLY by the funnel (auto-lock timer elapse, +// hash hard-expiry) is reflected at this gate up to SESSION_AUTH_VALID_TTL_MS +// late. That is bounded and immaterial in practice (auto-lock windows are +// minutes to hours) and does not leak key material: explicit lock/logout/wipe +// paths clear the keypair cache AND flip in-memory authStatus, so during a +// stale-true memo getAuthKeypair's warm-cache path finds an empty cache and its +// derive path is blocked by getActiveMnemonicPhrase's AUTHENTICATED gate. +export const SESSION_AUTH_VALID_TTL_MS = 1000; +let sessionAuthValidMemo: { value: boolean; expiresAt: number } | null = null; +let sessionAuthValidInFlight: Promise | null = null; + +/** Drops the isSessionAuthValid memo (tests + any caller needing a fresh read). */ +export const clearSessionAuthValidMemo = (): void => { + sessionAuthValidMemo = null; +}; + /** * Session-validity gate for backend auth-key use. Delegates to the authoritative * `getAuthStatus()` funnel — the single source of truth for lock transitions — * so it evaluates account existence, persisted LOCKED, hash-key hard-expiry AND * the auto-lock timer (`backgroundedAt` + `autoLockTimer`), rather than - * re-implementing a subset. This closes the foreground-before-funnel window - * where a stale in-memory AUTHENTICATED + within-TTL hash key would otherwise - * report valid after the auto-lock timer had already elapsed. Does not decrypt - * the temporary store (no PBKDF2); it reads persisted/secure state only. + * re-implementing a subset. Result is memoized for SESSION_AUTH_VALID_TTL_MS + * (see above). Does not decrypt the temporary store (no PBKDF2); persisted/secure + * reads only. */ -export const isSessionAuthValid = async (): Promise => - (await getAuthStatus()) === AUTH_STATUS.AUTHENTICATED; +export const isSessionAuthValid = async (): Promise => { + const memo = sessionAuthValidMemo; + if (memo && Date.now() < memo.expiresAt) return memo.value; + + if (!sessionAuthValidInFlight) { + sessionAuthValidInFlight = (async () => { + try { + const value = (await getAuthStatus()) === AUTH_STATUS.AUTHENTICATED; + sessionAuthValidMemo = { + value, + expiresAt: Date.now() + SESSION_AUTH_VALID_TTL_MS, + }; + return value; + } finally { + sessionAuthValidInFlight = null; + } + })(); + } + return sessionAuthValidInFlight; +}; diff --git a/src/services/apiFactory.ts b/src/services/apiFactory.ts index 0d5138d42..956b8c9c1 100644 --- a/src/services/apiFactory.ts +++ b/src/services/apiFactory.ts @@ -92,6 +92,43 @@ export interface ApiServiceOptions { headers?: Record; /** Whether to log requests and responses */ logRequests?: boolean; + /** + * Optional hook invoked on the raw AxiosInstance BEFORE the error-normalizing + * response interceptor is registered. Use this to attach interceptors that + * must run BEFORE the normalizer on the error path (e.g. a 401-retry handler + * that needs access to `error.response` and `error.config` — both of which + * the normalizer strips when it converts the AxiosError into a plain ApiError). + * + * Axios runs response interceptors in registration order, so any response + * interceptor added here is guaranteed to see the raw AxiosError. + * + * @example + * createApiService({ baseURL, configureInstance: attachAuthInterceptors }) + */ + configureInstance?: (instance: AxiosInstance) => void; +} + +/** + * Type guard that returns true for any value that has already been normalized + * into an ApiError by the apiFactory response interceptor. Used by the + * normalizer itself to make error normalization idempotent: when a nested + * instance.request() (e.g. the JWT 401-retry in attachAuth) surfaces an + * ApiError up through the outer interceptor chain, the normalizer must not + * re-process it — doing so would clobber the real status (e.g. 401 → 0) + * because ApiError carries no .response field. + */ +export function isApiError(error: unknown): error is ApiError { + return ( + typeof error === "object" && + error !== null && + !axios.isAxiosError(error) && + "status" in error && + typeof (error as ApiError).status === "number" && + "isNetworkError" in error && + typeof (error as ApiError).isNetworkError === "boolean" && + "message" in error && + typeof (error as ApiError).message === "string" + ); } /** @@ -136,6 +173,7 @@ export function createApiService(options: ApiServiceOptions) { Accept: "application/json", }, logRequests = false, + configureInstance, } = options; // Create axios instance @@ -146,22 +184,45 @@ export function createApiService(options: ApiServiceOptions) { }); if (logRequests) { + // Never log the Bearer token (this instance may carry per-request JWT auth + // via configureInstance). Mask any Authorization header at any depth — + // covers request.headers and response.config.headers. + const redactAuth = (key: string, value: unknown) => + /^authorization$/i.test(key) ? "[REDACTED]" : value; + instance.interceptors.request.use((request) => { - debug("Starting Request", JSON.stringify(request, null, 2)); + debug("Starting Request", JSON.stringify(request, redactAuth, 2)); return request; }); instance.interceptors.response.use((response) => { - debug("Response:", JSON.stringify(response, null, 2)); + debug("Response:", JSON.stringify(response, redactAuth, 2)); return response; }); } + // Allow the caller to register interceptors on the raw instance BEFORE the + // error-normalizing response interceptor below. Axios runs response + // interceptors in registration order, so any response interceptor added by + // this hook sees the original AxiosError (with .response / .config intact) + // before the normalizer converts it to a plain ApiError and drops those + // fields. This is the correct insertion point for a 401-retry handler. + configureInstance?.(instance); + // Add response interceptor for error handling instance.interceptors.response.use( (response) => response, /* eslint-disable @typescript-eslint/no-unsafe-member-access */ (error) => { + // If the error is already a normalized ApiError (e.g. surfaced from a + // nested instance.request() inside another interceptor, like the JWT + // 401-retry), don't re-normalize — a second pass would clobber the + // real status (e.g. 401) to 0 because ApiError has no .response field. + if (isApiError(error)) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw error; + } + // When the server didn't respond at all (offline, DNS, TLS failure, // captive portal, request aborted before headers), use 0 as the // status to match the ApiError contract ("HTTP status code (or 0 @@ -289,7 +350,11 @@ export function createApiService(options: ApiServiceOptions) { * Makes a POST request * * @param url The URL to request (will be appended to baseURL) - * @param data The data to send in the request body + * @param data The request body, typed `unknown` — pass a plain object. + * (The `TWriteBody` generic was removed with the string-only body contract.) + * On an auth-wired instance (see `configureInstance`), the request + * interceptor JSON-serializes object bodies centrally so the signed JWT + * `bodyHash` matches the wire bytes; string bodies pass through untouched. * @param config Additional request configuration * @returns Promise with the API response * @@ -305,9 +370,9 @@ export function createApiService(options: ApiServiceOptions) { * { headers: { 'X-Custom-Header': 'value' } } * ); */ - async post( + async post( url: string, - data?: D, + data?: unknown, config?: RequestConfig, ): Promise> { const { retry, ...axiosConfig } = config || {}; @@ -326,7 +391,11 @@ export function createApiService(options: ApiServiceOptions) { * Makes a PUT request * * @param url The URL to request (will be appended to baseURL) - * @param data The data to send in the request body + * @param data The request body, typed `unknown` — pass a plain object. + * (The `TWriteBody` generic was removed with the string-only body contract.) + * On an auth-wired instance (see `configureInstance`), the request + * interceptor JSON-serializes object bodies centrally so the signed JWT + * `bodyHash` matches the wire bytes; string bodies pass through untouched. * @param config Additional request configuration * @returns Promise with the API response * @@ -334,9 +403,9 @@ export function createApiService(options: ApiServiceOptions) { * // Basic PUT request * const { data } = await api.put('/users/123', { name: 'Updated Name' }); */ - async put( + async put( url: string, - data?: D, + data?: unknown, config?: RequestConfig, ): Promise> { const { retry, ...axiosConfig } = config || {}; diff --git a/src/services/auth/attachAuth.ts b/src/services/auth/attachAuth.ts new file mode 100644 index 000000000..184bef645 --- /dev/null +++ b/src/services/auth/attachAuth.ts @@ -0,0 +1,232 @@ +/* eslint-disable no-underscore-dangle */ +/** + * attachAuth — wire per-request JWT auth onto an AxiosInstance. + * + * Adds two additive interceptors to the given instance: + * + * 1. **Request interceptor**: builds a short-lived EdDSA JWT (via + * `buildAuthJwt`) and injects it as `Authorization: Bearer `. + * When the wallet is locked (`getAuthKeypair()` → null) the config is + * returned unmodified and the backend permissively allows the request. + * + * 2. **Response interceptor**: on a 401, rebuilds the JWT once and re-issues + * the request via `instance.request(config)`, returning that response + * (success or the second 401). A `__isAuthRetry` flag prevents infinite + * loops. Mirrors the extension's `authedFetch` (retry-once-then-return). + * + * Path derivation + * --------------- + * The JWT `methodAndPath` must match the server's `r.URL.RequestURI()` (full + * path + query). We derive it from `instance.getUri(config)` — axios's own + * baseURL-join + param serializer, i.e. exactly what it will put on the wire — + * and strip the origin to `pathname + search`. Delegating means the signed path + * cannot drift from the wire path (both route through the same builder), and we + * leave `config.params` untouched; axios serializes them identically. + * + * Example: + * baseURL = "https://api.example.com/api/v1" + * url = "/protocols", params = { network: "PUBLIC" } + * signed = "/api/v1/protocols?network=PUBLIC" ✓ + * + * Body hashing + * ------------ + * Object bodies are serialized here — in the request interceptor, before + * axios's own `transformRequest` — so the bytes hashed into `bodyHash` are + * exactly the bytes that go on the wire. Call sites pass plain objects; an + * already-serialized string is left untouched; GET/no-body yields an empty + * bodyHash. (The anonymous/locked path lets axios serialize the object + * normally, producing the identical JSON bytes, so the two paths agree.) + * + * Import strategy + * --------------- + * `getAuthKeypair` and `buildAuthJwt` are imported lazily inside the + * interceptor callbacks (via `require()`). This keeps the module load of + * `attachAuth.ts` itself side-effect-free so that tests which import + * `backend.ts` indirectly (e.g. via a duck) do not inadvertently pull in + * `ducks/auth` → `react-native-config` at import time. Interceptor + * callbacks only fire when an actual request is made, so the lazy require + * never runs in tests that mock the store/duck and never exercise the + * backend HTTP layer. + */ +import { AxiosInstance, InternalAxiosRequestConfig } from "axios"; + +/** + * Internal marker added to config when we have already attempted one + * JWT refresh-and-retry cycle for a given request. Prevents infinite + * retry loops on persistent 401s. + */ +interface AuthRetryConfig extends InternalAxiosRequestConfig { + __isAuthRetry?: boolean; +} + +/** Strips any Authorization header so a request goes out anonymously. */ +function stripAuthHeaders(config: InternalAxiosRequestConfig): void { + if (!config.headers) return; + /* eslint-disable no-param-reassign */ + delete (config.headers as Record).Authorization; + delete (config.headers as Record).authorization; + /* eslint-enable no-param-reassign */ +} + +/** + * Derives the server request-target (pathname + search) that axios will put on + * the wire, so the signed JWT `methodAndPath` equals the server's + * `r.URL.RequestURI()` by construction. + * + * We delegate to `instance.getUri(config)` — the same `baseURL` join + param + * serializer axios uses to build the actual request URL — rather than + * hand-rolling the merge. That guarantees signed path == wire path (and stays + * true across axios upgrades, since both route through the same builder). We + * only strip the origin down to `pathname + search`; axios owns the rest. + */ +function deriveServerPath( + instance: AxiosInstance, + config: InternalAxiosRequestConfig, +): string { + // Strip the scheme://host[:port] origin from what axios will send, leaving + // exactly the pathname+query it puts on the wire. + // + // Do NOT use `new URL(...).pathname`: React Native's built-in URL is NOT + // WHATWG-compliant — its constructor appends a trailing "/" to any URL with + // no "?"/"#" (RN 0.81 Libraries/Blob/URL.js), so a query-less request would + // sign "/api/v1/x/" while the wire target is "/api/v1/x" → guaranteed 401. + // Jest uses Node's compliant URL, so that divergence is invisible in tests. + // A plain origin-strip is environment-independent and preserves getUri's + // output verbatim (== the wire request-target). + const uri = instance.getUri(config); + return uri.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, "") || "/"; +} + +/** + * Attaches per-request JWT authentication interceptors to `instance`. + * + * ORDERING REQUIREMENT: This function MUST be wired via the `configureInstance` + * hook of `createApiService` (not called after `createApiService` returns). + * Axios runs response interceptors in registration order. The 401-retry + * handler below inspects `error.response?.status` and `error.config` — fields + * that are present on a raw AxiosError but are stripped by apiFactory's + * error-normalizing response interceptor (which converts the error into a plain + * `ApiError`). If this function were called after `createApiService`, the + * retry handler would be registered *after* the normalizer and would never fire + * on a real 401: by the time it runs, the error is already an `ApiError` with + * no `.response` property. + * + * Using `configureInstance` guarantees these interceptors are registered BEFORE + * the normalizer, so the 401-retry path works correctly in production. + */ +export function attachAuthInterceptors(instance: AxiosInstance): void { + // ------------------------------------------------------------------ + // 1. Request interceptor — inject JWT when unlocked + // + // `getAuthKeypair` and `buildAuthJwt` are required lazily here so that + // importing `attachAuth` (and transitively `backend.ts`) does not pull + // `ducks/auth` into the module graph at import time. Tests that load + // `backend.ts` indirectly (e.g. via a duck) but never make HTTP + // requests will never trigger these requires. + // ------------------------------------------------------------------ + instance.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require */ + const { getAuthKeypair } = + require("services/auth/getAuthKeypair") as typeof import("services/auth/getAuthKeypair"); + /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require */ + const keypair = await getAuthKeypair(); + if (!keypair) { + // Wallet is locked — pass through anonymously. Strip any stale + // Authorization header first: a 401-retry copies the failed request's + // config (which carried a Bearer token), so if the wallet locked + // between the original request and the retry, returning config as-is + // would re-send the now-stale token. Locked requests must be anonymous. + stripAuthHeaders(config); + return config; + } + + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require */ + const { buildAuthJwt } = + require("services/auth/buildAuthJwt") as typeof import("services/auth/buildAuthJwt"); + /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require */ + + // Sign the exact request-target axios will send (baseURL + url + params), + // leaving config.params in place — axios serializes them identically on + // the wire, so signed path == wire path with no manual folding. + const serverPath = deriveServerPath(instance, config); + const method = config.method ?? "get"; + + // Serialize plain JSON bodies here — before axios's transformRequest — so + // the bytes we hash are exactly the bytes that go on the wire. A string + // body passes through untouched (no double-encode); GET/no-body stays + // undefined. Only plain objects / arrays are serialized: FormData / Blob / + // URLSearchParams etc. are left alone (JSON.stringify would corrupt them to + // "{}" on the wire). No such body exists on a v2 call site today; a future + // non-JSON authenticated body would need explicit bodyHash handling. + const isPlainJsonBody = + typeof config.data === "object" && + config.data !== null && + ((config.data as object).constructor === Object || + Array.isArray(config.data)); + if (isPlainJsonBody) { + // eslint-disable-next-line no-param-reassign + config.data = JSON.stringify(config.data); + } + const body = typeof config.data === "string" ? config.data : undefined; + + const jwt = buildAuthJwt({ + keypair, + method, + path: serverPath, + body, + }); + + // `config.headers` is always present on InternalAxiosRequestConfig. + // Cast to avoid index-signature complaints from strict typing. + // Mutating config.headers is idiomatic axios interceptor usage. + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-param-reassign + (config.headers as Record).Authorization = `Bearer ${jwt}`; + + return config; + }, + ); + + // ------------------------------------------------------------------ + // 2. Response interceptor — retry once on 401 + // ------------------------------------------------------------------ + instance.interceptors.response.use( + (response) => response, + async (error: unknown) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = error as { response?: { status?: number }; config?: any }; + const config = err.config as AuthRetryConfig | undefined; + + // Only retry if the original request actually carried a JWT. + // Retrying an anonymous request is pointless — nothing would change on + // the second attempt (the wallet is locked, so no Authorization header + // would be attached) and it would double locked-wallet traffic. + const hadAuth = Boolean( + (config?.headers as Record | undefined)?.Authorization, + ); + + if ( + err.response?.status === 401 && + config !== undefined && + !config.__isAuthRetry && + hadAuth + ) { + // Spread into a new config object to avoid mutating the original + // (satisfies no-param-reassign) while marking the retry flag. + const retryConfig: AuthRetryConfig = { + ...config, + __isAuthRetry: true, + }; + // The request interceptor above will rebuild a fresh JWT when + // instance.request() runs again. + return instance.request(retryConfig); + } + + // Not a retryable 401 (already retried, non-401, or anonymous): reject. + // A persistent 401 surfaces to the caller — we do NOT fall back to an + // anonymous request (matches the extension's authedFetch; clock-skew + // handling is deferred to the backend leeway, see #930). + return Promise.reject(error); + }, + ); +} diff --git a/src/services/auth/buildAuthJwt.ts b/src/services/auth/buildAuthJwt.ts new file mode 100644 index 000000000..ae0f101d2 --- /dev/null +++ b/src/services/auth/buildAuthJwt.ts @@ -0,0 +1,50 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import { createHash } from "crypto"; + +export const ISS = "freighter-mobile"; +export const JWT_LIFETIME_SECONDS = 15; + +export interface BuildAuthJwtParams { + keypair: Keypair; + method: string; + path: string; // full request-target incl. query, e.g. "/api/v1/auth/whoami" + body?: string; // pre-serialized; omit for GET + now?: number; // ms epoch; injectable for tests +} + +const b64url = (b: Buffer): string => + b + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +const seg = (v: unknown): string => + b64url(Buffer.from(JSON.stringify(v), "utf8")); + +// The header is constant for every token — compute it once at module load +// rather than re-running JSON.stringify + base64 + three regex replaces per +// authenticated request. +const HEADER_SEG = seg({ alg: "EdDSA", typ: "JWT" }); + +export const buildAuthJwt = ({ + keypair, + method, + path, + body, + now, +}: BuildAuthJwtParams): string => { + const iat = Math.floor((now ?? Date.now()) / 1000); + const bodyHash = createHash("sha256") + .update(body ?? "") + .digest("hex"); + const payload = { + sub: keypair.rawPublicKey().toString("hex"), + iss: ISS, + iat, + exp: iat + JWT_LIFETIME_SECONDS, + bodyHash, + methodAndPath: `${method.toUpperCase()} ${path}`, + }; + const signingInput = `${HEADER_SEG}.${seg(payload)}`; + return `${signingInput}.${b64url(keypair.sign(Buffer.from(signingInput, "utf8")))}`; +}; diff --git a/src/services/backend.ts b/src/services/backend.ts index 64faa17fb..c35751457 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -37,13 +37,21 @@ import { isRequestCanceled, logApiError, } from "services/apiFactory"; +import { attachAuthInterceptors } from "services/auth/attachAuth"; // Create dedicated API services for backend operations export const freighterBackendV1 = createApiService({ baseURL: BackendEnvConfig.FREIGHTER_BACKEND_V1_URL, }); +// Attach per-request JWT auth to the v2 backend instance via the +// configureInstance hook so the auth interceptors are registered BEFORE +// apiFactory's error-normalizing response interceptor. Axios runs response +// interceptors in registration order; if attachAuthInterceptors were called +// after createApiService returns, the 401-retry handler would run AFTER the +// normalizer and never see error.response (already converted to ApiError). export const freighterBackendV2 = createApiService({ baseURL: BackendEnvConfig.FREIGHTER_BACKEND_V2_URL, + configureInstance: attachAuthInterceptors, }); /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -1201,11 +1209,9 @@ export const fetchCollectibles = async ({ > => { try { const { data } = await freighterBackendV2.post( - `/collectibles?network=${network}`, - { - owner, - contracts, - }, + "/collectibles", + { owner, contracts }, + { params: { network } }, ); if (!data.data || !data.data.collections) {