Skip to content

Commit 3b80a15

Browse files
authored
fetchWithAuth carries the whole request context, so route-to-route calls work (#279)
1 parent 465690b commit 3b80a15

4 files changed

Lines changed: 257 additions & 33 deletions

File tree

‎src/client.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,14 @@ export function createClient(config: CreateClientConfig): Base44Client {
325325
...userModules,
326326

327327
/** See {@link Base44Client.fetchWithAuth}. */
328-
fetchWithAuth: createFetchWithAuth(axiosClient),
328+
fetchWithAuth: createFetchWithAuth({
329+
axios: axiosClient,
330+
serviceRoleAxios: serviceRoleAxiosClient,
331+
appId: String(appId),
332+
serverUrl,
333+
functionsVersion,
334+
platformHeaders: optionalHeaders,
335+
}),
329336

330337
/**
331338
* Sets a new authentication token for all subsequent requests.

‎src/client.types.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { AppLogsModule } from "./modules/app-logs.types.js";
1313
import type { AppModule } from "./modules/app.types.js";
1414
import type { AnalyticsModule } from "./modules/analytics.types.js";
1515
import type { ActorsModule } from "./modules/actors.types.js";
16+
import type { FetchWithAuthInit } from "./utils/fetch-with-auth.js";
1617

1718
/**
1819
* Options for creating a Base44 client.
@@ -148,24 +149,29 @@ export interface Base44Client {
148149
cleanup: () => void;
149150

150151
/**
151-
* Calls one of your app's own server routes with the signed-in user's access token attached.
152+
* Calls one of your app's own server routes with this client's credentials attached.
152153
*
153-
* 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 <token>` header added, which is what lets a server route act on behalf of the signed-in user.
154+
* 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.
154155
*
155-
* 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()`.
156+
* What that means depends on where the client came from, because a client can only send what it has:
156157
*
157-
* 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.
158+
* - In a browser, from {@linkcode createClient | createClient()}: the signed-in user's `Authorization: Bearer <token>`. When nobody is signed in the request goes without it, so routes open to anonymous callers keep working.
159+
* - 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.
158160
*
159-
* When no user is signed in the request is sent without an `Authorization` header, so routes that allow anonymous access keep working.
161+
* 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`.
162+
*
163+
* 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()`.
164+
*
165+
* 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.
160166
*
161167
* @param path - A relative path on your app's own origin, such as `/api/orders`.
162-
* @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.
168+
* @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.
163169
* @returns Promise resolving to a native [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
164170
* @throws {Error} When `path` is not a relative path on your app's own origin.
165171
*
166172
* @example
167173
* ```typescript
168-
* // Call your app's own server route as the signed-in user
174+
* // Browser: call your app's own server route as the signed-in user
169175
* const response = await base44.fetchWithAuth('/api/orders');
170176
* const orders = await response.json();
171177
* ```
@@ -183,8 +189,20 @@ export interface Base44Client {
183189
* throw new Error(`Request failed: ${response.status}`);
184190
* }
185191
* ```
192+
*
193+
* @example
194+
* ```typescript
195+
* // Server-side render: reach the app's own route as this request
196+
* import { fetch } from 'nitro';
197+
* import { createClientFromRequest } from '@base44/sdk';
198+
*
199+
* const base44 = createClientFromRequest(event.req);
200+
* const response = await base44.fetchWithAuth('/api/items', { fetch });
201+
* const items = await response.json();
202+
* ```
186203
*/
187-
fetchWithAuth(path: string, init?: RequestInit): Promise<Response>;
204+
fetchWithAuth(path: string, init?: FetchWithAuthInit): Promise<Response>;
205+
188206

189207
/**
190208
* Sets a new authentication token for all subsequent requests.

‎src/utils/fetch-with-auth.ts‎

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,96 @@
11
import type { AxiosInstance } from "axios";
22

3+
/** Options for {@link Base44Client.fetchWithAuth}. */
4+
export interface FetchWithAuthInit extends RequestInit {
5+
/**
6+
* The `fetch` that resolves a root-relative path against your app's own
7+
* routes. Defaults to the global `fetch`, which does that in a browser but
8+
* not on a server, where a path with no origin has nothing to resolve
9+
* against. In Nitro pass its own, which routes a leading-slash path
10+
* in-process: `import { fetch } from "nitro"`.
11+
*/
12+
fetch?: (input: string, init?: RequestInit) => Promise<Response>;
13+
}
14+
315
/**
4-
* Builds the client's `fetchWithAuth`: a `fetch` that attaches the signed-in
5-
* user's access token to a request for the app's own origin.
16+
* Builds the client's `fetchWithAuth`: a `fetch` to the app's own origin that
17+
* carries whatever credentials this client holds.
18+
*
19+
* That is the user's access token in a browser, and from
20+
* `createClientFromRequest()` the full set that function reads back — so a
21+
* route reached this way rebuilds the caller's client, service role included.
22+
* The service credential is minted per request for the app as a whole, not for
23+
* one route, so a handler in the same worker already runs with that authority;
24+
* reaching it through a route hop is the privilege it would have had by
25+
* importing a shared function. What must never happen is a credential leaving
26+
* the app, and the relative-path rule, not a shorter header list, is what
27+
* prevents that.
628
*
7-
* @param axios - The user-scoped axios instance. Its `Authorization` default is
8-
* the live token: it follows `setToken()` and is deleted on `logout()`, so a
9-
* request never carries a token the user no longer has. In a server-side client
10-
* from `createClientFromRequest()` it holds the caller's own token.
29+
* @param axios - The user-scoped instance. Its `Authorization` default is the
30+
* live token: it follows `setToken()` and is deleted on `logout()`, so a
31+
* request never carries a token the user no longer has. From
32+
* `createClientFromRequest()` it holds the caller's own token.
33+
* @param serviceRoleAxios - The service-role instance, holding the app's
34+
* per-request credential as its own `Authorization` default. A browser client
35+
* has none, so nothing is sent.
1136
* @internal
1237
*/
13-
export function createFetchWithAuth(axios: AxiosInstance) {
14-
const currentToken = (): string | null => {
15-
const header = axios.defaults.headers.common["Authorization"];
16-
if (typeof header !== "string" || !header.startsWith("Bearer ")) {
17-
return null;
18-
}
19-
return header.slice("Bearer ".length) || null;
38+
export function createFetchWithAuth({
39+
axios,
40+
serviceRoleAxios,
41+
appId,
42+
serverUrl,
43+
functionsVersion,
44+
platformHeaders,
45+
}: {
46+
axios: AxiosInstance;
47+
serviceRoleAxios: AxiosInstance;
48+
appId: string;
49+
serverUrl: string;
50+
functionsVersion?: string;
51+
platformHeaders?: Record<string, string>;
52+
}) {
53+
const inherited = new Headers(platformHeaders);
54+
55+
const bearer = (client: AxiosInstance): string | null => {
56+
const header = client.defaults.headers.common["Authorization"];
57+
return typeof header === "string" && header.startsWith("Bearer ")
58+
? header
59+
: null;
2060
};
2161

2262
return async function fetchWithAuth(
2363
path: string,
24-
init: RequestInit = {}
64+
init: FetchWithAuthInit = {}
2565
): Promise<Response> {
2666
assertOwnOriginPath(path);
2767

68+
const { fetch: transport = fetch, ...requestInit } = init;
2869
const headers = new Headers(init.headers);
29-
const token = currentToken();
3070

31-
if (token && !headers.has("Authorization")) {
32-
headers.set("Authorization", `Bearer ${token}`);
33-
}
71+
// A caller-supplied value always wins, so a route can hand the callee a
72+
// different identity on purpose (say, dropping Authorization to render a
73+
// page as anonymous).
74+
const inherit = (name: string, value: string | null | undefined) => {
75+
if (value && !headers.has(name)) headers.set(name, value);
76+
};
77+
78+
// Exactly what createClientFromRequest reads, so the callee can rebuild
79+
// this client. Keep the two in step.
80+
inherit("Authorization", bearer(axios));
81+
inherit("Base44-Service-Authorization", bearer(serviceRoleAxios));
82+
inherit("Base44-App-Id", appId);
83+
inherit("Base44-Api-Url", serverUrl);
84+
inherit("Base44-Functions-Version", functionsVersion);
85+
inherit("Base44-State", inherited.get("Base44-State"));
86+
inherit("X-Data-Env", inherited.get("X-Data-Env"));
3487

35-
// Passed through untouched: resolving it here would need a document, and a
36-
// root-relative path is already what a runtime that dispatches in-process
37-
// (Nitro's `fetch`) expects.
38-
return fetch(path, { ...init, headers });
88+
// The path is passed through untouched: resolving it here would need a
89+
// document, and a root-relative path is already what a runtime that
90+
// dispatches in-process (Nitro's `fetch`) expects. `host` is deliberately
91+
// never sent — such a runtime synthesizes the sub-request's origin from it,
92+
// so forwarding the inbound one would point the hop at another host.
93+
return transport(path, { ...requestInit, headers });
3994
};
4095
}
4196

@@ -59,7 +114,7 @@ function assertOwnOriginPath(path: string): void {
59114
asParsed.startsWith("/\\")
60115
) {
61116
throw new Error(
62-
`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.`
117+
`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.`
63118
);
64119
}
65120
}

‎tests/unit/fetch-with-auth.test.ts‎

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2-
import { createClient } from "../../src/index.ts";
2+
import { createClient, createClientFromRequest } from "../../src/index.ts";
33

44
const appId = "test-app-id";
55
const origin = "https://my-app.base44.app";
@@ -191,3 +191,147 @@ describe("fetchWithAuth", () => {
191191
expect(fetchMock).not.toHaveBeenCalled();
192192
});
193193
});
194+
195+
const apiUrl = "https://base44.app";
196+
197+
/** The header set the platform puts on a fullstack worker request. */
198+
function inboundRequest(
199+
overrides: Record<string, string | undefined> = {}
200+
): Request {
201+
const headers: Record<string, string> = {
202+
Authorization: "Bearer caller-user-token",
203+
"Base44-Service-Authorization": "Bearer service-credential",
204+
"Base44-App-Id": appId,
205+
"Base44-Api-Url": apiUrl,
206+
"Base44-Functions-Version": "draft",
207+
"Base44-State": "signed-state-jwt",
208+
"X-Data-Env": "dev",
209+
host: "my-app.base44.app",
210+
cookie: "session=irrelevant",
211+
};
212+
for (const [name, value] of Object.entries(overrides)) {
213+
if (value === undefined) delete headers[name];
214+
else headers[name] = value;
215+
}
216+
return new Request(`${origin}/page`, { headers });
217+
}
218+
219+
describe("fetchWithAuth from a server route", () => {
220+
test("sends every header createClientFromRequest reads, so the callee rebuilds the same client", async () => {
221+
const base44 = createClientFromRequest(inboundRequest());
222+
223+
await base44.fetchWithAuth("/api/items", { fetch: fetchMock });
224+
225+
const { url, headers } = lastCall();
226+
expect(url).toBe("/api/items");
227+
expect(headers.get("Authorization")).toBe("Bearer caller-user-token");
228+
expect(headers.get("Base44-App-Id")).toBe(appId);
229+
expect(headers.get("Base44-Api-Url")).toBe(apiUrl);
230+
expect(headers.get("Base44-Functions-Version")).toBe("draft");
231+
expect(headers.get("Base44-State")).toBe("signed-state-jwt");
232+
expect(headers.get("X-Data-Env")).toBe("dev");
233+
});
234+
235+
test("carries the service credential, so asServiceRole works in the callee", async () => {
236+
const base44 = createClientFromRequest(inboundRequest());
237+
238+
await base44.fetchWithAuth("/api/items", { fetch: fetchMock });
239+
240+
expect(lastCall().headers.get("Base44-Service-Authorization")).toBe(
241+
"Bearer service-credential"
242+
);
243+
});
244+
245+
test("does not forward host, which would repoint the sub-request's origin", async () => {
246+
const base44 = createClientFromRequest(inboundRequest());
247+
248+
await base44.fetchWithAuth("/api/items", { fetch: fetchMock });
249+
250+
expect(lastCall().headers.has("host")).toBe(false);
251+
});
252+
253+
test("forwards nothing from the inbound request beyond that set", async () => {
254+
const base44 = createClientFromRequest(inboundRequest());
255+
256+
await base44.fetchWithAuth("/api/items", { fetch: fetchMock });
257+
258+
expect(lastCall().headers.has("cookie")).toBe(false);
259+
});
260+
261+
test("stays anonymous when the caller is", async () => {
262+
const base44 = createClientFromRequest(
263+
inboundRequest({ Authorization: undefined })
264+
);
265+
266+
await base44.fetchWithAuth("/api/items", { fetch: fetchMock });
267+
268+
const { headers } = lastCall();
269+
expect(headers.has("Authorization")).toBe(false);
270+
expect(headers.get("Base44-Service-Authorization")).toBe(
271+
"Bearer service-credential"
272+
);
273+
});
274+
275+
test("omits headers the inbound request did not carry", async () => {
276+
const base44 = createClientFromRequest(
277+
inboundRequest({
278+
"Base44-State": undefined,
279+
"X-Data-Env": undefined,
280+
"Base44-Functions-Version": undefined,
281+
})
282+
);
283+
284+
await base44.fetchWithAuth("/api/items", { fetch: fetchMock });
285+
286+
const { headers } = lastCall();
287+
expect(headers.has("Base44-State")).toBe(false);
288+
expect(headers.has("X-Data-Env")).toBe(false);
289+
expect(headers.has("Base44-Functions-Version")).toBe(false);
290+
});
291+
292+
test("renders as anonymous when the caller drops Authorization on purpose", async () => {
293+
const base44 = createClientFromRequest(inboundRequest());
294+
295+
await base44.fetchWithAuth("/api/items", {
296+
fetch: fetchMock,
297+
headers: { Authorization: "" },
298+
});
299+
300+
expect(lastCall().headers.get("Authorization")).toBe("");
301+
});
302+
303+
test("uses the given transport and does not pass it on as request init", async () => {
304+
vi.stubGlobal("fetch", vi.fn());
305+
const base44 = createClientFromRequest(inboundRequest());
306+
307+
await base44.fetchWithAuth("/api/items", { fetch: fetchMock });
308+
309+
expect(fetchMock).toHaveBeenCalledOnce();
310+
expect(lastCall().init).not.toHaveProperty("fetch");
311+
});
312+
313+
test("refuses to send the app's credentials to another origin", async () => {
314+
const base44 = createClientFromRequest(inboundRequest());
315+
316+
await expect(
317+
base44.fetchWithAuth("https://evil.example/steal", { fetch: fetchMock })
318+
).rejects.toThrow(/only sends requests to your app's own origin/);
319+
expect(fetchMock).not.toHaveBeenCalled();
320+
});
321+
});
322+
323+
describe("fetchWithAuth in a browser", () => {
324+
// The reason one method can serve both: a browser client is built without a
325+
// serviceToken, so there is no service credential for it to send. This is
326+
// what makes the wider header set safe to apply everywhere.
327+
test("sends no service credential, having none", async () => {
328+
stubBrowser();
329+
const base44 = createTestClient("user-token");
330+
331+
await base44.fetchWithAuth("/api/orders");
332+
333+
const { headers } = lastCall();
334+
expect(headers.get("Authorization")).toBe("Bearer user-token");
335+
expect(headers.has("Base44-Service-Authorization")).toBe(false);
336+
});
337+
});

0 commit comments

Comments
 (0)