diff --git a/.changeset/calm-readers-call.md b/.changeset/calm-readers-call.md new file mode 100644 index 00000000000..e75de4e85a2 --- /dev/null +++ b/.changeset/calm-readers-call.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/backend': minor +'@clerk/shared': minor +--- + +Support reading / writing / removing suffixed/un-suffixed cookies from `@clerk/clerk-js` and `@clerk/backend`. +Everyone of `__session`, `__clerk_db_jwt` and `__client_uat` cookies will also be set with a suffix to support multiple apps on the same domain. diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts index 23b1b3e48a3..dde8a9f488d 100644 --- a/integration/tests/handshake.test.ts +++ b/integration/tests/handshake.test.ts @@ -164,7 +164,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -185,7 +185,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -207,7 +209,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -230,7 +234,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -254,7 +258,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -278,7 +282,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -300,7 +304,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -324,7 +330,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -346,7 +352,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -367,7 +375,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -386,7 +394,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -485,7 +495,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(app.serverUrl + '/')}`, + `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent( + app.serverUrl + '/', + )}&suffixed_cookies=true`, ); }); @@ -520,7 +532,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -543,7 +557,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}hello%3Ffoo%3Dbar${devBrowserQuery}`, + )}hello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -566,7 +580,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}hello%3Ffoo%3Dbar`, + )}hello%3Ffoo%3Dbar&suffixed_cookies=true`, ); }); @@ -589,7 +603,7 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -612,7 +626,7 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true`, ); }); @@ -635,7 +649,7 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -658,7 +672,7 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true`, ); }); @@ -787,7 +801,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}&__clerk_db_jwt=asdf`, + )}&suffixed_cookies=true&__clerk_db_jwt=asdf`, ); }); diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index fbff09bc419..ecf019ab5c1 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -6,7 +6,7 @@ import { API_URL, API_VERSION, constants, USER_AGENT } from '../constants'; // DO NOT CHANGE: Runtime needs to be imported as a default export so that we can stub its dependencies with Sinon.js // For more information refer to https://sinonjs.org/how-to/stub-dependency/ import runtime from '../runtime'; -import { assertValidSecretKey } from '../util/assertValidSecretKey'; +import { assertValidSecretKey } from '../util/optionsAssertions'; import { joinPaths } from '../util/path'; import { deserialize } from './resources/Deserializer'; diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index fa7ac5c5680..684fe8eeaf0 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -19,6 +19,7 @@ const Cookies = { ClientUat: '__client_uat', Handshake: '__clerk_handshake', DevBrowser: '__clerk_db_jwt', + SuffixedCookies: '__clerk_suffixed_cookies', } as const; const QueryParameters = { diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 56c00181d6f..e7657eb3a37 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,4 +1,7 @@ +import { parsePublishableKey } from '@clerk/shared/keys'; + import { constants } from '../constants'; +import { assertValidPublishableKey } from '../util/optionsAssertions'; import type { ClerkRequest } from './clerkRequest'; import type { AuthenticateRequestOptions } from './types'; @@ -16,6 +19,7 @@ interface AuthenticateContextInterface extends AuthenticateRequestOptions { // cookie-based values sessionTokenInCookie: string | undefined; clientUat: number; + suffixedCookies: boolean; // handshake-related values devBrowserToken: string | undefined; handshakeToken: string | undefined; @@ -23,6 +27,10 @@ interface AuthenticateContextInterface extends AuthenticateRequestOptions { clerkUrl: URL; // cookie or header session token sessionToken: string | undefined; + // enforce existence of the following props + publishableKey: string; + instanceType: string; + frontendApi: string; } interface AuthenticateContext extends AuthenticateContextInterface {} @@ -38,45 +46,91 @@ class AuthenticateContext { return this.sessionTokenInCookie || this.sessionTokenInHeader; } + private get cookieSuffix() { + return this.publishableKey?.split('_').pop(); + } + public constructor(private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions) { + // Even though the options are assigned to this later in this function + // we set the publishableKey here because it is being used in cookies/headers/handshake-values + // as part of getMultipleAppsCookie + this.initPublishableKeyValues(options); this.initHeaderValues(); + // initCookieValues should be used before initHandshakeValues because the it depends on suffixedCookies this.initCookieValues(); this.initHandshakeValues(); Object.assign(this, options); this.clerkUrl = this.clerkRequest.clerkUrl; } - private initHandshakeValues() { - this.devBrowserToken = - this.clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) || - this.clerkRequest.cookies.get(constants.Cookies.DevBrowser); - this.handshakeToken = - this.clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.Handshake) || - this.clerkRequest.cookies.get(constants.Cookies.Handshake); + private initPublishableKeyValues(options: AuthenticateRequestOptions) { + assertValidPublishableKey(options.publishableKey); + this.publishableKey = options.publishableKey; + + const pk = parsePublishableKey(this.publishableKey, { + fatal: true, + proxyUrl: options.proxyUrl, + domain: options.domain, + }); + this.instanceType = pk.instanceType; + this.frontendApi = pk.frontendApi; } private initHeaderValues() { - const get = (name: string) => this.clerkRequest.headers.get(name) || undefined; - this.sessionTokenInHeader = this.stripAuthorizationHeader(get(constants.Headers.Authorization)); - this.origin = get(constants.Headers.Origin); - this.host = get(constants.Headers.Host); - this.forwardedHost = get(constants.Headers.ForwardedHost); - this.forwardedProto = get(constants.Headers.CloudFrontForwardedProto) || get(constants.Headers.ForwardedProto); - this.referrer = get(constants.Headers.Referrer); - this.userAgent = get(constants.Headers.UserAgent); - this.secFetchDest = get(constants.Headers.SecFetchDest); - this.accept = get(constants.Headers.Accept); + this.sessionTokenInHeader = this.stripAuthorizationHeader(this.getHeader(constants.Headers.Authorization)); + this.origin = this.getHeader(constants.Headers.Origin); + this.host = this.getHeader(constants.Headers.Host); + this.forwardedHost = this.getHeader(constants.Headers.ForwardedHost); + this.forwardedProto = + this.getHeader(constants.Headers.CloudFrontForwardedProto) || this.getHeader(constants.Headers.ForwardedProto); + this.referrer = this.getHeader(constants.Headers.Referrer); + this.userAgent = this.getHeader(constants.Headers.UserAgent); + this.secFetchDest = this.getHeader(constants.Headers.SecFetchDest); + this.accept = this.getHeader(constants.Headers.Accept); } private initCookieValues() { - const get = (name: string) => this.clerkRequest.cookies.get(name) || undefined; - this.sessionTokenInCookie = get(constants.Cookies.Session); - this.clientUat = Number.parseInt(get(constants.Cookies.ClientUat) || '') || 0; + // suffixedCookies needs to be set first because it's used in getMultipleAppsCookie + this.suffixedCookies = this.getSuffixedCookie(constants.Cookies.SuffixedCookies) === 'true'; + this.sessionTokenInCookie = this.getSuffixedOrUnSuffixedCookie(constants.Cookies.Session); + this.clientUat = Number.parseInt(this.getSuffixedOrUnSuffixedCookie(constants.Cookies.ClientUat) || '') || 0; + } + + private initHandshakeValues() { + this.devBrowserToken = + this.getQueryParam(constants.QueryParameters.DevBrowser) || + this.getSuffixedOrUnSuffixedCookie(constants.Cookies.DevBrowser); + // Using getCookie since we don't suffix the handshake token cookie + this.handshakeToken = + this.getQueryParam(constants.QueryParameters.Handshake) || this.getCookie(constants.Cookies.Handshake); } private stripAuthorizationHeader(authValue: string | undefined | null): string | undefined { return authValue?.replace('Bearer ', ''); } + + private getQueryParam(name: string) { + return this.clerkRequest.clerkUrl.searchParams.get(name); + } + + private getHeader(name: string) { + return this.clerkRequest.headers.get(name) || undefined; + } + + private getCookie(name: string) { + return this.clerkRequest.cookies.get(name) || undefined; + } + + private getSuffixedCookie(name: string) { + return this.getCookie(`${name}_${this.cookieSuffix}`) || undefined; + } + + private getSuffixedOrUnSuffixedCookie(cookieName: string) { + if (this.suffixedCookies) { + return this.getSuffixedCookie(cookieName); + } + return this.getCookie(cookieName); + } } export type { AuthenticateContext }; diff --git a/packages/backend/src/tokens/cookie.ts b/packages/backend/src/tokens/cookie.ts new file mode 100644 index 00000000000..65b7bba500b --- /dev/null +++ b/packages/backend/src/tokens/cookie.ts @@ -0,0 +1,7 @@ +export const getCookieName = (cookieDirective: string): string => { + return cookieDirective.split(';')[0]?.split('=')[0]; +}; + +export const getCookieValue = (cookieDirective: string): string => { + return cookieDirective.split(';')[0]?.split('=')[1]; +}; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 9248e2c887f..a4f17d60c24 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,16 +1,15 @@ -import { parsePublishableKey } from '@clerk/shared/keys'; - import { constants } from '../constants'; import type { TokenCarrier } from '../errors'; import { TokenVerificationError, TokenVerificationErrorReason } from '../errors'; import { decodeJwt } from '../jwt/verifyJwt'; -import { assertValidSecretKey } from '../util/assertValidSecretKey'; +import { assertValidSecretKey } from '../util/optionsAssertions'; import { isDevelopmentFromSecretKey } from '../util/shared'; import type { AuthenticateContext } from './authenticateContext'; import { createAuthenticateContext } from './authenticateContext'; import type { RequestState } from './authStatus'; import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; import { createClerkRequest } from './clerkRequest'; +import { getCookieName, getCookieValue } from './cookie'; import { verifyHandshakeToken } from './handshake'; import type { AuthenticateRequestOptions } from './types'; import { verifyToken } from './verify'; @@ -86,12 +85,13 @@ export async function authenticateRequest( function buildRedirectToHandshake() { const redirectUrl = removeDevBrowserFromURL(authenticateContext.clerkUrl); - const frontendApiNoProtocol = pk.frontendApi.replace(/http(s)?:\/\//, ''); + const frontendApiNoProtocol = authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); url.searchParams.append('redirect_url', redirectUrl?.href || ''); + url.searchParams.append('suffixed_cookies', authenticateContext.suffixedCookies.toString()); - if (pk?.instanceType === 'development' && authenticateContext.devBrowserToken) { + if (authenticateContext.instanceType === 'development' && authenticateContext.devBrowserToken) { url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken); } @@ -110,12 +110,12 @@ export async function authenticateRequest( let sessionToken = ''; cookiesToSet.forEach((x: string) => { headers.append('Set-Cookie', x); - if (x.startsWith(`${constants.Cookies.Session}=`)) { - sessionToken = x.split(';')[0].substring(10); + if (getCookieName(x).startsWith(constants.Cookies.Session)) { + sessionToken = getCookieValue(x); } }); - if (instanceType === 'development') { + if (authenticateContext.instanceType === 'development') { const newUrl = new URL(authenticateContext.clerkUrl); newUrl.searchParams.delete(constants.QueryParameters.Handshake); newUrl.searchParams.delete(constants.QueryParameters.HandshakeHelp); @@ -132,7 +132,7 @@ export async function authenticateRequest( } if ( - instanceType === 'development' && + authenticateContext.instanceType === 'development' && (error?.reason === TokenVerificationErrorReason.TokenExpired || error?.reason === TokenVerificationErrorReason.TokenNotActiveYet) ) { @@ -177,14 +177,6 @@ ${error.getFullMessage()}`, return signedOut(authenticateContext, reason, message, new Headers()); } - const pk = parsePublishableKey(options.publishableKey, { - fatal: true, - proxyUrl: options.proxyUrl, - domain: options.domain, - }); - - const instanceType = pk.instanceType; - async function authenticateRequestWithTokenInHeader() { const { sessionTokenInHeader } = authenticateContext; @@ -220,7 +212,7 @@ ${error.getFullMessage()}`, // If for some reason the handshake token is invalid or stale, we ignore it and continue trying to authenticate the request. // Worst case, the handshake will trigger again and return a refreshed token. if (error instanceof TokenVerificationError) { - if (instanceType === 'development') { + if (authenticateContext.instanceType === 'development') { if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) { throw new Error( `Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`, @@ -249,7 +241,7 @@ ${error.getFullMessage()}`, * Otherwise, check for "known unknown" auth states that we can resolve with a handshake. */ if ( - instanceType === 'development' && + authenticateContext.instanceType === 'development' && authenticateContext.clerkUrl.searchParams.has(constants.QueryParameters.DevBrowser) ) { return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.DevBrowserSync, ''); @@ -258,12 +250,12 @@ ${error.getFullMessage()}`, /** * Begin multi-domain sync flows */ - if (instanceType === 'production' && isRequestEligibleForMultiDomainSync) { + if (authenticateContext.instanceType === 'production' && isRequestEligibleForMultiDomainSync) { return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, ''); } // Multi-domain development sync flow - if (instanceType === 'development' && isRequestEligibleForMultiDomainSync) { + if (authenticateContext.instanceType === 'development' && isRequestEligibleForMultiDomainSync) { // initiate MD sync // signInUrl exists, checked at the top of `authenticateRequest` @@ -281,7 +273,7 @@ ${error.getFullMessage()}`, const redirectUrl = new URL(authenticateContext.clerkUrl).searchParams.get( constants.QueryParameters.ClerkRedirectUrl, ); - if (instanceType === 'development' && !authenticateContext.isSatellite && redirectUrl) { + if (authenticateContext.instanceType === 'development' && !authenticateContext.isSatellite && redirectUrl) { // Dev MD sync from primary, redirect back to satellite w/ dev browser query param const redirectBackToSatelliteUrl = new URL(redirectUrl); @@ -300,7 +292,7 @@ ${error.getFullMessage()}`, * End multi-domain sync flows */ - if (instanceType === 'development' && !hasDevBrowserToken) { + if (authenticateContext.instanceType === 'development' && !hasDevBrowserToken) { return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.DevBrowserMissing, ''); } diff --git a/packages/backend/src/util/assertValidSecretKey.ts b/packages/backend/src/util/optionsAssertions.ts similarity index 58% rename from packages/backend/src/util/assertValidSecretKey.ts rename to packages/backend/src/util/optionsAssertions.ts index eecd6db2041..9adebbdd0f9 100644 --- a/packages/backend/src/util/assertValidSecretKey.ts +++ b/packages/backend/src/util/optionsAssertions.ts @@ -1,3 +1,5 @@ +import { parsePublishableKey } from '@clerk/shared/keys'; + export function assertValidSecretKey(val: unknown): asserts val is string { if (!val || typeof val !== 'string') { throw Error('Missing Clerk Secret Key. Go to https://dashboard.clerk.com and get your key for your instance.'); @@ -5,3 +7,7 @@ export function assertValidSecretKey(val: unknown): asserts val is string { //TODO: Check if the key is invalid and throw error } + +export function assertValidPublishableKey(val: unknown): asserts val is string { + parsePublishableKey(val as string | undefined, { fatal: true }); +} diff --git a/packages/backend/src/util/shared.ts b/packages/backend/src/util/shared.ts index c873c2822dd..3e97567d40f 100644 --- a/packages/backend/src/util/shared.ts +++ b/packages/backend/src/util/shared.ts @@ -1,6 +1,11 @@ export { addClerkPrefix, getScriptUrl, getClerkJsMajorVersionOrTag } from '@clerk/shared/url'; export { callWithRetry } from '@clerk/shared/callWithRetry'; -export { isDevelopmentFromSecretKey, isProductionFromSecretKey, parsePublishableKey } from '@clerk/shared/keys'; +export { + isDevelopmentFromSecretKey, + isProductionFromSecretKey, + parsePublishableKey, + getCookieSuffix, +} from '@clerk/shared/keys'; export { deprecated, deprecatedProperty } from '@clerk/shared/deprecated'; import { buildErrorThrower } from '@clerk/shared/error'; diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 16afda27a92..730316ed390 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -9,6 +9,8 @@ import type { ClientUatCookieHandler } from './cookies/clientUat'; import { createClientUatCookie } from './cookies/clientUat'; import type { SessionCookieHandler } from './cookies/session'; import { createSessionCookie } from './cookies/session'; +import type { SuffixedCookiesCookieHandler } from './cookies/suffixedCookies'; +import { createSuffixedCookiesCookie } from './cookies/suffixedCookies'; import type { DevBrowser } from './devBrowser'; import { createDevBrowser } from './devBrowser'; import { SessionCookiePoller } from './SessionCookiePoller'; @@ -38,6 +40,7 @@ export class AuthCookieService { private clientUat: ClientUatCookieHandler; private sessionCookie: SessionCookieHandler; private devBrowser: DevBrowser; + private suffixedCookies: SuffixedCookiesCookieHandler; constructor(private clerk: Clerk, fapiClient: FapiClient) { // set cookie on token update @@ -49,12 +52,14 @@ export class AuthCookieService { this.refreshTokenOnVisibilityChange(); this.startPollingForToken(); - this.clientUat = createClientUatCookie(); - this.sessionCookie = createSessionCookie(); + this.clientUat = createClientUatCookie(clerk.publishableKey); + this.sessionCookie = createSessionCookie(clerk.publishableKey); this.devBrowser = createDevBrowser({ frontendApi: clerk.frontendApi, fapiClient, + publishableKey: clerk.publishableKey, }); + this.suffixedCookies = createSuffixedCookiesCookie(clerk.publishableKey); } // TODO(@dimkl): Replace this method call with an event listener to decouple Clerk with setEnvironment @@ -72,6 +77,7 @@ export class AuthCookieService { public async setupDevelopment() { await this.devBrowser.setup(); + this.suffixedCookies.set(true); } public setupProduction() { diff --git a/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts b/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts index d8849bc5a16..787824c65e2 100644 --- a/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts @@ -48,6 +48,7 @@ describe('Thrown errors', () => { const devBrowserHandler = createDevBrowser({ frontendApi: 'white-koala-42.clerk.accounts.dev', fapiClient: mockFapiClient, + publishableKey: 'pk_test_d2hpdGUta29hbGEtNDIuY2xlcmsuYWNjb3VudHMuZGV2JA', }); await expect(devBrowserHandler.setup()).rejects.toThrow( diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index f731ce9dab8..ca2049c84e6 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -1,5 +1,6 @@ import { createCookieHandler } from '@clerk/shared/cookie'; import { addYears } from '@clerk/shared/date'; +import { getSuffixedCookieName } from '@clerk/shared/keys'; import type { ClientResource } from '@clerk/types'; import { inCrossOriginIframe } from '../../../utils'; @@ -18,11 +19,13 @@ export type ClientUatCookieHandler = { * The cookie is used as hint from the Clerk Backend packages to identify * if the user is authenticated or not. */ -export const createClientUatCookie = (): ClientUatCookieHandler => { +export const createClientUatCookie = (publishableKey: string): ClientUatCookieHandler => { const clientUatCookie = createCookieHandler(CLIENT_UAT_COOKIE_NAME); + const suffixedClientUatCookie = createCookieHandler(getSuffixedCookieName(CLIENT_UAT_COOKIE_NAME, publishableKey)); const get = (): number => { - return parseInt(clientUatCookie.get() || '0', 10); + const value = suffixedClientUatCookie.get() || clientUatCookie.get(); + return parseInt(value || '0', 10); }; const set = (client: ClientResource | undefined) => { @@ -40,14 +43,11 @@ export const createClientUatCookie = (): ClientUatCookieHandler => { } // Removes any existing cookies without a domain specified to ensure the change doesn't break existing sessions. + suffixedClientUatCookie.remove(); clientUatCookie.remove(); - return clientUatCookie.set(val, { - expires, - sameSite, - domain, - secure, - }); + suffixedClientUatCookie.set(val, { expires, sameSite, domain, secure }); + clientUatCookie.set(val, { expires, sameSite, domain, secure }); }; return { diff --git a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts index d32555644b9..584d7dde736 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -1,6 +1,7 @@ import { createCookieHandler } from '@clerk/shared/cookie'; import { addYears } from '@clerk/shared/date'; import { DEV_BROWSER_JWT_KEY } from '@clerk/shared/devBrowser'; +import { getSuffixedCookieName } from '@clerk/shared/keys'; import { inCrossOriginIframe } from '../../../utils'; @@ -16,24 +17,25 @@ export type DevBrowserCookieHandler = { * The cookie is used to authenticate FAPI requests and pass * authentication from AP to the app. */ -export const createDevBrowserCookie = (): DevBrowserCookieHandler => { +export const createDevBrowserCookie = (publishableKey: string): DevBrowserCookieHandler => { const devBrowserCookie = createCookieHandler(DEV_BROWSER_JWT_KEY); + const suffixedDevBrowserCookie = createCookieHandler(getSuffixedCookieName(DEV_BROWSER_JWT_KEY, publishableKey)); - const get = () => devBrowserCookie.get(); + const get = () => suffixedDevBrowserCookie.get() || devBrowserCookie.get(); const set = (jwt: string) => { const expires = addYears(Date.now(), 1); const sameSite = inCrossOriginIframe() ? 'None' : 'Lax'; const secure = window.location.protocol === 'https:'; - return devBrowserCookie.set(jwt, { - expires, - sameSite, - secure, - }); + suffixedDevBrowserCookie.set(jwt, { expires, sameSite, secure }); + devBrowserCookie.set(jwt, { expires, sameSite, secure }); }; - const remove = () => devBrowserCookie.remove(); + const remove = () => { + suffixedDevBrowserCookie.remove(); + devBrowserCookie.remove(); + }; return { get, diff --git a/packages/clerk-js/src/core/auth/cookies/session.ts b/packages/clerk-js/src/core/auth/cookies/session.ts index e36899ad7ed..3e0a49a0770 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -1,5 +1,6 @@ import { createCookieHandler } from '@clerk/shared/cookie'; import { addYears } from '@clerk/shared/date'; +import { getSuffixedCookieName } from '@clerk/shared/keys'; import { inCrossOriginIframe } from '../../../utils'; @@ -15,21 +16,22 @@ export type SessionCookieHandler = { * The cookie is used by the Clerk backend SDKs to identify * the authenticated user. */ -export const createSessionCookie = (): SessionCookieHandler => { +export const createSessionCookie = (publishableKey: string): SessionCookieHandler => { const sessionCookie = createCookieHandler(SESSION_COOKIE_NAME); + const suffixedSessionCookie = createCookieHandler(getSuffixedCookieName(SESSION_COOKIE_NAME, publishableKey)); - const remove = () => sessionCookie.remove(); + const remove = () => { + suffixedSessionCookie.remove(); + sessionCookie.remove(); + }; const set = (token: string) => { const expires = addYears(Date.now(), 1); const sameSite = inCrossOriginIframe() ? 'None' : 'Lax'; const secure = window.location.protocol === 'https:'; - return sessionCookie.set(token, { - expires, - sameSite, - secure, - }); + suffixedSessionCookie.set(token, { expires, sameSite, secure }); + sessionCookie.set(token, { expires, sameSite, secure }); }; return { diff --git a/packages/clerk-js/src/core/auth/cookies/suffixedCookies.ts b/packages/clerk-js/src/core/auth/cookies/suffixedCookies.ts new file mode 100644 index 00000000000..bed5db072ce --- /dev/null +++ b/packages/clerk-js/src/core/auth/cookies/suffixedCookies.ts @@ -0,0 +1,35 @@ +import { createCookieHandler } from '@clerk/shared/cookie'; +import { addYears } from '@clerk/shared/date'; +import { getSuffixedCookieName } from '@clerk/shared/keys'; + +import { inCrossOriginIframe } from '../../../utils'; + +const SUFFIXED_COOKIES_COOKIE_NAME = '__clerk_suffixed_cookies'; + +export type SuffixedCookiesCookieHandler = { + set: (enabled: boolean) => void; +}; + +/** + * Create JS cookie as hint for the backend SDKs to allow them identify if the suffixed + * cookies are supported in ClerkJS. + * This cookie will be set by the ClerkJS in development instances with custom development domains + * (eg host != frontendApi eTLD+1 domain) and by FAPI on all the other cases. + */ +export const createSuffixedCookiesCookie = (publishableKey: string): SuffixedCookiesCookieHandler => { + const suffixedCookiesCookie = createCookieHandler( + getSuffixedCookieName(SUFFIXED_COOKIES_COOKIE_NAME, publishableKey), + ); + + const set = (enabled: boolean) => { + const expires = addYears(Date.now(), 1); + const sameSite = inCrossOriginIframe() ? 'None' : 'Lax'; + const secure = window.location.protocol === 'https:'; + + suffixedCookiesCookie.set(enabled.toString(), { expires, sameSite, secure }); + }; + + return { + set, + }; +}; diff --git a/packages/clerk-js/src/core/auth/devBrowser.ts b/packages/clerk-js/src/core/auth/devBrowser.ts index a0a71b860dd..94b2c2d7662 100644 --- a/packages/clerk-js/src/core/auth/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/devBrowser.ts @@ -21,11 +21,12 @@ export interface DevBrowser { export type CreateDevBrowserOptions = { frontendApi: string; + publishableKey: string; fapiClient: FapiClient; }; -export function createDevBrowser({ frontendApi, fapiClient }: CreateDevBrowserOptions): DevBrowser { - const devBrowserCookie = createDevBrowserCookie(); +export function createDevBrowser({ publishableKey, frontendApi, fapiClient }: CreateDevBrowserOptions): DevBrowser { + const devBrowserCookie = createDevBrowserCookie(publishableKey); function getDevBrowserJWT() { return devBrowserCookie.get(); diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 1919299b49a..538e7628420 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -109,3 +109,11 @@ export function isDevelopmentFromSecretKey(apiKey: string): boolean { export function isProductionFromSecretKey(apiKey: string): boolean { return apiKey.startsWith('live_') || apiKey.startsWith('sk_live_'); } + +export const getCookieSuffix = (publishableKey: string): string => { + return publishableKey.split('_').pop() || ''; +}; + +export const getSuffixedCookieName = (cookieName: string, publishableKey: string): string => { + return `${cookieName}_${getCookieSuffix(publishableKey)}`; +};