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..9b954982 100644 --- a/src/utils/fetch-with-auth.ts +++ b/src/utils/fetch-with-auth.ts @@ -1,41 +1,96 @@ import type { AxiosInstance } from "axios"; +/** 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?: (input: string, init?: RequestInit) => Promise; +} + /** - * 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. + * + * 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 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. + * @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 +114,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); + }); +});