Skip to content
1,938 changes: 1,010 additions & 928 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
"axios": "^1.6.2"
},
"devDependencies": {
"vitest": "^1.0.0",
"@vitest/ui": "^1.0.0",
"@vitest/coverage-istanbul": "^1.0.0",
"@vitest/coverage-v8": "^1.0.0",
"@types/node": "^24.2.0",
"@vitest/coverage-istanbul": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"dotenv": "^16.3.1",
"eslint": "^8.54.0",
"nock": "^13.4.0",
"typescript": "^5.3.2"
"typescript": "^5.3.2",
"vitest": "^3.2.4"
},
"keywords": [
"base44",
Expand Down
45 changes: 31 additions & 14 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,46 @@ import { createAuthModule } from "./modules/auth.js";
import { getAccessToken } from "./utils/auth-utils.js";
import { createFunctionsModule } from "./modules/functions.js";

/**
* Client configuration - supports token OR API key authentication (mutually exclusive)
*/
type ClientConfig = {
serverUrl?: string;
appId: string;
env?: string;
requiresAuth?: boolean;
} & (
| {}
| {
apiKey: string;
}
| {
token: string;
}
);

/**
* Create a Base44 client instance
* @param {Object} config - Client configuration
* @param {string} [config.serverUrl='https://base44.app'] - API server URL
* @param {string|number} config.appId - Application ID
* @param {string} [config.env='prod'] - Environment ('prod' or 'dev')
* @param {string} [config.token] - Authentication token
* @param {string} [config.token] - Authentication token (mutually exclusive with apiKey)
* @param {string} [config.apiKey] - API key (mutually exclusive with token)
* @param {boolean} [config.requiresAuth=false] - Whether the app requires authentication
* @returns {Object} Base44 client instance
*/
export function createClient(config: {
serverUrl?: string;
appId: string;
env?: string;
token?: string;
requiresAuth?: boolean;
}) {
export function createClient(config: ClientConfig) {
const {
serverUrl = "https://base44.app",
appId,
env = "prod",
token,
requiresAuth = false,
} = config;

const apiKey = "apiKey" in config ? config.apiKey : undefined;
const token = "token" in config ? config.token : undefined;

// Create the base axios client
const axiosClient = createAxiosClient({
baseURL: `${serverUrl}/api`,
Expand All @@ -38,6 +53,7 @@ export function createClient(config: {
"X-Environment": env,
},
token,
apiKey,
requiresAuth, // Pass requiresAuth to axios client
appId, // Pass appId for login redirect
serverUrl, // Pass serverUrl for login redirect
Expand All @@ -50,6 +66,7 @@ export function createClient(config: {
"X-Environment": env,
},
token,
apiKey,
requiresAuth,
appId,
serverUrl,
Expand All @@ -59,20 +76,20 @@ export function createClient(config: {
// Create modules
const entities = createEntitiesModule(axiosClient, appId);
const integrations = createIntegrationsModule(axiosClient, appId);
const auth = createAuthModule(axiosClient, appId, serverUrl);
const auth = createAuthModule(axiosClient, appId, serverUrl, !!apiKey);
const functions = createFunctionsModule(functionsAxiosClient, appId);

// Always try to get token from localStorage or URL parameters
if (typeof window !== "undefined") {
// Always try to get token from localStorage or URL parameters (only for token auth)
if (typeof window !== "undefined" && !apiKey) {
// Get token from URL or localStorage
const accessToken = token || getAccessToken();
if (accessToken) {
auth.setToken(accessToken);
}
}

// If authentication is required, verify token and redirect to login if needed
if (requiresAuth && typeof window !== "undefined") {
// If authentication is required, verify token and redirect to login if needed (only for token auth)
if (requiresAuth && typeof window !== "undefined" && !apiKey) {
// We perform this check asynchronously to not block client creation
setTimeout(async () => {
try {
Expand Down
38 changes: 36 additions & 2 deletions src/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,51 @@ import { AxiosInstance } from "axios";
* @param {import('axios').AxiosInstance} axios - Axios instance
* @param {string|number} appId - Application ID
* @param {string} serverUrl - Server URL
* @param {boolean} isApiKeyAuth - Whether using API key authentication
* @returns {Object} Auth module with authentication methods
*/
export function createAuthModule(
axios: AxiosInstance,
appId: string,
serverUrl: string
serverUrl: string,
isApiKeyAuth = false
) {
return {
/**
* Get current user information
* @returns {Promise<Object>} Current user data
* @throws {Error} When called with API key authentication
*/
async me() {
if (isApiKeyAuth) {
throw new Error("The .me() method cannot be used with API key authentication. This method requires a user token to access user-specific data.");
}
return axios.get(`/apps/${appId}/entities/User/me`);
},

/**
* Update current user data
* @param {Object} data - Updated user data
* @returns {Promise<Object>} Updated user
* @throws {Error} When called with API key authentication
*/
async updateMe(data: Record<string, any>) {
if (isApiKeyAuth) {
throw new Error("The .updateMe() method cannot be used with API key authentication. This method requires a user token to access user-specific data.");
}
return axios.put(`/apps/${appId}/entities/User/me`, data);
},

/**
* Redirects the user to the app's login page
* @param {string} nextUrl - URL to redirect to after successful login
* @throws {Error} When not in a browser environment
* @throws {Error} When not in a browser environment or when using API key authentication
*/
redirectToLogin(nextUrl: string) {
if (isApiKeyAuth) {
throw new Error("The .redirectToLogin() method cannot be used with API key authentication. API keys do not require user login flows.");
}

// This function only works in a browser environment
if (typeof window === "undefined") {
throw new Error(
Expand All @@ -58,8 +72,13 @@ export function createAuthModule(
* Removes the token from localStorage and optionally redirects to a URL or reloads the page
* @param redirectUrl - Optional URL to redirect to after logout. Reloads the page if not provided
* @returns {Promise<void>}
* @throws {Error} When called with API key authentication
*/
logout(redirectUrl?: string) {
if (isApiKeyAuth) {
throw new Error("The .logout() method cannot be used with API key authentication. API keys do not have user sessions to logout from.");
}

// Remove token from axios headers
delete axios.defaults.headers.common["Authorization"];

Expand All @@ -86,8 +105,13 @@ export function createAuthModule(
* Set authentication token
* @param {string} token - Auth token
* @param {boolean} [saveToStorage=true] - Whether to save the token to localStorage
* @throws {Error} When called with API key authentication
*/
setToken(token: string, saveToStorage = true) {
if (isApiKeyAuth) {
throw new Error("The .setToken() method cannot be used with API key authentication. API keys are set during client initialization.");
}

if (!token) return;

axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
Expand All @@ -112,12 +136,17 @@ export function createAuthModule(
* @param password - User password
* @param turnstileToken - Optional Turnstile captcha token
* @returns Login response with access_token and user
* @throws {Error} When called with API key authentication
*/
async loginViaUsernamePassword(
email: string,
password: string,
turnstileToken?: string
) {
if (isApiKeyAuth) {
throw new Error("The .loginViaUsernamePassword() method cannot be used with API key authentication. API keys do not require user login flows.");
}

try {
const response: { access_token: string; user: any } = await axios.post(
`/apps/${appId}/auth/login`,
Expand Down Expand Up @@ -150,8 +179,13 @@ export function createAuthModule(
/**
* Verify if the current token is valid
* @returns {Promise<boolean>} True if token is valid
* @throws {Error} When called with API key authentication
*/
async isAuthenticated() {
if (isApiKeyAuth) {
throw new Error("The .isAuthenticated() method cannot be used with API key authentication. API keys do not have user authentication states.");
}

try {
await this.me();
return true;
Expand Down
13 changes: 6 additions & 7 deletions src/utils/axios-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function redirectToLogin(serverUrl: string, appId: string) {
* @param {string} options.baseURL - Base URL for all requests
* @param {Object} options.headers - Additional headers
* @param {string} options.token - Auth token
* @param {string} options.apiKey - API key
* @param {boolean} options.requiresAuth - Whether the application requires authentication
* @param {string|number} options.appId - Application ID (needed for login redirect)
* @param {string} options.serverUrl - Server URL (needed for login redirect)
Expand All @@ -85,6 +86,7 @@ export function createAxiosClient({
baseURL,
headers = {},
token,
apiKey,
requiresAuth = false,
appId,
serverUrl,
Expand All @@ -93,6 +95,7 @@ export function createAxiosClient({
baseURL: string;
headers?: Record<string, string>;
token?: string;
apiKey?: string;
requiresAuth?: boolean;
appId: string;
serverUrl: string;
Expand All @@ -107,9 +110,11 @@ export function createAxiosClient({
},
});

// Add token to requests if available
// Add authentication headers
if (token) {
client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
} else if (apiKey) {
client.defaults.headers.common["api_key"] = apiKey;
}

// Add origin URL in browser environment
Expand Down Expand Up @@ -144,17 +149,11 @@ export function createAxiosClient({
}

// Check for 403 Forbidden (authentication required) and redirect to login if requiresAuth is true
console.log(
requiresAuth,
error.response?.status,
typeof window !== "undefined"
);
if (
requiresAuth &&
error.response?.status === 403 &&
typeof window !== "undefined"
) {
console.log("Authentication required. Redirecting to login...");
// Use a slight delay to allow the error to propagate first
setTimeout(() => {
redirectToLogin(serverUrl, appId);
Expand Down
101 changes: 101 additions & 0 deletions tests/unit/api-key-auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createClient } from '../../src/index.ts';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import nock from 'nock';

describe('API Key Authentication', () => {
let client;
const testAppId = 'test-app-id';
const testApiKey = 'test-api-key';
const baseURL = 'https://base44.app';

beforeEach(() => {
// Clean up any previous nock interceptors
nock.cleanAll();

client = createClient({
appId: testAppId,
apiKey: testApiKey,
});
});

test('should send API key in header for requests', async () => {
// Mock a request to verify the API key header is sent
const scope = nock(baseURL)
.matchHeader('api_key', testApiKey)
.get('/api/apps/test-app-id/entities/TestEntity/test-id')
.reply(200, { data: 'test response' });

try {
await client.entities.TestEntity.get('test-id');
} catch (error) {
// The request might fail due to other reasons, but we're testing the header
}

// Verify the request was made with the correct header
expect(scope.isDone()).toBe(true);
});

test('should not send Authorization header when using API key', async () => {
const scope = nock(baseURL)
.get('/api/apps/test-app-id/entities/TestEntity/test-id')
.reply(function() {
// Verify Authorization header is not present
expect(this.req.headers.authorization).toBeUndefined();
// Verify API key header is present
expect(this.req.headers.api_key).toBe(testApiKey);
return [200, { data: 'test response' }];
});

try {
await client.entities.TestEntity.get('test-id');
} catch (error) {
// The request might fail due to other reasons, but we're testing the header
}

expect(scope.isDone()).toBe(true);
});

describe('User-specific method restrictions', () => {
test('auth.me() should throw error with API key', async () => {
await expect(client.auth.me()).rejects.toThrow(
'The .me() method cannot be used with API key authentication. This method requires a user token to access user-specific data.'
);
});

test('auth.updateMe() should throw error with API key', async () => {
await expect(client.auth.updateMe({})).rejects.toThrow(
'The .updateMe() method cannot be used with API key authentication. This method requires a user token to access user-specific data.'
);
});

test('auth.redirectToLogin() should throw error with API key', () => {
expect(() => client.auth.redirectToLogin('/')).toThrow(
'The .redirectToLogin() method cannot be used with API key authentication. API keys do not require user login flows.'
);
});

test('auth.logout() should throw error with API key', () => {
expect(() => client.auth.logout()).toThrow(
'The .logout() method cannot be used with API key authentication. API keys do not have user sessions to logout from.'
);
});

test('auth.setToken() should throw error with API key', () => {
expect(() => client.auth.setToken('new-token')).toThrow(
'The .setToken() method cannot be used with API key authentication. API keys are set during client initialization.'
);
});

test('auth.loginViaUsernamePassword() should throw error with API key', async () => {
await expect(client.auth.loginViaUsernamePassword('[email protected]', 'password')).rejects.toThrow(
'The .loginViaUsernamePassword() method cannot be used with API key authentication. API keys do not require user login flows.'
);
});

test('auth.isAuthenticated() should throw error with API key', async () => {
await expect(client.auth.isAuthenticated()).rejects.toThrow(
'The .isAuthenticated() method cannot be used with API key authentication. API keys do not have user authentication states.'
);
});
});
});
Loading