diff --git a/.changeset/plenty-pandas-leave.md b/.changeset/plenty-pandas-leave.md new file mode 100644 index 00000000000..18746e120ad --- /dev/null +++ b/.changeset/plenty-pandas-leave.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-js": patch +--- + +Set the localhost cookies with the Secure attribute diff --git a/packages/clerk-js/src/core/auth/__tests__/getSecureAttribute.test.ts b/packages/clerk-js/src/core/auth/__tests__/getSecureAttribute.test.ts new file mode 100644 index 00000000000..36b2b699de6 --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/getSecureAttribute.test.ts @@ -0,0 +1,94 @@ +import { getSecureAttribute } from '../getSecureAttribute'; + +describe('getSecureAttribute', () => { + let windowSpy: any; + + beforeEach(() => { + windowSpy = jest.spyOn(window, 'window', 'get'); + }); + + afterEach(() => { + windowSpy.mockRestore(); + }); + + it('returns true if the protocol is https', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('https://www.clerk.com'), + safari: undefined, + isSecureContext: undefined, + })); + expect(getSecureAttribute('None')).toBe(true); + }); + + it('returns false if the protocol is not https and the SameSite is not None', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('http://www.clerk.com'), + safari: undefined, + isSecureContext: undefined, + })); + expect(getSecureAttribute('Lax')).toBe(false); + }); + + it('returns false if the protocol is not https and the browser is Safari', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('http://www.clerk.com'), + safari: { dummyValue: true }, + isSecureContext: undefined, + })); + expect(getSecureAttribute('None')).toBe(false); + }); + + it('returns true if isSecureContext is true', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('http://www.clerk.com'), + safari: undefined, + isSecureContext: true, + })); + expect(getSecureAttribute('None')).toBe(true); + }); + + it('returns false if isSecureContext is false', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('http://www.clerk.com'), + safari: undefined, + isSecureContext: false, + })); + expect(getSecureAttribute('None')).toBe(false); + }); + + it('returns true if the protocol is http and the hostname is localhost and sameSite is None', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('http://localhost'), + safari: undefined, + isSecureContext: undefined, + })); + expect(getSecureAttribute('None')).toBe(true); + }); + + it('returns false if the protocol is http and the hostname is localhost and sameSite is not None', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('http://localhost'), + safari: undefined, + isSecureContext: undefined, + })); + expect(getSecureAttribute('Lax')).toBe(false); + }); + + it('returns false if the protocol is http and the hostname is not localhost', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('http://www.clerk.com'), + safari: undefined, + isSecureContext: undefined, + })); + expect(getSecureAttribute('None')).toBe(false); + }); + + it('returns false in case window.safari is undefined and isSecureContext is true on localhost for Lax cookie', () => { + windowSpy.mockImplementation(() => ({ + location: new URL('http://localhost'), + safari: undefined, + isSecureContext: true, + })); + expect(getSecureAttribute('Lax')).toBe(false); + }); +}); diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index f731ce9dab8..332e001c767 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -4,6 +4,7 @@ import type { ClientResource } from '@clerk/types'; import { inCrossOriginIframe } from '../../../utils'; import { getCookieDomain } from '../getCookieDomain'; +import { getSecureAttribute } from '../getSecureAttribute'; const CLIENT_UAT_COOKIE_NAME = '__client_uat'; @@ -28,7 +29,7 @@ export const createClientUatCookie = (): ClientUatCookieHandler => { const set = (client: ClientResource | undefined) => { const expires = addYears(Date.now(), 1); const sameSite = inCrossOriginIframe() ? 'None' : 'Strict'; - const secure = window.location.protocol === 'https:'; + const secure = getSecureAttribute(sameSite); const domain = getCookieDomain(); // '0' indicates the user is signed out diff --git a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts index d32555644b9..270dbc67750 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -3,6 +3,7 @@ import { addYears } from '@clerk/shared/date'; import { DEV_BROWSER_JWT_KEY } from '@clerk/shared/devBrowser'; import { inCrossOriginIframe } from '../../../utils'; +import { getSecureAttribute } from '../getSecureAttribute'; export type DevBrowserCookieHandler = { set: (jwt: string) => void; @@ -24,7 +25,7 @@ export const createDevBrowserCookie = (): DevBrowserCookieHandler => { const set = (jwt: string) => { const expires = addYears(Date.now(), 1); const sameSite = inCrossOriginIframe() ? 'None' : 'Lax'; - const secure = window.location.protocol === 'https:'; + const secure = getSecureAttribute(sameSite); return devBrowserCookie.set(jwt, { expires, diff --git a/packages/clerk-js/src/core/auth/cookies/session.ts b/packages/clerk-js/src/core/auth/cookies/session.ts index e36899ad7ed..85e4c08d753 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -2,6 +2,7 @@ import { createCookieHandler } from '@clerk/shared/cookie'; import { addYears } from '@clerk/shared/date'; import { inCrossOriginIframe } from '../../../utils'; +import { getSecureAttribute } from '../getSecureAttribute'; const SESSION_COOKIE_NAME = '__session'; @@ -23,7 +24,7 @@ export const createSessionCookie = (): SessionCookieHandler => { const set = (token: string) => { const expires = addYears(Date.now(), 1); const sameSite = inCrossOriginIframe() ? 'None' : 'Lax'; - const secure = window.location.protocol === 'https:'; + const secure = getSecureAttribute(sameSite); return sessionCookie.set(token, { expires, diff --git a/packages/clerk-js/src/core/auth/getSecureAttribute.ts b/packages/clerk-js/src/core/auth/getSecureAttribute.ts new file mode 100644 index 00000000000..18c6083a1a8 --- /dev/null +++ b/packages/clerk-js/src/core/auth/getSecureAttribute.ts @@ -0,0 +1,30 @@ +type SameSiteAttributeType = 'None' | 'Lax' | 'Strict'; + +// The Secure attribute is required for SameSite=None on Chrome and Firefox +// Also, localhost is considered secure for testing purposes by Chrome and Firefox +// Safari does not support the Secure attribute on localhost, although it returns true for isSecureContext +// ref: https://bugs.webkit.org/show_bug.cgi?id=232088#c8 +export const getSecureAttribute = (sameSite: SameSiteAttributeType): boolean => { + if (window.location.protocol === 'https:') { + return true; + } + + // If the cookie is not SameSite=None, then the Secure attribute is not required + if (sameSite !== 'None') { + return false; + } + + // This is because Safari does not support the Secure attribute on localhost + if (typeof (window as any).safari !== 'undefined') { + return false; + } + + // This is a useful way to check if the current context is secure + // This is needed for example in environments like Firefox Remote Control + // where the `localhost` is not considered secure + if (typeof window.isSecureContext !== 'undefined') { + return window.isSecureContext; + } + + return window.location.hostname === 'localhost'; +}; diff --git a/packages/clerk-js/src/utils/runtime.ts b/packages/clerk-js/src/utils/runtime.ts index 06a40433579..10b190d4344 100644 --- a/packages/clerk-js/src/utils/runtime.ts +++ b/packages/clerk-js/src/utils/runtime.ts @@ -10,14 +10,9 @@ export function usesHttps() { return inBrowser() && window.location.protocol === 'https:'; } -function isCypress() { - return typeof window !== 'undefined' && typeof (window as any).Cypress !== 'undefined'; -} - export function inIframe() { // checks if the current window is an iframe - // excludes the case where the current window runs in a Cypress test - return inBrowser() && window.self !== window.top && !isCypress(); + return inBrowser() && window.self !== window.top; } export function inCrossOriginIframe() {