diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 6f877506f..e8e065c4d 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -17,6 +17,8 @@ import { appendAccounts, clearAccountData, isSessionAuthValid, + clearSessionAuthValidMemo, + SESSION_AUTH_VALID_TTL_MS, getActiveMnemonicPhrase, } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; @@ -2426,6 +2428,11 @@ describe("auth duck", () => { (secureDataStorage.setItem as jest.Mock).mockResolvedValue(undefined); }; + // The memo is module-scoped state that would otherwise leak across tests. + beforeEach(() => { + clearSessionAuthValidMemo(); + }); + it("returns true when the funnel reports AUTHENTICATED (accounts + valid hash + within timer)", async () => { mockFunnelStorage(); expect(await isSessionAuthValid()).toBe(true); @@ -2467,6 +2474,40 @@ describe("auth duck", () => { }); expect(await isSessionAuthValid()).toBe(false); }); + + it("dedupes a concurrent burst into a single funnel run (in-flight promise shared)", async () => { + mockFunnelStorage(); + (getHashKey as jest.Mock).mockClear(); + + const results = await Promise.all([ + isSessionAuthValid(), + isSessionAuthValid(), + isSessionAuthValid(), + ]); + + expect(results).toEqual([true, true, true]); + // getAuthStatus (→ getHashKey once per run) ran a single time for all three. + expect(getHashKey).toHaveBeenCalledTimes(1); + }); + + it("memoizes within the TTL and re-runs the funnel once it expires", async () => { + const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_000_000); + mockFunnelStorage(); + (getHashKey as jest.Mock).mockClear(); + + await isSessionAuthValid(); + await isSessionAuthValid(); + await isSessionAuthValid(); + // Second and third calls are served from the memo — one funnel run. + expect(getHashKey).toHaveBeenCalledTimes(1); + + // Advance past the TTL: the memo expires and the funnel runs again. + nowSpy.mockReturnValue(1_000_000 + SESSION_AUTH_VALID_TTL_MS + 1); + await isSessionAuthValid(); + expect(getHashKey).toHaveBeenCalledTimes(2); + + nowSpy.mockRestore(); + }); }); describe("getActiveMnemonicPhrase lock race (TOCTOU)", () => { diff --git a/__tests__/services/api.test.ts b/__tests__/services/api.test.ts index 5681a8670..037e2dad7 100644 --- a/__tests__/services/api.test.ts +++ b/__tests__/services/api.test.ts @@ -14,6 +14,12 @@ jest.mock("services/apiFactory", () => ({ createApiService: jest.fn(() => ({ get: jest.fn(), post: jest.fn(), + getInstance: jest.fn(() => ({ + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + })), })), isRequestCanceled: jest.fn(), })); diff --git a/__tests__/services/auth/attachAuth.test.ts b/__tests__/services/auth/attachAuth.test.ts new file mode 100644 index 000000000..ee33ac587 --- /dev/null +++ b/__tests__/services/auth/attachAuth.test.ts @@ -0,0 +1,969 @@ +/* eslint-disable no-underscore-dangle */ +/** + * Tests for attachAuthInterceptors helper. + * + * Strategy A (isolated handler tests): build a minimal fake AxiosInstance that + * captures the interceptor functions registered via `interceptors.request.use` + * and `interceptors.response.use`. We then invoke those functions directly to + * exercise the logic without any real HTTP calls. + * + * Strategy B (integration tests): build a REAL instance via + * `createApiService({ configureInstance: attachAuthInterceptors })` with a + * custom axios adapter so requests never hit the network. These tests prove + * that the 401-retry fires through the full interceptor chain — including + * apiFactory's error-normalizing response interceptor — which the isolated + * handler tests cannot cover because they bypass the normalizer entirely. + * + * Modules under test: + * - services/auth/attachAuth (the helper being implemented) + * - services/apiFactory (createApiService, integration only) + * + * Dependencies mocked: + * - services/auth/getAuthKeypair (getAuthKeypair) + * - services/auth/buildAuthJwt (buildAuthJwt — mocked for isolated tests, + * real implementation used for integration) + */ +import { Keypair } from "@stellar/stellar-sdk"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { debug } from "helpers/debug"; +import { createApiService, isApiError } from "services/apiFactory"; +import { attachAuthInterceptors } from "services/auth/attachAuth"; +import { buildAuthJwt } from "services/auth/buildAuthJwt"; +import { getAuthKeypair } from "services/auth/getAuthKeypair"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +jest.mock("services/auth/getAuthKeypair", () => ({ + getAuthKeypair: jest.fn(), +})); + +// apiFactory's logRequests debug logger — spy so we can assert redaction. +jest.mock("helpers/debug", () => ({ + debug: jest.fn(), +})); + +jest.mock("services/auth/buildAuthJwt", () => ({ + buildAuthJwt: jest.fn(), +})); + +// apiFactory imports config/logger; silence it in the integration tests. +jest.mock("config/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const mockGetAuthKeypair = jest.mocked(getAuthKeypair); +const mockBuildAuthJwt = jest.mocked(buildAuthJwt); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Fixed test keypair — deterministic, never changes. */ +const SEED = Buffer.alloc(32, 7); +const TEST_KEYPAIR = Keypair.fromRawEd25519Seed(SEED); + +/** b64url → plain JSON */ +const decodeSegment = (seg: string): Record => + JSON.parse( + Buffer.from(seg.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8", + ), + ) as Record; + +/** Decode the payload segment from a JWT string. */ +const decodePayload = (jwt: string): Record => + decodeSegment(jwt.split(".")[1]); + +/** + * Build a minimal fake AxiosInstance that records every interceptor registered + * with it and exposes helpers for invoking them. + */ +function makeFakeInstance() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reqFulfilled: Array<(cfg: any) => any> = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resRejected: Array<(err: any) => any> = []; + + // Tracks calls to instance.request(...) for 401 retry assertions. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requestCalls: any[][] = []; + + const instance = { + interceptors: { + request: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + use(fulfilled: (cfg: any) => any) { + reqFulfilled.push(fulfilled); + }, + }, + response: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + use(_fulfilled: (res: any) => any, rejected?: (err: any) => any) { + if (rejected) resRejected.push(rejected); + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: jest.fn((...args: any[]) => { + requestCalls.push(args); + return Promise.resolve({ data: "retried" }); + }), + // Faithful stand-in for axios's getUri (baseURL join + param serialize) so + // the interceptor can derive the signed path. Overridable per test via + // mockReturnValue; the REAL serializer is exercised in the integration tests. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getUri: jest.fn((config: any) => { + const base = (config.baseURL ?? "").replace(/\/+$/, ""); + let uri = `${base}${config.url ?? ""}`; + const p = config.params; + if (p) { + const qs = + p instanceof URLSearchParams + ? p.toString() + : new URLSearchParams( + Object.entries(p) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => [k, String(v)] as [string, string]), + ).toString(); + if (qs) uri += (uri.includes("?") ? "&" : "?") + qs; + } + return uri; + }), + }; + + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instance: instance as unknown as import("axios").AxiosInstance, + + /** Run all registered request interceptors in order. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async runRequestInterceptors(cfg: any): Promise { + let current = cfg; + for (let i = 0; i < reqFulfilled.length; i += 1) { + // eslint-disable-next-line no-await-in-loop + current = await reqFulfilled[i](current); + } + return current; + }, + + /** Run the first registered response error handler with an error object. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runResponseError(err: any): Promise { + if (resRejected.length === 0) + throw new Error("No response error handler registered"); + return resRejected[0](err) as Promise; + }, + + requestCalls, + }; +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("attachAuthInterceptors", () => { + const BASE_URL = "https://mock-backend-v2-dev.example.com/api/v1"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // (a) Unlocked wallet — Authorization header present, path includes /api/v1 + // ------------------------------------------------------------------------- + + describe("when wallet is unlocked (getAuthKeypair returns a Keypair)", () => { + it("sets Authorization: Bearer on the config", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + // Use jest.requireActual to get the real buildAuthJwt without going + // through the mock (which would cause an infinite loop). + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { buildAuthJwt: realBuildAuthJwt } = jest.requireActual( + "services/auth/buildAuthJwt", + ); + mockBuildAuthJwt.mockImplementation(realBuildAuthJwt); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + }; + const result = await runRequestInterceptors(cfg); + + expect(result.headers?.Authorization).toMatch(/^Bearer /); + }); + + it("builds the JWT with methodAndPath including /api/v1 prefix and query", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols?network=PUBLIC", + method: "get", + headers: {}, + data: undefined, + }; + await runRequestInterceptors(cfg); + + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/api/v1/protocols?network=PUBLIC", + }), + ); + }); + + it("signs a query-less path with NO trailing slash (RN URL non-spec regression)", async () => { + // React Native's built-in URL appends "/" to a query-less URL; if the + // signed path used `new URL().pathname` it would be "/api/v1/protocols/" + // on device (≠ wire "/api/v1/protocols" → 401). The origin-strip must + // preserve getUri's output verbatim. + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + }; + await runRequestInterceptors(cfg); + + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ path: "/api/v1/protocols" }), + ); + }); + + it("passes method and body correctly to buildAuthJwt", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const bodyStr = '{"tokens":["XLM"]}'; + const cfg = { + baseURL: BASE_URL, + url: "/token-prices?network=PUBLIC", + method: "POST", + headers: {}, + data: bodyStr, + }; + await runRequestInterceptors(cfg); + + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ + keypair: TEST_KEYPAIR, + method: "POST", + path: "/api/v1/token-prices?network=PUBLIC", + body: bodyStr, + }), + ); + }); + + it("serializes an object body so the hashed bytes equal the wire bytes", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/token-prices", + method: "post", + headers: {}, + data: { tokens: ["XLM"] }, // plain object — interceptor serializes it + }; + const result = await runRequestInterceptors(cfg); + + const expected = JSON.stringify({ tokens: ["XLM"] }); + // config.data is rewritten to the serialized string axios will send... + expect(result.data).toBe(expected); + // ...and that exact string is what gets hashed. + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ body: expected }), + ); + }); + + it("leaves an already-serialized string body untouched (no double-encode)", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const raw = JSON.stringify({ tokens: ["XLM"] }); + const cfg = { + baseURL: BASE_URL, + url: "/token-prices", + method: "post", + headers: {}, + data: raw, + }; + const result = await runRequestInterceptors(cfg); + + expect(result.data).toBe(raw); + expect(mockBuildAuthJwt).toHaveBeenCalledWith( + expect.objectContaining({ body: raw }), + ); + }); + + it("decoded JWT methodAndPath equals full path derived from baseURL + url", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + // Use jest.requireActual to get the real buildAuthJwt so we can decode the JWT. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { buildAuthJwt: realBuildAuthJwt } = jest.requireActual( + "services/auth/buildAuthJwt", + ); + mockBuildAuthJwt.mockImplementation(realBuildAuthJwt); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/collectibles?network=PUBLIC", + method: "get", + headers: {}, + data: undefined, + }; + const result = await runRequestInterceptors(cfg); + + const payload = decodePayload( + (result.headers?.Authorization as string).replace("Bearer ", ""), + ); + + expect(payload.methodAndPath).toBe( + "GET /api/v1/collectibles?network=PUBLIC", + ); + }); + }); + + // ------------------------------------------------------------------------- + // (a2) Unlocked wallet — config.params folding + // ------------------------------------------------------------------------- + + describe("when wallet is unlocked and config.params is supplied", () => { + it("signs the request-target from instance.getUri and leaves config.params for axios", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { buildAuthJwt: realBuildAuthJwt } = jest.requireActual( + "services/auth/buildAuthJwt", + ); + mockBuildAuthJwt.mockImplementation(realBuildAuthJwt); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + // getUri returns exactly what axios would put on the wire. + (instance.getUri as jest.Mock).mockReturnValue( + `${BASE_URL}/protocols?network=PUBLIC`, + ); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + params: { network: "PUBLIC" }, + }; + const result = await runRequestInterceptors(cfg); + + // Signed methodAndPath is the pathname+search of getUri's output. + const payload = decodePayload( + (result.headers?.Authorization as string).replace("Bearer ", ""), + ); + expect(payload.methodAndPath).toBe( + "GET /api/v1/protocols?network=PUBLIC", + ); + // getUri was consulted with the request config... + expect(instance.getUri).toHaveBeenCalledWith(cfg); + // ...and we do NOT fold/delete — params and url are left intact for axios + // to serialize identically on the wire. (Real serialization: integration.) + expect(result.params).toEqual({ network: "PUBLIC" }); + expect(result.url).toBe("/protocols"); + }); + }); + + // ------------------------------------------------------------------------- + // (b) Locked wallet — no Authorization header + // ------------------------------------------------------------------------- + + describe("when wallet is locked (getAuthKeypair returns null)", () => { + it("returns config unmodified (no Authorization header)", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + }; + const result = await runRequestInterceptors(cfg); + + expect(result.headers?.Authorization).toBeUndefined(); + expect(mockBuildAuthJwt).not.toHaveBeenCalled(); + }); + + it("does NOT touch config.params when wallet is locked (params flow through axios normally)", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: {}, + data: undefined, + params: { network: "PUBLIC" }, + }; + const result = await runRequestInterceptors(cfg); + + // No Authorization header. + expect(result.headers?.Authorization).toBeUndefined(); + // config.params must be left intact for axios to append on send. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(result.params).toEqual({ network: "PUBLIC" }); + // config.url must be unchanged (no merging). + expect(result.url).toBe("/protocols"); + }); + + it("strips a stale Authorization header when locked (401-retry copies the pre-lock token)", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + + const { instance, runRequestInterceptors } = makeFakeInstance(); + attachAuthInterceptors(instance); + + // Simulates a 401-retry whose config was copied from the authed first + // attempt (carrying a Bearer token) after the wallet locked. The locked + // request must go out anonymously — the stale token must not be re-sent. + const cfg = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + headers: { Authorization: "Bearer stale.jwt.token" }, + data: undefined, + }; + const result = await runRequestInterceptors(cfg); + + expect(result.headers?.Authorization).toBeUndefined(); + expect(mockBuildAuthJwt).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // (c) 401 response — retry exactly once with a fresh token + // ------------------------------------------------------------------------- + + describe("on 401 response", () => { + it("retries exactly once via instance.request with __isAuthRetry=true", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runResponseError, requestCalls } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const originalConfig = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + // Simulate that the request interceptor already attached a JWT — + // hadAuth must be true for the retry guard to fire. + headers: { Authorization: "Bearer mock.jwt.token" }, + data: undefined, + __isAuthRetry: false, + }; + const err = { + response: { status: 401 }, + config: originalConfig, + }; + + await runResponseError(err); + + expect(requestCalls).toHaveLength(1); + expect(requestCalls[0][0].__isAuthRetry).toBe(true); + }); + + it("does NOT retry a second time when __isAuthRetry is already true (returns the second 401)", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("mock.jwt.token"); + + const { instance, runResponseError, requestCalls } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const retryConfig = { + baseURL: BASE_URL, + url: "/protocols", + method: "get", + // The signed retry carried a JWT (hadAuth) and just 401'd again. + headers: { Authorization: "Bearer mock.jwt.token" }, + data: undefined, + __isAuthRetry: true, + }; + const err = { response: { status: 401 }, config: retryConfig }; + + // No anonymous fallback (matches the extension): the 401 surfaces. + await expect(runResponseError(err)).rejects.toBe(err); + expect(requestCalls).toHaveLength(0); + }); + + it("does NOT retry on non-401 errors", async () => { + const { instance, runResponseError, requestCalls } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const err = { + response: { status: 500 }, + config: { headers: {}, __isAuthRetry: false }, + }; + + await expect(runResponseError(err)).rejects.toBe(err); + expect(requestCalls).toHaveLength(0); + }); + + it("does NOT retry when error has no response (network error)", async () => { + const { instance, runResponseError, requestCalls } = makeFakeInstance(); + attachAuthInterceptors(instance); + + const err = { + response: undefined, + config: { headers: {}, __isAuthRetry: false }, + }; + + await expect(runResponseError(err)).rejects.toBe(err); + expect(requestCalls).toHaveLength(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests — real createApiService + real interceptor chain +// +// These tests prove the 401-retry fires through the FULL interceptor chain, +// including apiFactory's error-normalizing response interceptor. The isolated +// handler tests above bypass that normalizer; this suite covers the gap that +// hid the original bug (auth interceptors registered after the normalizer). +// --------------------------------------------------------------------------- + +/** + * Reject with a proper AxiosError so that axios response interceptors receive + * a real AxiosError (with `.response` and `.config` set) rather than a plain + * object. Custom adapters in axios 1.x do NOT run through `settle` + * automatically — the built-in http/xhr adapters call `settle` internally. + * For a custom adapter to trigger the error path the adapter must reject with + * an AxiosError itself. + */ +function make401AxiosError(config: InternalAxiosRequestConfig): AxiosError { + const response = { + data: { message: "Unauthorized" }, + status: 401, + statusText: "Unauthorized", + headers: {}, + config, + } as import("axios").AxiosResponse; + const err = new axios.AxiosError( + "Request failed with status code 401", + "ERR_BAD_REQUEST", + config, + null, + response, + ); + return err; +} + +/** + * Build an axios adapter that rejects with a 401 AxiosError on the first call + * and resolves with 200 on the second. Captures every config it receives so + * the caller can inspect the Authorization headers used on each attempt. + */ +function makeTwoCallAdapter(capturedConfigs: InternalAxiosRequestConfig[]) { + let callCount = 0; + return async function adapter( + config: InternalAxiosRequestConfig, + ): Promise { + capturedConfigs.push(config); + callCount += 1; + if (callCount === 1) { + // First call → 401 Unauthorized (reject with AxiosError so axios + // response error interceptors see .response/.config intact). + return Promise.reject(make401AxiosError(config)); + } + // Second call → 200 OK. + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; +} + +/** + * Build an adapter that always rejects with 401 (simulates a second + * consecutive 401 after the retry — terminal failure path). + */ +function makeAlways401Adapter(capturedConfigs: InternalAxiosRequestConfig[]) { + return async function adapter( + config: InternalAxiosRequestConfig, + ): Promise { + capturedConfigs.push(config); + return Promise.reject(make401AxiosError(config)); + }; +} + +describe("attachAuthInterceptors — integration through full apiFactory chain", () => { + const BASE_URL = "https://mock-backend-v2-dev.example.com/api/v1"; + + beforeEach(() => { + jest.clearAllMocks(); + // Use the real buildAuthJwt for all integration tests so we can inspect + // the actual JWT payloads and assert fresh tokens on retry. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { buildAuthJwt: realBuildAuthJwt } = jest.requireActual( + "services/auth/buildAuthJwt", + ); + mockBuildAuthJwt.mockImplementation(realBuildAuthJwt); + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + }); + + it("adapter is called exactly twice and request succeeds with 200 on the second call", async () => { + // This is the regression test for the ordering bug. Before the fix, + // the auth retry interceptor was registered AFTER apiFactory's normalizer. + // On a 401 the normalizer would convert the AxiosError to a plain ApiError + // (dropping .response), so err.response?.status === 401 was never true and + // the retry never fired — the request failed with ApiError{ status: 401 }. + // After the fix, auth interceptors are registered via configureInstance + // (BEFORE the normalizer), so they see the raw AxiosError and the retry + // fires correctly. + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + // Inject the adapter after service creation — this controls HTTP responses + // without changing the interceptor registration order. + api.getInstance().defaults.adapter = makeTwoCallAdapter(capturedConfigs); + + const result = await api.get<{ ok: boolean }>("/token-prices"); + + // Adapter was called twice: first 401, then 200. + expect(capturedConfigs).toHaveLength(2); + // The final response reaches the consumer as a 200. + expect(result.status).toBe(200); + expect(result.data).toEqual({ ok: true }); + }); + + it("second request carries a fresh JWT that differs from the first", async () => { + // The retry must re-run the request interceptor to get a new JWT. + // We use fake timers to advance the clock by 2 s between the first and + // second call so the `iat` claim differs deterministically — without this + // the two JWTs may be identical when both calls land in the same second. + jest.useFakeTimers(); + const baseNow = Date.now(); + jest.setSystemTime(baseNow); + + const capturedConfigs: InternalAxiosRequestConfig[] = []; + + // Wrap the real adapter so we can advance the clock between calls. + let callCount = 0; + const clockAdvancingAdapter = async ( + config: InternalAxiosRequestConfig, + ): Promise => { + capturedConfigs.push(config); + callCount += 1; + if (callCount === 1) { + // Advance clock BEFORE the retry so the second JWT has a later iat. + jest.advanceTimersByTime(2000); + // Reject with an AxiosError so interceptors see .response/.config. + return Promise.reject(make401AxiosError(config)); + } + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; + + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = clockAdvancingAdapter; + + await api.get<{ ok: boolean }>("/protocols"); + + jest.useRealTimers(); + + expect(capturedConfigs).toHaveLength(2); + + // Extract Authorization headers from both captured configs. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const token1 = (capturedConfigs[0].headers as Record) + .Authorization as string | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const token2 = (capturedConfigs[1].headers as Record) + .Authorization as string | undefined; + + expect(token1).toMatch(/^Bearer /); + expect(token2).toMatch(/^Bearer /); + + // The two tokens must differ — the second request must have triggered the + // request interceptor again to build a fresh JWT. + expect(token1).not.toBe(token2); + + // Verify the iat in the second JWT is later than in the first. + const payload1 = decodePayload(token1!.replace("Bearer ", "")); + const payload2 = decodePayload(token2!.replace("Bearer ", "")); + expect(payload2.iat as number).toBeGreaterThan(payload1.iat as number); + }); + + it("a second consecutive 401 rejects with ApiError{ status: 401 } and does NOT retry more than once", async () => { + // When both the initial request AND the retry return 401: + // - original (JWT) → 401 → signed retry (__isAuthRetry) → 401 → reject. + // No anonymous fallback (matches the extension's authedFetch). + // - The inner normalizer converts the second AxiosError → ApiError{ 401 }; + // the outer normalizer is idempotent (isApiError guard) and rethrows it + // unchanged, preserving status: 401 instead of clobbering it to 0. + // Key assertions: + // (a) no infinite retry loop (adapter called exactly twice), + // (b) the consumer receives ApiError{ status: 401 } — NOT status: 0. + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = makeAlways401Adapter(capturedConfigs); + + await expect(api.get("/protocols")).rejects.toMatchObject({ + status: 401, + isNetworkError: false, + message: expect.any(String), + }); + + // Exactly 2 adapter calls: original + one retry. A third would mean the + // guard flag is broken (or an anonymous fallback crept back in). + expect(capturedConfigs).toHaveLength(2); + }); + + // ------------------------------------------------------------------------- + // isApiError type guard + // ------------------------------------------------------------------------- + + it("isApiError returns true for an ApiError-shaped object", () => { + const apiErr = { + message: "Unauthorized", + status: 401, + isNetworkError: false, + }; + expect(isApiError(apiErr)).toBe(true); + }); + + it("isApiError returns false for a real AxiosError", () => { + const axiosErr = new axios.AxiosError("Request failed"); + expect(isApiError(axiosErr)).toBe(false); + }); + + it("isApiError returns false for a plain object missing required fields", () => { + expect(isApiError({ message: "oops" })).toBe(false); + expect(isApiError(null)).toBe(false); + expect(isApiError("string error")).toBe(false); + }); + + it("anonymous request (no JWT) that receives a 401 does NOT retry", async () => { + // When the wallet is locked, getAuthKeypair() returns null and no + // Authorization header is attached. Retrying such a request is pointless + // — nothing would change on the second attempt. The response interceptor + // must detect the absence of an Authorization header and skip the retry. + mockGetAuthKeypair.mockResolvedValue(null); + + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = makeAlways401Adapter(capturedConfigs); + + await expect(api.get("/protocols")).rejects.toMatchObject({ + status: 401, + isNetworkError: false, + message: expect.any(String), + }); + + // Exactly 1 adapter call — the retry must not have fired. + expect(capturedConfigs).toHaveLength(1); + }); + + // ------------------------------------------------------------------------- + // config.params folding — integration: signed path == wire path + // ------------------------------------------------------------------------- + + it("authed request with config.params: signed methodAndPath matches the wire query (params left for axios)", async () => { + // Prove it end-to-end with the REAL axios chain: the JWT claim equals the + // request-target axios will build, and we don't fold/delete params. + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + // Adapter: always 200 so we can inspect what it received. + api.getInstance().defaults.adapter = ( + config: InternalAxiosRequestConfig, + ) => { + capturedConfigs.push(config); + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; + + await api.get("/token-prices", { params: { network: "PUBLIC" } }); + + expect(capturedConfigs).toHaveLength(1); + const captured = capturedConfigs[0]; + + // The signed methodAndPath equals the request-target axios builds for the + // captured config (getUri shares axios's builder with the adapter). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const authHeader = (captured.headers as Record) + .Authorization as string; + expect(authHeader).toMatch(/^Bearer /); + const payload = decodePayload(authHeader.replace("Bearer ", "")); + expect(payload.methodAndPath).toBe( + "GET /api/v1/token-prices?network=PUBLIC", + ); + // The query reaches the wire (regardless of whether axios keeps it on + // url or params) — proving we didn't drop it by not folding. + expect(api.getInstance().getUri(captured)).toContain("?network=PUBLIC"); + }); + + it("config.url already has a query + config.params: joined with & not a second ?", async () => { + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = ( + config: InternalAxiosRequestConfig, + ) => { + capturedConfigs.push(config); + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; + + await api.get("/foo?a=1", { params: { b: "2" } }); + + expect(capturedConfigs).toHaveLength(1); + const captured = capturedConfigs[0]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const authHeader = (captured.headers as Record) + .Authorization as string; + const payload = decodePayload(authHeader.replace("Bearer ", "")); + // Signed path uses & (axios's builder joins the pre-existing query). + expect(payload.methodAndPath).toBe("GET /api/v1/foo?a=1&b=2"); + // And that's the URL axios will actually send. + expect(api.getInstance().getUri(captured)).toContain("/foo?a=1&b=2"); + }); + + it("anonymous request with config.params: interceptor leaves params for axios to handle", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + + const capturedConfigs: InternalAxiosRequestConfig[] = []; + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + }); + api.getInstance().defaults.adapter = ( + config: InternalAxiosRequestConfig, + ) => { + capturedConfigs.push(config); + return Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + }; + + await api.get("/protocols", { params: { network: "PUBLIC" } }); + + expect(capturedConfigs).toHaveLength(1); + const captured = capturedConfigs[0]; + + // Anonymous path: interceptor must not strip params. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const authHeader = (captured.headers as Record) + .Authorization as string | undefined; + expect(authHeader).toBeUndefined(); + // axios serialises params onto the URL for the adapter, so by the time + // the adapter fires, params may or may not still be on the config — + // what matters is that no Authorization header was set. + }); + + it("logRequests never logs the Bearer token (redacted)", async () => { + mockGetAuthKeypair.mockResolvedValue(TEST_KEYPAIR); + mockBuildAuthJwt.mockReturnValue("super.secret.jwt"); + (debug as jest.Mock).mockClear(); + + const api = createApiService({ + baseURL: BASE_URL, + configureInstance: attachAuthInterceptors, + logRequests: true, + }); + api.getInstance().defaults.adapter = (config: InternalAxiosRequestConfig) => + Promise.resolve({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + + await api.get("/token-prices"); + + // The request logger ran with the token attached, but redacted it. + const logged = (debug as jest.Mock).mock.calls + .map((args) => args.join(" ")) + .join("\n"); + expect(logged).toContain("[REDACTED]"); + expect(logged).not.toContain("super.secret.jwt"); + }); +}); diff --git a/__tests__/services/auth/authJwt.integration.test.ts b/__tests__/services/auth/authJwt.integration.test.ts new file mode 100644 index 000000000..935eff1ee --- /dev/null +++ b/__tests__/services/auth/authJwt.integration.test.ts @@ -0,0 +1,136 @@ +/** + * Gated E2E integration test: round-trips a real JWT against the staging + * backend's /api/v1/auth/whoami route. + * + * HOW TO RUN: + * BACKEND_V2_URL=https://freighter-backend-v2-stg.stellar.org \ + * yarn jest __tests__/services/auth/authJwt.integration.test.ts + * + * Or with the IS_INTEGRATION_MODE flag (uses the staging URL by default): + * IS_INTEGRATION_MODE=1 yarn jest __tests__/services/auth/authJwt.integration.test.ts + * + * Normal CI: suite is SKIPPED — set neither env var and all tests are omitted. + */ +import { AUTH_KEYPAIR_VECTORS } from "services/auth/authKeypairVectors"; +import { buildAuthJwt } from "services/auth/buildAuthJwt"; +import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; + +// --------------------------------------------------------------------------- +// Gating: skip the whole suite unless an integration flag is present +// --------------------------------------------------------------------------- +const RUN = !!(process.env.BACKEND_V2_URL || process.env.IS_INTEGRATION_MODE); +const d = RUN ? describe : describe.skip; + +const BASE_URL = + process.env.BACKEND_V2_URL || "https://freighter-backend-v2-stg.stellar.org"; + +const WHOAMI_PATH = "/api/v1/auth/whoami"; +const WHOAMI_URL = `${BASE_URL}${WHOAMI_PATH}`; + +// Vector index 1 (0-based): +// mnemonic: "illness spike retreat truth genius clock brain pass fit cave bargain toe" +// userId: "bd9498475c7191c5e9a5e18edda2402ab0ae527580a6c38b2a32a77c65729cd7" +const VECTOR = AUTH_KEYPAIR_VECTORS[1]; + +d("auth JWT e2e", () => { + let keypair: Awaited>["keypair"]; + let userId: string; + + beforeAll(async () => { + // deriveAuthKeypair is async as of #920 (bip39 mnemonicToSeed off the JS thread). + const derived = await deriveAuthKeypair(VECTOR.mnemonic); + keypair = derived.keypair; + userId = derived.userId; + }); + + it("valid JWT → 200 with authenticated:true and correct userId", async () => { + const jwt = buildAuthJwt({ + keypair, + method: "GET", + path: WHOAMI_PATH, + }); + + const res = await fetch(WHOAMI_URL, { + method: "GET", + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status).toBe(200); + + // The backend wraps successful responses in its standard `{ data: ... }` + // envelope (verified against staging), so whoami is at body.data. + const body = (await res.json()) as { + data: { authenticated: boolean; userId: string }; + }; + expect(body.data.authenticated).toBe(true); + expect(body.data.userId).toBe(userId); + // Double-check against the hard-coded vector value for extra safety + expect(body.data.userId).toBe(VECTOR.userId); + }); + + it("tampered body: JWT built with body hash mismatch → 401", async () => { + // JWT claims a non-empty body but GET is sent with no body — server must + // reject because the bodyHash in the token won't match the empty-body hash. + const jwt = buildAuthJwt({ + keypair, + method: "GET", + path: WHOAMI_PATH, + body: '{"tampered":true}', + }); + + const res = await fetch(WHOAMI_URL, { + method: "GET", + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status).toBe(401); + }); + + it("flipped signature byte → 401", async () => { + const jwt = buildAuthJwt({ + keypair, + method: "GET", + path: WHOAMI_PATH, + }); + + const parts = jwt.split("."); + // Decode the signature segment, flip a byte, re-encode + const sigBytes = Buffer.from( + parts[2].replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ); + sigBytes[0] = sigBytes[0] === 0 ? 1 : 0; // flip the first byte to something different + const corruptedSig = sigBytes + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const corruptedJwt = `${parts[0]}.${parts[1]}.${corruptedSig}`; + + const res = await fetch(WHOAMI_URL, { + method: "GET", + headers: { Authorization: `Bearer ${corruptedJwt}` }, + }); + + expect(res.status).toBe(401); + }); + + it("expired JWT → 401", async () => { + // Build with now 60 seconds in the past so exp is already past + const expiredNow = Date.now() - 60_000; + const jwt = buildAuthJwt({ + keypair, + method: "GET", + path: WHOAMI_PATH, + now: expiredNow, + }); + + const res = await fetch(WHOAMI_URL, { + method: "GET", + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/__tests__/services/auth/buildAuthJwt.test.ts b/__tests__/services/auth/buildAuthJwt.test.ts new file mode 100644 index 000000000..cd28e2963 --- /dev/null +++ b/__tests__/services/auth/buildAuthJwt.test.ts @@ -0,0 +1,122 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import { + ISS, + JWT_LIFETIME_SECONDS, + buildAuthJwt, +} from "services/auth/buildAuthJwt"; + +// Fixed test fixtures +const SEED = Buffer.alloc(32, 7); +const keypair = Keypair.fromRawEd25519Seed(SEED); +const NOW = 1_700_000_000_000; + +const b64urlDecode = (s: string): Buffer => + Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64"); + +describe("buildAuthJwt", () => { + it("exports ISS = 'freighter-mobile'", () => { + expect(ISS).toBe("freighter-mobile"); + }); + + it("exports JWT_LIFETIME_SECONDS = 15", () => { + expect(JWT_LIFETIME_SECONDS).toBe(15); + }); + + describe("JWT structure", () => { + let token: string; + let headerJson: Record; + let payloadJson: Record; + let sigBytes: Buffer; + let headerSeg: string; + let payloadSeg: string; + + beforeEach(() => { + token = buildAuthJwt({ + keypair, + method: "get", + path: "/api/v1/auth/whoami", + now: NOW, + }); + const parts = token.split("."); + expect(parts).toHaveLength(3); + [headerSeg, payloadSeg] = parts; + headerJson = JSON.parse( + b64urlDecode(parts[0]).toString("utf8"), + ) as Record; + payloadJson = JSON.parse( + b64urlDecode(parts[1]).toString("utf8"), + ) as Record; + sigBytes = b64urlDecode(parts[2]); + }); + + it("has EdDSA header", () => { + expect(headerJson).toEqual({ alg: "EdDSA", typ: "JWT" }); + }); + + it("has sub = raw public key hex", () => { + expect(payloadJson.sub).toBe(keypair.rawPublicKey().toString("hex")); + }); + + it("has iss = 'freighter-mobile'", () => { + expect(payloadJson.iss).toBe("freighter-mobile"); + }); + + it("has iat derived from now", () => { + expect(payloadJson.iat).toBe(Math.floor(NOW / 1000)); + }); + + it("has exp - iat === 15", () => { + expect((payloadJson.exp as number) - (payloadJson.iat as number)).toBe( + 15, + ); + }); + + it("has methodAndPath = 'GET /api/v1/auth/whoami'", () => { + expect(payloadJson.methodAndPath).toBe("GET /api/v1/auth/whoami"); + }); + + it("upper-cases the method", () => { + const t = buildAuthJwt({ + keypair, + method: "post", + path: "/foo", + now: NOW, + }); + const p = JSON.parse( + b64urlDecode(t.split(".")[1]).toString("utf8"), + ) as Record; + expect(p.methodAndPath).toBe("POST /foo"); + }); + + it("has empty-body SHA-256 when no body is supplied", () => { + expect(payloadJson.bodyHash).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + }); + + it("has correct SHA-256 for body = 'hello'", () => { + const t = buildAuthJwt({ + keypair, + method: "POST", + path: "/foo", + body: "hello", + now: NOW, + }); + const p = JSON.parse( + b64urlDecode(t.split(".")[1]).toString("utf8"), + ) as Record; + expect(p.bodyHash).toBe( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ); + }); + + it("signature verifies against signing input", () => { + const signingInput = Buffer.from(`${headerSeg}.${payloadSeg}`, "utf8"); + expect(keypair.verify(signingInput, sigBytes)).toBe(true); + }); + + it("is url-safe: no +, /, or = characters", () => { + expect(token).not.toMatch(/[+/=]/); + }); + }); +}); diff --git a/__tests__/services/backend.test.ts b/__tests__/services/backend.test.ts index 435c661af..e765f4912 100644 --- a/__tests__/services/backend.test.ts +++ b/__tests__/services/backend.test.ts @@ -20,6 +20,12 @@ jest.mock("services/apiFactory", () => { createApiService: jest.fn(() => ({ get: jest.fn(), post: jest.fn(), + getInstance: jest.fn(() => ({ + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + })), })), isRequestCanceled: jest.fn(), }; @@ -705,6 +711,39 @@ describe("Backend Service - fetchCollectibles severity split", () => { expect.any(Error), ); }); + + it("sends a pre-serialized string body and query-in-URL so the JWT interceptor hashes the correct bytes", async () => { + // The JWT request interceptor hashes config.data ONLY when it is already a + // string. If the body is an object the interceptor would sign the empty-string + // hash, producing a token that never validates on the backend. This test locks + // the contract: body must be a string, query must be in the URL. + mockV2Post.mockResolvedValue({ + data: { + data: { + collections: [ + { + contract_id: "C...", + collection_name: "Test", + nfts: [], + }, + ], + }, + }, + status: 200, + statusText: "OK", + }); + + await fetchCollectibles(params); + + // Idiomatic axios: the network goes via `{ params }` (the JWT interceptor + // folds it into the signed path via getUri) and the body is a plain object + // (the interceptor serializes it so bodyHash matches the wire). + expect(mockV2Post).toHaveBeenCalledWith( + "/collectibles", + { owner: params.owner, contracts: params.contracts }, + { params: { network: params.network } }, + ); + }); }); describe("Backend Service - fetchTokenPrices v2 migration", () => { @@ -737,7 +776,10 @@ describe("Backend Service - fetchTokenPrices v2 migration", () => { it("hits the v2 client with a network query param and native id when useV2 is true", async () => { await fetchTokenPrices({ tokens, network: NETWORKS.PUBLIC, useV2: true }); - // Native "XLM" is sent to v2 as "native". + // Native "XLM" is sent to v2 as "native"; the network goes via `{ params }` + // (the JWT interceptor folds it into the signed path via getUri). The body + // is a plain object — the interceptor serializes it centrally so bodyHash + // matches the wire bytes. expect(mockV2Post).toHaveBeenCalledWith( "/token-prices", { tokens: v2Tokens }, diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 810bd5ff8..2308d4558 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2368,6 +2368,13 @@ export const useAuthenticationStore = create()((set, get) => ({ isLoadingAccount: true, }); + // Drop any stale isSessionAuthValid memo captured while locked — otherwise + // the post-unlock request burst (balances + prices + history) could read a + // cached `false` for up to the TTL and go out anonymous, defeating the auth + // this feature exists to attach. (Defined below; resolved at call time.) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + clearSessionAuthValidMemo(); + getActiveAccount(AUTH_STATUS.AUTHENTICATED) .then((activeAccount) => { if (!activeAccount) { @@ -3155,15 +3162,55 @@ export const getActiveMnemonicPhrase = async (): Promise => { } }; +// Short-TTL memo + in-flight dedup for the session-validity funnel result. +// getAuthKeypair runs isSessionAuthValid on every call — per backend request +// once the JWT interceptor is wired — and each funnel run is ~5 keychain reads. +// The in-flight promise collapses a near-simultaneous burst (balances + prices +// + history on unlock) to one run; the value memo collapses sequential calls +// within the window. +// +// Tradeoff: a transition detected ONLY by the funnel (auto-lock timer elapse, +// hash hard-expiry) is reflected at this gate up to SESSION_AUTH_VALID_TTL_MS +// late. That is bounded and immaterial in practice (auto-lock windows are +// minutes to hours) and does not leak key material: explicit lock/logout/wipe +// paths clear the keypair cache AND flip in-memory authStatus, so during a +// stale-true memo getAuthKeypair's warm-cache path finds an empty cache and its +// derive path is blocked by getActiveMnemonicPhrase's AUTHENTICATED gate. +export const SESSION_AUTH_VALID_TTL_MS = 1000; +let sessionAuthValidMemo: { value: boolean; expiresAt: number } | null = null; +let sessionAuthValidInFlight: Promise | null = null; + +/** Drops the isSessionAuthValid memo (tests + any caller needing a fresh read). */ +export const clearSessionAuthValidMemo = (): void => { + sessionAuthValidMemo = null; +}; + /** * Session-validity gate for backend auth-key use. Delegates to the authoritative * `getAuthStatus()` funnel — the single source of truth for lock transitions — * so it evaluates account existence, persisted LOCKED, hash-key hard-expiry AND * the auto-lock timer (`backgroundedAt` + `autoLockTimer`), rather than - * re-implementing a subset. This closes the foreground-before-funnel window - * where a stale in-memory AUTHENTICATED + within-TTL hash key would otherwise - * report valid after the auto-lock timer had already elapsed. Does not decrypt - * the temporary store (no PBKDF2); it reads persisted/secure state only. + * re-implementing a subset. Result is memoized for SESSION_AUTH_VALID_TTL_MS + * (see above). Does not decrypt the temporary store (no PBKDF2); persisted/secure + * reads only. */ -export const isSessionAuthValid = async (): Promise => - (await getAuthStatus()) === AUTH_STATUS.AUTHENTICATED; +export const isSessionAuthValid = async (): Promise => { + const memo = sessionAuthValidMemo; + if (memo && Date.now() < memo.expiresAt) return memo.value; + + if (!sessionAuthValidInFlight) { + sessionAuthValidInFlight = (async () => { + try { + const value = (await getAuthStatus()) === AUTH_STATUS.AUTHENTICATED; + sessionAuthValidMemo = { + value, + expiresAt: Date.now() + SESSION_AUTH_VALID_TTL_MS, + }; + return value; + } finally { + sessionAuthValidInFlight = null; + } + })(); + } + return sessionAuthValidInFlight; +}; diff --git a/src/services/apiFactory.ts b/src/services/apiFactory.ts index 0d5138d42..956b8c9c1 100644 --- a/src/services/apiFactory.ts +++ b/src/services/apiFactory.ts @@ -92,6 +92,43 @@ export interface ApiServiceOptions { headers?: Record; /** Whether to log requests and responses */ logRequests?: boolean; + /** + * Optional hook invoked on the raw AxiosInstance BEFORE the error-normalizing + * response interceptor is registered. Use this to attach interceptors that + * must run BEFORE the normalizer on the error path (e.g. a 401-retry handler + * that needs access to `error.response` and `error.config` — both of which + * the normalizer strips when it converts the AxiosError into a plain ApiError). + * + * Axios runs response interceptors in registration order, so any response + * interceptor added here is guaranteed to see the raw AxiosError. + * + * @example + * createApiService({ baseURL, configureInstance: attachAuthInterceptors }) + */ + configureInstance?: (instance: AxiosInstance) => void; +} + +/** + * Type guard that returns true for any value that has already been normalized + * into an ApiError by the apiFactory response interceptor. Used by the + * normalizer itself to make error normalization idempotent: when a nested + * instance.request() (e.g. the JWT 401-retry in attachAuth) surfaces an + * ApiError up through the outer interceptor chain, the normalizer must not + * re-process it — doing so would clobber the real status (e.g. 401 → 0) + * because ApiError carries no .response field. + */ +export function isApiError(error: unknown): error is ApiError { + return ( + typeof error === "object" && + error !== null && + !axios.isAxiosError(error) && + "status" in error && + typeof (error as ApiError).status === "number" && + "isNetworkError" in error && + typeof (error as ApiError).isNetworkError === "boolean" && + "message" in error && + typeof (error as ApiError).message === "string" + ); } /** @@ -136,6 +173,7 @@ export function createApiService(options: ApiServiceOptions) { Accept: "application/json", }, logRequests = false, + configureInstance, } = options; // Create axios instance @@ -146,22 +184,45 @@ export function createApiService(options: ApiServiceOptions) { }); if (logRequests) { + // Never log the Bearer token (this instance may carry per-request JWT auth + // via configureInstance). Mask any Authorization header at any depth — + // covers request.headers and response.config.headers. + const redactAuth = (key: string, value: unknown) => + /^authorization$/i.test(key) ? "[REDACTED]" : value; + instance.interceptors.request.use((request) => { - debug("Starting Request", JSON.stringify(request, null, 2)); + debug("Starting Request", JSON.stringify(request, redactAuth, 2)); return request; }); instance.interceptors.response.use((response) => { - debug("Response:", JSON.stringify(response, null, 2)); + debug("Response:", JSON.stringify(response, redactAuth, 2)); return response; }); } + // Allow the caller to register interceptors on the raw instance BEFORE the + // error-normalizing response interceptor below. Axios runs response + // interceptors in registration order, so any response interceptor added by + // this hook sees the original AxiosError (with .response / .config intact) + // before the normalizer converts it to a plain ApiError and drops those + // fields. This is the correct insertion point for a 401-retry handler. + configureInstance?.(instance); + // Add response interceptor for error handling instance.interceptors.response.use( (response) => response, /* eslint-disable @typescript-eslint/no-unsafe-member-access */ (error) => { + // If the error is already a normalized ApiError (e.g. surfaced from a + // nested instance.request() inside another interceptor, like the JWT + // 401-retry), don't re-normalize — a second pass would clobber the + // real status (e.g. 401) to 0 because ApiError has no .response field. + if (isApiError(error)) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw error; + } + // When the server didn't respond at all (offline, DNS, TLS failure, // captive portal, request aborted before headers), use 0 as the // status to match the ApiError contract ("HTTP status code (or 0 @@ -289,7 +350,11 @@ export function createApiService(options: ApiServiceOptions) { * Makes a POST request * * @param url The URL to request (will be appended to baseURL) - * @param data The data to send in the request body + * @param data The request body, typed `unknown` — pass a plain object. + * (The `TWriteBody` generic was removed with the string-only body contract.) + * On an auth-wired instance (see `configureInstance`), the request + * interceptor JSON-serializes object bodies centrally so the signed JWT + * `bodyHash` matches the wire bytes; string bodies pass through untouched. * @param config Additional request configuration * @returns Promise with the API response * @@ -305,9 +370,9 @@ export function createApiService(options: ApiServiceOptions) { * { headers: { 'X-Custom-Header': 'value' } } * ); */ - async post( + async post( url: string, - data?: D, + data?: unknown, config?: RequestConfig, ): Promise> { const { retry, ...axiosConfig } = config || {}; @@ -326,7 +391,11 @@ export function createApiService(options: ApiServiceOptions) { * Makes a PUT request * * @param url The URL to request (will be appended to baseURL) - * @param data The data to send in the request body + * @param data The request body, typed `unknown` — pass a plain object. + * (The `TWriteBody` generic was removed with the string-only body contract.) + * On an auth-wired instance (see `configureInstance`), the request + * interceptor JSON-serializes object bodies centrally so the signed JWT + * `bodyHash` matches the wire bytes; string bodies pass through untouched. * @param config Additional request configuration * @returns Promise with the API response * @@ -334,9 +403,9 @@ export function createApiService(options: ApiServiceOptions) { * // Basic PUT request * const { data } = await api.put('/users/123', { name: 'Updated Name' }); */ - async put( + async put( url: string, - data?: D, + data?: unknown, config?: RequestConfig, ): Promise> { const { retry, ...axiosConfig } = config || {}; diff --git a/src/services/auth/attachAuth.ts b/src/services/auth/attachAuth.ts new file mode 100644 index 000000000..184bef645 --- /dev/null +++ b/src/services/auth/attachAuth.ts @@ -0,0 +1,232 @@ +/* eslint-disable no-underscore-dangle */ +/** + * attachAuth — wire per-request JWT auth onto an AxiosInstance. + * + * Adds two additive interceptors to the given instance: + * + * 1. **Request interceptor**: builds a short-lived EdDSA JWT (via + * `buildAuthJwt`) and injects it as `Authorization: Bearer `. + * When the wallet is locked (`getAuthKeypair()` → null) the config is + * returned unmodified and the backend permissively allows the request. + * + * 2. **Response interceptor**: on a 401, rebuilds the JWT once and re-issues + * the request via `instance.request(config)`, returning that response + * (success or the second 401). A `__isAuthRetry` flag prevents infinite + * loops. Mirrors the extension's `authedFetch` (retry-once-then-return). + * + * Path derivation + * --------------- + * The JWT `methodAndPath` must match the server's `r.URL.RequestURI()` (full + * path + query). We derive it from `instance.getUri(config)` — axios's own + * baseURL-join + param serializer, i.e. exactly what it will put on the wire — + * and strip the origin to `pathname + search`. Delegating means the signed path + * cannot drift from the wire path (both route through the same builder), and we + * leave `config.params` untouched; axios serializes them identically. + * + * Example: + * baseURL = "https://api.example.com/api/v1" + * url = "/protocols", params = { network: "PUBLIC" } + * signed = "/api/v1/protocols?network=PUBLIC" ✓ + * + * Body hashing + * ------------ + * Object bodies are serialized here — in the request interceptor, before + * axios's own `transformRequest` — so the bytes hashed into `bodyHash` are + * exactly the bytes that go on the wire. Call sites pass plain objects; an + * already-serialized string is left untouched; GET/no-body yields an empty + * bodyHash. (The anonymous/locked path lets axios serialize the object + * normally, producing the identical JSON bytes, so the two paths agree.) + * + * Import strategy + * --------------- + * `getAuthKeypair` and `buildAuthJwt` are imported lazily inside the + * interceptor callbacks (via `require()`). This keeps the module load of + * `attachAuth.ts` itself side-effect-free so that tests which import + * `backend.ts` indirectly (e.g. via a duck) do not inadvertently pull in + * `ducks/auth` → `react-native-config` at import time. Interceptor + * callbacks only fire when an actual request is made, so the lazy require + * never runs in tests that mock the store/duck and never exercise the + * backend HTTP layer. + */ +import { AxiosInstance, InternalAxiosRequestConfig } from "axios"; + +/** + * Internal marker added to config when we have already attempted one + * JWT refresh-and-retry cycle for a given request. Prevents infinite + * retry loops on persistent 401s. + */ +interface AuthRetryConfig extends InternalAxiosRequestConfig { + __isAuthRetry?: boolean; +} + +/** Strips any Authorization header so a request goes out anonymously. */ +function stripAuthHeaders(config: InternalAxiosRequestConfig): void { + if (!config.headers) return; + /* eslint-disable no-param-reassign */ + delete (config.headers as Record).Authorization; + delete (config.headers as Record).authorization; + /* eslint-enable no-param-reassign */ +} + +/** + * Derives the server request-target (pathname + search) that axios will put on + * the wire, so the signed JWT `methodAndPath` equals the server's + * `r.URL.RequestURI()` by construction. + * + * We delegate to `instance.getUri(config)` — the same `baseURL` join + param + * serializer axios uses to build the actual request URL — rather than + * hand-rolling the merge. That guarantees signed path == wire path (and stays + * true across axios upgrades, since both route through the same builder). We + * only strip the origin down to `pathname + search`; axios owns the rest. + */ +function deriveServerPath( + instance: AxiosInstance, + config: InternalAxiosRequestConfig, +): string { + // Strip the scheme://host[:port] origin from what axios will send, leaving + // exactly the pathname+query it puts on the wire. + // + // Do NOT use `new URL(...).pathname`: React Native's built-in URL is NOT + // WHATWG-compliant — its constructor appends a trailing "/" to any URL with + // no "?"/"#" (RN 0.81 Libraries/Blob/URL.js), so a query-less request would + // sign "/api/v1/x/" while the wire target is "/api/v1/x" → guaranteed 401. + // Jest uses Node's compliant URL, so that divergence is invisible in tests. + // A plain origin-strip is environment-independent and preserves getUri's + // output verbatim (== the wire request-target). + const uri = instance.getUri(config); + return uri.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, "") || "/"; +} + +/** + * Attaches per-request JWT authentication interceptors to `instance`. + * + * ORDERING REQUIREMENT: This function MUST be wired via the `configureInstance` + * hook of `createApiService` (not called after `createApiService` returns). + * Axios runs response interceptors in registration order. The 401-retry + * handler below inspects `error.response?.status` and `error.config` — fields + * that are present on a raw AxiosError but are stripped by apiFactory's + * error-normalizing response interceptor (which converts the error into a plain + * `ApiError`). If this function were called after `createApiService`, the + * retry handler would be registered *after* the normalizer and would never fire + * on a real 401: by the time it runs, the error is already an `ApiError` with + * no `.response` property. + * + * Using `configureInstance` guarantees these interceptors are registered BEFORE + * the normalizer, so the 401-retry path works correctly in production. + */ +export function attachAuthInterceptors(instance: AxiosInstance): void { + // ------------------------------------------------------------------ + // 1. Request interceptor — inject JWT when unlocked + // + // `getAuthKeypair` and `buildAuthJwt` are required lazily here so that + // importing `attachAuth` (and transitively `backend.ts`) does not pull + // `ducks/auth` into the module graph at import time. Tests that load + // `backend.ts` indirectly (e.g. via a duck) but never make HTTP + // requests will never trigger these requires. + // ------------------------------------------------------------------ + instance.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require */ + const { getAuthKeypair } = + require("services/auth/getAuthKeypair") as typeof import("services/auth/getAuthKeypair"); + /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require */ + const keypair = await getAuthKeypair(); + if (!keypair) { + // Wallet is locked — pass through anonymously. Strip any stale + // Authorization header first: a 401-retry copies the failed request's + // config (which carried a Bearer token), so if the wallet locked + // between the original request and the retry, returning config as-is + // would re-send the now-stale token. Locked requests must be anonymous. + stripAuthHeaders(config); + return config; + } + + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require */ + const { buildAuthJwt } = + require("services/auth/buildAuthJwt") as typeof import("services/auth/buildAuthJwt"); + /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require */ + + // Sign the exact request-target axios will send (baseURL + url + params), + // leaving config.params in place — axios serializes them identically on + // the wire, so signed path == wire path with no manual folding. + const serverPath = deriveServerPath(instance, config); + const method = config.method ?? "get"; + + // Serialize plain JSON bodies here — before axios's transformRequest — so + // the bytes we hash are exactly the bytes that go on the wire. A string + // body passes through untouched (no double-encode); GET/no-body stays + // undefined. Only plain objects / arrays are serialized: FormData / Blob / + // URLSearchParams etc. are left alone (JSON.stringify would corrupt them to + // "{}" on the wire). No such body exists on a v2 call site today; a future + // non-JSON authenticated body would need explicit bodyHash handling. + const isPlainJsonBody = + typeof config.data === "object" && + config.data !== null && + ((config.data as object).constructor === Object || + Array.isArray(config.data)); + if (isPlainJsonBody) { + // eslint-disable-next-line no-param-reassign + config.data = JSON.stringify(config.data); + } + const body = typeof config.data === "string" ? config.data : undefined; + + const jwt = buildAuthJwt({ + keypair, + method, + path: serverPath, + body, + }); + + // `config.headers` is always present on InternalAxiosRequestConfig. + // Cast to avoid index-signature complaints from strict typing. + // Mutating config.headers is idiomatic axios interceptor usage. + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-param-reassign + (config.headers as Record).Authorization = `Bearer ${jwt}`; + + return config; + }, + ); + + // ------------------------------------------------------------------ + // 2. Response interceptor — retry once on 401 + // ------------------------------------------------------------------ + instance.interceptors.response.use( + (response) => response, + async (error: unknown) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = error as { response?: { status?: number }; config?: any }; + const config = err.config as AuthRetryConfig | undefined; + + // Only retry if the original request actually carried a JWT. + // Retrying an anonymous request is pointless — nothing would change on + // the second attempt (the wallet is locked, so no Authorization header + // would be attached) and it would double locked-wallet traffic. + const hadAuth = Boolean( + (config?.headers as Record | undefined)?.Authorization, + ); + + if ( + err.response?.status === 401 && + config !== undefined && + !config.__isAuthRetry && + hadAuth + ) { + // Spread into a new config object to avoid mutating the original + // (satisfies no-param-reassign) while marking the retry flag. + const retryConfig: AuthRetryConfig = { + ...config, + __isAuthRetry: true, + }; + // The request interceptor above will rebuild a fresh JWT when + // instance.request() runs again. + return instance.request(retryConfig); + } + + // Not a retryable 401 (already retried, non-401, or anonymous): reject. + // A persistent 401 surfaces to the caller — we do NOT fall back to an + // anonymous request (matches the extension's authedFetch; clock-skew + // handling is deferred to the backend leeway, see #930). + return Promise.reject(error); + }, + ); +} diff --git a/src/services/auth/buildAuthJwt.ts b/src/services/auth/buildAuthJwt.ts new file mode 100644 index 000000000..ae0f101d2 --- /dev/null +++ b/src/services/auth/buildAuthJwt.ts @@ -0,0 +1,50 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import { createHash } from "crypto"; + +export const ISS = "freighter-mobile"; +export const JWT_LIFETIME_SECONDS = 15; + +export interface BuildAuthJwtParams { + keypair: Keypair; + method: string; + path: string; // full request-target incl. query, e.g. "/api/v1/auth/whoami" + body?: string; // pre-serialized; omit for GET + now?: number; // ms epoch; injectable for tests +} + +const b64url = (b: Buffer): string => + b + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +const seg = (v: unknown): string => + b64url(Buffer.from(JSON.stringify(v), "utf8")); + +// The header is constant for every token — compute it once at module load +// rather than re-running JSON.stringify + base64 + three regex replaces per +// authenticated request. +const HEADER_SEG = seg({ alg: "EdDSA", typ: "JWT" }); + +export const buildAuthJwt = ({ + keypair, + method, + path, + body, + now, +}: BuildAuthJwtParams): string => { + const iat = Math.floor((now ?? Date.now()) / 1000); + const bodyHash = createHash("sha256") + .update(body ?? "") + .digest("hex"); + const payload = { + sub: keypair.rawPublicKey().toString("hex"), + iss: ISS, + iat, + exp: iat + JWT_LIFETIME_SECONDS, + bodyHash, + methodAndPath: `${method.toUpperCase()} ${path}`, + }; + const signingInput = `${HEADER_SEG}.${seg(payload)}`; + return `${signingInput}.${b64url(keypair.sign(Buffer.from(signingInput, "utf8")))}`; +}; diff --git a/src/services/backend.ts b/src/services/backend.ts index 64faa17fb..c35751457 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -37,13 +37,21 @@ import { isRequestCanceled, logApiError, } from "services/apiFactory"; +import { attachAuthInterceptors } from "services/auth/attachAuth"; // Create dedicated API services for backend operations export const freighterBackendV1 = createApiService({ baseURL: BackendEnvConfig.FREIGHTER_BACKEND_V1_URL, }); +// Attach per-request JWT auth to the v2 backend instance via the +// configureInstance hook so the auth interceptors are registered BEFORE +// apiFactory's error-normalizing response interceptor. Axios runs response +// interceptors in registration order; if attachAuthInterceptors were called +// after createApiService returns, the 401-retry handler would run AFTER the +// normalizer and never see error.response (already converted to ApiError). export const freighterBackendV2 = createApiService({ baseURL: BackendEnvConfig.FREIGHTER_BACKEND_V2_URL, + configureInstance: attachAuthInterceptors, }); /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -1201,11 +1209,9 @@ export const fetchCollectibles = async ({ > => { try { const { data } = await freighterBackendV2.post( - `/collectibles?network=${network}`, - { - owner, - contracts, - }, + "/collectibles", + { owner, contracts }, + { params: { network } }, ); if (!data.data || !data.data.collections) {