diff --git a/src/client.ts b/src/client.ts index 84bfc15a..6ea54adc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -8,6 +8,7 @@ import { createUserConnectorsModule, } from "./modules/connectors.js"; import { getAccessToken } from "./utils/auth-utils.js"; +import { createFetchWithAuth } from "./utils/fetch-with-auth.js"; import { createFunctionsModule } from "./modules/functions.js"; import { createAgentsModule } from "./modules/agents.js"; import { createAiGatewayModule } from "./modules/ai-gateway.js"; @@ -323,6 +324,9 @@ export function createClient(config: CreateClientConfig): Base44Client { const client = { ...userModules, + /** See {@link Base44Client.fetchWithAuth}. */ + fetchWithAuth: createFetchWithAuth(axiosClient), + /** * Sets a new authentication token for all subsequent requests. * diff --git a/src/client.types.ts b/src/client.types.ts index b94a6333..3d36018c 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -147,6 +147,45 @@ export interface Base44Client { /** Cleanup function to disconnect WebSocket connections. Call when you're done with the client. */ cleanup: () => void; + /** + * Calls one of your app's own server routes with the signed-in user's access token attached. + * + * Base44 keeps the user's access token in the browser's local storage, so a plain `fetch()` to your app's server routes arrives without it and the route sees an anonymous caller. `fetchWithAuth()` is the same `fetch()` with the `Authorization: Bearer ` header added, which is what lets a server route act on behalf of the signed-in user. + * + * Requests are restricted to your app's own origin so the token is never sent to a third party: pass a relative path beginning with a single `/`, such as `/api/orders`. An absolute URL, a protocol-relative `//host`, or anything else that a URL parser would read as another origin throws. To call a Base44 backend function, use {@linkcode FunctionsModule.fetch | functions.fetch()}; for another origin, use plain `fetch()`. + * + * The path is passed to `fetch` unchanged, so this also works in server code, where the runtime's `fetch` decides what a relative path means — a server-side client from {@linkcode createClientFromRequest | createClientFromRequest()} carries the caller's own token. Note that only the `Authorization` header is added: a route that builds its own client from the incoming request also needs the platform's `Base44-App-Id` and `Base44-Api-Url`, which a request you construct yourself does not have. + * + * When no user is signed in the request is sent without an `Authorization` header, so routes that allow anonymous access keep working. + * + * @param path - A relative path on your app's own origin, such as `/api/orders`. + * @param init - Optional [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) options such as `method`, `headers`, `body`, and `signal`. The auth header is added automatically; an `Authorization` header you set yourself is kept. + * @returns Promise resolving to a native [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + * @throws {Error} When `path` is not a relative path on your app's own origin. + * + * @example + * ```typescript + * // Call your app's own server route as the signed-in user + * const response = await base44.fetchWithAuth('/api/orders'); + * const orders = await response.json(); + * ``` + * + * @example + * ```typescript + * // POST with a JSON body + * const response = await base44.fetchWithAuth('/api/orders', { + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: JSON.stringify({ productId: 'abc', quantity: 2 }), + * }); + * + * if (!response.ok) { + * throw new Error(`Request failed: ${response.status}`); + * } + * ``` + */ + fetchWithAuth(path: string, init?: RequestInit): Promise; + /** * Sets a new authentication token for all subsequent requests. * diff --git a/src/utils/fetch-with-auth.ts b/src/utils/fetch-with-auth.ts new file mode 100644 index 00000000..73c44be9 --- /dev/null +++ b/src/utils/fetch-with-auth.ts @@ -0,0 +1,65 @@ +import type { AxiosInstance } from "axios"; + +/** + * Builds the client's `fetchWithAuth`: a `fetch` that attaches the signed-in + * user's access token to a request for the app's own origin. + * + * @param axios - The user-scoped axios instance. Its `Authorization` default is + * the live token: it follows `setToken()` and is deleted on `logout()`, so a + * request never carries a token the user no longer has. In a server-side client + * from `createClientFromRequest()` it holds the caller's own token. + * @internal + */ +export function createFetchWithAuth(axios: AxiosInstance) { + const currentToken = (): string | null => { + const header = axios.defaults.headers.common["Authorization"]; + if (typeof header !== "string" || !header.startsWith("Bearer ")) { + return null; + } + return header.slice("Bearer ".length) || null; + }; + + return async function fetchWithAuth( + path: string, + init: RequestInit = {} + ): Promise { + assertOwnOriginPath(path); + + const headers = new Headers(init.headers); + const token = currentToken(); + + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + + // Passed through untouched: resolving it here would need a document, and a + // root-relative path is already what a runtime that dispatches in-process + // (Nitro's `fetch`) expects. + return fetch(path, { ...init, headers }); + }; +} + +function assertOwnOriginPath(path: string): void { + if (typeof path !== "string" || path === "") { + throw new Error("fetchWithAuth() requires a path, such as '/api/orders'."); + } + + // Check what a URL parser would see, not the raw string: it drops every ASCII + // tab/newline anywhere in the input and trims leading C0/space, so + // "//evil.example" would pass a naive prefix check and then resolve to + // another host. + const asParsed = path.replace(/[\t\n\r]/g, "").replace(/^[\x00-\x20]+/, ""); + + // One leading slash is the whole rule: it cannot carry a scheme, and it rules + // out the two forms that reach another origin — "//host" and, since URL + // parsing treats a backslash as a slash, "/\host". + if ( + !asParsed.startsWith("/") || + asParsed.startsWith("//") || + asParsed.startsWith("/\\") + ) { + throw new Error( + `fetchWithAuth() only sends requests to your app's own origin, so the access token never reaches a third party. "${path}" is not a path on it — pass a relative path such as '/api/orders'. Use base44.functions.fetch() to call a Base44 backend function, or plain fetch() for another origin.` + ); + } +} diff --git a/tests/unit/fetch-with-auth.test.ts b/tests/unit/fetch-with-auth.test.ts new file mode 100644 index 00000000..7534b843 --- /dev/null +++ b/tests/unit/fetch-with-auth.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createClient } from "../../src/index.ts"; + +const appId = "test-app-id"; +const origin = "https://my-app.base44.app"; + +function makeLocalStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + store.delete(k); + }, + clear: () => store.clear(), + }; +} + +function stubBrowser(storage = makeLocalStorage()) { + vi.stubGlobal("document", { referrer: "", visibilityState: "visible" }); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + history: { replaceState: vi.fn() }, + location: { + href: `${origin}/dashboard`, + origin, + pathname: "/dashboard", + search: "", + }, + localStorage: storage, + }); + vi.stubGlobal("localStorage", storage); +} + +const createTestClient = (token?: string) => + createClient({ + serverUrl: "", + appId, + token, + analytics: { enabled: false }, + }); + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(new Response("{}")); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +const lastCall = () => { + const [url, init] = fetchMock.mock.calls[0]; + return { url, init, headers: new Headers(init.headers) }; +}; + +describe("fetchWithAuth", () => { + test("attaches the user's token to a same-origin path", async () => { + stubBrowser(); + const base44 = createTestClient("user-token"); + + await base44.fetchWithAuth("/api/orders"); + + const { url, headers } = lastCall(); + expect(url).toBe("/api/orders"); + expect(headers.get("Authorization")).toBe("Bearer user-token"); + }); + + test("reads the token from local storage when the client was created without one", async () => { + stubBrowser(makeLocalStorage({ base44_access_token: "stored-token" })); + const base44 = createTestClient(); + + await base44.fetchWithAuth("/api/orders"); + + expect(lastCall().headers.get("Authorization")).toBe("Bearer stored-token"); + }); + + test("uses the token set after login", async () => { + stubBrowser(); + const base44 = createTestClient("old-token"); + + base44.setToken("new-token"); + await base44.fetchWithAuth("/api/orders"); + + expect(lastCall().headers.get("Authorization")).toBe("Bearer new-token"); + }); + + test("sends no auth header after logout", async () => { + stubBrowser(); + const base44 = createTestClient("user-token"); + + base44.auth.logout(); + // logout() navigates the page; the test keeps the stubbed location usable. + (globalThis as any).window.location.href = `${origin}/dashboard`; + await base44.fetchWithAuth("/api/orders"); + + expect(lastCall().headers.get("Authorization")).toBeNull(); + }); + + test("sends no auth header when no user is signed in", async () => { + stubBrowser(); + const base44 = createTestClient(); + + await base44.fetchWithAuth("/api/public"); + + expect(lastCall().headers.get("Authorization")).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("forwards init options and keeps a caller-set Authorization header", async () => { + stubBrowser(); + const base44 = createTestClient("user-token"); + + await base44.fetchWithAuth("/api/orders", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer caller-token", + }, + body: JSON.stringify({ productId: "abc" }), + }); + + const { init, headers } = lastCall(); + expect(init.method).toBe("POST"); + expect(init.body).toBe(JSON.stringify({ productId: "abc" })); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("Authorization")).toBe("Bearer caller-token"); + }); + + test("passes the path through untouched", async () => { + stubBrowser(); + const base44 = createTestClient("user-token"); + + await base44.fetchWithAuth("/api/orders?status=open#top"); + + expect(lastCall().url).toBe("/api/orders?status=open#top"); + }); + + test.each([ + ["an absolute URL", "https://evil.example/steal"], + ["a protocol-relative path", "//evil.example/steal"], + ["a backslash-prefixed path", "/\\evil.example/steal"], + ["an absolute URL on another port", `${origin}:8443/api/orders`], + ["a bare relative path", "api/orders"], + // A URL parser drops tabs/newlines and trims leading space, so these read + // as "//evil.example" by the time the request is built. + ["a tab-split protocol-relative path", "/\t/evil.example/steal"], + ["a newline-split protocol-relative path", "/\n/evil.example/steal"], + ["a space-padded protocol-relative path", " //evil.example/steal"], + ])("rejects %s", async (_label, path) => { + stubBrowser(); + const base44 = createTestClient("user-token"); + + await expect(base44.fetchWithAuth(path)).rejects.toThrow( + /only sends requests to your app's own origin/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("rejects an empty path", async () => { + stubBrowser(); + const base44 = createTestClient("user-token"); + + await expect(base44.fetchWithAuth("")).rejects.toThrow(/requires a path/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("works with no document, as in a server route", async () => { + // No stubBrowser(): window is undefined here, the way it is on a worker. + const base44 = createTestClient("caller-token"); + + await base44.fetchWithAuth("/api/orders"); + + const { url, headers } = lastCall(); + expect(url).toBe("/api/orders"); + expect(headers.get("Authorization")).toBe("Bearer caller-token"); + }); + + test("rejects another origin with no document too", async () => { + const base44 = createTestClient("caller-token"); + + await expect( + base44.fetchWithAuth("https://evil.example/steal") + ).rejects.toThrow(/only sends requests to your app's own origin/); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});