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
5 changes: 5 additions & 0 deletions .changeset/slow-loops-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix iframe detetction and ensure we prefer the oauth popup flow when in an iframe.
229 changes: 229 additions & 0 deletions packages/clerk-js/src/ui/utils/__tests__/originPrefersPopup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { originPrefersPopup } from '../originPrefersPopup';

// Mock the inIframe function
vi.mock('@/utils', () => ({
inIframe: vi.fn(),
}));

// Import the mocked function
import { inIframe } from '@/utils';
const mockInIframe = vi.mocked(inIframe);

describe('originPrefersPopup', () => {
// Store original location to restore after tests
const originalLocation = window.location;

// Helper function to mock window.location.origin
const mockLocationOrigin = (origin: string) => {
Object.defineProperty(window, 'location', {
value: {
origin,
},
writable: true,
configurable: true,
});
};

beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();

// Set default origin
mockLocationOrigin('https://example.com');
});

afterEach(() => {
// Restore original location
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
configurable: true,
});
});

describe('when in iframe', () => {
it('should return true regardless of origin', () => {
mockInIframe.mockReturnValue(true);
mockLocationOrigin('https://not-a-preferred-origin.com');

expect(originPrefersPopup()).toBe(true);
});

it('should return true even with preferred origin', () => {
mockInIframe.mockReturnValue(true);
mockLocationOrigin('https://app.lovable.app');

expect(originPrefersPopup()).toBe(true);
});
});

describe('when not in iframe', () => {
beforeEach(() => {
mockInIframe.mockReturnValue(false);
});

describe('with preferred origins', () => {
it('should return true for .lovable.app domains', () => {
mockLocationOrigin('https://app.lovable.app');
expect(originPrefersPopup()).toBe(true);

mockLocationOrigin('https://my-project.lovable.app');
expect(originPrefersPopup()).toBe(true);
});

it('should return true for .lovableproject.com domains', () => {
mockLocationOrigin('https://project.lovableproject.com');
expect(originPrefersPopup()).toBe(true);

mockLocationOrigin('https://demo.lovableproject.com');
expect(originPrefersPopup()).toBe(true);
});

it('should return true for .webcontainer-api.io domains', () => {
mockLocationOrigin('https://stackblitz.webcontainer-api.io');
expect(originPrefersPopup()).toBe(true);

mockLocationOrigin('https://container.webcontainer-api.io');
expect(originPrefersPopup()).toBe(true);
});

it('should return true for .vusercontent.net domains', () => {
mockLocationOrigin('https://codesandbox.vusercontent.net');
expect(originPrefersPopup()).toBe(true);

mockLocationOrigin('https://preview.vusercontent.net');
expect(originPrefersPopup()).toBe(true);
});

it('should return true for .v0.dev domains', () => {
mockLocationOrigin('https://preview.v0.dev');
expect(originPrefersPopup()).toBe(true);

mockLocationOrigin('https://app.v0.dev');
expect(originPrefersPopup()).toBe(true);
});

it('should handle HTTPS and HTTP protocols', () => {
mockLocationOrigin('http://localhost.lovable.app');
expect(originPrefersPopup()).toBe(true);

mockLocationOrigin('https://secure.v0.dev');
expect(originPrefersPopup()).toBe(true);
});
});

describe('with non-preferred origins', () => {
it('should return false for common domains', () => {
const nonPreferredOrigins = [
'https://example.com',
'https://google.com',
'https://github.com',
'https://localhost:3000',
'https://app.mycompany.com',
'https://production-site.com',
];

nonPreferredOrigins.forEach(origin => {
mockLocationOrigin(origin);
expect(originPrefersPopup()).toBe(false);
});
});

it('should return false for similar but non-matching domains', () => {
const similarOrigins = [
'https://lovable.app.com', // wrong order
'https://notlovable.app', // different subdomain structure
'https://lovableproject.org', // wrong TLD
'https://webcontainer.io', // missing -api
'https://vusercontent.com', // wrong TLD
'https://v0.com', // missing .dev
'https://v1.dev', // wrong subdomain
];

similarOrigins.forEach(origin => {
mockLocationOrigin(origin);
expect(originPrefersPopup()).toBe(false);
});
});

it('should return false for domains that contain preferred origins as substrings', () => {
const containingOrigins = [
'https://not-lovable.app-something.com',
'https://fake-webcontainer-api.io.malicious.com',
'https://evil-vusercontent.net.phishing.com',
];

containingOrigins.forEach(origin => {
mockLocationOrigin(origin);
expect(originPrefersPopup()).toBe(false);
});
});
});

describe('edge cases', () => {
it('should handle empty origin', () => {
mockLocationOrigin('');
expect(originPrefersPopup()).toBe(false);
});

it('should be case sensitive', () => {
mockLocationOrigin('https://app.LOVABLE.APP');
expect(originPrefersPopup()).toBe(false);

mockLocationOrigin('https://APP.V0.DEV');
expect(originPrefersPopup()).toBe(false);
});

it('should handle malformed origins gracefully', () => {
// These shouldn't normally happen, but we should handle them gracefully
mockLocationOrigin('not-a-url');
expect(originPrefersPopup()).toBe(false);

mockLocationOrigin('file://');
expect(originPrefersPopup()).toBe(false);
});
});
});

