Skip to content

Commit 0b165f8

Browse files
authored
fix(clerk-js): Always prefer oauth popup flow in an iframe (#6455)
1 parent f93965f commit 0b165f8

File tree

4 files changed

+246
-3
lines changed

4 files changed

+246
-3
lines changed

.changeset/slow-loops-mix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Fix iframe detetction and ensure we prefer the oauth popup flow when in an iframe.
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { originPrefersPopup } from '../originPrefersPopup';
4+
5+
// Mock the inIframe function
6+
vi.mock('@/utils', () => ({
7+
inIframe: vi.fn(),
8+
}));
9+
10+
// Import the mocked function
11+
import { inIframe } from '@/utils';
12+
const mockInIframe = vi.mocked(inIframe);
13+
14+
describe('originPrefersPopup', () => {
15+
// Store original location to restore after tests
16+
const originalLocation = window.location;
17+
18+
// Helper function to mock window.location.origin
19+
const mockLocationOrigin = (origin: string) => {
20+
Object.defineProperty(window, 'location', {
21+
value: {
22+
origin,
23+
},
24+
writable: true,
25+
configurable: true,
26+
});
27+
};
28+
29+
beforeEach(() => {
30+
// Reset all mocks before each test
31+
vi.clearAllMocks();
32+
33+
// Set default origin
34+
mockLocationOrigin('https://example.com');
35+
});
36+
37+
afterEach(() => {
38+
// Restore original location
39+
Object.defineProperty(window, 'location', {
40+
value: originalLocation,
41+
writable: true,
42+
configurable: true,
43+
});
44+
});
45+
46+
describe('when in iframe', () => {
47+
it('should return true regardless of origin', () => {
48+
mockInIframe.mockReturnValue(true);
49+
mockLocationOrigin('https://not-a-preferred-origin.com');
50+
51+
expect(originPrefersPopup()).toBe(true);
52+
});
53+
54+
it('should return true even with preferred origin', () => {
55+
mockInIframe.mockReturnValue(true);
56+
mockLocationOrigin('https://app.lovable.app');
57+
58+
expect(originPrefersPopup()).toBe(true);
59+
});
60+
});
61+
62+
describe('when not in iframe', () => {
63+
beforeEach(() => {
64+
mockInIframe.mockReturnValue(false);
65+
});
66+
67+
describe('with preferred origins', () => {
68+
it('should return true for .lovable.app domains', () => {
69+
mockLocationOrigin('https://app.lovable.app');
70+
expect(originPrefersPopup()).toBe(true);
71+
72+
mockLocationOrigin('https://my-project.lovable.app');
73+
expect(originPrefersPopup()).toBe(true);
74+
});
75+
76+
it('should return true for .lovableproject.com domains', () => {
77+
mockLocationOrigin('https://project.lovableproject.com');
78+
expect(originPrefersPopup()).toBe(true);
79+
80+
mockLocationOrigin('https://demo.lovableproject.com');
81+
expect(originPrefersPopup()).toBe(true);
82+
});
83+
84+
it('should return true for .webcontainer-api.io domains', () => {
85+
mockLocationOrigin('https://stackblitz.webcontainer-api.io');
86+
expect(originPrefersPopup()).toBe(true);
87+
88+
mockLocationOrigin('https://container.webcontainer-api.io');
89+
expect(originPrefersPopup()).toBe(true);
90+
});
91+
92+
it('should return true for .vusercontent.net domains', () => {
93+
mockLocationOrigin('https://codesandbox.vusercontent.net');
94+
expect(originPrefersPopup()).toBe(true);
95+
96+
mockLocationOrigin('https://preview.vusercontent.net');
97+
expect(originPrefersPopup()).toBe(true);
98+
});
99+
100+
it('should return true for .v0.dev domains', () => {
101+
mockLocationOrigin('https://preview.v0.dev');
102+
expect(originPrefersPopup()).toBe(true);
103+
104+
mockLocationOrigin('https://app.v0.dev');
105+
expect(originPrefersPopup()).toBe(true);
106+
});
107+
108+
it('should handle HTTPS and HTTP protocols', () => {
109+
mockLocationOrigin('http://localhost.lovable.app');
110+
expect(originPrefersPopup()).toBe(true);
111+
112+
mockLocationOrigin('https://secure.v0.dev');
113+
expect(originPrefersPopup()).toBe(true);
114+
});
115+
});
116+
117+
describe('with non-preferred origins', () => {
118+
it('should return false for common domains', () => {
119+
const nonPreferredOrigins = [
120+
'https://example.com',
121+
'https://google.com',
122+
'https://github.com',
123+
'https://localhost:3000',
124+
'https://app.mycompany.com',
125+
'https://production-site.com',
126+
];
127+
128+
nonPreferredOrigins.forEach(origin => {
129+
mockLocationOrigin(origin);
130+
expect(originPrefersPopup()).toBe(false);
131+
});
132+
});
133+
134+
it('should return false for similar but non-matching domains', () => {
135+
const similarOrigins = [
136+
'https://lovable.app.com', // wrong order
137+
'https://notlovable.app', // different subdomain structure
138+
'https://lovableproject.org', // wrong TLD
139+
'https://webcontainer.io', // missing -api
140+
'https://vusercontent.com', // wrong TLD
141+
'https://v0.com', // missing .dev
142+
'https://v1.dev', // wrong subdomain
143+
];
144+
145+
similarOrigins.forEach(origin => {
146+
mockLocationOrigin(origin);
147+
expect(originPrefersPopup()).toBe(false);
148+
});
149+
});
150+
151+
it('should return false for domains that contain preferred origins as substrings', () => {
152+
const containingOrigins = [
153+
'https://not-lovable.app-something.com',
154+
'https://fake-webcontainer-api.io.malicious.com',
155+
'https://evil-vusercontent.net.phishing.com',
156+
];
157+
158+
containingOrigins.forEach(origin => {
159+
mockLocationOrigin(origin);
160+
expect(originPrefersPopup()).toBe(false);
161+
});
162+
});
163+
});
164+
165+
describe('edge cases', () => {
166+
it('should handle empty origin', () => {
167+
mockLocationOrigin('');
168+
expect(originPrefersPopup()).toBe(false);
169+
});
170+
171+
it('should be case sensitive', () => {
172+
mockLocationOrigin('https://app.LOVABLE.APP');
173+
expect(originPrefersPopup()).toBe(false);
174+
175+
mockLocationOrigin('https://APP.V0.DEV');
176+
expect(originPrefersPopup()).toBe(false);
177+
});
178+
179+
it('should handle malformed origins gracefully', () => {
180+
// These shouldn't normally happen, but we should handle them gracefully
181+
mockLocationOrigin('not-a-url');
182+
expect(originPrefersPopup()).toBe(false);
183+
184+
mockLocationOrigin('file://');
185+
expect(originPrefersPopup()).toBe(false);
186+
});
187+
});
188+
});
189+
190+
describe('integration scenarios', () => {
191+
it('should prioritize iframe detection over origin matching', () => {
192+
mockInIframe.mockReturnValue(true);
193+
mockLocationOrigin('https://definitely-not-preferred.com');
194+
195+
expect(originPrefersPopup()).toBe(true);
196+
expect(mockInIframe).toHaveBeenCalledOnce();
197+
});
198+
199+
it('should call inIframe function', () => {
200+
mockInIframe.mockReturnValue(false);
201+
mockLocationOrigin('https://example.com');
202+
203+
originPrefersPopup();
204+
205+
expect(mockInIframe).toHaveBeenCalledOnce();
206+
});
207+
208+
it('should work with real-world scenarios', () => {
209+
// Scenario 1: Developer working in CodeSandbox
210+
mockInIframe.mockReturnValue(false);
211+
mockLocationOrigin('https://csb-123abc.vusercontent.net');
212+
expect(originPrefersPopup()).toBe(true);
213+
214+
// Scenario 2: Developer working in StackBlitz
215+
mockLocationOrigin('https://stackblitz.webcontainer-api.io');
216+
expect(originPrefersPopup()).toBe(true);
217+
218+
// Scenario 3: App embedded in iframe on regular domain
219+
mockInIframe.mockReturnValue(true);
220+
mockLocationOrigin('https://myapp.com');
221+
expect(originPrefersPopup()).toBe(true);
222+
223+
// Scenario 4: Regular production app
224+
mockInIframe.mockReturnValue(false);
225+
mockLocationOrigin('https://myapp.com');
226+
expect(originPrefersPopup()).toBe(false);
227+
});
228+
});
229+
});

packages/clerk-js/src/ui/utils/originPrefersPopup.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { inIframe } from '@/utils';
2+
13
const POPUP_PREFERRED_ORIGINS = [
24
'.lovable.app',
35
'.lovableproject.com',
@@ -12,5 +14,5 @@ const POPUP_PREFERRED_ORIGINS = [
1214
* @returns {boolean} Whether the current origin prefers the popup flow.
1315
*/
1416
export function originPrefersPopup(): boolean {
15-
return POPUP_PREFERRED_ORIGINS.some(origin => window.location.origin.endsWith(origin));
17+
return inIframe() || POPUP_PREFERRED_ORIGINS.some(origin => window.location.origin.endsWith(origin));
1618
}

packages/clerk-js/src/utils/runtime.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@ export function usesHttps() {
1111
}
1212

1313
export function inIframe() {
14-
// checks if the current window is an iframe
15-
return inBrowser() && window.self !== window.top;
14+
if (!inBrowser()) return false;
15+
16+
try {
17+
// checks if the current window is an iframe
18+
return window.self !== window.top;
19+
} catch {
20+
// Cross-origin access denied - we're definitely in an iframe
21+
return true;
22+
}
1623
}
1724

1825
export function inCrossOriginIframe() {

0 commit comments

Comments
 (0)