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
7 changes: 7 additions & 0 deletions .changeset/fix-samesite-replit-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/shared': patch
'@clerk/clerk-js': patch
'@clerk/ui': patch
---

Set `SameSite=None` on cookies for `.replit.dev` origins and consolidate third-party domain list
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{ "path": "./dist/clerk.js", "maxSize": "539KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "66KB" },
{ "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "107KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "65KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getCookieDomain } from '../../getCookieDomain';
import { getSecureAttribute } from '../../getSecureAttribute';
import { createClientUatCookie } from '../clientUat';
import { requiresSameSiteNone } from '../requireSameSiteNone';

vi.mock('@clerk/shared/cookie');
vi.mock('@clerk/shared/date');
vi.mock('@clerk/shared/internal/clerk-js/runtime');
vi.mock('../../getCookieDomain');
vi.mock('../../getSecureAttribute');
vi.mock('../requireSameSiteNone');

describe('createClientUatCookie', () => {
const mockCookieSuffix = 'test-suffix';
Expand All @@ -26,6 +28,7 @@ describe('createClientUatCookie', () => {
mockGet.mockReset();
(addYears as ReturnType<typeof vi.fn>).mockReturnValue(mockExpires);
(inCrossOriginIframe as ReturnType<typeof vi.fn>).mockReturnValue(false);
(requiresSameSiteNone as ReturnType<typeof vi.fn>).mockReturnValue(false);
(getCookieDomain as ReturnType<typeof vi.fn>).mockReturnValue(mockDomain);
(getSecureAttribute as ReturnType<typeof vi.fn>).mockReturnValue(true);
(createCookieHandler as ReturnType<typeof vi.fn>).mockImplementation(() => ({
Expand Down Expand Up @@ -125,4 +128,22 @@ describe('createClientUatCookie', () => {

expect(result).toBe(0);
});

it('should set cookies with SameSite=None when the host requires it', () => {
(requiresSameSiteNone as ReturnType<typeof vi.fn>).mockReturnValue(true);
const cookieHandler = createClientUatCookie(mockCookieSuffix);
cookieHandler.set({
id: 'test-client',
updatedAt: new Date('2024-01-01'),
signedInSessions: ['session1'],
});

expect(mockSet).toHaveBeenCalledWith('1704067200', {
domain: mockDomain,
expires: mockExpires,
sameSite: 'None',
secure: true,
partitioned: false,
});
});
});
16 changes: 16 additions & 0 deletions packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { getSecureAttribute } from '../../getSecureAttribute';
import { requiresSameSiteNone } from '../requireSameSiteNone';
import { createSessionCookie } from '../session';

vi.mock('@clerk/shared/cookie');
vi.mock('@clerk/shared/date');
vi.mock('@clerk/shared/internal/clerk-js/runtime');
vi.mock('../../getSecureAttribute');
vi.mock('../requireSameSiteNone');

describe('createSessionCookie', () => {
const mockCookieSuffix = 'test-suffix';
Expand All @@ -24,6 +26,7 @@ describe('createSessionCookie', () => {
mockGet.mockReset();
(addYears as ReturnType<typeof vi.fn>).mockReturnValue(mockExpires);
(inCrossOriginIframe as ReturnType<typeof vi.fn>).mockReturnValue(false);
(requiresSameSiteNone as ReturnType<typeof vi.fn>).mockReturnValue(false);
(getSecureAttribute as ReturnType<typeof vi.fn>).mockReturnValue(true);
(createCookieHandler as ReturnType<typeof vi.fn>).mockImplementation(() => ({
set: mockSet,
Expand Down Expand Up @@ -113,4 +116,17 @@ describe('createSessionCookie', () => {

expect(result).toBe('non-suffixed-value');
});

it('should set cookies with None sameSite on .replit.dev origins', () => {
(requiresSameSiteNone as ReturnType<typeof vi.fn>).mockReturnValue(true);
const cookieHandler = createSessionCookie(mockCookieSuffix);
cookieHandler.set(mockToken);

expect(mockSet).toHaveBeenCalledWith(mockToken, {
expires: mockExpires,
sameSite: 'None',
secure: true,
partitioned: false,
});
});
});
7 changes: 6 additions & 1 deletion packages/clerk-js/src/core/auth/cookies/clientUat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ClientResource } from '@clerk/shared/types';

import { getCookieDomain } from '../getCookieDomain';
import { getSecureAttribute } from '../getSecureAttribute';
import { requiresSameSiteNone } from './requireSameSiteNone';

const CLIENT_UAT_COOKIE_NAME = '__client_uat';

Expand Down Expand Up @@ -37,7 +38,11 @@ export const createClientUatCookie = (cookieSuffix: string): ClientUatCookieHand
* Generally, this is handled by redirectWithAuth() being called and relying on the dev browser ID in the URL,
* but if that isn't used we rely on this. In production, nothing is cross-domain and Lax is used when client_uat is set from FAPI.
*/
const sameSite = __BUILD_VARIANT_CHIPS__ ? 'None' : inCrossOriginIframe() ? 'None' : 'Strict';
const sameSite = __BUILD_VARIANT_CHIPS__
? 'None'
: inCrossOriginIframe() || requiresSameSiteNone()
? 'None'
: 'Strict';
const secure = getSecureAttribute(sameSite);
const partitioned = __BUILD_VARIANT_CHIPS__ && secure;
const domain = getCookieDomain();
Expand Down
3 changes: 2 additions & 1 deletion packages/clerk-js/src/core/auth/cookies/devBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import { getSuffixedCookieName } from '@clerk/shared/keys';

import { getSecureAttribute } from '../getSecureAttribute';
import { requiresSameSiteNone } from './requireSameSiteNone';

export type DevBrowserCookieHandler = {
set: (jwt: string) => void;
Expand All @@ -13,7 +14,7 @@ export type DevBrowserCookieHandler = {
};

const getCookieAttributes = () => {
const sameSite = inCrossOriginIframe() ? 'None' : 'Lax';
const sameSite = inCrossOriginIframe() || requiresSameSiteNone() ? 'None' : 'Lax';
const secure = getSecureAttribute(sameSite);
return { sameSite, secure } as const;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { isThirdPartyCookieDomain as requiresSameSiteNone } from '@clerk/shared/internal/clerk-js/thirdPartyDomains';
3 changes: 2 additions & 1 deletion packages/clerk-js/src/core/auth/cookies/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import { getSuffixedCookieName } from '@clerk/shared/keys';

import { getSecureAttribute } from '../getSecureAttribute';
import { requiresSameSiteNone } from './requireSameSiteNone';

const SESSION_COOKIE_NAME = '__session';

Expand All @@ -14,7 +15,7 @@ export type SessionCookieHandler = {
};

const getCookieAttributes = () => {
const sameSite = __BUILD_VARIANT_CHIPS__ ? 'None' : inCrossOriginIframe() ? 'None' : 'Lax';
const sameSite = __BUILD_VARIANT_CHIPS__ ? 'None' : inCrossOriginIframe() || requiresSameSiteNone() ? 'None' : 'Lax';
const secure = getSecureAttribute(sameSite);
const partitioned = __BUILD_VARIANT_CHIPS__ && secure;
return { sameSite, secure, partitioned } as const;
Expand Down
30 changes: 30 additions & 0 deletions packages/shared/src/internal/clerk-js/thirdPartyDomains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Domains of third-party embedding platforms (e.g. online IDEs, preview environments)
* that require special handling for cookies and OAuth flows.
*
* These domains need:
* - `SameSite=None` on cookies to function correctly
* - Popup-based OAuth flows instead of redirects
*/
export const THIRD_PARTY_COOKIE_DOMAINS = [
'.lovable.app',
'.lovableproject.com',
'.webcontainer-api.io',
'.vusercontent.net',
'.v0.dev',
'.v0.app',
'.lp.dev',
'.replit.dev',
];

/**
* Returns `true` if the current origin belongs to a known third-party
* embedding platform that requires `SameSite=None` on cookies.
*/
export function isThirdPartyCookieDomain(): boolean {
try {
return THIRD_PARTY_COOKIE_DOMAINS.some(domain => window.location.hostname.endsWith(domain));
} catch {
return false;
}
}
15 changes: 11 additions & 4 deletions packages/ui/src/utils/__tests__/originPrefersPopup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ describe('originPrefersPopup', () => {
// Store original location to restore after tests
const originalLocation = window.location;

// Helper function to mock window.location.origin
// Helper function to mock window.location.hostname
const mockLocationOrigin = (origin: string) => {
let hostname: string;
try {
hostname = new URL(origin).hostname;
} catch {
hostname = origin;
}
Object.defineProperty(window, 'location', {
value: {
origin,
hostname,
},
writable: true,
configurable: true,
Expand Down Expand Up @@ -176,12 +183,12 @@ describe('originPrefersPopup', () => {
expect(originPrefersPopup()).toBe(false);
});

it('should be case sensitive', () => {
it('should be case insensitive (hostnames are normalized to lowercase)', () => {
mockLocationOrigin('https://app.LOVABLE.APP');
expect(originPrefersPopup()).toBe(false);
expect(originPrefersPopup()).toBe(true);

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

it('should handle malformed origins gracefully', () => {
Expand Down
13 changes: 2 additions & 11 deletions packages/ui/src/utils/originPrefersPopup.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import { inIframe } from '@clerk/shared/internal/clerk-js/runtime';

const POPUP_PREFERRED_ORIGINS = [
'.lovable.app',
'.lovableproject.com',
'.webcontainer-api.io',
'.vusercontent.net',
'.v0.dev',
'.v0.app',
'.lp.dev',
];
import { THIRD_PARTY_COOKIE_DOMAINS } from '@clerk/shared/internal/clerk-js/thirdPartyDomains';

/**
* Returns `true` if the current origin is one that is typically embedded via an iframe, which would benefit from the
* popup flow.
* @returns {boolean} Whether the current origin prefers the popup flow.
*/
export function originPrefersPopup(): boolean {
return inIframe() || POPUP_PREFERRED_ORIGINS.some(origin => window.location.origin.endsWith(origin));
return inIframe() || THIRD_PARTY_COOKIE_DOMAINS.some(domain => window.location.hostname.endsWith(domain));
}