describe('integration scenarios', () => {
it('should prioritize iframe detection over origin matching', () => {
mockInIframe.mockReturnValue(true);
mockLocationOrigin('https://definitely-not-preferred.com');

expect(originPrefersPopup()).toBe(true);
expect(mockInIframe).toHaveBeenCalledOnce();
});

it('should call inIframe function', () => {
mockInIframe.mockReturnValue(false);
mockLocationOrigin('https://example.com');

originPrefersPopup();

expect(mockInIframe).toHaveBeenCalledOnce();
});

it('should work with real-world scenarios', () => {
// Scenario 1: Developer working in CodeSandbox
mockInIframe.mockReturnValue(false);
mockLocationOrigin('https://csb-123abc.vusercontent.net');
expect(originPrefersPopup()).toBe(true);

// Scenario 2: Developer working in StackBlitz
mockLocationOrigin('https://stackblitz.webcontainer-api.io');
expect(originPrefersPopup()).toBe(true);

// Scenario 3: App embedded in iframe on regular domain
mockInIframe.mockReturnValue(true);
mockLocationOrigin('https://myapp.com');
expect(originPrefersPopup()).toBe(true);

// Scenario 4: Regular production app
mockInIframe.mockReturnValue(false);
mockLocationOrigin('https://myapp.com');
expect(originPrefersPopup()).toBe(false);
});
});
});
4 changes: 3 additions & 1 deletion packages/clerk-js/src/ui/utils/originPrefersPopup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { inIframe } from '@/utils';

const POPUP_PREFERRED_ORIGINS = [
'.lovable.app',
'.lovableproject.com',
Expand All @@ -12,5 +14,5 @@ const POPUP_PREFERRED_ORIGINS = [
* @returns {boolean} Whether the current origin prefers the popup flow.
*/
export function originPrefersPopup(): boolean {
return POPUP_PREFERRED_ORIGINS.some(origin => window.location.origin.endsWith(origin));
return inIframe() || POPUP_PREFERRED_ORIGINS.some(origin => window.location.origin.endsWith(origin));
}
11 changes: 9 additions & 2 deletions packages/clerk-js/src/utils/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ export function usesHttps() {
}

export function inIframe() {
// checks if the current window is an iframe
return inBrowser() && window.self !== window.top;
if (!inBrowser()) return false;

try {
// checks if the current window is an iframe
return window.self !== window.top;
} catch {
// Cross-origin access denied - we're definitely in an iframe
return true;
}
}

export function inCrossOriginIframe() {
Expand Down
Loading