From a512170123dc8d84c5645bdf879f74aa8d0be9ef Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 11:30:31 -0400 Subject: [PATCH 01/11] feat(auth): per-request EdDSA JWT builder in @shared/api (#2770) Co-Authored-By: Claude Sonnet 4.6 --- .../helpers/__tests__/buildAuthJwt.test.ts | 94 +++++++++++++++++++ @shared/api/helpers/buildAuthJwt.ts | 64 +++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 @shared/api/helpers/__tests__/buildAuthJwt.test.ts create mode 100644 @shared/api/helpers/buildAuthJwt.ts diff --git a/@shared/api/helpers/__tests__/buildAuthJwt.test.ts b/@shared/api/helpers/__tests__/buildAuthJwt.test.ts new file mode 100644 index 0000000000..8530b6d360 --- /dev/null +++ b/@shared/api/helpers/__tests__/buildAuthJwt.test.ts @@ -0,0 +1,94 @@ +import { Buffer } from "buffer"; +import { Keypair } from "stellar-sdk"; + +import { buildAuthJwt, ISS, JWT_LIFETIME_SECONDS } from "../buildAuthJwt"; + +const KP = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)); +const NOW = 1_700_000_000_000; // fixed ms epoch +const PATH = "/api/v1/auth/whoami"; + +const decodeSegment = (seg: string): Record => + JSON.parse( + Buffer.from(seg.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8", + ), + ); + +describe("buildAuthJwt", () => { + it("uses an EdDSA JWS header", async () => { + const jwt = await buildAuthJwt({ + keypair: KP, + method: "GET", + path: PATH, + now: NOW, + }); + expect(decodeSegment(jwt.split(".")[0])).toEqual({ + alg: "EdDSA", + typ: "JWT", + }); + }); + + it("sets the documented claims", async () => { + const jwt = await buildAuthJwt({ + keypair: KP, + method: "GET", + path: PATH, + now: NOW, + }); + const claims = decodeSegment(jwt.split(".")[1]); + expect(claims.sub).toBe(KP.rawPublicKey().toString("hex")); + expect(claims.iss).toBe(ISS); + expect(claims.iat).toBe(Math.floor(NOW / 1000)); + expect((claims.exp as number) - (claims.iat as number)).toBe( + JWT_LIFETIME_SECONDS, + ); + expect(claims.methodAndPath).toBe("GET /api/v1/auth/whoami"); + }); + + it("hashes an absent body to the empty-input SHA-256", async () => { + const jwt = await buildAuthJwt({ + keypair: KP, + method: "GET", + path: PATH, + now: NOW, + }); + expect(decodeSegment(jwt.split(".")[1]).bodyHash).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + }); + + it("hashes a non-empty body with hex SHA-256", async () => { + const jwt = await buildAuthJwt({ + keypair: KP, + method: "PUT", + path: "/x", + body: "hello", + now: NOW, + }); + expect(decodeSegment(jwt.split(".")[1]).bodyHash).toBe( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ); + }); + + it("produces a signature that verifies over the signing input", async () => { + const jwt = await buildAuthJwt({ + keypair: KP, + method: "GET", + path: PATH, + now: NOW, + }); + const [h, p, s] = jwt.split("."); + const sig = Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64"); + expect(KP.verify(Buffer.from(`${h}.${p}`, "utf8"), sig)).toBe(true); + }); + + it("emits url-safe base64 with no padding", async () => { + const jwt = await buildAuthJwt({ + keypair: KP, + method: "GET", + path: PATH, + now: NOW, + }); + expect(jwt).not.toMatch(/[+/=]/); + }); +}); diff --git a/@shared/api/helpers/buildAuthJwt.ts b/@shared/api/helpers/buildAuthJwt.ts new file mode 100644 index 0000000000..3dc724f006 --- /dev/null +++ b/@shared/api/helpers/buildAuthJwt.ts @@ -0,0 +1,64 @@ +import { Buffer } from "buffer"; +import { Keypair } from "stellar-sdk"; + +/** Platform tag recorded in the JWT `iss` claim (logging/metrics on the server). */ +export const ISS = "freighter-extension"; +/** Token lifetime in seconds; the server enforces exp - iat <= 15s. */ +export const JWT_LIFETIME_SECONDS = 15; + +export interface BuildAuthJwtParams { + keypair: Keypair; + method: string; + /** Full request-target including any query string, e.g. "/api/v1/auth/whoami". */ + path: string; + /** Raw request body; omit for GET (hashes the empty byte array). */ + body?: Uint8Array | string; + /** Injectable clock in ms epoch; defaults to Date.now(). */ + now?: number; +} + +const toBytes = (body?: Uint8Array | string): Uint8Array => { + if (body === undefined) return new Uint8Array(); + return typeof body === "string" ? new TextEncoder().encode(body) : body; +}; + +// URL-safe base64 with padding stripped. +const base64url = (bytes: Uint8Array): string => + Buffer.from(bytes) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + +const sha256Hex = async (body?: Uint8Array | string): Promise => { + const digest = await crypto.subtle.digest("SHA-256", toBytes(body)); + return Buffer.from(digest).toString("hex"); +}; + +const encodeSegment = (value: unknown): string => + base64url(new TextEncoder().encode(JSON.stringify(value))); + +/** + * Builds a fresh compact EdDSA JWS bound to this exact request. The caller + * supplies the auth keypair (from deriveAuthKeypair). Never cached. + */ +export const buildAuthJwt = async ({ + keypair, + method, + path, + body, + now, +}: BuildAuthJwtParams): Promise => { + const iat = Math.floor((now ?? Date.now()) / 1000); + const payload = { + sub: keypair.rawPublicKey().toString("hex"), + iss: ISS, + iat, + exp: iat + JWT_LIFETIME_SECONDS, + bodyHash: await sha256Hex(body), + methodAndPath: `${method} ${path}`, + }; + const signingInput = `${encodeSegment({ alg: "EdDSA", typ: "JWT" })}.${encodeSegment(payload)}`; + const signature = keypair.sign(Buffer.from(signingInput, "utf8")); + return `${signingInput}.${base64url(signature)}`; +}; From ce47679e0b08c0f27db20d7546ae83fcaf7d8780 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 11:35:08 -0400 Subject: [PATCH 02/11] feat(auth): authedFetch wrapper with retry-once-on-401 (#2770) Co-Authored-By: Claude Sonnet 4.6 --- .../api/helpers/__tests__/authedFetch.test.ts | 56 +++++++++++++++++++ @shared/api/helpers/authedFetch.ts | 52 +++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 @shared/api/helpers/__tests__/authedFetch.test.ts create mode 100644 @shared/api/helpers/authedFetch.ts diff --git a/@shared/api/helpers/__tests__/authedFetch.test.ts b/@shared/api/helpers/__tests__/authedFetch.test.ts new file mode 100644 index 0000000000..b27980f56d --- /dev/null +++ b/@shared/api/helpers/__tests__/authedFetch.test.ts @@ -0,0 +1,56 @@ +import { Buffer } from "buffer"; +import { Keypair } from "stellar-sdk"; + +import { authedFetch } from "../authedFetch"; + +const KP = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 9)); +const resp = (status: number): Response => new Response(null, { status }); + +const authHeaderOf = (call: unknown[]): string => + ((call[1] as RequestInit).headers as Record).Authorization; + +describe("authedFetch", () => { + it("returns the response and does not retry on success", async () => { + const fetchImpl = jest.fn().mockResolvedValue(resp(200)); + const r = await authedFetch({ + keypair: KP, + baseUrl: "http://x", + method: "GET", + path: "/p", + fetchImpl, + }); + expect(r.status).toBe(200); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(authHeaderOf(fetchImpl.mock.calls[0])).toMatch(/^Bearer .+/); + }); + + it("regenerates the JWT and retries once on 401, returning the retry result", async () => { + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(resp(401)) + .mockResolvedValueOnce(resp(200)); + const r = await authedFetch({ + keypair: KP, + baseUrl: "http://x", + method: "GET", + path: "/p", + fetchImpl, + }); + expect(r.status).toBe(200); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(authHeaderOf(fetchImpl.mock.calls[1])).toMatch(/^Bearer .+/); + }); + + it("retries at most once on persistent 401", async () => { + const fetchImpl = jest.fn().mockResolvedValue(resp(401)); + const r = await authedFetch({ + keypair: KP, + baseUrl: "http://x", + method: "GET", + path: "/p", + fetchImpl, + }); + expect(r.status).toBe(401); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/@shared/api/helpers/authedFetch.ts b/@shared/api/helpers/authedFetch.ts new file mode 100644 index 0000000000..1a85ba2ca7 --- /dev/null +++ b/@shared/api/helpers/authedFetch.ts @@ -0,0 +1,52 @@ +import { Keypair } from "stellar-sdk"; + +import { buildAuthJwt } from "./buildAuthJwt"; + +export interface AuthedFetchParams { + keypair: Keypair; + baseUrl: string; + method: string; + /** Full request-target including any query string. */ + path: string; + body?: Uint8Array | string; + headers?: Record; + /** Injectable for tests; defaults to the global fetch. */ + fetchImpl?: typeof fetch; +} + +/** + * Sends a request authenticated with a fresh per-request JWT. On 401 it rebuilds + * a fresh JWT and retries exactly once, returning that response (success or the + * second 401). The JWT is never cached. + */ +export const authedFetch = async ({ + keypair, + baseUrl, + method, + path, + body, + headers, + fetchImpl, +}: AuthedFetchParams): Promise => { + const doFetch = fetchImpl ?? fetch; + const url = `${baseUrl}${path}`; + const baseHeaders: Record = { + ...(method.toUpperCase() === "GET" + ? {} + : { "Content-Type": "application/json" }), + ...headers, + }; + + const send = async (): Promise => { + const jwt = await buildAuthJwt({ keypair, method, path, body }); + return doFetch(url, { + method, + headers: { ...baseHeaders, Authorization: `Bearer ${jwt}` }, + body, + }); + }; + + const first = await send(); + if (first.status !== 401) return first; + return send(); +}; From 43adc1a68d400a007b0eeb2c1e1c949f7c8bdb9e Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 11:42:50 -0400 Subject: [PATCH 03/11] test(auth): cover authedFetch Content-Type default + override (#2770) --- .../api/helpers/__tests__/authedFetch.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/@shared/api/helpers/__tests__/authedFetch.test.ts b/@shared/api/helpers/__tests__/authedFetch.test.ts index b27980f56d..45a6fb4e3a 100644 --- a/@shared/api/helpers/__tests__/authedFetch.test.ts +++ b/@shared/api/helpers/__tests__/authedFetch.test.ts @@ -53,4 +53,39 @@ describe("authedFetch", () => { expect(r.status).toBe(401); expect(fetchImpl).toHaveBeenCalledTimes(2); }); + + it("sets Content-Type: application/json for non-GET by default", async () => { + const fetchImpl = jest.fn().mockResolvedValue(resp(201)); + await authedFetch({ + keypair: KP, + baseUrl: "http://x", + method: "POST", + path: "/p", + body: "{}", + fetchImpl, + }); + const hdrs = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record< + string, + string + >; + expect(hdrs["Content-Type"]).toBe("application/json"); + }); + + it("lets caller headers override the default Content-Type", async () => { + const fetchImpl = jest.fn().mockResolvedValue(resp(200)); + await authedFetch({ + keypair: KP, + baseUrl: "http://x", + method: "POST", + path: "/p", + body: "x", + headers: { "Content-Type": "text/plain" }, + fetchImpl, + }); + const hdrs = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record< + string, + string + >; + expect(hdrs["Content-Type"]).toBe("text/plain"); + }); }); From 2c9871048ca17654a1f6c82def83b5a28dd42f35 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 12:06:32 -0400 Subject: [PATCH 04/11] feat(auth): runnable E2E script for backend JWT round-trip (#2770) --- scripts/auth-e2e.ts | 115 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 scripts/auth-e2e.ts diff --git a/scripts/auth-e2e.ts b/scripts/auth-e2e.ts new file mode 100644 index 0000000000..a94fab1d24 --- /dev/null +++ b/scripts/auth-e2e.ts @@ -0,0 +1,115 @@ +/* + * Runnable end-to-end check of the per-request backend JWT against a locally + * running freighter-backend-v2. + * + * Usage: + * 1. In freighter-backend-v2: make run # serves on :3002 by default + * 2. From this repo: npx tsx scripts/auth-e2e.ts + * + * Env: + * BACKEND_V2_URL default http://localhost:3002 + * AUTH_E2E_MNEMONIC default: the #2769 vector mnemonic (known userId) + * + * Exits non-zero if any case fails. + */ +import { Buffer } from "buffer"; + +import { buildAuthJwt } from "../@shared/api/helpers/buildAuthJwt"; +import { deriveAuthKeypair } from "../@shared/api/helpers/deriveAuthKeypair"; + +const BASE = process.env.BACKEND_V2_URL ?? "http://localhost:3002"; +const MNEMONIC = + process.env.AUTH_E2E_MNEMONIC ?? + "illness spike retreat truth genius clock brain pass fit cave bargain toe"; +const PATH = "/api/v1/auth/whoami"; + +let failures = 0; +const check = (name: string, ok: boolean, detail = ""): void => { + // eslint-disable-next-line no-console + console.log( + ` ${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`, + ); + if (!ok) failures += 1; +}; + +const get = (jwt: string): Promise => + fetch(`${BASE}${PATH}`, { headers: { Authorization: `Bearer ${jwt}` } }); + +const main = async (): Promise => { + const { keypair, userId } = await deriveAuthKeypair(MNEMONIC); + // eslint-disable-next-line no-console + console.log(`backend: ${BASE}\nuserId: ${userId}\n`); + + // 1. Valid request. + { + const r = await get( + await buildAuthJwt({ keypair, method: "GET", path: PATH }), + ); + const json = + r.status === 200 + ? ((await r.json()) as { authenticated?: boolean; userId?: string }) + : null; + check( + "valid JWT -> 200 + matching userId", + r.status === 200 && + json?.authenticated === true && + json?.userId === userId, + `status=${r.status}`, + ); + } + + // 2. Tampered body: claim a non-empty bodyHash but send an empty GET body. + { + const r = await get( + await buildAuthJwt({ + keypair, + method: "GET", + path: PATH, + body: "tamper", + }), + ); + check("tampered bodyHash -> 401", r.status === 401, `status=${r.status}`); + } + + // 3. Wrong key: flip a byte in the signature segment. + { + const [h, p, s] = ( + await buildAuthJwt({ keypair, method: "GET", path: PATH }) + ).split("."); + const sig = Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64"); + sig[0] ^= 0xff; + const badSig = sig + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + const r = await get(`${h}.${p}.${badSig}`); + check("bad signature -> 401", r.status === 401, `status=${r.status}`); + } + + // 4. Expired: backdate the clock beyond lifetime + skew. + { + const r = await get( + await buildAuthJwt({ + keypair, + method: "GET", + path: PATH, + now: Date.now() - 60_000, + }), + ); + check("expired token -> 401", r.status === 401, `status=${r.status}`); + } + + // eslint-disable-next-line no-console + console.log(`\n${failures === 0 ? "ALL PASSED" : `${failures} FAILED`}`); + process.exit(failures === 0 ? 0 : 1); +}; + +main().catch((e) => { + // eslint-disable-next-line no-console + console.error( + `\nE2E run failed (is the backend up at ${BASE}? start it with 'make run' in freighter-backend-v2)\n`, + e, + ); + process.exit(1); +}); From e1c994ac8229f47ca9d4202b8ad9d6cec81dcc48 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 12:12:50 -0400 Subject: [PATCH 05/11] fix(auth): E2E script records per-case failures instead of aborting (#2770) Co-Authored-By: Claude Sonnet 4.6 --- scripts/auth-e2e.ts | 54 ++++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/scripts/auth-e2e.ts b/scripts/auth-e2e.ts index a94fab1d24..733ab40f22 100644 --- a/scripts/auth-e2e.ts +++ b/scripts/auth-e2e.ts @@ -35,13 +35,24 @@ const check = (name: string, ok: boolean, detail = ""): void => { const get = (jwt: string): Promise => fetch(`${BASE}${PATH}`, { headers: { Authorization: `Bearer ${jwt}` } }); +const runCase = async ( + name: string, + fn: () => Promise<{ ok: boolean; detail?: string }>, +): Promise => { + try { + const { ok, detail } = await fn(); + check(name, ok, detail); + } catch (e) { + check(name, false, `request threw: ${(e as Error).message}`); + } +}; + const main = async (): Promise => { const { keypair, userId } = await deriveAuthKeypair(MNEMONIC); // eslint-disable-next-line no-console console.log(`backend: ${BASE}\nuserId: ${userId}\n`); - // 1. Valid request. - { + await runCase("valid JWT -> 200 + matching userId", async () => { const r = await get( await buildAuthJwt({ keypair, method: "GET", path: PATH }), ); @@ -49,17 +60,16 @@ const main = async (): Promise => { r.status === 200 ? ((await r.json()) as { authenticated?: boolean; userId?: string }) : null; - check( - "valid JWT -> 200 + matching userId", - r.status === 200 && + return { + ok: + r.status === 200 && json?.authenticated === true && json?.userId === userId, - `status=${r.status}`, - ); - } + detail: `status=${r.status}`, + }; + }); - // 2. Tampered body: claim a non-empty bodyHash but send an empty GET body. - { + await runCase("tampered bodyHash -> 401", async () => { const r = await get( await buildAuthJwt({ keypair, @@ -68,11 +78,10 @@ const main = async (): Promise => { body: "tamper", }), ); - check("tampered bodyHash -> 401", r.status === 401, `status=${r.status}`); - } + return { ok: r.status === 401, detail: `status=${r.status}` }; + }); - // 3. Wrong key: flip a byte in the signature segment. - { + await runCase("bad signature -> 401", async () => { const [h, p, s] = ( await buildAuthJwt({ keypair, method: "GET", path: PATH }) ).split("."); @@ -84,11 +93,10 @@ const main = async (): Promise => { .replace(/\//g, "_") .replace(/=+$/, ""); const r = await get(`${h}.${p}.${badSig}`); - check("bad signature -> 401", r.status === 401, `status=${r.status}`); - } + return { ok: r.status === 401, detail: `status=${r.status}` }; + }); - // 4. Expired: backdate the clock beyond lifetime + skew. - { + await runCase("expired token -> 401", async () => { const r = await get( await buildAuthJwt({ keypair, @@ -97,9 +105,15 @@ const main = async (): Promise => { now: Date.now() - 60_000, }), ); - check("expired token -> 401", r.status === 401, `status=${r.status}`); - } + return { ok: r.status === 401, detail: `status=${r.status}` }; + }); + if (failures > 0) { + // eslint-disable-next-line no-console + console.log( + `\n(${failures} failed — is the backend up at ${BASE}? start it with 'make run' in freighter-backend-v2)`, + ); + } // eslint-disable-next-line no-console console.log(`\n${failures === 0 ? "ALL PASSED" : `${failures} FAILED`}`); process.exit(failures === 0 ? 0 : 1); From 2fcdcbb07b13efbeab993657e190f1d2fbd30234 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 12:32:27 -0400 Subject: [PATCH 06/11] fix(auth): uppercase signed method, normalize baseUrl join, pin tsx in script (#2770) Co-Authored-By: Claude Sonnet 4.6 --- @shared/api/helpers/__tests__/authedFetch.test.ts | 12 ++++++++++++ @shared/api/helpers/__tests__/buildAuthJwt.test.ts | 12 ++++++++++++ @shared/api/helpers/authedFetch.ts | 5 ++++- @shared/api/helpers/buildAuthJwt.ts | 2 +- scripts/auth-e2e.ts | 2 +- 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/@shared/api/helpers/__tests__/authedFetch.test.ts b/@shared/api/helpers/__tests__/authedFetch.test.ts index 45a6fb4e3a..a9fc99541e 100644 --- a/@shared/api/helpers/__tests__/authedFetch.test.ts +++ b/@shared/api/helpers/__tests__/authedFetch.test.ts @@ -88,4 +88,16 @@ describe("authedFetch", () => { >; expect(hdrs["Content-Type"]).toBe("text/plain"); }); + + it("strips a trailing slash from baseUrl when building the URL", async () => { + const fetchImpl = jest.fn().mockResolvedValue(resp(200)); + await authedFetch({ + keypair: KP, + baseUrl: "http://x/", + method: "GET", + path: "/p", + fetchImpl, + }); + expect(fetchImpl.mock.calls[0][0]).toBe("http://x/p"); + }); }); diff --git a/@shared/api/helpers/__tests__/buildAuthJwt.test.ts b/@shared/api/helpers/__tests__/buildAuthJwt.test.ts index 8530b6d360..f8a8da50fe 100644 --- a/@shared/api/helpers/__tests__/buildAuthJwt.test.ts +++ b/@shared/api/helpers/__tests__/buildAuthJwt.test.ts @@ -91,4 +91,16 @@ describe("buildAuthJwt", () => { }); expect(jwt).not.toMatch(/[+/=]/); }); + + it("uppercases the method in methodAndPath", async () => { + const jwt = await buildAuthJwt({ + keypair: KP, + method: "get", + path: PATH, + now: NOW, + }); + expect(decodeSegment(jwt.split(".")[1]).methodAndPath).toBe( + "GET /api/v1/auth/whoami", + ); + }); }); diff --git a/@shared/api/helpers/authedFetch.ts b/@shared/api/helpers/authedFetch.ts index 1a85ba2ca7..b4a69667a3 100644 --- a/@shared/api/helpers/authedFetch.ts +++ b/@shared/api/helpers/authedFetch.ts @@ -29,7 +29,10 @@ export const authedFetch = async ({ fetchImpl, }: AuthedFetchParams): Promise => { const doFetch = fetchImpl ?? fetch; - const url = `${baseUrl}${path}`; + // Strip a trailing slash so the fetched URL can't diverge from the `path` + // baked into the JWT's methodAndPath claim (a "//api/..." vs "/api/..." split + // would be a silent 401). + const url = `${baseUrl.replace(/\/+$/, "")}${path}`; const baseHeaders: Record = { ...(method.toUpperCase() === "GET" ? {} diff --git a/@shared/api/helpers/buildAuthJwt.ts b/@shared/api/helpers/buildAuthJwt.ts index 3dc724f006..15d74d16d7 100644 --- a/@shared/api/helpers/buildAuthJwt.ts +++ b/@shared/api/helpers/buildAuthJwt.ts @@ -56,7 +56,7 @@ export const buildAuthJwt = async ({ iat, exp: iat + JWT_LIFETIME_SECONDS, bodyHash: await sha256Hex(body), - methodAndPath: `${method} ${path}`, + methodAndPath: `${method.toUpperCase()} ${path}`, }; const signingInput = `${encodeSegment({ alg: "EdDSA", typ: "JWT" })}.${encodeSegment(payload)}`; const signature = keypair.sign(Buffer.from(signingInput, "utf8")); diff --git a/scripts/auth-e2e.ts b/scripts/auth-e2e.ts index 733ab40f22..3552101d7a 100644 --- a/scripts/auth-e2e.ts +++ b/scripts/auth-e2e.ts @@ -4,7 +4,7 @@ * * Usage: * 1. In freighter-backend-v2: make run # serves on :3002 by default - * 2. From this repo: npx tsx scripts/auth-e2e.ts + * 2. From this repo: npx --yes tsx@4 scripts/auth-e2e.ts * * Env: * BACKEND_V2_URL default http://localhost:3002 From e2fcf16dbb9e493ebb8cd712724fa0324caa6d66 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 13:06:16 -0400 Subject: [PATCH 07/11] refactor(auth): narrow JWT body to string, drop unused authedFetch headers override (#2770) --- .../api/helpers/__tests__/authedFetch.test.ts | 18 ------------------ @shared/api/helpers/authedFetch.ts | 13 +++++-------- @shared/api/helpers/buildAuthJwt.ts | 10 ++++------ 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/@shared/api/helpers/__tests__/authedFetch.test.ts b/@shared/api/helpers/__tests__/authedFetch.test.ts index a9fc99541e..789c61ab0b 100644 --- a/@shared/api/helpers/__tests__/authedFetch.test.ts +++ b/@shared/api/helpers/__tests__/authedFetch.test.ts @@ -71,24 +71,6 @@ describe("authedFetch", () => { expect(hdrs["Content-Type"]).toBe("application/json"); }); - it("lets caller headers override the default Content-Type", async () => { - const fetchImpl = jest.fn().mockResolvedValue(resp(200)); - await authedFetch({ - keypair: KP, - baseUrl: "http://x", - method: "POST", - path: "/p", - body: "x", - headers: { "Content-Type": "text/plain" }, - fetchImpl, - }); - const hdrs = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record< - string, - string - >; - expect(hdrs["Content-Type"]).toBe("text/plain"); - }); - it("strips a trailing slash from baseUrl when building the URL", async () => { const fetchImpl = jest.fn().mockResolvedValue(resp(200)); await authedFetch({ diff --git a/@shared/api/helpers/authedFetch.ts b/@shared/api/helpers/authedFetch.ts index b4a69667a3..79e5e82852 100644 --- a/@shared/api/helpers/authedFetch.ts +++ b/@shared/api/helpers/authedFetch.ts @@ -8,8 +8,7 @@ export interface AuthedFetchParams { method: string; /** Full request-target including any query string. */ path: string; - body?: Uint8Array | string; - headers?: Record; + body?: string; /** Injectable for tests; defaults to the global fetch. */ fetchImpl?: typeof fetch; } @@ -25,7 +24,6 @@ export const authedFetch = async ({ method, path, body, - headers, fetchImpl, }: AuthedFetchParams): Promise => { const doFetch = fetchImpl ?? fetch; @@ -33,12 +31,11 @@ export const authedFetch = async ({ // baked into the JWT's methodAndPath claim (a "//api/..." vs "/api/..." split // would be a silent 401). const url = `${baseUrl.replace(/\/+$/, "")}${path}`; - const baseHeaders: Record = { - ...(method.toUpperCase() === "GET" + // Non-GET requests require Content-Type: application/json per the backend contract. + const baseHeaders: Record = + method.toUpperCase() === "GET" ? {} - : { "Content-Type": "application/json" }), - ...headers, - }; + : { "Content-Type": "application/json" }; const send = async (): Promise => { const jwt = await buildAuthJwt({ keypair, method, path, body }); diff --git a/@shared/api/helpers/buildAuthJwt.ts b/@shared/api/helpers/buildAuthJwt.ts index 15d74d16d7..8263c1cc0b 100644 --- a/@shared/api/helpers/buildAuthJwt.ts +++ b/@shared/api/helpers/buildAuthJwt.ts @@ -12,15 +12,13 @@ export interface BuildAuthJwtParams { /** Full request-target including any query string, e.g. "/api/v1/auth/whoami". */ path: string; /** Raw request body; omit for GET (hashes the empty byte array). */ - body?: Uint8Array | string; + body?: string; /** Injectable clock in ms epoch; defaults to Date.now(). */ now?: number; } -const toBytes = (body?: Uint8Array | string): Uint8Array => { - if (body === undefined) return new Uint8Array(); - return typeof body === "string" ? new TextEncoder().encode(body) : body; -}; +const toBytes = (body?: string): Uint8Array => + body === undefined ? new Uint8Array() : new TextEncoder().encode(body); // URL-safe base64 with padding stripped. const base64url = (bytes: Uint8Array): string => @@ -30,7 +28,7 @@ const base64url = (bytes: Uint8Array): string => .replace(/\//g, "_") .replace(/=+$/, ""); -const sha256Hex = async (body?: Uint8Array | string): Promise => { +const sha256Hex = async (body?: string): Promise => { const digest = await crypto.subtle.digest("SHA-256", toBytes(body)); return Buffer.from(digest).toString("hex"); }; From 1da07ecdd961b00764f8fee403755606b2e67f69 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 14:22:11 -0400 Subject: [PATCH 08/11] test(auth): replace E2E script with gated Playwright integration test (#2770) --- scripts/auth-e2e.ts | 129 -------------------------------------------- 1 file changed, 129 deletions(-) delete mode 100644 scripts/auth-e2e.ts diff --git a/scripts/auth-e2e.ts b/scripts/auth-e2e.ts deleted file mode 100644 index 3552101d7a..0000000000 --- a/scripts/auth-e2e.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Runnable end-to-end check of the per-request backend JWT against a locally - * running freighter-backend-v2. - * - * Usage: - * 1. In freighter-backend-v2: make run # serves on :3002 by default - * 2. From this repo: npx --yes tsx@4 scripts/auth-e2e.ts - * - * Env: - * BACKEND_V2_URL default http://localhost:3002 - * AUTH_E2E_MNEMONIC default: the #2769 vector mnemonic (known userId) - * - * Exits non-zero if any case fails. - */ -import { Buffer } from "buffer"; - -import { buildAuthJwt } from "../@shared/api/helpers/buildAuthJwt"; -import { deriveAuthKeypair } from "../@shared/api/helpers/deriveAuthKeypair"; - -const BASE = process.env.BACKEND_V2_URL ?? "http://localhost:3002"; -const MNEMONIC = - process.env.AUTH_E2E_MNEMONIC ?? - "illness spike retreat truth genius clock brain pass fit cave bargain toe"; -const PATH = "/api/v1/auth/whoami"; - -let failures = 0; -const check = (name: string, ok: boolean, detail = ""): void => { - // eslint-disable-next-line no-console - console.log( - ` ${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`, - ); - if (!ok) failures += 1; -}; - -const get = (jwt: string): Promise => - fetch(`${BASE}${PATH}`, { headers: { Authorization: `Bearer ${jwt}` } }); - -const runCase = async ( - name: string, - fn: () => Promise<{ ok: boolean; detail?: string }>, -): Promise => { - try { - const { ok, detail } = await fn(); - check(name, ok, detail); - } catch (e) { - check(name, false, `request threw: ${(e as Error).message}`); - } -}; - -const main = async (): Promise => { - const { keypair, userId } = await deriveAuthKeypair(MNEMONIC); - // eslint-disable-next-line no-console - console.log(`backend: ${BASE}\nuserId: ${userId}\n`); - - await runCase("valid JWT -> 200 + matching userId", async () => { - const r = await get( - await buildAuthJwt({ keypair, method: "GET", path: PATH }), - ); - const json = - r.status === 200 - ? ((await r.json()) as { authenticated?: boolean; userId?: string }) - : null; - return { - ok: - r.status === 200 && - json?.authenticated === true && - json?.userId === userId, - detail: `status=${r.status}`, - }; - }); - - await runCase("tampered bodyHash -> 401", async () => { - const r = await get( - await buildAuthJwt({ - keypair, - method: "GET", - path: PATH, - body: "tamper", - }), - ); - return { ok: r.status === 401, detail: `status=${r.status}` }; - }); - - await runCase("bad signature -> 401", async () => { - const [h, p, s] = ( - await buildAuthJwt({ keypair, method: "GET", path: PATH }) - ).split("."); - const sig = Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64"); - sig[0] ^= 0xff; - const badSig = sig - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - const r = await get(`${h}.${p}.${badSig}`); - return { ok: r.status === 401, detail: `status=${r.status}` }; - }); - - await runCase("expired token -> 401", async () => { - const r = await get( - await buildAuthJwt({ - keypair, - method: "GET", - path: PATH, - now: Date.now() - 60_000, - }), - ); - return { ok: r.status === 401, detail: `status=${r.status}` }; - }); - - if (failures > 0) { - // eslint-disable-next-line no-console - console.log( - `\n(${failures} failed — is the backend up at ${BASE}? start it with 'make run' in freighter-backend-v2)`, - ); - } - // eslint-disable-next-line no-console - console.log(`\n${failures === 0 ? "ALL PASSED" : `${failures} FAILED`}`); - process.exit(failures === 0 ? 0 : 1); -}; - -main().catch((e) => { - // eslint-disable-next-line no-console - console.error( - `\nE2E run failed (is the backend up at ${BASE}? start it with 'make run' in freighter-backend-v2)\n`, - e, - ); - process.exit(1); -}); From 9d52e0e4ca5fd3793fddd502445ac2a5e7a0a93a Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 29 Jun 2026 16:02:42 -0400 Subject: [PATCH 09/11] test(auth): add gated Playwright auth e2e test (dropped from the replace-script commit) (#2770) --- .../integration-tests/authJwt.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 extension/e2e-tests/integration-tests/authJwt.test.ts diff --git a/extension/e2e-tests/integration-tests/authJwt.test.ts b/extension/e2e-tests/integration-tests/authJwt.test.ts new file mode 100644 index 0000000000..cef825849d --- /dev/null +++ b/extension/e2e-tests/integration-tests/authJwt.test.ts @@ -0,0 +1,103 @@ +import { test, expect } from "@playwright/test"; +import { Buffer } from "buffer"; + +import { buildAuthJwt } from "../../../@shared/api/helpers/buildAuthJwt"; +import { deriveAuthKeypair } from "../../../@shared/api/helpers/deriveAuthKeypair"; + +/* + * End-to-end round-trip of the per-request backend JWT (#2770) against a live + * freighter-backend-v2. Pure HTTP via Playwright's `request` fixture — no + * browser/extension needed. Gated on IS_INTEGRATION_MODE so it never runs in the + * normal (stubbed) e2e/CI pass. + * + * Run: IS_INTEGRATION_MODE=true yarn workspace extension test:e2e authJwt + * Env: BACKEND_V2_URL (default: staging, https://freighter-backend-v2-stg.stellar.org) + * AUTH_E2E_MNEMONIC (default: the #2769 vector mnemonic, known userId) + */ +const isIntegrationMode = process.env.IS_INTEGRATION_MODE === "true"; +const BASE = + process.env.BACKEND_V2_URL ?? "https://freighter-backend-v2-stg.stellar.org"; +const MNEMONIC = + process.env.AUTH_E2E_MNEMONIC ?? + "illness spike retreat truth genius clock brain pass fit cave bargain toe"; +const PATH = "/api/v1/auth/whoami"; + +const toBase64Url = (bytes: Buffer): string => + bytes + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + +test.describe("backend auth JWT round-trip (#2770)", () => { + test.skip( + !isIntegrationMode, + "Requires a live freighter-backend-v2; run with IS_INTEGRATION_MODE=true", + ); + + test("valid JWT -> 200 with matching userId", async ({ request }) => { + const { keypair, userId } = await deriveAuthKeypair(MNEMONIC); + const jwt = await buildAuthJwt({ keypair, method: "GET", path: PATH }); + + const res = await request.get(`${BASE}${PATH}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status()).toBe(200); + expect(await res.json()).toMatchObject({ authenticated: true, userId }); + }); + + test("tampered body -> 401", async ({ request }) => { + const { keypair } = await deriveAuthKeypair(MNEMONIC); + // Sign a JWT whose bodyHash is for "tamper", but send an empty GET body, so + // the server's recomputed (empty) body hash won't match the claim. + const jwt = await buildAuthJwt({ + keypair, + method: "GET", + path: PATH, + body: "tamper", + }); + + const res = await request.get(`${BASE}${PATH}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status()).toBe(401); + }); + + test("bad signature -> 401", async ({ request }) => { + const { keypair } = await deriveAuthKeypair(MNEMONIC); + const [header, payload, sig] = ( + await buildAuthJwt({ keypair, method: "GET", path: PATH }) + ).split("."); + const sigBytes = Buffer.from( + sig.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ); + sigBytes[0] ^= 0xff; + const tampered = `${header}.${payload}.${toBase64Url(sigBytes)}`; + + const res = await request.get(`${BASE}${PATH}`, { + headers: { Authorization: `Bearer ${tampered}` }, + }); + + expect(res.status()).toBe(401); + }); + + test("expired token -> 401", async ({ request }) => { + const { keypair } = await deriveAuthKeypair(MNEMONIC); + // Backdate well beyond lifetime (15s) + skew (5s). + const jwt = await buildAuthJwt({ + keypair, + method: "GET", + path: PATH, + now: Date.now() - 60_000, + }); + + const res = await request.get(`${BASE}${PATH}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + expect(res.status()).toBe(401); + }); +}); From fae6da34914f3701abb6cb254bccda71d71d202c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 29 Jun 2026 16:38:47 -0400 Subject: [PATCH 10/11] fix(auth): upper-case authedFetch wire method to match signed methodAndPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildAuthJwt bakes method.toUpperCase() into the methodAndPath claim, but authedFetch sent the raw-case method on the wire. fetch only auto-uppercases the standard verbs (GET/POST/...), not PATCH or custom methods — so a lower-case non-standard method would leave the server's r.Method mismatching the signed claim and yield a silent 401. Normalize the method once and use it for both the JWT and the request. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- @shared/api/helpers/__tests__/authedFetch.test.ts | 15 +++++++++++++++ @shared/api/helpers/authedFetch.ts | 15 ++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/@shared/api/helpers/__tests__/authedFetch.test.ts b/@shared/api/helpers/__tests__/authedFetch.test.ts index 789c61ab0b..312589421e 100644 --- a/@shared/api/helpers/__tests__/authedFetch.test.ts +++ b/@shared/api/helpers/__tests__/authedFetch.test.ts @@ -71,6 +71,21 @@ describe("authedFetch", () => { expect(hdrs["Content-Type"]).toBe("application/json"); }); + it("upper-cases the wire method so it matches the signed methodAndPath", async () => { + // fetch does not auto-uppercase PATCH/custom verbs; authedFetch must, or the + // server's r.Method ("patch") won't match the JWT's "PATCH ..." claim -> 401. + const fetchImpl = jest.fn().mockResolvedValue(resp(200)); + await authedFetch({ + keypair: KP, + baseUrl: "http://x", + method: "patch", + path: "/p", + body: "{}", + fetchImpl, + }); + expect((fetchImpl.mock.calls[0][1] as RequestInit).method).toBe("PATCH"); + }); + it("strips a trailing slash from baseUrl when building the URL", async () => { const fetchImpl = jest.fn().mockResolvedValue(resp(200)); await authedFetch({ diff --git a/@shared/api/helpers/authedFetch.ts b/@shared/api/helpers/authedFetch.ts index 79e5e82852..b06328f594 100644 --- a/@shared/api/helpers/authedFetch.ts +++ b/@shared/api/helpers/authedFetch.ts @@ -27,20 +27,25 @@ export const authedFetch = async ({ fetchImpl, }: AuthedFetchParams): Promise => { const doFetch = fetchImpl ?? fetch; + // Upper-case the method once and use it for BOTH the signed methodAndPath + // claim and the wire request, so they can't diverge. buildAuthJwt signs the + // upper-cased method, but fetch only auto-uppercases the standard verbs + // (GET/POST/...), not PATCH or custom methods — sending the raw lower-case + // method would leave the server's `r.Method` mismatching the signed claim and + // produce a silent 401. + const httpMethod = method.toUpperCase(); // Strip a trailing slash so the fetched URL can't diverge from the `path` // baked into the JWT's methodAndPath claim (a "//api/..." vs "/api/..." split // would be a silent 401). const url = `${baseUrl.replace(/\/+$/, "")}${path}`; // Non-GET requests require Content-Type: application/json per the backend contract. const baseHeaders: Record = - method.toUpperCase() === "GET" - ? {} - : { "Content-Type": "application/json" }; + httpMethod === "GET" ? {} : { "Content-Type": "application/json" }; const send = async (): Promise => { - const jwt = await buildAuthJwt({ keypair, method, path, body }); + const jwt = await buildAuthJwt({ keypair, method: httpMethod, path, body }); return doFetch(url, { - method, + method: httpMethod, headers: { ...baseHeaders, Authorization: `Bearer ${jwt}` }, body, }); From ac942ce206c46a2d9938c9f4bd16492f6381fbe4 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 29 Jun 2026 17:33:20 -0400 Subject: [PATCH 11/11] fix(auth): sign the full request target in authedFetch, not the bare path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend verifies methodAndPath against r.URL.RequestURI() (the full path+query). authedFetch signed the caller's `path` fragment alone, but the backend base URL (INDEXER_V2_URL) carries an "/api/v1" prefix and helpers append the endpoint suffix — so base "/api/v1" + path "/contacts" fetched "/api/v1/contacts" while signing "/contacts", a guaranteed 401 once wired into the real path. Derive the signed target from the final URL's pathname+search so it always matches the wire request regardless of where the prefix lives. Adds prefix + query-string regression tests. Addresses Codex review (P2) on PR #2877. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/helpers/__tests__/authedFetch.test.ts | 38 +++++++++++++++++++ @shared/api/helpers/authedFetch.ts | 27 ++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/@shared/api/helpers/__tests__/authedFetch.test.ts b/@shared/api/helpers/__tests__/authedFetch.test.ts index 312589421e..3f4a2dc7b8 100644 --- a/@shared/api/helpers/__tests__/authedFetch.test.ts +++ b/@shared/api/helpers/__tests__/authedFetch.test.ts @@ -9,6 +9,12 @@ const resp = (status: number): Response => new Response(null, { status }); const authHeaderOf = (call: unknown[]): string => ((call[1] as RequestInit).headers as Record).Authorization; +const claimsOf = (call: unknown[]): Record => { + const jwt = authHeaderOf(call).replace(/^Bearer /, ""); + const payload = jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); + return JSON.parse(Buffer.from(payload, "base64").toString("utf8")); +}; + describe("authedFetch", () => { it("returns the response and does not retry on success", async () => { const fetchImpl = jest.fn().mockResolvedValue(resp(200)); @@ -86,6 +92,38 @@ describe("authedFetch", () => { expect((fetchImpl.mock.calls[0][1] as RequestInit).method).toBe("PATCH"); }); + it("signs the full request target when baseUrl carries an /api/v1 prefix", async () => { + // INDEXER_V2_URL is "/api/v1"; callers append the endpoint suffix. The + // signed methodAndPath must be the full URI the server sees, not the bare + // path fragment, or the request 401s. + const fetchImpl = jest.fn().mockResolvedValue(resp(200)); + await authedFetch({ + keypair: KP, + baseUrl: "http://x/api/v1", + method: "GET", + path: "/contacts", + fetchImpl, + }); + expect(fetchImpl.mock.calls[0][0]).toBe("http://x/api/v1/contacts"); + expect(claimsOf(fetchImpl.mock.calls[0]).methodAndPath).toBe( + "GET /api/v1/contacts", + ); + }); + + it("includes the query string in the signed request target", async () => { + const fetchImpl = jest.fn().mockResolvedValue(resp(200)); + await authedFetch({ + keypair: KP, + baseUrl: "http://x/api/v1", + method: "GET", + path: "/contacts?cursor=abc", + fetchImpl, + }); + expect(claimsOf(fetchImpl.mock.calls[0]).methodAndPath).toBe( + "GET /api/v1/contacts?cursor=abc", + ); + }); + it("strips a trailing slash from baseUrl when building the URL", async () => { const fetchImpl = jest.fn().mockResolvedValue(resp(200)); await authedFetch({ diff --git a/@shared/api/helpers/authedFetch.ts b/@shared/api/helpers/authedFetch.ts index b06328f594..8437675308 100644 --- a/@shared/api/helpers/authedFetch.ts +++ b/@shared/api/helpers/authedFetch.ts @@ -6,7 +6,12 @@ export interface AuthedFetchParams { keypair: Keypair; baseUrl: string; method: string; - /** Full request-target including any query string. */ + /** + * Path appended to `baseUrl`; include any query string. The signed + * methodAndPath is derived from the resulting URL's path+query (not this + * fragment alone), so it always matches the request target the server sees — + * regardless of whether an `/api/v1` prefix lives in `baseUrl` or here. + */ path: string; body?: string; /** Injectable for tests; defaults to the global fetch. */ @@ -34,16 +39,28 @@ export const authedFetch = async ({ // method would leave the server's `r.Method` mismatching the signed claim and // produce a silent 401. const httpMethod = method.toUpperCase(); - // Strip a trailing slash so the fetched URL can't diverge from the `path` - // baked into the JWT's methodAndPath claim (a "//api/..." vs "/api/..." split - // would be a silent 401). + // Strip a trailing slash so the fetched URL can't gain a "//api/..." split. const url = `${baseUrl.replace(/\/+$/, "")}${path}`; + // Sign the ACTUAL request target the server compares against — its + // r.URL.RequestURI() is the full path+query, including any prefix carried by + // baseUrl (the backend base is "/api/v1"). Deriving it from the final + // URL, rather than signing the bare `path` fragment, keeps the signed + // methodAndPath identical to the wire request no matter how the prefix is + // split between baseUrl and path — otherwise base "/api/v1" + path + // "/contacts" would fetch "/api/v1/contacts" but sign "/contacts" → 401. + const { pathname, search } = new URL(url); + const requestTarget = `${pathname}${search}`; // Non-GET requests require Content-Type: application/json per the backend contract. const baseHeaders: Record = httpMethod === "GET" ? {} : { "Content-Type": "application/json" }; const send = async (): Promise => { - const jwt = await buildAuthJwt({ keypair, method: httpMethod, path, body }); + const jwt = await buildAuthJwt({ + keypair, + method: httpMethod, + path: requestTarget, + body, + }); return doFetch(url, { method: httpMethod, headers: { ...baseHeaders, Authorization: `Bearer ${jwt}` },