From d74bc8a0766586ddbe1abdc361ab9a2ecb0587c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 10:45:09 +0000 Subject: [PATCH 1/2] feat(client): add fetchWithAuth for same-origin server routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-stack apps serve their own server routes next to the frontend, and those routes read the caller's identity from the Authorization header. The access token lives in local storage and is attached to the SDK's own clients, so a plain fetch("/api/orders") reaches the route anonymous — apps work around it by reading the token and building the header by hand. fetchWithAuth() is that fetch with the header already on it. It resolves the path against the page and refuses anything landing on another origin, so an absolute URL, a protocol-relative path or a backslash-prefixed one cannot carry the token off-site. The token comes from the user axios client's Authorization default, which follows setToken() and is cleared by logout(), so a request never carries a token the user no longer has. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS --- src/client.ts | 4 + src/client.types.ts | 39 +++++++ src/utils/fetch-with-auth.ts | 68 +++++++++++ tests/unit/fetch-with-auth.test.ts | 176 +++++++++++++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 src/utils/fetch-with-auth.ts create mode 100644 tests/unit/fetch-with-auth.test.ts 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..94daa3d5 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 path such as `/api/orders`, not a full URL. An absolute URL, a protocol-relative path, or anything else that resolves to another origin throws. To call a Base44 backend function, use {@linkcode FunctionsModule.fetch | functions.fetch()}; for another origin, use plain `fetch()`. + * + * When no user is signed in the request is sent without an `Authorization` header, so routes that allow anonymous access keep working. + * + * This method is browser-only. In server code, read the caller's token from the incoming request instead. + * + * @param path - A 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` resolves to a different origin, or when called outside the browser. + * + * @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..4223481c --- /dev/null +++ b/src/utils/fetch-with-auth.ts @@ -0,0 +1,68 @@ +import type { AxiosInstance } from "axios"; + +/** + * Builds the client's `fetchWithAuth`: a `fetch` that attaches the signed-in + * user's access token, restricted to 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. + * @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 { + const url = resolveSameOriginUrl(path); + const headers = new Headers(init.headers); + const token = currentToken(); + + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + + return fetch(url, { ...init, headers }); + }; +} + +function resolveSameOriginUrl(path: string): string { + if (typeof path !== "string" || path === "") { + throw new Error("fetchWithAuth() requires a path, such as '/api/orders'."); + } + + const location = typeof window !== "undefined" ? window.location : undefined; + if (!location?.href) { + throw new Error( + "fetchWithAuth() is only available in the browser. In server code, read the caller's token from the request instead — see createClientFromRequest()." + ); + } + + let pageUrl: URL; + let resolved: URL; + try { + pageUrl = new URL(location.href); + resolved = new URL(path, pageUrl); + } catch { + throw new Error(`fetchWithAuth() received an invalid path: "${path}".`); + } + + // Resolving before comparing is what makes this safe: a protocol-relative + // path ("//evil.example"), a backslash ("/\\evil.example") and an absolute URL + // all land on another origin here, and are rejected the same way. + if (resolved.origin !== pageUrl.origin) { + throw new Error( + `fetchWithAuth() only sends requests to your app's own origin, so the access token never reaches a third party. "${path}" resolves to ${resolved.origin}. Use base44.functions.fetch() to call a Base44 backend function, or plain fetch() for another origin.` + ); + } + + return resolved.toString(); +} diff --git a/tests/unit/fetch-with-auth.test.ts b/tests/unit/fetch-with-auth.test.ts new file mode 100644 index 00000000..2c127d4a --- /dev/null +++ b/tests/unit/fetch-with-auth.test.ts @@ -0,0 +1,176 @@ +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(`${origin}/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("resolves a path relative to the current page", async () => { + stubBrowser(); + const base44 = createTestClient("user-token"); + + await base44.fetchWithAuth("api/orders"); + + expect(lastCall().url).toBe(`${origin}/api/orders`); + }); + + 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`], + ])("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("throws outside the browser", async () => { + const base44 = createTestClient("user-token"); + + await expect(base44.fetchWithAuth("/api/orders")).rejects.toThrow( + /only available in the browser/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); From aaf6a4a7946ae5322f57ec31671041055d87dcca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:03:39 +0000 Subject: [PATCH 2/2] fetchWithAuth: validate the path is relative instead of resolving it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: don't resolve the path against the document and don't gate the method on a browser — Nitro dispatches a relative path to another server route in-process, and the SDK should not stand in the way of that. The origin guarantee now comes from the path shape rather than from a comparison: one leading slash cannot carry a scheme, and rejecting "//host" and "/\host" covers the two forms that reach another origin. The check runs on the string a URL parser would see — tabs and newlines removed, leading C0/space trimmed — because "//evil.example" reads as protocol-relative by the time the request is built and would otherwise pass a prefix test. The path then goes to fetch untouched, so a server-side client works: the token on such a client is the caller's own, from createClientFromRequest. Only Authorization is added — a callee building its own client from the request still needs the platform headers, which the JSDoc now says. A bare relative path ("api/orders") is no longer accepted; a leading slash is also what an in-process dispatcher expects. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS --- src/client.types.ts | 10 +++--- src/utils/fetch-with-auth.ts | 51 ++++++++++++++---------------- tests/unit/fetch-with-auth.test.ts | 35 ++++++++++++++------ 3 files changed, 55 insertions(+), 41 deletions(-) diff --git a/src/client.types.ts b/src/client.types.ts index 94daa3d5..3d36018c 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -152,16 +152,16 @@ export interface Base44Client { * * 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 path such as `/api/orders`, not a full URL. An absolute URL, a protocol-relative path, or anything else that resolves to another origin throws. To call a Base44 backend function, use {@linkcode FunctionsModule.fetch | functions.fetch()}; for another origin, use plain `fetch()`. + * 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()`. * - * When no user is signed in the request is sent without an `Authorization` header, so routes that allow anonymous access keep working. + * 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. * - * This method is browser-only. In server code, read the caller's token from the incoming request instead. + * 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 path on your app's own origin, such as `/api/orders`. + * @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` resolves to a different origin, or when called outside the browser. + * @throws {Error} When `path` is not a relative path on your app's own origin. * * @example * ```typescript diff --git a/src/utils/fetch-with-auth.ts b/src/utils/fetch-with-auth.ts index 4223481c..73c44be9 100644 --- a/src/utils/fetch-with-auth.ts +++ b/src/utils/fetch-with-auth.ts @@ -2,11 +2,12 @@ import type { AxiosInstance } from "axios"; /** * Builds the client's `fetchWithAuth`: a `fetch` that attaches the signed-in - * user's access token, restricted to the app's own origin. + * 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. + * 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) { @@ -22,7 +23,8 @@ export function createFetchWithAuth(axios: AxiosInstance) { path: string, init: RequestInit = {} ): Promise { - const url = resolveSameOriginUrl(path); + assertOwnOriginPath(path); + const headers = new Headers(init.headers); const token = currentToken(); @@ -30,39 +32,34 @@ export function createFetchWithAuth(axios: AxiosInstance) { headers.set("Authorization", `Bearer ${token}`); } - return fetch(url, { ...init, headers }); + // 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 resolveSameOriginUrl(path: string): string { +function assertOwnOriginPath(path: string): void { if (typeof path !== "string" || path === "") { throw new Error("fetchWithAuth() requires a path, such as '/api/orders'."); } - const location = typeof window !== "undefined" ? window.location : undefined; - if (!location?.href) { - throw new Error( - "fetchWithAuth() is only available in the browser. In server code, read the caller's token from the request instead — see createClientFromRequest()." - ); - } + // 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]+/, ""); - let pageUrl: URL; - let resolved: URL; - try { - pageUrl = new URL(location.href); - resolved = new URL(path, pageUrl); - } catch { - throw new Error(`fetchWithAuth() received an invalid path: "${path}".`); - } - - // Resolving before comparing is what makes this safe: a protocol-relative - // path ("//evil.example"), a backslash ("/\\evil.example") and an absolute URL - // all land on another origin here, and are rejected the same way. - if (resolved.origin !== pageUrl.origin) { + // 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}" resolves to ${resolved.origin}. Use base44.functions.fetch() to call a Base44 backend function, or plain fetch() for another origin.` + `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.` ); } - - return resolved.toString(); } diff --git a/tests/unit/fetch-with-auth.test.ts b/tests/unit/fetch-with-auth.test.ts index 2c127d4a..7534b843 100644 --- a/tests/unit/fetch-with-auth.test.ts +++ b/tests/unit/fetch-with-auth.test.ts @@ -68,7 +68,7 @@ describe("fetchWithAuth", () => { await base44.fetchWithAuth("/api/orders"); const { url, headers } = lastCall(); - expect(url).toBe(`${origin}/api/orders`); + expect(url).toBe("/api/orders"); expect(headers.get("Authorization")).toBe("Bearer user-token"); }); @@ -133,13 +133,13 @@ describe("fetchWithAuth", () => { expect(headers.get("Authorization")).toBe("Bearer caller-token"); }); - test("resolves a path relative to the current page", async () => { + test("passes the path through untouched", async () => { stubBrowser(); const base44 = createTestClient("user-token"); - await base44.fetchWithAuth("api/orders"); + await base44.fetchWithAuth("/api/orders?status=open#top"); - expect(lastCall().url).toBe(`${origin}/api/orders`); + expect(lastCall().url).toBe("/api/orders?status=open#top"); }); test.each([ @@ -147,6 +147,12 @@ describe("fetchWithAuth", () => { ["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"); @@ -165,12 +171,23 @@ describe("fetchWithAuth", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - test("throws outside the browser", async () => { - const base44 = createTestClient("user-token"); + 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 expect(base44.fetchWithAuth("/api/orders")).rejects.toThrow( - /only available in the browser/ - ); + 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(); }); });