diff --git a/@shared/api/helpers/__tests__/authedFetch.test.ts b/@shared/api/helpers/__tests__/authedFetch.test.ts new file mode 100644 index 0000000000..3f4a2dc7b8 --- /dev/null +++ b/@shared/api/helpers/__tests__/authedFetch.test.ts @@ -0,0 +1,138 @@ +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; + +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)); + 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); + }); + + 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("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("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({ + 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 new file mode 100644 index 0000000000..f8a8da50fe --- /dev/null +++ b/@shared/api/helpers/__tests__/buildAuthJwt.test.ts @@ -0,0 +1,106 @@ +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(/[+/=]/); + }); + + 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 new file mode 100644 index 0000000000..8437675308 --- /dev/null +++ b/@shared/api/helpers/authedFetch.ts @@ -0,0 +1,74 @@ +import { Keypair } from "stellar-sdk"; + +import { buildAuthJwt } from "./buildAuthJwt"; + +export interface AuthedFetchParams { + keypair: Keypair; + baseUrl: string; + method: 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. */ + 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, + 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 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: requestTarget, + body, + }); + return doFetch(url, { + method: httpMethod, + headers: { ...baseHeaders, Authorization: `Bearer ${jwt}` }, + body, + }); + }; + + const first = await send(); + if (first.status !== 401) return first; + return send(); +}; diff --git a/@shared/api/helpers/buildAuthJwt.ts b/@shared/api/helpers/buildAuthJwt.ts new file mode 100644 index 0000000000..8263c1cc0b --- /dev/null +++ b/@shared/api/helpers/buildAuthJwt.ts @@ -0,0 +1,62 @@ +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?: string; + /** Injectable clock in ms epoch; defaults to Date.now(). */ + now?: number; +} + +const toBytes = (body?: string): Uint8Array => + body === undefined ? new Uint8Array() : new TextEncoder().encode(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?: 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.toUpperCase()} ${path}`, + }; + const signingInput = `${encodeSegment({ alg: "EdDSA", typ: "JWT" })}.${encodeSegment(payload)}`; + const signature = keypair.sign(Buffer.from(signingInput, "utf8")); + return `${signingInput}.${base64url(signature)}`; +}; 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); + }); +});