Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 26 additions & 8 deletions src/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <token>` 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 <token>`. 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();
* ```
Expand All @@ -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<Response>;
fetchWithAuth(path: string, init?: FetchWithAuthInit): Promise<Response>;


/**
* Sets a new authentication token for all subsequent requests.
Expand Down
101 changes: 78 additions & 23 deletions src/utils/fetch-with-auth.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;
}

/**
* 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<string, string>;
}) {
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<Response> {
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 });
};
}

Expand All @@ -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.`
);
}
}
146 changes: 145 additions & 1 deletion tests/unit/fetch-with-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, string | undefined> = {}
): Request {
const headers: Record<string, string> = {
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);
});
});
Loading