Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 39 additions & 17 deletions packages/sdk/src/modules/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { AxiosInstance } from "axios";

import { getAccessToken, removeAccessToken } from "../utils/auth-utils.js";
import {
AuthModuleOptions,
AuthModule,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -120,15 +144,22 @@ export function createAuthModule(

// Get current user information
async me() {
const sentAuthorization = axios.defaults.headers.common["Authorization"];
const request: Promise<User> =
pendingMe ??
axios.get<any, User>(`/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<any, User>(`/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;
},
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 76 additions & 1 deletion packages/sdk/tests/unit/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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();
Expand Down
Loading