From 6242d3e568bc50bd6f0df407c47be72da7486d21 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Thu, 27 Aug 2026 10:05:58 +0300 Subject: [PATCH 1/4] fix(auth): drop the stored token before redirecting to login --- src/modules/auth.ts | 28 +++++++++++++++++----------- tests/unit/auth.test.js | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/modules/auth.ts b/src/modules/auth.ts index b9e23747..1c9decb7 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -77,6 +77,19 @@ function loginViaPopup( window.addEventListener("message", onMessage); } +function removeStoredAccessToken() { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + try { + window.localStorage.removeItem("base44_access_token"); + // "token" is the key the platform-v2 built-in SDK used + window.localStorage.removeItem("token"); + } catch (e) { + console.error("Failed to remove token from localStorage:", e); + } +} + /** * Creates the auth module for the Base44 SDK. * @@ -155,7 +168,9 @@ export function createAuthModule( // Build the login URL const loginUrl = `${options.appBaseUrl}/login?from_url=${encodeURIComponent(redirectUrl)}`; - // Redirect to the login page + // The login page must not boot with the token that just got us here, + // or a rejected token redirects to /login forever. + removeStoredAccessToken(); window.location.href = loginUrl; }, @@ -202,16 +217,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); - } - } + removeStoredAccessToken(); // Determine the from_url parameter const fromUrl = redirectUrl || window.location.href; diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index c79df86b..a3c5fc10 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -287,6 +287,21 @@ describe('Auth Module', () => { global.window = originalWindow; }); + test('should drop the stored token before 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).toHaveBeenCalledWith('base44_access_token'); + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('token'); + 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({ From fdd45fc6b580f5502beab1fe873da327812a27a9 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Thu, 27 Aug 2026 10:14:02 +0300 Subject: [PATCH 2/4] fix(auth): reuse removeAccessToken instead of a new helper --- src/modules/auth.ts | 21 ++++++--------------- tests/unit/auth.test.js | 2 +- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 1c9decb7..8b84cdf4 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -1,4 +1,6 @@ import { AxiosInstance } from "axios"; + +import { removeAccessToken } from "../utils/auth-utils.js"; import { AuthModuleOptions, InternalAuthModule, @@ -77,19 +79,6 @@ function loginViaPopup( window.addEventListener("message", onMessage); } -function removeStoredAccessToken() { - if (typeof window === "undefined" || !window.localStorage) { - return; - } - try { - window.localStorage.removeItem("base44_access_token"); - // "token" is the key the platform-v2 built-in SDK used - window.localStorage.removeItem("token"); - } catch (e) { - console.error("Failed to remove token from localStorage:", e); - } -} - /** * Creates the auth module for the Base44 SDK. * @@ -170,7 +159,8 @@ export function createAuthModule( // The login page must not boot with the token that just got us here, // or a rejected token redirects to /login forever. - removeStoredAccessToken(); + removeAccessToken({}); + removeAccessToken({ storageKey: "token" }); window.location.href = loginUrl; }, @@ -217,7 +207,8 @@ export function createAuthModule( // Only do the rest if in a browser environment if (typeof window !== "undefined") { - removeStoredAccessToken(); + removeAccessToken({}); + removeAccessToken({ storageKey: "token" }); // Determine the from_url parameter const fromUrl = redirectUrl || window.location.href; diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index a3c5fc10..f1289c51 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -434,7 +434,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(); From af5026562b92f68ac0d80d1c34492e0a5d424fb7 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Thu, 27 Aug 2026 10:52:47 +0300 Subject: [PATCH 3/4] refactor(auth): share the stored-token removal between logout and redirectToLogin --- src/modules/auth.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 8b84cdf4..150728c4 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -79,6 +79,12 @@ function loginViaPopup( window.addEventListener("message", onMessage); } +// "token" is the key the platform-v2 built-in SDK wrote. +function removeStoredAccessTokens() { + removeAccessToken({}); + removeAccessToken({ storageKey: "token" }); +} + /** * Creates the auth module for the Base44 SDK. * @@ -159,8 +165,7 @@ export function createAuthModule( // The login page must not boot with the token that just got us here, // or a rejected token redirects to /login forever. - removeAccessToken({}); - removeAccessToken({ storageKey: "token" }); + removeStoredAccessTokens(); window.location.href = loginUrl; }, @@ -207,8 +212,7 @@ export function createAuthModule( // Only do the rest if in a browser environment if (typeof window !== "undefined") { - removeAccessToken({}); - removeAccessToken({ storageKey: "token" }); + removeStoredAccessTokens(); // Determine the from_url parameter const fromUrl = redirectUrl || window.location.href; From 62382c57da79f1a3007631d0b5902f18bf1c73ed Mon Sep 17 00:00:00 2001 From: David Susskind Date: Tue, 20 Oct 2026 13:59:58 +0300 Subject: [PATCH 4/4] fix(auth): clear the stored token only when me() rejects it redirectToLogin() cleared storage on every call, so a valid session was lost on a transient me() failure (requiresAuth treats any error as signed out), a "Sign in" click while signed in, or an abandoned login. Clear instead when me() gets a 401 and storage still holds the token it sent, the same rule as the server-side script this replaces. A token stored mid-flight is kept. Task: https://b44sync.base44.app/rd-hub/tasks/BASE-12635 Task file: https://github.com/base44-dev/suss-tasks/blob/7bad58778ebc784b3e49b15fb4faee1016a9384f/apper/retire_clear_token_script/land_sdk_264.md agent: sdk.264 --- packages/sdk/src/modules/auth.ts | 43 +++++++++++++----- packages/sdk/tests/unit/auth.test.js | 66 ++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/packages/sdk/src/modules/auth.ts b/packages/sdk/src/modules/auth.ts index 4e53dddb..fe0a4837 100644 --- a/packages/sdk/src/modules/auth.ts +++ b/packages/sdk/src/modules/auth.ts @@ -1,6 +1,6 @@ import { AxiosInstance } from "axios"; -import { removeAccessToken } from "../utils/auth-utils.js"; +import { getAccessToken, removeAccessToken } from "../utils/auth-utils.js"; import { AuthModuleOptions, AuthModule, @@ -85,6 +85,22 @@ function removeStoredAccessTokens() { 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. * @@ -128,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; }, @@ -163,9 +186,7 @@ export function createAuthModule( // Build the login URL const loginUrl = `${options.appBaseUrl}/login?from_url=${encodeURIComponent(redirectUrl)}`; - // The login page must not boot with the token that just got us here, - // or a rejected token redirects to /login forever. - removeStoredAccessTokens(); + // Redirect to the login page window.location.href = loginUrl; }, diff --git a/packages/sdk/tests/unit/auth.test.js b/packages/sdk/tests/unit/auth.test.js index f1289c51..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,7 +348,7 @@ describe('Auth Module', () => { global.window = originalWindow; }); - test('should drop the stored token before redirecting to login', () => { + 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; @@ -295,8 +356,7 @@ describe('Auth Module', () => { base44.auth.redirectToLogin(); - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('base44_access_token'); - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('token'); + expect(mockLocalStorage.removeItem).not.toHaveBeenCalled(); expect(mockLocation.href).toContain('/login?from_url='); global.window = originalWindow;