Skip to content
Draft
85 changes: 85 additions & 0 deletions @shared/api/helpers/__tests__/authedFetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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<string, string>).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);
});

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("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");
});
});
106 changes: 106 additions & 0 deletions @shared/api/helpers/__tests__/buildAuthJwt.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> =>
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",
);
});
});
52 changes: 52 additions & 0 deletions @shared/api/helpers/authedFetch.ts
Original file line number Diff line number Diff line change
@@ -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?: 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<Response> => {
const doFetch = fetchImpl ?? fetch;
// 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<string, string> =
method.toUpperCase() === "GET"
? {}
: { "Content-Type": "application/json" };

const send = async (): Promise<Response> => {
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();
};
62 changes: 62 additions & 0 deletions @shared/api/helpers/buildAuthJwt.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<string> => {
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)}`;
};
Loading