diff --git a/src/client.ts b/src/client.ts index 5a061ec..5a8038b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -70,6 +70,9 @@ export function createClient(config: CreateClientConfig): Base44Client { headers: optionalHeaders, } = config; + // Normalize appBaseUrl to always be a string (empty if not provided or invalid) + const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; + const socketConfig: RoomsSocketConfig = { serverUrl, mountPath: "/ws-user-apps/socket.io/", @@ -135,7 +138,7 @@ export function createClient(config: CreateClientConfig): Base44Client { functionsAxiosClient, appId, { - appBaseUrl, + appBaseUrl: normalizedAppBaseUrl, serverUrl, } ); diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 50f0e0e..d186622 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -49,9 +49,7 @@ export function createAuthModule( : window.location.href; // Build the login URL - const loginUrl = `${ - options.appBaseUrl ?? "" - }/login?from_url=${encodeURIComponent(redirectUrl)}`; + const loginUrl = `${options.appBaseUrl}/login?from_url=${encodeURIComponent(redirectUrl)}`; // Redirect to the login page window.location.href = loginUrl; @@ -65,7 +63,7 @@ export function createAuthModule( // Build the provider login URL (google is the default, so no provider path needed) const providerPath = provider === "google" ? "" : `/${provider}`; const loginUrl = `${ - options.serverUrl + options.appBaseUrl }/api/apps/auth${providerPath}/login?app_id=${appId}&from_url=${encodeURIComponent( redirectUrl )}`; @@ -75,29 +73,29 @@ export function createAuthModule( }, // Logout the current user - // Removes the token from localStorage and optionally redirects to a URL or reloads the page logout(redirectUrl?: string) { - // Remove token from axios headers + // Remove token from axios headers (always do this) delete axios.defaults.headers.common["Authorization"]; - // Remove token from localStorage - if (typeof window !== "undefined" && window.localStorage) { - try { - window.localStorage.removeItem("base44_access_token"); - // Remove "token" that is set by the built-in SDK of platform version 2 - window.localStorage.removeItem("token"); - } catch (e) { - console.error("Failed to remove token from localStorage:", e); - } - } - - // Redirect if a URL is provided + // Only do the rest if in a browser environment if (typeof window !== "undefined") { - if (redirectUrl) { - window.location.href = redirectUrl; - } else { - window.location.reload(); + // Remove token from localStorage + if (window.localStorage) { + try { + window.localStorage.removeItem("base44_access_token"); + // Remove "token" that is set by the built-in SDK of platform version 2 + window.localStorage.removeItem("token"); + } catch (e) { + console.error("Failed to remove token from localStorage:", e); + } } + + // Determine the from_url parameter + const fromUrl = redirectUrl || window.location.href; + + // Redirect to server-side logout endpoint to clear HTTP-only cookies + const logoutUrl = `${options.appBaseUrl}/api/apps/auth/logout?from_url=${encodeURIComponent(fromUrl)}`; + window.location.href = logoutUrl; } }, diff --git a/src/modules/auth.types.ts b/src/modules/auth.types.ts index 48d5101..52dedec 100644 --- a/src/modules/auth.types.ts +++ b/src/modules/auth.types.ts @@ -98,8 +98,8 @@ export interface ResetPasswordParams { export interface AuthModuleOptions { /** Server URL for API requests. */ serverUrl: string; - /** Optional base URL for the app (used for login redirects). */ - appBaseUrl?: string; + /** Base URL for the app (used for login redirects). */ + appBaseUrl: string; } /** diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 9ed354e..81aa9aa 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -7,17 +7,33 @@ describe('Auth Module', () => { let scope; const appId = 'test-app-id'; const serverUrl = 'https://api.base44.com'; - + const appBaseUrl = 'https://api.base44.com'; + beforeEach(() => { + // Mock window.addEventListener and document for analytics module + if (typeof window !== 'undefined') { + if (!window.addEventListener) { + window.addEventListener = vi.fn(); + window.removeEventListener = vi.fn(); + } + } + if (typeof document === 'undefined') { + global.document = { + referrer: '', + visibilityState: 'visible' + }; + } + // Create a new client for each test base44 = createClient({ serverUrl, appId, + appBaseUrl, }); - + // Create a nock scope for mocking API calls scope = nock(serverUrl); - + // Enable request debugging for Nock nock.disableNetConnect(); nock.emitter.on('no match', (req) => { @@ -143,15 +159,15 @@ describe('Auth Module', () => { global.window = { location: mockLocation }; - + const nextUrl = 'https://example.com/dashboard'; base44.auth.redirectToLogin(nextUrl); - + // Verify the redirect URL was set correctly expect(mockLocation.href).toBe( - `/login?from_url=${encodeURIComponent(nextUrl)}` + `${appBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}` ); - + // Restore window global.window = originalWindow; }); @@ -169,7 +185,7 @@ describe('Auth Module', () => { // Verify the redirect URL uses current URL expect(mockLocation.href).toBe( - `/login?from_url=${encodeURIComponent(currentUrl)}` + `${appBaseUrl}/login?from_url=${encodeURIComponent(currentUrl)}` ); // Restore window @@ -204,6 +220,12 @@ describe('Auth Module', () => { }); test('should use relative URL for login redirect when appBaseUrl is not provided', () => { + // Create a client without appBaseUrl + const clientWithoutAppBaseUrl = createClient({ + serverUrl, + appId, + }); + // Mock window.location const originalWindow = global.window; const mockLocation = { href: '', origin: 'https://current-app.com' }; @@ -212,7 +234,7 @@ describe('Auth Module', () => { }; const nextUrl = 'https://example.com/dashboard'; - base44.auth.redirectToLogin(nextUrl); + clientWithoutAppBaseUrl.auth.redirectToLogin(nextUrl); // Verify the redirect URL uses a relative path (no appBaseUrl prefix) expect(mockLocation.href).toBe( @@ -316,33 +338,33 @@ describe('Auth Module', () => { global.window = { location: mockLocation }; - + const redirectUrl = 'https://example.com/logout-success'; base44.auth.logout(redirectUrl); - - // Verify redirect - expect(mockLocation.href).toBe(redirectUrl); - + + // Verify redirect to server-side logout endpoint with from_url parameter + const expectedUrl = `${appBaseUrl}/api/apps/auth/logout?from_url=${encodeURIComponent(redirectUrl)}`; + expect(mockLocation.href).toBe(expectedUrl); + // Restore window global.window = originalWindow; }); - test('should reload page when no redirect URL is provided', async () => { - // Mock window object with reload function - const mockReload = vi.fn(); + test('should redirect to logout endpoint when no redirect URL is provided', async () => { + // Mock window object + const mockLocation = { href: 'https://example.com/current-page' }; const originalWindow = global.window; global.window = { - location: { - reload: mockReload - } + location: mockLocation }; - + // Call logout without redirect URL base44.auth.logout(); - - // Verify page reload was called - expect(mockReload).toHaveBeenCalledTimes(1); - + + // Verify redirect to server-side logout endpoint with current page as from_url + const expectedUrl = `${appBaseUrl}/api/apps/auth/logout?from_url=${encodeURIComponent('https://example.com/current-page')}`; + expect(mockLocation.href).toBe(expectedUrl); + // Restore window global.window = originalWindow; }); diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 74c97aa..4a7b3ef 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -64,7 +64,7 @@ describe('Client Creation', () => { serviceToken: 'service-token-123', requiresAuth: true, }); - + expect(client).toBeDefined(); expect(client.entities).toBeDefined(); expect(client.integrations).toBeDefined(); @@ -78,6 +78,59 @@ describe('Client Creation', () => { }); +describe('appBaseUrl Normalization', () => { + test('should use appBaseUrl when provided as a string', () => { + const customAppBaseUrl = 'https://custom-app.example.com'; + const client = createClient({ + appId: 'test-app-id', + appBaseUrl: customAppBaseUrl, + }); + + // Mock window.location + const originalWindow = global.window; + const mockLocation = { href: '', origin: 'https://current-app.com' }; + global.window = { + location: mockLocation + }; + + const nextUrl = 'https://example.com/dashboard'; + client.auth.redirectToLogin(nextUrl); + + // Verify the redirect URL uses the custom appBaseUrl + expect(mockLocation.href).toBe( + `${customAppBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}` + ); + + // Restore window + global.window = originalWindow; + }); + + test('should normalize appBaseUrl to empty string when not provided', () => { + const client = createClient({ + appId: 'test-app-id', + // appBaseUrl not provided + }); + + // Mock window.location + const originalWindow = global.window; + const mockLocation = { href: '', origin: 'https://current-app.com' }; + global.window = { + location: mockLocation + }; + + const nextUrl = 'https://example.com/dashboard'; + client.auth.redirectToLogin(nextUrl); + + // Verify the redirect URL uses empty string (relative path) + expect(mockLocation.href).toBe( + `/login?from_url=${encodeURIComponent(nextUrl)}` + ); + + // Restore window + global.window = originalWindow; + }); +}); + describe('createClientFromRequest', () => { test('should create client from request with all headers', () => { const mockRequest = {