diff --git a/packages/sdk/src/modules/auth.ts b/packages/sdk/src/modules/auth.ts index 111b886d..fe0a4837 100644 --- a/packages/sdk/src/modules/auth.ts +++ b/packages/sdk/src/modules/auth.ts @@ -1,4 +1,6 @@ import { AxiosInstance } from "axios"; + +import { getAccessToken, removeAccessToken } from "../utils/auth-utils.js"; import { AuthModuleOptions, AuthModule, @@ -77,6 +79,28 @@ function loginViaPopup( window.addEventListener("message", onMessage); } +// "token" is the key the platform-v2 built-in SDK wrote. +function removeStoredAccessTokens() { + removeAccessToken({}); + removeAccessToken({ storageKey: "token" }); +} + +const isStoredAccessToken = (authorization: unknown) => + authorization === + `Bearer ${getAccessToken({ saveToStorage: false, removeFromUrl: false })}`; + +// A rejected token left in storage boots the next page with it, and an app +// whose own /login does that loops 401 → /login. A token stored since the +// request went out (a login in this or another tab) is not the rejected one. +function clearTokenOnRejection( + error: { status?: number }, + sentAuthorization: unknown +) { + if (error.status === 401 && isStoredAccessToken(sentAuthorization)) { + removeStoredAccessTokens(); + } +} + /** * Creates the auth module for the Base44 SDK. * @@ -120,15 +144,22 @@ export function createAuthModule( // Get current user information async me() { + const sentAuthorization = axios.defaults.headers.common["Authorization"]; const request: Promise = pendingMe ?? - axios.get(`/apps/${appId}/entities/User/me`).finally(() => { - // Only retire this request if it is still the shared one. An identity - // change mid-flight clears `pendingMe` and the next caller starts a - // fresh request; an unconditional clear here would retire that newer - // request instead, so a third caller would issue a duplicate. - if (pendingMe === request) pendingMe = null; - }); + axios + .get(`/apps/${appId}/entities/User/me`) + .catch((error) => { + clearTokenOnRejection(error, sentAuthorization); + throw error; + }) + .finally(() => { + // Only retire this request if it is still the shared one. An identity + // change mid-flight clears `pendingMe` and the next caller starts a + // fresh request; an unconditional clear here would retire that newer + // request instead, so a third caller would issue a duplicate. + if (pendingMe === request) pendingMe = null; + }); pendingMe = request; return request; }, @@ -202,16 +233,7 @@ export function createAuthModule( // Only do the rest if in a browser environment if (typeof window !== "undefined") { - // 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); - } - } + removeStoredAccessTokens(); // Determine the from_url parameter const fromUrl = redirectUrl || window.location.href; diff --git a/packages/sdk/tests/unit/auth.test.js b/packages/sdk/tests/unit/auth.test.js index c79df86b..32d14f1c 100644 --- a/packages/sdk/tests/unit/auth.test.js +++ b/packages/sdk/tests/unit/auth.test.js @@ -132,6 +132,67 @@ describe('Auth Module', () => { expect(scope.isDone()).toBe(true); }); + describe('stored token', () => { + let originalWindow; + let storage; + + const storeToken = (token) => { + storage = new Map([['base44_access_token', token], ['token', token]]); + global.window = { + location: { search: '', href: 'https://example.com/' }, + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + clear: () => storage.clear(), + }, + }; + base44.auth.setToken(token, false); + }; + + beforeEach(() => { + originalWindow = global.window; + }); + + afterEach(() => { + global.window = originalWindow; + }); + + test('is forgotten when me() rejects it with a 401', async () => { + storeToken('dead-token'); + scope.get(`/api/apps/${appId}/entities/User/me`).reply(401, { detail: 'Unauthorized' }); + + await expect(base44.auth.me()).rejects.toThrow(); + + expect(storage.has('base44_access_token')).toBe(false); + expect(storage.has('token')).toBe(false); + }); + + test('is kept when me() fails for another reason', async () => { + storeToken('valid-token'); + scope.get(`/api/apps/${appId}/entities/User/me`).reply(503, { detail: 'Unavailable' }); + + await expect(base44.auth.me()).rejects.toThrow(); + + expect(storage.get('base44_access_token')).toBe('valid-token'); + }); + + test('is kept when a login replaced it while me() was in flight', async () => { + storeToken('dead-token'); + scope + .get(`/api/apps/${appId}/entities/User/me`) + .delay(50) + .reply(401, { detail: 'Unauthorized' }); + + const staleRequest = base44.auth.me(); + storage.set('base44_access_token', 'new-token'); + + await expect(staleRequest).rejects.toThrow(); + + expect(storage.get('base44_access_token')).toBe('new-token'); + }); + }); + test('setToken() drops an in-flight request from the previous identity', async () => { scope .get(`/api/apps/${appId}/entities/User/me`) @@ -287,6 +348,20 @@ describe('Auth Module', () => { global.window = originalWindow; }); + test('should keep a valid stored token when redirecting to login', () => { + const mockLocalStorage = { removeItem: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), clear: vi.fn() }; + const mockLocation = { href: 'https://example.com/current-page' }; + const originalWindow = global.window; + global.window = { localStorage: mockLocalStorage, location: mockLocation }; + + base44.auth.redirectToLogin(); + + expect(mockLocalStorage.removeItem).not.toHaveBeenCalled(); + expect(mockLocation.href).toContain('/login?from_url='); + + global.window = originalWindow; + }); + test('should use appBaseUrl for login redirect when provided', () => { const customAppBaseUrl = 'https://custom-app.example.com'; const clientWithCustomUrl = createClient({ @@ -419,7 +494,7 @@ describe('Auth Module', () => { base44.auth.logout(); // Verify error was logged - expect(consoleSpy).toHaveBeenCalledWith('Failed to remove token from localStorage:', expect.any(Error)); + expect(consoleSpy).toHaveBeenCalledWith('Error removing token from local storage:', expect.any(Error)); // Restore consoleSpy.mockRestore();