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 37479f34522..da3be295699 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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c28dfeb5e9b..5ebfd5c54ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2475,7 +2475,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -11878,6 +11878,30 @@ packages: resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==} engines: {node: '>= 0.4.0'} + pkglab-darwin-arm64@0.11.1: + resolution: {integrity: sha512-gy2qv+HWlB1y3jr0cT6wjYkVT/tLoFu2UvqE0oJcY3Ca3FqnsAPnwss8let+L1rb79hzqTSZycOuC+yc6dMiaw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + pkglab-darwin-x64@0.11.1: + resolution: {integrity: sha512-cLXqfSZs2qkvNs4QIQOAICm7nM7p6DaVPz9Wj6RXa4ovkzd5KYD3EO+vQGFojACNKKlWpn28tpp9Z3zaEldvsQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + pkglab-linux-arm64@0.11.1: + resolution: {integrity: sha512-xfXHvwHi+tt4/nRJ4dQZXbiB212YqweJQzIGinwWdHtEAvWl57PBx0giYfpQF3oOHsoVCh65vRCYHmDouLg0BQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + pkglab-linux-x64@0.11.1: + resolution: {integrity: sha512-JFICMlCTRBPi0s2zl8ZrDmh+biyK/c/DkuefdK8irfXPjAYTIq5obe7hrWYEz0v2rT/vDPrBRZ+wg6j1ThpB7Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + pkglab@0.11.1: resolution: {integrity: sha512-FdcO24fiY1DE9Y5ZHpQWfihB67x13KTZm56WNfRpsHgCRALjbSLsJRe7B7Q0OPhCN7Vrmzfg2QULZYHNT+FBIA==} engines: {node: '>=18'} @@ -29311,7 +29335,24 @@ snapshots: pkginfo@0.4.1: {} - pkglab@0.11.1: {} + pkglab-darwin-arm64@0.11.1: + optional: true + + pkglab-darwin-x64@0.11.1: + optional: true + + pkglab-linux-arm64@0.11.1: + optional: true + + pkglab-linux-x64@0.11.1: + optional: true + + pkglab@0.11.1: + optionalDependencies: + pkglab-darwin-arm64: 0.11.1 + pkglab-darwin-x64: 0.11.1 + pkglab-linux-arm64: 0.11.1 + pkglab-linux-x64: 0.11.1 playwright-core@1.56.1: {}