diff --git a/.changeset/fix-samesite-replit-dev.md b/.changeset/fix-samesite-replit-dev.md new file mode 100644 index 00000000000..eed958a398a --- /dev/null +++ b/.changeset/fix-samesite-replit-dev.md @@ -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 diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index c694488eb56..18b7da9307e 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -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" }, diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts index 1f48f4e4ee0..7e19bbc200e 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts @@ -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'; @@ -26,6 +28,7 @@ describe('createClientUatCookie', () => { mockGet.mockReset(); (addYears as ReturnType).mockReturnValue(mockExpires); (inCrossOriginIframe as ReturnType).mockReturnValue(false); + (requiresSameSiteNone as ReturnType).mockReturnValue(false); (getCookieDomain as ReturnType).mockReturnValue(mockDomain); (getSecureAttribute as ReturnType).mockReturnValue(true); (createCookieHandler as ReturnType).mockImplementation(() => ({ @@ -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).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, + }); + }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts index d51bf732de9..e034c796d05 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts @@ -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'; @@ -24,6 +26,7 @@ describe('createSessionCookie', () => { mockGet.mockReset(); (addYears as ReturnType).mockReturnValue(mockExpires); (inCrossOriginIframe as ReturnType).mockReturnValue(false); + (requiresSameSiteNone as ReturnType).mockReturnValue(false); (getSecureAttribute as ReturnType).mockReturnValue(true); (createCookieHandler as ReturnType).mockImplementation(() => ({ set: mockSet, @@ -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).mockReturnValue(true); + const cookieHandler = createSessionCookie(mockCookieSuffix); + cookieHandler.set(mockToken); + + expect(mockSet).toHaveBeenCalledWith(mockToken, { + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: false, + }); + }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index 20a620938b8..37479f34522 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -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'; @@ -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(); diff --git a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts index ab3f66eb6d4..3bd305e5126 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -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; @@ -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; }; diff --git a/packages/clerk-js/src/core/auth/cookies/requireSameSiteNone.ts b/packages/clerk-js/src/core/auth/cookies/requireSameSiteNone.ts new file mode 100644 index 00000000000..7797f0b8b51 --- /dev/null +++ b/packages/clerk-js/src/core/auth/cookies/requireSameSiteNone.ts @@ -0,0 +1 @@ +export { isThirdPartyCookieDomain as requiresSameSiteNone } from '@clerk/shared/internal/clerk-js/thirdPartyDomains'; diff --git a/packages/clerk-js/src/core/auth/cookies/session.ts b/packages/clerk-js/src/core/auth/cookies/session.ts index e4362a2522d..754f2262544 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -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'; @@ -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; diff --git a/packages/shared/src/internal/clerk-js/thirdPartyDomains.ts b/packages/shared/src/internal/clerk-js/thirdPartyDomains.ts new file mode 100644 index 00000000000..b61aa143641 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/thirdPartyDomains.ts @@ -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; + } +} diff --git a/packages/ui/src/utils/__tests__/originPrefersPopup.test.ts b/packages/ui/src/utils/__tests__/originPrefersPopup.test.ts index c80f250a245..3ecd0ddccc2 100644 --- a/packages/ui/src/utils/__tests__/originPrefersPopup.test.ts +++ b/packages/ui/src/utils/__tests__/originPrefersPopup.test.ts @@ -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, @@ -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', () => { diff --git a/packages/ui/src/utils/originPrefersPopup.ts b/packages/ui/src/utils/originPrefersPopup.ts index 26c107cfd9f..a54a66432cc 100644 --- a/packages/ui/src/utils/originPrefersPopup.ts +++ b/packages/ui/src/utils/originPrefersPopup.ts @@ -1,14 +1,5 @@ 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 @@ -16,5 +7,5 @@ const POPUP_PREFERRED_ORIGINS = [ * @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)); }