Skip to content

Commit d74bc8a

Browse files
netanelgiladclaude
andcommitted
feat(client): add fetchWithAuth for same-origin server routes
Full-stack apps serve their own server routes next to the frontend, and those routes read the caller's identity from the Authorization header. The access token lives in local storage and is attached to the SDK's own clients, so a plain fetch("/api/orders") reaches the route anonymous — apps work around it by reading the token and building the header by hand. fetchWithAuth() is that fetch with the header already on it. It resolves the path against the page and refuses anything landing on another origin, so an absolute URL, a protocol-relative path or a backslash-prefixed one cannot carry the token off-site. The token comes from the user axios client's Authorization default, which follows setToken() and is cleared by logout(), so a request never carries a token the user no longer has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS
1 parent 75f64fe commit d74bc8a

4 files changed

Lines changed: 287 additions & 0 deletions

File tree

‎src/client.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createUserConnectorsModule,
99
} from "./modules/connectors.js";
1010
import { getAccessToken } from "./utils/auth-utils.js";
11+
import { createFetchWithAuth } from "./utils/fetch-with-auth.js";
1112
import { createFunctionsModule } from "./modules/functions.js";
1213
import { createAgentsModule } from "./modules/agents.js";
1314
import { createAiGatewayModule } from "./modules/ai-gateway.js";
@@ -323,6 +324,9 @@ export function createClient(config: CreateClientConfig): Base44Client {
323324
const client = {
324325
...userModules,
325326

327+
/** See {@link Base44Client.fetchWithAuth}. */
328+
fetchWithAuth: createFetchWithAuth(axiosClient),
329+
326330
/**
327331
* Sets a new authentication token for all subsequent requests.
328332
*

‎src/client.types.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,45 @@ export interface Base44Client {
147147
/** Cleanup function to disconnect WebSocket connections. Call when you're done with the client. */
148148
cleanup: () => void;
149149

150+
/**
151+
* Calls one of your app's own server routes with the signed-in user's access token attached.
152+
*
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+
*
155+
* Requests are restricted to your app's own origin so the token is never sent to a third party: pass a path such as `/api/orders`, not a full URL. An absolute URL, a protocol-relative path, or anything else that resolves to another origin throws. To call a Base44 backend function, use {@linkcode FunctionsModule.fetch | functions.fetch()}; for another origin, use plain `fetch()`.
156+
*
157+
* When no user is signed in the request is sent without an `Authorization` header, so routes that allow anonymous access keep working.
158+
*
159+
* This method is browser-only. In server code, read the caller's token from the incoming request instead.
160+
*
161+
* @param path - A 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.
163+
* @returns Promise resolving to a native [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
164+
* @throws {Error} When `path` resolves to a different origin, or when called outside the browser.
165+
*
166+
* @example
167+
* ```typescript
168+
* // Call your app's own server route as the signed-in user
169+
* const response = await base44.fetchWithAuth('/api/orders');
170+
* const orders = await response.json();
171+
* ```
172+
*
173+
* @example
174+
* ```typescript
175+
* // POST with a JSON body
176+
* const response = await base44.fetchWithAuth('/api/orders', {
177+
* method: 'POST',
178+
* headers: { 'Content-Type': 'application/json' },
179+
* body: JSON.stringify({ productId: 'abc', quantity: 2 }),
180+
* });
181+
*
182+
* if (!response.ok) {
183+
* throw new Error(`Request failed: ${response.status}`);
184+
* }
185+
* ```
186+
*/
187+
fetchWithAuth(path: string, init?: RequestInit): Promise<Response>;
188+
150189
/**
151190
* Sets a new authentication token for all subsequent requests.
152191
*

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { AxiosInstance } from "axios";
2+
3+
/**
4+
* Builds the client's `fetchWithAuth`: a `fetch` that attaches the signed-in
5+
* user's access token, restricted to the app's own origin.
6+
*
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.
10+
* @internal
11+
*/
12+
export function createFetchWithAuth(axios: AxiosInstance) {
13+
const currentToken = (): string | null => {
14+
const header = axios.defaults.headers.common["Authorization"];
15+
if (typeof header !== "string" || !header.startsWith("Bearer ")) {
16+
return null;
17+
}
18+
return header.slice("Bearer ".length) || null;
19+
};
20+
21+
return async function fetchWithAuth(
22+
path: string,
23+
init: RequestInit = {}
24+
): Promise<Response> {
25+
const url = resolveSameOriginUrl(path);
26+
const headers = new Headers(init.headers);
27+
const token = currentToken();
28+
29+
if (token && !headers.has("Authorization")) {
30+
headers.set("Authorization", `Bearer ${token}`);
31+
}
32+
33+
return fetch(url, { ...init, headers });
34+
};
35+
}
36+
37+
function resolveSameOriginUrl(path: string): string {
38+
if (typeof path !== "string" || path === "") {
39+
throw new Error("fetchWithAuth() requires a path, such as '/api/orders'.");
40+
}
41+
42+
const location = typeof window !== "undefined" ? window.location : undefined;
43+
if (!location?.href) {
44+
throw new Error(
45+
"fetchWithAuth() is only available in the browser. In server code, read the caller's token from the request instead — see createClientFromRequest()."
46+
);
47+
}
48+
49+
let pageUrl: URL;
50+
let resolved: URL;
51+
try {
52+
pageUrl = new URL(location.href);
53+
resolved = new URL(path, pageUrl);
54+
} catch {
55+
throw new Error(`fetchWithAuth() received an invalid path: "${path}".`);
56+
}
57+
58+
// Resolving before comparing is what makes this safe: a protocol-relative
59+
// path ("//evil.example"), a backslash ("/\\evil.example") and an absolute URL
60+
// all land on another origin here, and are rejected the same way.
61+
if (resolved.origin !== pageUrl.origin) {
62+
throw new Error(
63+
`fetchWithAuth() only sends requests to your app's own origin, so the access token never reaches a third party. "${path}" resolves to ${resolved.origin}. Use base44.functions.fetch() to call a Base44 backend function, or plain fetch() for another origin.`
64+
);
65+
}
66+
67+
return resolved.toString();
68+
}

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

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2+
import { createClient } from "../../src/index.ts";
3+
4+
const appId = "test-app-id";
5+
const origin = "https://my-app.base44.app";
6+
7+
function makeLocalStorage(initial: Record<string, string> = {}) {
8+
const store = new Map<string, string>(Object.entries(initial));
9+
return {
10+
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
11+
setItem: (k: string, v: string) => {
12+
store.set(k, v);
13+
},
14+
removeItem: (k: string) => {
15+
store.delete(k);
16+
},
17+
clear: () => store.clear(),
18+
};
19+
}
20+
21+
function stubBrowser(storage = makeLocalStorage()) {
22+
vi.stubGlobal("document", { referrer: "", visibilityState: "visible" });
23+
vi.stubGlobal("window", {
24+
addEventListener: vi.fn(),
25+
removeEventListener: vi.fn(),
26+
history: { replaceState: vi.fn() },
27+
location: {
28+
href: `${origin}/dashboard`,
29+
origin,
30+
pathname: "/dashboard",
31+
search: "",
32+
},
33+
localStorage: storage,
34+
});
35+
vi.stubGlobal("localStorage", storage);
36+
}
37+
38+
const createTestClient = (token?: string) =>
39+
createClient({
40+
serverUrl: "",
41+
appId,
42+
token,
43+
analytics: { enabled: false },
44+
});
45+
46+
let fetchMock: ReturnType<typeof vi.fn>;
47+
48+
beforeEach(() => {
49+
fetchMock = vi.fn().mockResolvedValue(new Response("{}"));
50+
vi.stubGlobal("fetch", fetchMock);
51+
});
52+
53+
afterEach(() => {
54+
vi.unstubAllGlobals();
55+
vi.clearAllMocks();
56+
});
57+
58+
const lastCall = () => {
59+
const [url, init] = fetchMock.mock.calls[0];
60+
return { url, init, headers: new Headers(init.headers) };
61+
};
62+
63+
describe("fetchWithAuth", () => {
64+
test("attaches the user's token to a same-origin path", async () => {
65+
stubBrowser();
66+
const base44 = createTestClient("user-token");
67+
68+
await base44.fetchWithAuth("/api/orders");
69+
70+
const { url, headers } = lastCall();
71+
expect(url).toBe(`${origin}/api/orders`);
72+
expect(headers.get("Authorization")).toBe("Bearer user-token");
73+
});
74+
75+
test("reads the token from local storage when the client was created without one", async () => {
76+
stubBrowser(makeLocalStorage({ base44_access_token: "stored-token" }));
77+
const base44 = createTestClient();
78+
79+
await base44.fetchWithAuth("/api/orders");
80+
81+
expect(lastCall().headers.get("Authorization")).toBe("Bearer stored-token");
82+
});
83+
84+
test("uses the token set after login", async () => {
85+
stubBrowser();
86+
const base44 = createTestClient("old-token");
87+
88+
base44.setToken("new-token");
89+
await base44.fetchWithAuth("/api/orders");
90+
91+
expect(lastCall().headers.get("Authorization")).toBe("Bearer new-token");
92+
});
93+
94+
test("sends no auth header after logout", async () => {
95+
stubBrowser();
96+
const base44 = createTestClient("user-token");
97+
98+
base44.auth.logout();
99+
// logout() navigates the page; the test keeps the stubbed location usable.
100+
(globalThis as any).window.location.href = `${origin}/dashboard`;
101+
await base44.fetchWithAuth("/api/orders");
102+
103+
expect(lastCall().headers.get("Authorization")).toBeNull();
104+
});
105+
106+
test("sends no auth header when no user is signed in", async () => {
107+
stubBrowser();
108+
const base44 = createTestClient();
109+
110+
await base44.fetchWithAuth("/api/public");
111+
112+
expect(lastCall().headers.get("Authorization")).toBeNull();
113+
expect(fetchMock).toHaveBeenCalledTimes(1);
114+
});
115+
116+
test("forwards init options and keeps a caller-set Authorization header", async () => {
117+
stubBrowser();
118+
const base44 = createTestClient("user-token");
119+
120+
await base44.fetchWithAuth("/api/orders", {
121+
method: "POST",
122+
headers: {
123+
"Content-Type": "application/json",
124+
Authorization: "Bearer caller-token",
125+
},
126+
body: JSON.stringify({ productId: "abc" }),
127+
});
128+
129+
const { init, headers } = lastCall();
130+
expect(init.method).toBe("POST");
131+
expect(init.body).toBe(JSON.stringify({ productId: "abc" }));
132+
expect(headers.get("Content-Type")).toBe("application/json");
133+
expect(headers.get("Authorization")).toBe("Bearer caller-token");
134+
});
135+
136+
test("resolves a path relative to the current page", async () => {
137+
stubBrowser();
138+
const base44 = createTestClient("user-token");
139+
140+
await base44.fetchWithAuth("api/orders");
141+
142+
expect(lastCall().url).toBe(`${origin}/api/orders`);
143+
});
144+
145+
test.each([
146+
["an absolute URL", "https://evil.example/steal"],
147+
["a protocol-relative path", "//evil.example/steal"],
148+
["a backslash-prefixed path", "/\\evil.example/steal"],
149+
["an absolute URL on another port", `${origin}:8443/api/orders`],
150+
])("rejects %s", async (_label, path) => {
151+
stubBrowser();
152+
const base44 = createTestClient("user-token");
153+
154+
await expect(base44.fetchWithAuth(path)).rejects.toThrow(
155+
/only sends requests to your app's own origin/
156+
);
157+
expect(fetchMock).not.toHaveBeenCalled();
158+
});
159+
160+
test("rejects an empty path", async () => {
161+
stubBrowser();
162+
const base44 = createTestClient("user-token");
163+
164+
await expect(base44.fetchWithAuth("")).rejects.toThrow(/requires a path/);
165+
expect(fetchMock).not.toHaveBeenCalled();
166+
});
167+
168+
test("throws outside the browser", async () => {
169+
const base44 = createTestClient("user-token");
170+
171+
await expect(base44.fetchWithAuth("/api/orders")).rejects.toThrow(
172+
/only available in the browser/
173+
);
174+
expect(fetchMock).not.toHaveBeenCalled();
175+
});
176+
});

0 commit comments

Comments
 (0)