diff --git a/.changeset/fix-iframe-client-uat-cookie-domain.md b/.changeset/fix-iframe-client-uat-cookie-domain.md new file mode 100644 index 00000000000..a7fa09b3670 --- /dev/null +++ b/.changeset/fix-iframe-client-uat-cookie-domain.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix `__client_uat` cookie being set on two different domain scopes when app is loaded in both iframe and non-iframe contexts. `getCookieDomain()` now falls back to `hostname` instead of `undefined` when the eTLD+1 probe fails, and the eTLD+1 probe uses the same `SameSite`/`Secure` attributes as the actual cookie to ensure consistent behavior across contexts. diff --git a/packages/clerk-js/src/core/auth/__tests__/getCookieDomain.test.ts b/packages/clerk-js/src/core/auth/__tests__/getCookieDomain.test.ts index 2f8bae19ffb..9de44e8022c 100644 --- a/packages/clerk-js/src/core/auth/__tests__/getCookieDomain.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/getCookieDomain.test.ts @@ -35,6 +35,19 @@ describe('getCookieDomain', () => { expect(result).toBe(hostname); }); + it('passes cookie attributes to the probe', () => { + const hostname = 'app.example.com'; + const handler: CookieHandler = { + get: vi.fn().mockReturnValueOnce(undefined).mockReturnValueOnce('1'), + set: vi.fn().mockReturnValue(undefined), + remove: vi.fn().mockReturnValue(undefined), + }; + const attrs = { sameSite: 'None', secure: true }; + getCookieDomain(hostname, handler, attrs); + expect(handler.set).toHaveBeenCalledWith('1', { sameSite: 'None', secure: true, domain: 'example.com' }); + expect(handler.set).toHaveBeenCalledWith('1', { sameSite: 'None', secure: true, domain: 'app.example.com' }); + }); + it('handles localhost', () => { const hostname = 'localhost'; const result = getCookieDomain(hostname); @@ -53,7 +66,7 @@ describe('getCookieDomain', () => { expect(getCookieDomain('bryce-local')).toBe('bryce-local'); }); - it('returns undefined if the domain could not be determined', () => { + it('falls back to hostname if the domain could not be determined', () => { const handler: CookieHandler = { get: vi.fn().mockReturnValue(undefined), set: vi.fn().mockReturnValue(undefined), @@ -61,7 +74,7 @@ describe('getCookieDomain', () => { }; const hostname = 'app.hello.co.uk'; const result = getCookieDomain(hostname, handler); - expect(result).toBeUndefined(); + expect(result).toBe(hostname); }); it('uses cached value if there is one', () => { diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index 7d52af06e43..a90d5a77184 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -45,7 +45,7 @@ export const createClientUatCookie = (cookieSuffix: string): ClientUatCookieHand : 'Strict'; const secure = getSecureAttribute(sameSite); const partitioned = __BUILD_VARIANT_CHIPS__ && secure; - const domain = getCookieDomain(); + const domain = getCookieDomain(undefined, undefined, { sameSite, secure }); // '0' indicates the user is signed out let val = '0'; diff --git a/packages/clerk-js/src/core/auth/getCookieDomain.ts b/packages/clerk-js/src/core/auth/getCookieDomain.ts index 492c2beab12..42a1a4f0a39 100644 --- a/packages/clerk-js/src/core/auth/getCookieDomain.ts +++ b/packages/clerk-js/src/core/auth/getCookieDomain.ts @@ -8,7 +8,19 @@ import { createCookieHandler } from '@clerk/shared/cookie'; let cachedETLDPlusOne: string; const eTLDCookie = createCookieHandler('__clerk_test_etld'); -export function getCookieDomain(hostname = window.location.hostname, cookieHandler = eTLDCookie) { +/** + * @param hostname - The hostname to determine the eTLD+1 for. + * @param cookieHandler - The cookie handler to use for the eTLD+1 probe. + * @param cookieAttributes - Optional cookie attributes (sameSite, secure) to use + * during the eTLD+1 probe. These should match the attributes that will be used + * when setting the actual cookie, so the probe accurately reflects whether a + * domain-scoped cookie can be set in the current context. + */ +export function getCookieDomain( + hostname = window.location.hostname, + cookieHandler = eTLDCookie, + cookieAttributes?: { sameSite?: string; secure?: boolean }, +) { // only compute it once per session to avoid unnecessary cookie ops if (cachedETLDPlusOne) { return cachedETLDPlusOne; @@ -28,17 +40,22 @@ export function getCookieDomain(hostname = window.location.hostname, cookieHandl // we know for sure that the first entry is definitely a TLD, skip it for (let i = hostnameParts.length - 2; i >= 0; i--) { const eTLD = hostnameParts.slice(i).join('.'); - cookieHandler.set('1', { domain: eTLD }); + cookieHandler.set('1', { ...cookieAttributes, domain: eTLD }); const res = cookieHandler.get(); if (res === '1') { - cookieHandler.remove({ domain: eTLD }); + cookieHandler.remove({ ...cookieAttributes, domain: eTLD }); cachedETLDPlusOne = eTLD; return eTLD; } - cookieHandler.remove({ domain: eTLD }); + cookieHandler.remove({ ...cookieAttributes, domain: eTLD }); } - return; + // Fallback to hostname to ensure domain-scoped cookie rather than host-only. + // In restricted contexts (e.g. cross-origin iframes), the set() will silently + // fail — which is preferable to creating a host-only cookie that conflicts + // with domain-scoped cookies set by non-iframe contexts. + cachedETLDPlusOne = hostname; + return hostname; }