From f0e615778b46cd265a5356ac459d745bd290bdb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:55:08 +0000 Subject: [PATCH 1/2] feat(client): fetchWithAuth carries the whole request context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sub-request carries nothing from the request that triggered it — a framework builds it from the caller's arguments alone — so a route reached by an in-process hop sees no Authorization and none of the platform headers Base44 sets. Its createClientFromRequest then throws on the missing Base44-App-Id, and service role is unavailable there. Apps were papering over this by copying a hand-maintained list of header names off the inbound request. That list belongs in the SDK: it is exactly the set createClientFromRequest reads, it goes stale in every app the moment the platform adds one, and getting it wrong is a security bug rather than a broken feature. fetchWithAuth now sends whatever credentials the client holds, so the callee rebuilds the client the caller is holding — same user, same data environment, same signed Base44-State, and service role. One method covers both situations because a client can only send what it has: a browser client is built with no serviceToken, so there is no service credential for it to send, and its behavior is unchanged. The service credential is included deliberately. It is minted per request for the app as a whole, not for one route, so a handler in the same worker already runs with that authority — reaching it through a route hop is the privilege it would have had by importing a shared function. What must not happen is the credential leaving the app, and the relative-path rule is what prevents that: the trust boundary is the destination, not the header. An optional init.fetch supplies the transport, since the global fetch cannot resolve a root-relative path on a server. host is deliberately never sent: a runtime that routes a relative path in-process synthesizes the sub-request's origin from it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS --- src/client.ts | 9 +- src/client.types.ts | 34 +++++-- src/utils/fetch-with-auth.ts | 104 +++++++++++++++----- tests/unit/fetch-with-auth.test.ts | 146 ++++++++++++++++++++++++++++- 4 files changed, 260 insertions(+), 33 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6ea54adc..fe0c9834 100644 --- a/src/client.ts +++ b/src/client.ts @@ -325,7 +325,14 @@ export function createClient(config: CreateClientConfig): Base44Client { ...userModules, /** See {@link Base44Client.fetchWithAuth}. */ - fetchWithAuth: createFetchWithAuth(axiosClient), + fetchWithAuth: createFetchWithAuth({ + axios: axiosClient, + serviceRoleAxios: serviceRoleAxiosClient, + appId: String(appId), + serverUrl, + functionsVersion, + platformHeaders: optionalHeaders, + }), /** * Sets a new authentication token for all subsequent requests. diff --git a/src/client.types.ts b/src/client.types.ts index 3d36018c..31fa838a 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -13,6 +13,7 @@ import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AppModule } from "./modules/app.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; import type { ActorsModule } from "./modules/actors.types.js"; +import type { FetchWithAuthInit } from "./utils/fetch-with-auth.js"; /** * Options for creating a Base44 client. @@ -148,24 +149,29 @@ export interface Base44Client { cleanup: () => void; /** - * Calls one of your app's own server routes with the signed-in user's access token attached. + * Calls one of your app's own server routes with this client's credentials 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. + * Base44 keeps a user's access token in the browser's local storage, and the platform puts its own headers on a server request — so a plain `fetch()` to your app's routes carries neither, and the route sees an anonymous caller with no way to build a client. `fetchWithAuth()` is the same `fetch()` with whatever this client holds added, which is what lets the route act on behalf of the caller. * - * 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()`. + * What that means depends on where the client came from, because a client can only send what it has: * - * 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. + * - In a browser, from {@linkcode createClient | createClient()}: the signed-in user's `Authorization: Bearer `. When nobody is signed in the request goes without it, so routes open to anonymous callers keep working. + * - In one of your server routes, from {@linkcode createClientFromRequest | createClientFromRequest()}: everything that function reads back — the caller's token, `Base44-App-Id`, `Base44-Api-Url`, `Base44-Functions-Version`, the signed `Base44-State`, `X-Data-Env`, and the app's per-request service credential. The callee's own `createClientFromRequest()` then rebuilds the client you are holding, service role included. * - * When no user is signed in the request is sent without an `Authorization` header, so routes that allow anonymous access keep working. + * That second case is why route-to-route calls need this. A sub-request carries nothing from the request that triggered it — your framework builds it from your arguments alone — so a route reached by a plain `fetch()` sees no headers at all and its `createClientFromRequest()` throws on the missing `Base44-App-Id`. + * + * Requests are restricted to your app's own origin, which is what keeps these credentials inside your app: pass a relative path beginning with a single `/`, such as `/api/orders`. An absolute URL, a protocol-relative `//host`, or anything else 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()`. + * + * Two routes that need the same logic should call a shared function rather than each other — cheaper than an HTTP round trip, and it needs no headers at all. Hop when the hop is the point: rendering a page server-side, or going through a route for its own caching and route rules. * * @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. + * @param init - Optional [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) options such as `method`, `headers`, `body`, and `signal`, plus `fetch`: the transport that resolves a root-relative path against your app's routes. It defaults to the global `fetch`, which does that in a browser but not on a server — in Nitro pass its own (`import { fetch } from "nitro"`), which dispatches in-process with no network hop. Any header you set yourself is kept, so you can deliberately hand the callee a different identity. * @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 + * // Browser: call your app's own server route as the signed-in user * const response = await base44.fetchWithAuth('/api/orders'); * const orders = await response.json(); * ``` @@ -183,8 +189,20 @@ export interface Base44Client { * throw new Error(`Request failed: ${response.status}`); * } * ``` + * + * @example + * ```typescript + * // Server-side render: reach the app's own route as this request + * import { fetch } from 'nitro'; + * import { createClientFromRequest } from '@base44/sdk'; + * + * const base44 = createClientFromRequest(event.req); + * const response = await base44.fetchWithAuth('/api/items', { fetch }); + * const items = await response.json(); + * ``` */ - fetchWithAuth(path: string, init?: RequestInit): Promise; + fetchWithAuth(path: string, init?: FetchWithAuthInit): 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 index 73c44be9..31517987 100644 --- a/src/utils/fetch-with-auth.ts +++ b/src/utils/fetch-with-auth.ts @@ -1,41 +1,99 @@ import type { AxiosInstance } from "axios"; +/** `fetch`-compatible transport that can resolve a root-relative path. */ +type FetchLike = (input: string, init?: RequestInit) => Promise; + +/** Options for {@link Base44Client.fetchWithAuth}. */ +export interface FetchWithAuthInit extends RequestInit { + /** + * The `fetch` that resolves a root-relative path against your app's own + * routes. Defaults to the global `fetch`, which does that in a browser but + * not on a server, where a path with no origin has nothing to resolve + * against. In Nitro pass its own, which routes a leading-slash path + * in-process: `import { fetch } from "nitro"`. + */ + fetch?: FetchLike; +} + /** - * 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. + * Builds the client's `fetchWithAuth`: a `fetch` to the app's own origin that + * carries whatever credentials this client holds. * - * @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. + * That is the user's access token in a browser, and from + * `createClientFromRequest()` the full set that function reads back — so a + * route reached this way rebuilds the caller's client, service role included. + * The service credential is minted per request for the app as a whole, not for + * one route, so a handler in the same worker already runs with that authority; + * reaching it through a route hop is the privilege it would have had by + * importing a shared function. What must never happen is a credential leaving + * the app, and the relative-path rule, not a shorter header list, is what + * prevents that. + * + * @param axios - The user-scoped 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. From + * `createClientFromRequest()` it holds the caller's own token. + * @param serviceRoleAxios - The service-role instance, holding the app's + * per-request credential as its own `Authorization` default. A browser client + * has none, so nothing is sent. * @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; +export function createFetchWithAuth({ + axios, + serviceRoleAxios, + appId, + serverUrl, + functionsVersion, + platformHeaders, +}: { + axios: AxiosInstance; + serviceRoleAxios: AxiosInstance; + appId: string; + serverUrl: string; + functionsVersion?: string; + platformHeaders?: Record; +}) { + const inherited = new Headers(platformHeaders); + + const bearer = (client: AxiosInstance): string | null => { + const header = client.defaults.headers.common["Authorization"]; + return typeof header === "string" && header.startsWith("Bearer ") + ? header + : null; }; return async function fetchWithAuth( path: string, - init: RequestInit = {} + init: FetchWithAuthInit = {} ): Promise { assertOwnOriginPath(path); + const { fetch: transport = fetch, ...requestInit } = init; const headers = new Headers(init.headers); - const token = currentToken(); - if (token && !headers.has("Authorization")) { - headers.set("Authorization", `Bearer ${token}`); - } + // A caller-supplied value always wins, so a route can hand the callee a + // different identity on purpose (say, dropping Authorization to render a + // page as anonymous). + const inherit = (name: string, value: string | null | undefined) => { + if (value && !headers.has(name)) headers.set(name, value); + }; + + // Exactly what createClientFromRequest reads, so the callee can rebuild + // this client. Keep the two in step. + inherit("Authorization", bearer(axios)); + inherit("Base44-Service-Authorization", bearer(serviceRoleAxios)); + inherit("Base44-App-Id", appId); + inherit("Base44-Api-Url", serverUrl); + inherit("Base44-Functions-Version", functionsVersion); + inherit("Base44-State", inherited.get("Base44-State")); + inherit("X-Data-Env", inherited.get("X-Data-Env")); - // 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 }); + // The path is 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. `host` is deliberately + // never sent — such a runtime synthesizes the sub-request's origin from it, + // so forwarding the inbound one would point the hop at another host. + return transport(path, { ...requestInit, headers }); }; } @@ -59,7 +117,7 @@ function assertOwnOriginPath(path: string): void { 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.` + `fetchWithAuth() only sends requests to your app's own origin, so your app's credentials never reach 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 index 7534b843..73167ff2 100644 --- a/tests/unit/fetch-with-auth.test.ts +++ b/tests/unit/fetch-with-auth.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { createClient } from "../../src/index.ts"; +import { createClient, createClientFromRequest } from "../../src/index.ts"; const appId = "test-app-id"; const origin = "https://my-app.base44.app"; @@ -191,3 +191,147 @@ describe("fetchWithAuth", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +const apiUrl = "https://base44.app"; + +/** The header set the platform puts on a fullstack worker request. */ +function inboundRequest( + overrides: Record = {} +): Request { + const headers: Record = { + Authorization: "Bearer caller-user-token", + "Base44-Service-Authorization": "Bearer service-credential", + "Base44-App-Id": appId, + "Base44-Api-Url": apiUrl, + "Base44-Functions-Version": "draft", + "Base44-State": "signed-state-jwt", + "X-Data-Env": "dev", + host: "my-app.base44.app", + cookie: "session=irrelevant", + }; + for (const [name, value] of Object.entries(overrides)) { + if (value === undefined) delete headers[name]; + else headers[name] = value; + } + return new Request(`${origin}/page`, { headers }); +} + +describe("fetchWithAuth from a server route", () => { + test("sends every header createClientFromRequest reads, so the callee rebuilds the same client", async () => { + const base44 = createClientFromRequest(inboundRequest()); + + await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + + const { url, headers } = lastCall(); + expect(url).toBe("/api/items"); + expect(headers.get("Authorization")).toBe("Bearer caller-user-token"); + expect(headers.get("Base44-App-Id")).toBe(appId); + expect(headers.get("Base44-Api-Url")).toBe(apiUrl); + expect(headers.get("Base44-Functions-Version")).toBe("draft"); + expect(headers.get("Base44-State")).toBe("signed-state-jwt"); + expect(headers.get("X-Data-Env")).toBe("dev"); + }); + + test("carries the service credential, so asServiceRole works in the callee", async () => { + const base44 = createClientFromRequest(inboundRequest()); + + await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + + expect(lastCall().headers.get("Base44-Service-Authorization")).toBe( + "Bearer service-credential" + ); + }); + + test("does not forward host, which would repoint the sub-request's origin", async () => { + const base44 = createClientFromRequest(inboundRequest()); + + await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + + expect(lastCall().headers.has("host")).toBe(false); + }); + + test("forwards nothing from the inbound request beyond that set", async () => { + const base44 = createClientFromRequest(inboundRequest()); + + await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + + expect(lastCall().headers.has("cookie")).toBe(false); + }); + + test("stays anonymous when the caller is", async () => { + const base44 = createClientFromRequest( + inboundRequest({ Authorization: undefined }) + ); + + await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + + const { headers } = lastCall(); + expect(headers.has("Authorization")).toBe(false); + expect(headers.get("Base44-Service-Authorization")).toBe( + "Bearer service-credential" + ); + }); + + test("omits headers the inbound request did not carry", async () => { + const base44 = createClientFromRequest( + inboundRequest({ + "Base44-State": undefined, + "X-Data-Env": undefined, + "Base44-Functions-Version": undefined, + }) + ); + + await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + + const { headers } = lastCall(); + expect(headers.has("Base44-State")).toBe(false); + expect(headers.has("X-Data-Env")).toBe(false); + expect(headers.has("Base44-Functions-Version")).toBe(false); + }); + + test("renders as anonymous when the caller drops Authorization on purpose", async () => { + const base44 = createClientFromRequest(inboundRequest()); + + await base44.fetchWithAuth("/api/items", { + fetch: fetchMock, + headers: { Authorization: "" }, + }); + + expect(lastCall().headers.get("Authorization")).toBe(""); + }); + + test("uses the given transport and does not pass it on as request init", async () => { + vi.stubGlobal("fetch", vi.fn()); + const base44 = createClientFromRequest(inboundRequest()); + + await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(lastCall().init).not.toHaveProperty("fetch"); + }); + + test("refuses to send the app's credentials to another origin", async () => { + const base44 = createClientFromRequest(inboundRequest()); + + await expect( + base44.fetchWithAuth("https://evil.example/steal", { fetch: fetchMock }) + ).rejects.toThrow(/only sends requests to your app's own origin/); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("fetchWithAuth in a browser", () => { + // The reason one method can serve both: a browser client is built without a + // serviceToken, so there is no service credential for it to send. This is + // what makes the wider header set safe to apply everywhere. + test("sends no service credential, having none", async () => { + stubBrowser(); + const base44 = createTestClient("user-token"); + + await base44.fetchWithAuth("/api/orders"); + + const { headers } = lastCall(); + expect(headers.get("Authorization")).toBe("Bearer user-token"); + expect(headers.has("Base44-Service-Authorization")).toBe(false); + }); +}); From 9f46e7d6763ad9f93b2709f4d1c9054f78cca349 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:16:53 +0000 Subject: [PATCH 2/2] refactor(client): inline the transport type into the public init FetchWithAuthInit is exported and named in fetchWithAuth's signature, so referencing a non-exported alias from it gave consumers a type they could not name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS --- src/utils/fetch-with-auth.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/utils/fetch-with-auth.ts b/src/utils/fetch-with-auth.ts index 31517987..9b954982 100644 --- a/src/utils/fetch-with-auth.ts +++ b/src/utils/fetch-with-auth.ts @@ -1,8 +1,5 @@ import type { AxiosInstance } from "axios"; -/** `fetch`-compatible transport that can resolve a root-relative path. */ -type FetchLike = (input: string, init?: RequestInit) => Promise; - /** Options for {@link Base44Client.fetchWithAuth}. */ export interface FetchWithAuthInit extends RequestInit { /** @@ -12,7 +9,7 @@ export interface FetchWithAuthInit extends RequestInit { * against. In Nitro pass its own, which routes a leading-slash path * in-process: `import { fetch } from "nitro"`. */ - fetch?: FetchLike; + fetch?: (input: string, init?: RequestInit) => Promise; } /**