Skip to content
Open
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
45 changes: 44 additions & 1 deletion src/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ import {
ResetPasswordParams,
} from "./auth.types";

/**
* Validates that a redirect URL is safe to use (prevents open redirect attacks).
* Only allows same-origin URLs or relative paths.
*
* @param url - The URL to validate
* @param currentOrigin - The current window origin for comparison
* @returns true if the URL is safe to redirect to
*/
function isValidRedirectUrl(url: string): boolean {
// Relative URLs starting with / are always safe
if (url.startsWith("/") && !url.startsWith("//")) {
return true;
}

// For absolute URLs, verify same origin
try {
const parsed = new URL(url);
return (
typeof window !== "undefined" &&
parsed.origin === window.location.origin
);
} catch {
// If URL parsing fails, reject it for safety
return false;
}
}

/**
* Creates the auth module for the Base44 SDK.
*
Expand Down Expand Up @@ -44,10 +71,26 @@ export function createAuthModule(
}

// If nextUrl is not provided, use the current URL
const redirectUrl = nextUrl
let redirectUrl = nextUrl
? new URL(nextUrl, window.location.origin).toString()
: window.location.href;

// Prevent redirect loops: if redirectUrl is already a login URL, extract the original from_url
try {
const parsedUrl = new URL(redirectUrl);
const pathname = parsedUrl.pathname;
// Check for login path with or without trailing slash
if (pathname.endsWith("/login") || pathname.endsWith("/login/")) {
const originalFromUrl = parsedUrl.searchParams.get("from_url");
if (originalFromUrl && isValidRedirectUrl(originalFromUrl)) {
// Use the original destination instead of nesting login URLs
redirectUrl = originalFromUrl;
}
}
} catch {
// If URL parsing fails, continue with the original redirectUrl
}

// Build the login URL
const loginUrl = `${
options.appBaseUrl ?? ""
Expand Down
129 changes: 129 additions & 0 deletions tests/unit/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,135 @@ describe('Auth Module', () => {
// Restore window
global.window = originalWindow;
});

describe('redirect loop prevention', () => {
let testClient;
let originalWindow;

beforeEach(() => {
// Save and clear window state before each test
originalWindow = global.window;
});

afterEach(() => {
// Restore original window state after each test
global.window = originalWindow;
});

test('should prevent redirect loops when nextUrl is already a login URL with same-origin from_url', () => {
testClient = createClient({
serverUrl,
appId,
appBaseUrl: 'https://custom-app.example.com',
});

// Mock window.location for the test - must match the from_url origin for validation
const mockLocation = { href: '', origin: 'https://custom-app.example.com' };
global.window = {
location: mockLocation
};

// nextUrl is already a login URL with same-origin from_url - should extract original destination
const originalDestination = 'https://custom-app.example.com/dashboard';
const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}`;
testClient.auth.redirectToLogin(nestedLoginUrl);

// Should use the original destination, not nest login URLs
expect(mockLocation.href).toBe(
`https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}`
);
});

test('should prevent redirect loops when login URL has trailing slash', () => {
testClient = createClient({
serverUrl,
appId,
appBaseUrl: 'https://custom-app.example.com',
});

const mockLocation = { href: '', origin: 'https://custom-app.example.com' };
global.window = {
location: mockLocation
};

// Login URL with trailing slash
const originalDestination = 'https://custom-app.example.com/dashboard';
const nestedLoginUrl = `https://custom-app.example.com/login/?from_url=${encodeURIComponent(originalDestination)}`;
testClient.auth.redirectToLogin(nestedLoginUrl);

expect(mockLocation.href).toBe(
`https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}`
);
});

test('should allow relative URLs in from_url parameter', () => {
testClient = createClient({
serverUrl,
appId,
appBaseUrl: 'https://custom-app.example.com',
});

const mockLocation = { href: '', origin: 'https://custom-app.example.com' };
global.window = {
location: mockLocation
};

// from_url is a relative path
const originalDestination = '/dashboard';
const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}`;
testClient.auth.redirectToLogin(nestedLoginUrl);

expect(mockLocation.href).toBe(
`https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}`
);
});

test('should reject cross-origin from_url to prevent open redirect attacks', () => {
testClient = createClient({
serverUrl,
appId,
appBaseUrl: 'https://custom-app.example.com',
});

const mockLocation = { href: '', origin: 'https://custom-app.example.com' };
global.window = {
location: mockLocation
};

// Malicious cross-origin from_url - should NOT be extracted
const maliciousDestination = 'https://evil.com/phishing';
const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(maliciousDestination)}`;
testClient.auth.redirectToLogin(nestedLoginUrl);

// Should keep the nested login URL since the from_url is cross-origin (rejected)
expect(mockLocation.href).toBe(
`https://custom-app.example.com/login?from_url=${encodeURIComponent(nestedLoginUrl)}`
);
});

test('should reject protocol-relative URLs to prevent open redirect attacks', () => {
testClient = createClient({
serverUrl,
appId,
appBaseUrl: 'https://custom-app.example.com',
});

const mockLocation = { href: '', origin: 'https://custom-app.example.com' };
global.window = {
location: mockLocation
};

// Protocol-relative URL attempting open redirect - should be rejected
const maliciousDestination = '//evil.com/phishing';
const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(maliciousDestination)}`;
testClient.auth.redirectToLogin(nestedLoginUrl);

// Should keep the nested login URL since the from_url is malicious (rejected)
expect(mockLocation.href).toBe(
`https://custom-app.example.com/login?from_url=${encodeURIComponent(nestedLoginUrl)}`
);
});
});
});

describe('logout()', () => {
Expand Down