diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 6e18cb0e0..e8e065c4d 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -16,6 +16,10 @@ import { ActiveAccount, appendAccounts, clearAccountData, + isSessionAuthValid, + clearSessionAuthValidMemo, + SESSION_AUTH_VALID_TTL_MS, + getActiveMnemonicPhrase, } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useCollectiblesStore } from "ducks/collectibles"; @@ -34,6 +38,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, @@ -147,6 +152,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, })); @@ -1783,11 +1794,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; }); @@ -2074,7 +2091,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 +2373,198 @@ describe("auth duck", () => { expect(result.current.network).toBe(NETWORKS.PUBLIC); }); }); + + describe("isSessionAuthValid", () => { + // 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(hashKey); + (secureDataStorage.remove as jest.Mock).mockResolvedValue(undefined); + (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); + }); + + it("returns false when no accounts exist (NOT_AUTHENTICATED)", async () => { + mockFunnelStorage({ hasAccount: false }); + expect(await isSessionAuthValid()).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 the hash key is expired", async () => { + mockFunnelStorage({ + hashKey: { + hashKey: "mock-hash-key", + salt: "mock-salt", + expiresAt: Date.now() - 1000, + }, + }); + expect(await isSessionAuthValid()).toBe(false); + }); + + it("returns false when the session is persisted LOCKED", async () => { + mockFunnelStorage({ persistedLocked: true }); + expect(await isSessionAuthValid()).toBe(false); + }); + + 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) + }); + 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)", () => { + 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/__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/auth/deriveAuthKeypair.test.ts b/__tests__/services/auth/deriveAuthKeypair.test.ts new file mode 100644 index 000000000..1d02f34fb --- /dev/null +++ b/__tests__/services/auth/deriveAuthKeypair.test.ts @@ -0,0 +1,50 @@ +import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; +import { + AUTH_SALT, + deriveAuthKeypair, + deriveAuthSeed, +} from "services/auth/deriveAuthKeypair"; + +describe("deriveAuthKeypair", () => { + it("uses the versioned salt", () => { + expect(AUTH_SALT).toBe("freighter-auth-v1"); + }); + + it.each(AUTH_KEYPAIR_VECTORS)( + "matches vector %#", + async ({ mnemonic, authSeedHex, userId }) => { + expect((await deriveAuthSeed(mnemonic)).toString("hex")).toBe( + authSeedHex, + ); + expect((await deriveAuthKeypair(mnemonic)).userId).toBe(userId); + }, + ); + + it("is deterministic", async () => { + const m = AUTH_KEYPAIR_VECTORS[0].mnemonic; + 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", async () => { + expect( + (await deriveAuthKeypair(AUTH_KEYPAIR_VECTORS[0].mnemonic)).userId, + ).toMatch(/^[0-9a-f]{64}$/); + }); + + 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((await deriveAuthKeypair(m)).userId).not.toBe(walletAccountZeroHex); + }); + + 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 new file mode 100644 index 000000000..eba2352b7 --- /dev/null +++ b/__tests__/services/auth/getAuthKeypair.test.ts @@ -0,0 +1,105 @@ +import * as authDuck from "ducks/auth"; +import { clearAuthKeypairCache } from "services/auth/authKeypairCache"; +import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; +import { getAuthKeypair } from "services/auth/getAuthKeypair"; + +// Mock ducks/auth: expose both getActiveMnemonicPhrase and isSessionAuthValid +// so we can drive the auth-status gate directly. +jest.mock("ducks/auth", () => ({ + getActiveMnemonicPhrase: jest.fn(), + isSessionAuthValid: jest.fn(), +})); + +const mockMnemonic = authDuck.getActiveMnemonicPhrase as jest.Mock; +const mockIsSessionAuthValid = authDuck.isSessionAuthValid as jest.Mock; +const M = AUTH_KEYPAIR_VECTORS[0]; + +describe("getAuthKeypair", () => { + beforeEach(() => { + clearAuthKeypairCache(); + jest.clearAllMocks(); + // Default: session is fully valid. + mockIsSessionAuthValid.mockResolvedValue(true); + }); + + 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 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(); + 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. + mockIsSessionAuthValid.mockResolvedValue(false); + + expect(await getAuthKeypair()).toBeNull(); + }); + + it("returns null when AUTHENTICATED but no mnemonic available", 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); + }); + + 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); + }); + + 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/__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/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/ducks/auth.ts b/src/ducks/auth.ts index a9ee1b416..2308d4558 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/authKeypairCache"; import { clearBackgroundedAt, getAutoLockTimer, @@ -1102,6 +1103,7 @@ const deriveKeyPair = (params: DeriveKeypairParams) => { */ const clearAllData = async (): Promise => { clearDerivedKeyCache(); + clearAuthKeypairCache(); const allKeys = await keyManager.loadAllKeyIds(); @@ -2151,6 +2153,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 +2185,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(); @@ -2268,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 @@ -2363,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) { @@ -2813,6 +2825,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(); } @@ -2821,8 +2836,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(); } @@ -3106,3 +3123,94 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ signInMethod: method }); }, })); + +/** + * Returns the mnemonic phrase from the unlocked temporary store, + * 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, + ); + // 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 (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; + } +}; + +// 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. 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 => { + 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/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/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/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/auth/deriveAuthKeypair.ts b/src/services/auth/deriveAuthKeypair.ts new file mode 100644 index 000000000..ce21a1b6b --- /dev/null +++ b/src/services/auth/deriveAuthKeypair.ts @@ -0,0 +1,40 @@ +// `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 { 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). + * 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 = await mnemonicToSeed(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 = async ( + mnemonic: string, +): 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 new file mode 100644 index 000000000..386a41963 --- /dev/null +++ b/src/services/auth/getAuthKeypair.ts @@ -0,0 +1,49 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import { logger } from "config/logger"; +import { getActiveMnemonicPhrase, isSessionAuthValid } from "ducks/auth"; +import { + getCachedAuthKeypair, + setCachedAuthKeypair, +} from "services/auth/authKeypairCache"; +import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; + +// 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 => { + 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; + } +}; 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) { 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"