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
4 changes: 4 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*
Expand Down
39 changes: 39 additions & 0 deletions src/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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 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()`.
*
* 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.
*
* 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 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` 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
* 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<Response>;

/**
* Sets a new authentication token for all subsequent requests.
*
Expand Down
65 changes: 65 additions & 0 deletions src/utils/fetch-with-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { AxiosInstance } from "axios";

/**
* 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.
*
* @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.
* @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<Response> {
assertOwnOriginPath(path);

const headers = new Headers(init.headers);
const token = currentToken();

if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}

// 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 assertOwnOriginPath(path: string): void {
if (typeof path !== "string" || path === "") {
throw new Error("fetchWithAuth() requires a path, such as '/api/orders'.");
}

// 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
// "/<tab>/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]+/, "");

// 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}" 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.`
);
}
}
193 changes: 193 additions & 0 deletions tests/unit/fetch-with-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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<string, string> = {}) {
const store = new Map<string, string>(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<typeof vi.fn>;

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("/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("passes the path through untouched", async () => {
stubBrowser();
const base44 = createTestClient("user-token");

await base44.fetchWithAuth("/api/orders?status=open#top");

expect(lastCall().url).toBe("/api/orders?status=open#top");
});

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`],
["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");

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("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 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();
});
});
Loading