diff --git a/src/modules/auth.ts b/src/modules/auth.ts index fd3995f..13c35d7 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -7,6 +7,74 @@ import { ResetPasswordParams, } from "./auth.types"; +function isInsideIframe(): boolean { + if (typeof window === "undefined") return false; + return window !== window.parent; +} + +/** + * Opens a URL in a centered popup and waits for the backend to postMessage + * the auth result back. On success, redirects the current window to + * redirectUrl with the token params appended, preserving the same behaviour + * as a normal full-page redirect flow. + * + * @param url - The login URL to open in the popup (should include popup_origin). + * @param redirectUrl - Where to redirect after auth (the original fromUrl). + * @param expectedOrigin - The origin we expect the postMessage to come from. + */ +function loginViaPopup( + url: string, + redirectUrl: string, + expectedOrigin: string +): void { + const width = 500; + const height = 600; + const left = Math.round(window.screenX + (window.outerWidth - width) / 2); + const top = Math.round(window.screenY + (window.outerHeight - height) / 2); + + const popup = window.open( + url, + "base44_auth", + `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes` + ); + + if (!popup) { + return; + } + + const cleanup = () => { + window.removeEventListener("message", onMessage); + clearInterval(pollTimer); + if (!popup.closed) popup.close(); + }; + + const onMessage = (event: MessageEvent) => { + if (event.origin !== expectedOrigin) return; + if (event.source !== popup) return; + if (!event.data?.access_token) return; + + cleanup(); + + const callbackUrl = new URL(redirectUrl); + const { access_token, is_new_user } = event.data; + + callbackUrl.searchParams.set("access_token", access_token); + + if (is_new_user != null) { + callbackUrl.searchParams.set("is_new_user", String(is_new_user)); + } + + window.location.href = callbackUrl.toString(); + }; + + // Only used to detect the user closing the popup before auth completes + const pollTimer = setInterval(() => { + if (popup.closed) cleanup(); + }, 500); + + window.addEventListener("message", onMessage); +} + /** * Creates the auth module for the Base44 SDK. * @@ -74,7 +142,14 @@ export function createAuthModule( const loginUrl = `${options.appBaseUrl}/api${authPath}?${queryParams}`; - // Redirect to the provider login page + // When running inside an iframe, use a popup to avoid OAuth providers + // blocking iframe navigation. + if (isInsideIframe()) { + const popupLoginUrl = `${loginUrl}&popup_origin=${encodeURIComponent(window.location.origin)}`; + return loginViaPopup(popupLoginUrl, redirectUrl, window.location.origin); + } + + // Default: full-page redirect window.location.href = loginUrl; }, @@ -234,5 +309,6 @@ export function createAuthModule( new_password: newPassword, }); }, + }; } diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 81aa9aa..c31f7a1 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -632,15 +632,244 @@ describe('Auth Module', () => { // Mock network error scope.get(`/api/apps/${appId}/entities/User/me`) .replyWithError('Network error'); - + // Call the API const result = await base44.auth.isAuthenticated(); - + // Verify the response expect(result).toBe(false); - + // Verify all mocks were called expect(scope.isDone()).toBe(true); }); }); + + describe('loginWithProvider()', () => { + test('should redirect to google login URL by default', () => { + const originalWindow = global.window; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + const win = { location: mockLocation }; + win.parent = win; // not in iframe + global.window = win; + + base44.auth.loginWithProvider('google', '/dashboard'); + + expect(mockLocation.href).toContain(`${appBaseUrl}/api/apps/auth/login?`); + expect(mockLocation.href).toContain(`app_id=${appId}`); + expect(mockLocation.href).toContain('from_url='); + + global.window = originalWindow; + }); + + test('should include provider path for non-google providers', () => { + const originalWindow = global.window; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + const win = { location: mockLocation }; + win.parent = win; + global.window = win; + + base44.auth.loginWithProvider('microsoft', '/dashboard'); + + expect(mockLocation.href).toContain('/api/apps/auth/microsoft/login?'); + + global.window = originalWindow; + }); + + test('should use SSO URL structure for sso provider', () => { + const originalWindow = global.window; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + const win = { location: mockLocation }; + win.parent = win; + global.window = win; + + base44.auth.loginWithProvider('sso', '/dashboard'); + + expect(mockLocation.href).toContain(`/api/apps/${appId}/auth/sso/login?`); + + global.window = originalWindow; + }); + + test('should use popup when inside an iframe', () => { + const originalWindow = global.window; + const mockPopup = { closed: false, close: vi.fn() }; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + // Simulate iframe: window.parent !== window + const parentWindow = {}; + global.window = { + location: mockLocation, + parent: parentWindow, + screenX: 0, + screenY: 0, + outerWidth: 1024, + outerHeight: 768, + open: vi.fn().mockReturnValue(mockPopup), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + + base44.auth.loginWithProvider('google', '/dashboard'); + + // Should NOT have redirected + expect(mockLocation.href).toBe(''); + // Should have opened a popup + expect(global.window.open).toHaveBeenCalledTimes(1); + const openCall = global.window.open.mock.calls[0]; + expect(openCall[0]).toContain('popup_origin='); + expect(openCall[1]).toBe('base44_auth'); + + global.window = originalWindow; + }); + + test('should not use popup when not inside an iframe', () => { + const originalWindow = global.window; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + // window.parent === window (not in iframe) + const win = { location: mockLocation, open: vi.fn() }; + win.parent = win; + global.window = win; + + base44.auth.loginWithProvider('google', '/dashboard'); + + // Should have redirected directly + expect(mockLocation.href).toContain(`${appBaseUrl}/api/apps/auth/login?`); + // Should NOT have opened a popup + expect(global.window.open).not.toHaveBeenCalled(); + + global.window = originalWindow; + }); + + test('should handle popup being blocked by browser', () => { + const originalWindow = global.window; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + const parentWindow = {}; + global.window = { + location: mockLocation, + parent: parentWindow, + screenX: 0, + screenY: 0, + outerWidth: 1024, + outerHeight: 768, + open: vi.fn().mockReturnValue(null), // popup blocked + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + + // Should not throw + expect(() => { + base44.auth.loginWithProvider('google', '/dashboard'); + }).not.toThrow(); + + global.window = originalWindow; + }); + + test('should redirect on postMessage with valid token from popup', () => { + const originalWindow = global.window; + const mockPopup = { closed: false, close: vi.fn() }; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + const parentWindow = {}; + let messageHandler; + global.window = { + location: mockLocation, + parent: parentWindow, + screenX: 0, + screenY: 0, + outerWidth: 1024, + outerHeight: 768, + open: vi.fn().mockReturnValue(mockPopup), + addEventListener: vi.fn((event, handler) => { + if (event === 'message') messageHandler = handler; + }), + removeEventListener: vi.fn(), + }; + + base44.auth.loginWithProvider('google', '/callback'); + + // Simulate postMessage from popup + messageHandler({ + origin: 'https://myapp.com', + source: mockPopup, + data: { access_token: 'test-token-123', is_new_user: true }, + }); + + // Should redirect with token params + expect(mockLocation.href).toContain('access_token=test-token-123'); + expect(mockLocation.href).toContain('is_new_user=true'); + // Popup should be closed + expect(mockPopup.close).toHaveBeenCalled(); + + global.window = originalWindow; + }); + + test('should ignore postMessage from wrong origin', () => { + const originalWindow = global.window; + const mockPopup = { closed: false, close: vi.fn() }; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + const parentWindow = {}; + let messageHandler; + global.window = { + location: mockLocation, + parent: parentWindow, + screenX: 0, + screenY: 0, + outerWidth: 1024, + outerHeight: 768, + open: vi.fn().mockReturnValue(mockPopup), + addEventListener: vi.fn((event, handler) => { + if (event === 'message') messageHandler = handler; + }), + removeEventListener: vi.fn(), + }; + + base44.auth.loginWithProvider('google', '/callback'); + + // Simulate postMessage from wrong origin + messageHandler({ + origin: 'https://evil.com', + source: mockPopup, + data: { access_token: 'stolen-token' }, + }); + + // Should NOT have redirected + expect(mockLocation.href).toBe(''); + expect(mockPopup.close).not.toHaveBeenCalled(); + + global.window = originalWindow; + }); + + test('should ignore postMessage from wrong source', () => { + const originalWindow = global.window; + const mockPopup = { closed: false, close: vi.fn() }; + const mockLocation = { href: '', origin: 'https://myapp.com' }; + const parentWindow = {}; + let messageHandler; + global.window = { + location: mockLocation, + parent: parentWindow, + screenX: 0, + screenY: 0, + outerWidth: 1024, + outerHeight: 768, + open: vi.fn().mockReturnValue(mockPopup), + addEventListener: vi.fn((event, handler) => { + if (event === 'message') messageHandler = handler; + }), + removeEventListener: vi.fn(), + }; + + base44.auth.loginWithProvider('google', '/callback'); + + // Simulate postMessage from correct origin but different source + messageHandler({ + origin: 'https://myapp.com', + source: {}, // not the popup + data: { access_token: 'stolen-token' }, + }); + + // Should NOT have redirected + expect(mockLocation.href).toBe(''); + expect(mockPopup.close).not.toHaveBeenCalled(); + + global.window = originalWindow; + }); + }); }); \ No newline at end of file