From 1122ac8ccad32b4554ed85e548a500ab31fc1ed6 Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Fri, 17 May 2024 13:50:37 +0300 Subject: [PATCH 01/29] fix(clerk-js): Support get/set/remove into both suffixed/un-suffixed cookies This change is required to support setting both suffixed/un-suffixed cookies using part of the publishableKey to support Multiple apps running on the same eTLD+1 domain or localhost. Setting both suffixed/un-suffixed cookies is used to support backwards compatibility. --- .../src/core/auth/AuthCookieService.ts | 5 +++-- .../src/core/auth/__tests__/devBrowser.test.ts | 1 + .../src/core/auth/cookies/clientUat.ts | 16 ++++++++-------- .../src/core/auth/cookies/devBrowser.ts | 18 ++++++++++-------- .../clerk-js/src/core/auth/cookies/session.ts | 16 +++++++++------- packages/clerk-js/src/core/auth/devBrowser.ts | 5 +++-- packages/clerk-js/src/core/auth/utils.ts | 3 +++ 7 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 packages/clerk-js/src/core/auth/utils.ts diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index a82da9fe3c..eb752d6588 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -52,11 +52,12 @@ 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, }); } 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 d8849bc5a1..787824c65e 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 f731ce9dab..6d1d1aa8b0 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 { getSuffixedCookieName } from '../utils'; const CLIENT_UAT_COOKIE_NAME = '__client_uat'; @@ -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 d32555644b..34aebe618c 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 { getSuffixedCookieName } from '../utils'; export type DevBrowserCookieHandler = { set: (jwt: string) => void; @@ -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 e36899ad7e..1f4d62dda5 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 { getSuffixedCookieName } from '../utils'; const SESSION_COOKIE_NAME = '__session'; @@ -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/devBrowser.ts b/packages/clerk-js/src/core/auth/devBrowser.ts index a0a71b860d..94b2c2d766 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/clerk-js/src/core/auth/utils.ts b/packages/clerk-js/src/core/auth/utils.ts new file mode 100644 index 0000000000..fc841d9870 --- /dev/null +++ b/packages/clerk-js/src/core/auth/utils.ts @@ -0,0 +1,3 @@ +export const getSuffixedCookieName = (cookieName: string, publishableKey: string): string => { + return `${cookieName}_${publishableKey.split('_').pop() || ''}`; +}; From b4cb908eed5a473f400988520886a15029bb2559 Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Fri, 26 Apr 2024 15:21:21 +0300 Subject: [PATCH 02/29] chore(backend): Rename assertValidSecretKey file to optionsAssertions The optionsAssertions module will include assertion function for options used across our package --- packages/backend/src/api/request.ts | 2 +- packages/backend/src/tokens/request.ts | 2 +- .../util/{assertValidSecretKey.ts => optionsAssertions.ts} | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) rename packages/backend/src/util/{assertValidSecretKey.ts => optionsAssertions.ts} (58%) diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index fbff09bc41..ecf019ab5c 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/tokens/request.ts b/packages/backend/src/tokens/request.ts index 9248e2c887..c1b5a65e28 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -4,7 +4,7 @@ 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'; 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 eecd6db204..9adebbdd0f 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 }); +} From 7f7df47f9aaa436563d78591a364de7eb4ec4ef5 Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Fri, 26 Apr 2024 15:37:29 +0300 Subject: [PATCH 03/29] chore(backend): Enforce publishableKey/frontendApi/instanceType existence in AuthenticateContext --- .../backend/src/tokens/authenticateContext.ts | 79 ++++++++++++++----- packages/backend/src/tokens/request.ts | 31 +++----- 2 files changed, 69 insertions(+), 41 deletions(-) diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index f1d3c6e43c..d6a42e096a 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'; @@ -23,6 +26,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,10 +45,11 @@ class AuthenticateContext { return this.sessionTokenInCookie || this.sessionTokenInHeader; } - public constructor( - private clerkRequest: ClerkRequest, - options: AuthenticateRequestOptions, - ) { + 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(); this.initCookieValues(); this.initHandshakeValues(); @@ -49,37 +57,66 @@ class AuthenticateContext { this.clerkUrl = this.clerkRequest.clerkUrl; } + 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 initHandshakeValues() { this.devBrowserToken = - this.clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) || - this.clerkRequest.cookies.get(constants.Cookies.DevBrowser); + this.getQueryParam(constants.QueryParameters.DevBrowser) || + this.getMultipleAppsCookie(constants.Cookies.DevBrowser); + // Using getCookie since we don't suffix the handshake token cookie this.handshakeToken = - this.clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.Handshake) || - this.clerkRequest.cookies.get(constants.Cookies.Handshake); + this.getQueryParam(constants.QueryParameters.Handshake) || this.getCookie(constants.Cookies.Handshake); } 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; + this.sessionTokenInCookie = this.getMultipleAppsCookie(constants.Cookies.Session); + this.clientUat = Number.parseInt(this.getMultipleAppsCookie(constants.Cookies.ClientUat) || '') || 0; } 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 getMultipleAppsCookie(cookieName: string) { + const suffix = this.publishableKey?.split('_').pop(); + return this.getCookie(`${cookieName}_${suffix}`) || this.getCookie(cookieName) || undefined; + } } export type { AuthenticateContext }; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index c1b5a65e28..cf219ce403 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,5 +1,3 @@ -import { parsePublishableKey } from '@clerk/shared/keys'; - import { constants } from '../constants'; import type { TokenCarrier } from '../errors'; import { TokenVerificationError, TokenVerificationErrorReason } from '../errors'; @@ -86,12 +84,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', 'true'); - if (pk?.instanceType === 'development' && authenticateContext.devBrowserToken) { + if (authenticateContext.instanceType === 'development' && authenticateContext.devBrowserToken) { url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken); } @@ -115,7 +114,7 @@ export async function authenticateRequest( } }); - 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 +131,7 @@ export async function authenticateRequest( } if ( - instanceType === 'development' && + authenticateContext.instanceType === 'development' && (error?.reason === TokenVerificationErrorReason.TokenExpired || error?.reason === TokenVerificationErrorReason.TokenNotActiveYet) ) { @@ -177,14 +176,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 +211,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 +240,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 +249,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 +272,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 +291,7 @@ ${error.getFullMessage()}`, * End multi-domain sync flows */ - if (instanceType === 'development' && !hasDevBrowserToken) { + if (authenticateContext.instanceType === 'development' && !hasDevBrowserToken) { return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.DevBrowserMissing, ''); } From c4527979e6d5b7c2ddd3c02bcd0ae0c86191a556 Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Fri, 17 May 2024 16:55:11 +0300 Subject: [PATCH 04/29] chore(shared): Introduce cookie suffix utils to keys supath --- packages/shared/src/keys.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 1919299b49..538e762842 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)}`; +}; From ebcb98f598c2c8e0b0c067ac69210c7ce2ee4627 Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Fri, 17 May 2024 16:56:01 +0300 Subject: [PATCH 05/29] chore(clerk-js): Use cookie suffix utils from the `@clerk/shared/keys` --- packages/clerk-js/src/core/auth/cookies/clientUat.ts | 2 +- packages/clerk-js/src/core/auth/cookies/devBrowser.ts | 2 +- packages/clerk-js/src/core/auth/cookies/session.ts | 2 +- packages/clerk-js/src/core/auth/utils.ts | 3 --- 4 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 packages/clerk-js/src/core/auth/utils.ts diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index 6d1d1aa8b0..ca2049c84e 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -1,10 +1,10 @@ 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'; import { getCookieDomain } from '../getCookieDomain'; -import { getSuffixedCookieName } from '../utils'; const CLIENT_UAT_COOKIE_NAME = '__client_uat'; diff --git a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts index 34aebe618c..584d7dde73 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -1,9 +1,9 @@ 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'; -import { getSuffixedCookieName } from '../utils'; export type DevBrowserCookieHandler = { set: (jwt: string) => void; diff --git a/packages/clerk-js/src/core/auth/cookies/session.ts b/packages/clerk-js/src/core/auth/cookies/session.ts index 1f4d62dda5..3e0a49a077 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -1,8 +1,8 @@ import { createCookieHandler } from '@clerk/shared/cookie'; import { addYears } from '@clerk/shared/date'; +import { getSuffixedCookieName } from '@clerk/shared/keys'; import { inCrossOriginIframe } from '../../../utils'; -import { getSuffixedCookieName } from '../utils'; const SESSION_COOKIE_NAME = '__session'; diff --git a/packages/clerk-js/src/core/auth/utils.ts b/packages/clerk-js/src/core/auth/utils.ts deleted file mode 100644 index fc841d9870..0000000000 --- a/packages/clerk-js/src/core/auth/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const getSuffixedCookieName = (cookieName: string, publishableKey: string): string => { - return `${cookieName}_${publishableKey.split('_').pop() || ''}`; -}; From c0ffc8d2b4b727a14f078969f5bc6f81e73d510e Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Wed, 5 Jun 2024 16:47:34 +0300 Subject: [PATCH 06/29] feat(backend): Determine usage of suffixed / un-suddixed cookies using client_uat / session --- .changeset/calm-readers-call.md | 8 + integration/tests/handshake.test.ts | 54 +++-- packages/backend/src/fixtures/index.ts | 30 +++ .../__tests__/authenticateContext.test.ts | 220 ++++++++++++++++++ .../backend/src/tokens/authenticateContext.ts | 135 +++++++++-- packages/backend/src/tokens/cookie.ts | 7 + packages/backend/src/tokens/request.ts | 7 +- packages/backend/src/util/shared.ts | 7 +- packages/backend/tests/suites.ts | 2 + 9 files changed, 432 insertions(+), 38 deletions(-) create mode 100644 .changeset/calm-readers-call.md create mode 100644 packages/backend/src/tokens/__tests__/authenticateContext.test.ts create mode 100644 packages/backend/src/tokens/cookie.ts diff --git a/.changeset/calm-readers-call.md b/.changeset/calm-readers-call.md new file mode 100644 index 0000000000..e75de4e85a --- /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 7d8cd203e0..cbf2f960b5 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=false${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=false`, ); }); @@ -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=false`, ); }); @@ -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=false${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=false${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=false${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=false`, ); }); @@ -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=false${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=false`, ); }); @@ -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=false${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=false`, ); }); @@ -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=false`, ); }); @@ -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=false`, ); }); @@ -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=false${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=false`, ); }); @@ -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=false${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=false`, ); }); @@ -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=false${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=false`, ); }); @@ -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=false&__clerk_db_jwt=asdf`, ); }); diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index 1ab0471e10..52bd305738 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -1,3 +1,5 @@ +import { base64url } from '../util/rfc4648'; + export const mockJwt = 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; @@ -7,6 +9,9 @@ export const mockInvalidSignatureJwt = export const mockMalformedJwt = 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE2NjY2NDgyNTB9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; +const mockJwtSignature = + 'n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; + export const mockJwtHeader = { alg: 'RS256', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', @@ -137,3 +142,28 @@ export const publicJwks = { // this jwt has be signed with the keys above. The payload is mockJwtPayload and the header is mockJwtHeader export const signedJwt = 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.j3rB92k32WqbQDkFB093H4GoQsBVLH4HLGF6ObcwUaVGiHC8SEu6T31FuPf257SL8A5sSGtWWM1fqhQpdLohgZb_hbJswGBuYI-Clxl9BtpIRHbWFZkLBIj8yS9W9aVtD3fWBbF6PHx7BY1udio-rbGWg1YAOZNtVcxF02p-MvX-8XIK92Vwu3Un5zyfCoVIg__qo3Xntzw3tznsZ4XDe212c6kVz1R_L1d5DKjeWXpjUPAS_zFeZSIJEQLf4JNr4JCY38tfdnc3ajfDA3p36saf1XwmTdWXQKCXi75c2TJAXROs3Pgqr5Kw_5clygoFuxN5OEMhFWFSnvIBdi3M6w'; + +export const pkTest = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; +export const pkLive = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; + +type CreateJwt = (opts?: { header?: any; payload?: any; signature?: string }) => string; +export const createJwt: CreateJwt = ({ header, payload, signature = mockJwtSignature } = {}) => { + const encoder = new TextEncoder(); + + const stringifiedHeader = JSON.stringify({ ...mockJwtHeader, ...header }); + const stringifiedPayload = JSON.stringify({ ...mockJwtPayload, ...payload }); + + return [ + base64url.stringify(encoder.encode(stringifiedHeader), { pad: false }), + base64url.stringify(encoder.encode(stringifiedPayload), { pad: false }), + signature, + ].join('.'); +}; + +export function createCookieHeader(cookies: Record): string { + return Object.keys(cookies) + .reduce((result: string[], cookieName: string) => { + return [...result, `${cookieName}=${cookies[cookieName]}`]; + }, []) + .join('; '); +} diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts new file mode 100644 index 0000000000..64f40a877b --- /dev/null +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -0,0 +1,220 @@ +import type QUnit from 'qunit'; +import sinon from 'sinon'; + +import { createCookieHeader, createJwt, mockJwtPayload, pkLive, pkTest } from '../../fixtures'; +import { createAuthenticateContext } from '../authenticateContext'; +import { createClerkRequest } from '../clerkRequest'; + +export default (QUnit: QUnit) => { + const { module, test } = QUnit; + + module('AuthenticateContext', hooks => { + let fakeClock; + + const nowTimestampInSec = mockJwtPayload.iat; + + const suffixedSession = createJwt({ header: {} }); + const session = createJwt(); + const sessionWithInvalidIssuer = createJwt({ payload: { iss: 'http:whatever' } }); + const newSession = createJwt({ payload: { iat: nowTimestampInSec + 60 } }); + const clientUat = '1717490192'; + const suffixedClientUat = '1717490193'; + + hooks.beforeEach(() => { + fakeClock = sinon.useFakeTimers(nowTimestampInSec * 1000); + }); + + hooks.afterEach(() => { + fakeClock.restore(); + sinon.restore(); + }); + module('suffixedCookies', () => { + module('use un-suffixed cookies', () => { + test('request with un-suffixed cookies', assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __session: session, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + + assert.false(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, session); + assert.equal(context.clientUat, clientUat); + }); + + test('request with suffixed and valid newer un-suffixed cookies - case of ClerkJS downgrade', assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedClientUat, + __session: newSession, + __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + __clerk_db_jwt: '__clerk_db_jwt', + __clerk_db_jwt_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '__clerk_db_jwt-suffixed', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + + assert.false(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, newSession); + assert.equal(context.clientUat, clientUat); + assert.equal(context.devBrowserToken, '__clerk_db_jwt'); + }); + + test('request with suffixed client_uat as signed-out and un-suffixed client_uat as signed-in - case of ClerkJS downgrade', assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __session: session, + __clerk_db_jwt: '__clerk_db_jwt', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + assert.false(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, session); + assert.equal(context.clientUat, clientUat); + }); + + test('prod: request with suffixed session and signed-out suffixed client_uat', assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: '0', + __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __session: session, + __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + + assert.true(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, suffixedSession); + assert.equal(context.clientUat, '0'); + }); + }); + + module('use suffixed cookies', () => { + test('prod: request with valid suffixed and un-suffixed cookies', assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedClientUat, + __session: session, + __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + assert.true(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, suffixedSession); + assert.equal(context.clientUat, suffixedClientUat); + }); + + test('prod: request with invalid issuer un-suffixed and valid suffixed cookies - case of multiple apps on same eTLD+1 domain', assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedClientUat, + __session: sessionWithInvalidIssuer, + __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + assert.true(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, suffixedSession); + assert.equal(context.clientUat, suffixedClientUat); + }); + + test('dev: request with invalid issuer un-suffixed and valid multiple suffixed cookies - case of multiple apps on localhost', assert => { + const blahSession = createJwt({ payload: { iss: 'http://blah' } }); + const fooSession = createJwt({ payload: { iss: 'http://foo' } }); + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: '0', + __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __client_uat_Y2xlcmsuYmxhaC5wdW1hLTc1LmxjbC5kZXYk: '1717490193', + __client_uat_Y2xlcmsuZm9vLnB1bWEtNzUubGNsLmRldiQ: '1717490194', + __session: session, + __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + __session_Y2xlcmsuYmxhaC5wdW1hLTc1LmxjbC5kZXYk: blahSession, + __session_Y2xlcmsuZm9vLnB1bWEtNzUubGNsLmRldiQ: fooSession, + __clerk_db_jwt: '__clerk_db_jwt', + __clerk_db_jwt_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '__clerk_db_jwt-suffixed', + __clerk_db_jwt_Y2xlcmsuYmxhaC5wdW1hLTc1LmxjbC5kZXYk: '__clerk_db_jwt-suffixed-blah', + __clerk_db_jwt_Y2xlcmsuZm9vLnB1bWEtNzUubGNsLmRldiQ: '__clerk_db_jwt-suffixed-foo', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + assert.true(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, suffixedSession); + assert.equal(context.clientUat, '0'); + assert.equal(context.devBrowserToken, '__clerk_db_jwt-suffixed'); + }); + + test('dev: request with suffixed session and signed-out suffixed client_uat', assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: '0', + __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __session: session, + __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + __clerk_db_jwt: '__clerk_db_jwt', + __clerk_db_jwt_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '__clerk_db_jwt-suffixed', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + assert.true(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, suffixedSession); + assert.equal(context.clientUat, '0'); + assert.equal(context.devBrowserToken, '__clerk_db_jwt-suffixed'); + }); + + test('prod: request without suffixed session and signed-out suffixed client_uat', assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: '0', + __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __session: session, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + + assert.true(context.suffixedCookies); + assert.strictEqual(context.sessionTokenInCookie, undefined); + assert.equal(context.clientUat, '0'); + }); + }); + }); + }); +}; diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index d6a42e096a..c4cbb8ae8c 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,6 +1,8 @@ import { parsePublishableKey } from '@clerk/shared/keys'; +import type { Jwt } from '@clerk/types'; import { constants } from '../constants'; +import { decodeJwt } from '../jwt/verifyJwt'; import { assertValidPublishableKey } from '../util/optionsAssertions'; import type { ClerkRequest } from './clerkRequest'; import type { AuthenticateRequestOptions } from './types'; @@ -19,6 +21,7 @@ interface AuthenticateContextInterface extends AuthenticateRequestOptions { // cookie-based values sessionTokenInCookie: string | undefined; clientUat: number; + suffixedCookies: boolean; // handshake-related values devBrowserToken: string | undefined; handshakeToken: string | undefined; @@ -45,12 +48,17 @@ 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); @@ -70,15 +78,6 @@ class AuthenticateContext { this.frontendApi = pk.frontendApi; } - private initHandshakeValues() { - this.devBrowserToken = - this.getQueryParam(constants.QueryParameters.DevBrowser) || - this.getMultipleAppsCookie(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 initHeaderValues() { this.sessionTokenInHeader = this.stripAuthorizationHeader(this.getHeader(constants.Headers.Authorization)); this.origin = this.getHeader(constants.Headers.Origin); @@ -93,8 +92,19 @@ class AuthenticateContext { } private initCookieValues() { - this.sessionTokenInCookie = this.getMultipleAppsCookie(constants.Cookies.Session); - this.clientUat = Number.parseInt(this.getMultipleAppsCookie(constants.Cookies.ClientUat) || '') || 0; + // suffixedCookies needs to be set first because it's used in getMultipleAppsCookie + this.suffixedCookies = this.shouldUseSuffixed(); + 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 { @@ -113,9 +123,106 @@ class AuthenticateContext { return this.clerkRequest.cookies.get(name) || undefined; } - private getMultipleAppsCookie(cookieName: string) { - const suffix = this.publishableKey?.split('_').pop(); - return this.getCookie(`${cookieName}_${suffix}`) || this.getCookie(cookieName) || 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); + } + + private shouldUseSuffixed(): boolean { + const suffixedClientUat = this.getSuffixedCookie(constants.Cookies.ClientUat); + const clientUat = this.getCookie(constants.Cookies.ClientUat); + const suffixedSession = this.getSuffixedCookie(constants.Cookies.Session) || ''; + const session = this.getCookie(constants.Cookies.Session) || ''; + + // If there is no suffixed cookies use un-suffixed + if (!suffixedClientUat && !suffixedSession) { + return false; + } + + // If there's a token in un-suffixed, and it doesn't belong to this + // instance, then we must trust suffixed + if (session && !this.tokenBelongsToInstance(session)) { + return true; + } + + const { data: sessionData } = decodeJwt(session); + const sessionIat = sessionData?.payload.iat || 0; + const { data: suffixedSessionData } = decodeJwt(suffixedSession); + const suffixedSessionIat = suffixedSessionData?.payload.iat || 0; + + // Both indicate signed in, but un-suffixed is newer + // Trust un-suffixed because it's newer + if (suffixedClientUat !== '0' && clientUat !== '0' && sessionIat > suffixedSessionIat) { + return false; + } + + // Suffixed indicates signed out, but un-suffixed indicates signed in + // Trust un-suffixed because it gets set with both new and old clerk.js, + // so we can assume it's newer + if (suffixedClientUat === '0' && clientUat !== '0') { + return false; + } + + // Suffixed indicates signed in, un-suffixed indicates signed out + // This is the tricky one + + // In production, suffixed_uat should be set reliably, since it's + // set by FAPI and not clerk.js. So in the scenario where a developer + // downgrades, the state will look like this: + // - un-suffixed session cookie: empty + // - un-suffixed uat: 0 + // - suffixed session cookie: (possibly filled, possibly empty) + // - suffixed uat: 0 + + // Our SDK honors client_uat over the session cookie, so we don't + // need a special case for production. We can rely on suffixed, + // and the fact that the suffixed uat is set properly means and + // suffixed session cookie will be ignored. + + // The important thing to make sure we have a test that confirms + // the user ends up as signed out in this scenario, and the suffixed + // session cookie is ignored + + // In development, suffixed_uat is not set reliably, since it's done + // by clerk.js. If the developer downgrades to a pinned version of + // clerk.js, the suffixed uat will no longer be updated + + // The best we can do is look to see if the suffixed token is expired. + // This means that, if a developer downgrades, and then immediately + // signs out, all in the span of 1 minute, then they will inadvertently + // remain signed in for the rest of that minute. This is a known + // limitation of the strategy but seems highly unlikely. + if (this.instanceType !== 'production') { + const isSuffixedSessionExpired = this.sessionExpired(suffixedSessionData); + if (suffixedClientUat !== '0' && clientUat === '0' && isSuffixedSessionExpired) { + return false; + } + } + + return true; + } + + private tokenBelongsToInstance(token: string): boolean { + if (!token) { + return false; + } + + const { data, errors } = decodeJwt(token); + if (errors) { + return false; + } + const tokenIssuer = data.payload.iss.replace(/https?:\/\//gi, ''); + return this.frontendApi === tokenIssuer; + } + + private sessionExpired(jwt: Jwt | undefined): boolean { + return !!jwt && jwt?.payload.exp <= (Date.now() / 1000) >> 0; } } diff --git a/packages/backend/src/tokens/cookie.ts b/packages/backend/src/tokens/cookie.ts new file mode 100644 index 0000000000..65b7bba500 --- /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 cf219ce403..a4f17d60c2 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -9,6 +9,7 @@ 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'; @@ -88,7 +89,7 @@ export async function authenticateRequest( const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); url.searchParams.append('redirect_url', redirectUrl?.href || ''); - url.searchParams.append('suffixed_cookies', 'true'); + url.searchParams.append('suffixed_cookies', authenticateContext.suffixedCookies.toString()); if (authenticateContext.instanceType === 'development' && authenticateContext.devBrowserToken) { url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken); @@ -109,8 +110,8 @@ 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); } }); diff --git a/packages/backend/src/util/shared.ts b/packages/backend/src/util/shared.ts index c873c2822d..3e97567d40 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/backend/tests/suites.ts b/packages/backend/tests/suites.ts index 4a4700f7bc..94ec017b4f 100644 --- a/packages/backend/tests/suites.ts +++ b/packages/backend/tests/suites.ts @@ -8,6 +8,7 @@ import jwtAssertionsTest from './dist/jwt/__tests__/assertions.test.js'; import cryptoKeysTest from './dist/jwt/__tests__/cryptoKeys.test.js'; import signJwtTest from './dist/jwt/__tests__/signJwt.test.js'; import verifyJwtTest from './dist/jwt/__tests__/verifyJwt.test.js'; +import authenticateContextTest from './dist/tokens/__tests__/authenticateContext.test.js'; import authObjectsTest from './dist/tokens/__tests__/authObjects.test.js'; import authStatusTest from './dist/tokens/__tests__/authStatus.test.js'; import clerkRequestTest from './dist/tokens/__tests__/clerkRequest.test.js'; @@ -19,6 +20,7 @@ import pathTest from './dist/util/__tests__/path.test.js'; // Add them to the suite array const suites = [ + authenticateContextTest, authObjectsTest, authStatusTest, cryptoKeysTest, From 20a71e126c78674ffd335325a18c9570058b035d Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Wed, 12 Jun 2024 15:38:21 +0300 Subject: [PATCH 07/29] fix(backend,shared,clerk-js): Improve cookie suffixes (#3543) --- .../__tests__/authenticateContext.test.ts | 102 +++++++++++------- .../backend/src/tokens/authenticateContext.ts | 24 +++-- packages/backend/src/tokens/request.ts | 2 +- .../backend/src/util/optionsAssertions.ts | 2 +- packages/backend/src/util/shared.ts | 1 + .../src/core/auth/AuthCookieService.ts | 15 ++- .../src/core/auth/cookies/clientUat.ts | 4 +- .../src/core/auth/cookies/devBrowser.ts | 4 +- .../clerk-js/src/core/auth/cookies/session.ts | 4 +- packages/clerk-js/src/core/auth/devBrowser.ts | 6 +- packages/clerk-js/src/core/clerk.ts | 4 +- packages/shared/jest.setup.ts | 11 ++ packages/shared/src/__tests__/keys.test.ts | 20 ++++ packages/shared/src/keys.ts | 14 ++- 14 files changed, 139 insertions(+), 74 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 64f40a877b..802e320e71 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -2,6 +2,7 @@ import type QUnit from 'qunit'; import sinon from 'sinon'; import { createCookieHeader, createJwt, mockJwtPayload, pkLive, pkTest } from '../../fixtures'; +import { getCookieSuffix } from '../../util/shared'; import { createAuthenticateContext } from '../authenticateContext'; import { createClerkRequest } from '../clerkRequest'; @@ -30,7 +31,7 @@ export default (QUnit: QUnit) => { }); module('suffixedCookies', () => { module('use un-suffixed cookies', () => { - test('request with un-suffixed cookies', assert => { + test('request with un-suffixed cookies', async assert => { const headers = new Headers({ cookie: createCookieHeader({ __client_uat: clientUat, @@ -38,7 +39,7 @@ export default (QUnit: QUnit) => { }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive, }); @@ -47,19 +48,19 @@ export default (QUnit: QUnit) => { assert.equal(context.clientUat, clientUat); }); - test('request with suffixed and valid newer un-suffixed cookies - case of ClerkJS downgrade', assert => { + test('request with suffixed and valid newer un-suffixed cookies - case of ClerkJS downgrade', async assert => { const headers = new Headers({ cookie: createCookieHeader({ __client_uat: clientUat, - __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedClientUat, + __client_uat_MqCvchyS: suffixedClientUat, __session: newSession, - __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + __session_MqCvchyS: suffixedSession, __clerk_db_jwt: '__clerk_db_jwt', - __clerk_db_jwt_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '__clerk_db_jwt-suffixed', + __clerk_db_jwt_MqCvchyS: '__clerk_db_jwt-suffixed', }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive, }); @@ -69,17 +70,17 @@ export default (QUnit: QUnit) => { assert.equal(context.devBrowserToken, '__clerk_db_jwt'); }); - test('request with suffixed client_uat as signed-out and un-suffixed client_uat as signed-in - case of ClerkJS downgrade', assert => { + test('request with suffixed client_uat as signed-out and un-suffixed client_uat as signed-in - case of ClerkJS downgrade', async assert => { const headers = new Headers({ cookie: createCookieHeader({ __client_uat: clientUat, - __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __client_uat_vWCgMp3A: '0', __session: session, __clerk_db_jwt: '__clerk_db_jwt', }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkTest, }); @@ -88,17 +89,17 @@ export default (QUnit: QUnit) => { assert.equal(context.clientUat, clientUat); }); - test('prod: request with suffixed session and signed-out suffixed client_uat', assert => { + test('prod: request with suffixed session and signed-out suffixed client_uat', async assert => { const headers = new Headers({ cookie: createCookieHeader({ __client_uat: '0', - __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __client_uat_MqCvchyS: '0', __session: session, - __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + __session_MqCvchyS: suffixedSession, }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive, }); @@ -109,17 +110,17 @@ export default (QUnit: QUnit) => { }); module('use suffixed cookies', () => { - test('prod: request with valid suffixed and un-suffixed cookies', assert => { + test('prod: request with valid suffixed and un-suffixed cookies', async assert => { const headers = new Headers({ cookie: createCookieHeader({ __client_uat: clientUat, - __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedClientUat, + __client_uat_MqCvchyS: suffixedClientUat, __session: session, - __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + __session_MqCvchyS: suffixedSession, }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive, }); assert.true(context.suffixedCookies); @@ -127,17 +128,17 @@ export default (QUnit: QUnit) => { assert.equal(context.clientUat, suffixedClientUat); }); - test('prod: request with invalid issuer un-suffixed and valid suffixed cookies - case of multiple apps on same eTLD+1 domain', assert => { + test('prod: request with invalid issuer un-suffixed and valid suffixed cookies - case of multiple apps on same eTLD+1 domain', async assert => { const headers = new Headers({ cookie: createCookieHeader({ __client_uat: clientUat, - __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedClientUat, + __client_uat_MqCvchyS: suffixedClientUat, __session: sessionWithInvalidIssuer, - __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + __session_MqCvchyS: suffixedSession, }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive, }); assert.true(context.suffixedCookies); @@ -145,27 +146,27 @@ export default (QUnit: QUnit) => { assert.equal(context.clientUat, suffixedClientUat); }); - test('dev: request with invalid issuer un-suffixed and valid multiple suffixed cookies - case of multiple apps on localhost', assert => { + test('dev: request with invalid issuer un-suffixed and valid multiple suffixed cookies - case of multiple apps on localhost', async assert => { const blahSession = createJwt({ payload: { iss: 'http://blah' } }); const fooSession = createJwt({ payload: { iss: 'http://foo' } }); const headers = new Headers({ cookie: createCookieHeader({ __client_uat: '0', - __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', - __client_uat_Y2xlcmsuYmxhaC5wdW1hLTc1LmxjbC5kZXYk: '1717490193', - __client_uat_Y2xlcmsuZm9vLnB1bWEtNzUubGNsLmRldiQ: '1717490194', + __client_uat_vWCgMp3A: '0', + __client_uat_8HKF1r6W: '1717490193', + __client_uat_Rmi8c5i8: '1717490194', __session: session, - __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, - __session_Y2xlcmsuYmxhaC5wdW1hLTc1LmxjbC5kZXYk: blahSession, - __session_Y2xlcmsuZm9vLnB1bWEtNzUubGNsLmRldiQ: fooSession, + __session_vWCgMp3A: suffixedSession, + __session_8HKF1r6W: blahSession, + __session_Rmi8c5i8: fooSession, __clerk_db_jwt: '__clerk_db_jwt', - __clerk_db_jwt_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '__clerk_db_jwt-suffixed', - __clerk_db_jwt_Y2xlcmsuYmxhaC5wdW1hLTc1LmxjbC5kZXYk: '__clerk_db_jwt-suffixed-blah', - __clerk_db_jwt_Y2xlcmsuZm9vLnB1bWEtNzUubGNsLmRldiQ: '__clerk_db_jwt-suffixed-foo', + __clerk_db_jwt_vWCgMp3A: '__clerk_db_jwt-suffixed', + __clerk_db_jwt_8HKF1r6W: '__clerk_db_jwt-suffixed-blah', + __clerk_db_jwt_Rmi8c5i8: '__clerk_db_jwt-suffixed-foo', }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkTest, }); @@ -175,19 +176,19 @@ export default (QUnit: QUnit) => { assert.equal(context.devBrowserToken, '__clerk_db_jwt-suffixed'); }); - test('dev: request with suffixed session and signed-out suffixed client_uat', assert => { + test('dev: request with suffixed session and signed-out suffixed client_uat', async assert => { const headers = new Headers({ cookie: createCookieHeader({ __client_uat: '0', - __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __client_uat_vWCgMp3A: '0', __session: session, - __session_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: suffixedSession, + __session_vWCgMp3A: suffixedSession, __clerk_db_jwt: '__clerk_db_jwt', - __clerk_db_jwt_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '__clerk_db_jwt-suffixed', + __clerk_db_jwt_vWCgMp3A: '__clerk_db_jwt-suffixed', }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkTest, }); @@ -197,16 +198,16 @@ export default (QUnit: QUnit) => { assert.equal(context.devBrowserToken, '__clerk_db_jwt-suffixed'); }); - test('prod: request without suffixed session and signed-out suffixed client_uat', assert => { + test('prod: request without suffixed session and signed-out suffixed client_uat', async assert => { const headers = new Headers({ cookie: createCookieHeader({ __client_uat: '0', - __client_uat_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA: '0', + __client_uat_MqCvchyS: '0', __session: session, }), }); const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = createAuthenticateContext(clerkRequest, { + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive, }); @@ -217,4 +218,23 @@ export default (QUnit: QUnit) => { }); }); }); + + // Added these tests to verify that the generated sha-1 is the same as the one used in cookie assignment + // Tests copied from packages/shared/src/__tests__/keys.test.ts + module('getCookieSuffix(publishableKey)', () => { + test('given `pk_live_Y2xlcmsuY2xlcmsuZGV2JA` pk, returns `1Z8AzTQD` cookie suffix', async assert => { + assert.equal(await getCookieSuffix('pk_live_Y2xlcmsuY2xlcmsuZGV2JA'), '1Z8AzTQD'); + }); + + test('given `pk_test_Y2xlcmsuY2xlcmsuZGV2JA` pk, returns `QvfNY2dr` cookie suffix', async assert => { + assert.equal(await getCookieSuffix('pk_test_Y2xlcmsuY2xlcmsuZGV2JA'), 'QvfNY2dr'); + }); + + test('omits special characters from the cookie suffix', async assert => { + const pk = 'pk_test_ZW5vdWdoLWFscGFjYS04Mi5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; + assert.equal(await getCookieSuffix(pk), 'jtYvyt_H'); + const pk2 = 'pk_test_eHh4eHh4LXhhYWFhYS1hYS5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; + assert.equal(await getCookieSuffix(pk2), 'tZJdb-5s'); + }); + }); }; diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index c4cbb8ae8c..8ce07700bd 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,9 +1,9 @@ -import { parsePublishableKey } from '@clerk/shared/keys'; import type { Jwt } from '@clerk/types'; import { constants } from '../constants'; import { decodeJwt } from '../jwt/verifyJwt'; import { assertValidPublishableKey } from '../util/optionsAssertions'; +import { getCookieSuffix, getSuffixedCookieName, parsePublishableKey } from '../util/shared'; import type { ClerkRequest } from './clerkRequest'; import type { AuthenticateRequestOptions } from './types'; @@ -48,11 +48,11 @@ class AuthenticateContext { return this.sessionTokenInCookie || this.sessionTokenInHeader; } - private get cookieSuffix() { - return this.publishableKey?.split('_').pop(); - } - - public constructor(private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions) { + public constructor( + private cookieSuffix: string, + 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 @@ -124,7 +124,7 @@ class AuthenticateContext { } private getSuffixedCookie(name: string) { - return this.getCookie(`${name}_${this.cookieSuffix}`) || undefined; + return this.getCookie(getSuffixedCookieName(name, this.cookieSuffix)) || undefined; } private getSuffixedOrUnSuffixedCookie(cookieName: string) { @@ -228,8 +228,10 @@ class AuthenticateContext { export type { AuthenticateContext }; -export const createAuthenticateContext = ( - ...args: ConstructorParameters -): AuthenticateContext => { - return new AuthenticateContext(...args); +export const createAuthenticateContext = async ( + clerkRequest: ClerkRequest, + options: AuthenticateRequestOptions, +): Promise => { + const cookieSuffix = options.publishableKey ? await getCookieSuffix(options.publishableKey) : ''; + return new AuthenticateContext(cookieSuffix, clerkRequest, options); }; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index a4f17d60c2..aa017b2012 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -62,7 +62,7 @@ export async function authenticateRequest( request: Request, options: AuthenticateRequestOptions, ): Promise { - const authenticateContext = createAuthenticateContext(createClerkRequest(request), options); + const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options); assertValidSecretKey(authenticateContext.secretKey); if (authenticateContext.isSatellite) { diff --git a/packages/backend/src/util/optionsAssertions.ts b/packages/backend/src/util/optionsAssertions.ts index 9adebbdd0f..25be5f1d95 100644 --- a/packages/backend/src/util/optionsAssertions.ts +++ b/packages/backend/src/util/optionsAssertions.ts @@ -1,4 +1,4 @@ -import { parsePublishableKey } from '@clerk/shared/keys'; +import { parsePublishableKey } from './shared'; export function assertValidSecretKey(val: unknown): asserts val is string { if (!val || typeof val !== 'string') { diff --git a/packages/backend/src/util/shared.ts b/packages/backend/src/util/shared.ts index 3e97567d40..c5c941594e 100644 --- a/packages/backend/src/util/shared.ts +++ b/packages/backend/src/util/shared.ts @@ -5,6 +5,7 @@ export { isProductionFromSecretKey, parsePublishableKey, getCookieSuffix, + getSuffixedCookieName, } from '@clerk/shared/keys'; export { deprecated, deprecatedProperty } from '@clerk/shared/deprecated'; diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index eb752d6588..d901296e42 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -1,5 +1,6 @@ import { setDevBrowserJWTInURL } from '@clerk/shared/devBrowser'; import { is4xxError, isClerkAPIResponseError, isNetworkError } from '@clerk/shared/error'; +import { getCookieSuffix } from '@clerk/shared/keys'; import type { Clerk, EnvironmentResource } from '@clerk/types'; import { clerkCoreErrorTokenRefreshFailed, clerkMissingDevBrowserJwt } from '../errors'; @@ -39,9 +40,15 @@ export class AuthCookieService { private sessionCookie: SessionCookieHandler; private devBrowser: DevBrowser; - constructor( + public static async create(clerk: Clerk, fapiClient: FapiClient) { + const cookieSuffix = await getCookieSuffix(clerk.publishableKey); + return new AuthCookieService(clerk, fapiClient, cookieSuffix); + } + + private constructor( private clerk: Clerk, fapiClient: FapiClient, + cookieSuffix: string, ) { // set cookie on token update eventBus.on(events.TokenUpdate, ({ token }) => { @@ -52,12 +59,12 @@ export class AuthCookieService { this.refreshTokenOnVisibilityChange(); this.startPollingForToken(); - this.clientUat = createClientUatCookie(clerk.publishableKey); - this.sessionCookie = createSessionCookie(clerk.publishableKey); + this.clientUat = createClientUatCookie(cookieSuffix); + this.sessionCookie = createSessionCookie(cookieSuffix); this.devBrowser = createDevBrowser({ frontendApi: clerk.frontendApi, fapiClient, - publishableKey: clerk.publishableKey, + cookieSuffix, }); } diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index ca2049c84e..0ba16d1ac1 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -19,9 +19,9 @@ 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 = (publishableKey: string): ClientUatCookieHandler => { +export const createClientUatCookie = (cookieSuffix: string): ClientUatCookieHandler => { const clientUatCookie = createCookieHandler(CLIENT_UAT_COOKIE_NAME); - const suffixedClientUatCookie = createCookieHandler(getSuffixedCookieName(CLIENT_UAT_COOKIE_NAME, publishableKey)); + const suffixedClientUatCookie = createCookieHandler(getSuffixedCookieName(CLIENT_UAT_COOKIE_NAME, cookieSuffix)); const get = (): number => { const value = suffixedClientUatCookie.get() || clientUatCookie.get(); diff --git a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts index 584d7dde73..70e007f2c1 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -17,9 +17,9 @@ export type DevBrowserCookieHandler = { * The cookie is used to authenticate FAPI requests and pass * authentication from AP to the app. */ -export const createDevBrowserCookie = (publishableKey: string): DevBrowserCookieHandler => { +export const createDevBrowserCookie = (cookieSuffix: string): DevBrowserCookieHandler => { const devBrowserCookie = createCookieHandler(DEV_BROWSER_JWT_KEY); - const suffixedDevBrowserCookie = createCookieHandler(getSuffixedCookieName(DEV_BROWSER_JWT_KEY, publishableKey)); + const suffixedDevBrowserCookie = createCookieHandler(getSuffixedCookieName(DEV_BROWSER_JWT_KEY, cookieSuffix)); const get = () => suffixedDevBrowserCookie.get() || devBrowserCookie.get(); diff --git a/packages/clerk-js/src/core/auth/cookies/session.ts b/packages/clerk-js/src/core/auth/cookies/session.ts index 3e0a49a077..5a36dc87e3 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -16,9 +16,9 @@ export type SessionCookieHandler = { * The cookie is used by the Clerk backend SDKs to identify * the authenticated user. */ -export const createSessionCookie = (publishableKey: string): SessionCookieHandler => { +export const createSessionCookie = (cookieSuffix: string): SessionCookieHandler => { const sessionCookie = createCookieHandler(SESSION_COOKIE_NAME); - const suffixedSessionCookie = createCookieHandler(getSuffixedCookieName(SESSION_COOKIE_NAME, publishableKey)); + const suffixedSessionCookie = createCookieHandler(getSuffixedCookieName(SESSION_COOKIE_NAME, cookieSuffix)); const remove = () => { suffixedSessionCookie.remove(); diff --git a/packages/clerk-js/src/core/auth/devBrowser.ts b/packages/clerk-js/src/core/auth/devBrowser.ts index 94b2c2d766..f6674dfb8e 100644 --- a/packages/clerk-js/src/core/auth/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/devBrowser.ts @@ -21,12 +21,12 @@ export interface DevBrowser { export type CreateDevBrowserOptions = { frontendApi: string; - publishableKey: string; + cookieSuffix: string; fapiClient: FapiClient; }; -export function createDevBrowser({ publishableKey, frontendApi, fapiClient }: CreateDevBrowserOptions): DevBrowser { - const devBrowserCookie = createDevBrowserCookie(publishableKey); +export function createDevBrowser({ cookieSuffix, frontendApi, fapiClient }: CreateDevBrowserOptions): DevBrowser { + const devBrowserCookie = createDevBrowserCookie(cookieSuffix); function getDevBrowserJWT() { return devBrowserCookie.get(); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 5ba17d7ee3..1f9e125219 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -160,7 +160,7 @@ export class Clerk implements ClerkInterface { #publishableKey: string = ''; #domain: DomainOrProxyUrl['domain']; #proxyUrl: DomainOrProxyUrl['proxyUrl']; - #authService: AuthCookieService | null = null; + #authService?: AuthCookieService; #broadcastChannel: LocalStorageBroadcastChannel | null = null; #componentControls?: ReturnType | null; //@ts-expect-error with being undefined even though it's not possible - related to issue with ts and error thrower @@ -1518,7 +1518,7 @@ export class Clerk implements ClerkInterface { }; #loadInStandardBrowser = async (): Promise => { - this.#authService = new AuthCookieService(this, this.#fapiClient); + this.#authService = await AuthCookieService.create(this, this.#fapiClient); /** * 1. Multi-domain SSO handling diff --git a/packages/shared/jest.setup.ts b/packages/shared/jest.setup.ts index 2e4d8bbecc..82f8e2098c 100644 --- a/packages/shared/jest.setup.ts +++ b/packages/shared/jest.setup.ts @@ -1,3 +1,7 @@ +import { webcrypto } from 'node:crypto'; + +import { TextDecoder, TextEncoder } from 'util'; + const navigatorMock = {}; Object.defineProperty(navigatorMock, 'userAgent', { @@ -32,3 +36,10 @@ Object.defineProperty(global.window, 'navigator', { value: navigatorMock, writable: true, }); + +// polyfill TextDecoder, TextEncoder for jsdom >= 16 +Object.assign(global, { TextDecoder, TextEncoder }); + +// polyfill using webcrypto.subtle to fix issue with missing crypto.subtle +// @ts-ignore +globalThis.crypto.subtle = webcrypto.subtle; diff --git a/packages/shared/src/__tests__/keys.test.ts b/packages/shared/src/__tests__/keys.test.ts index 6c5b539742..6112a610db 100644 --- a/packages/shared/src/__tests__/keys.test.ts +++ b/packages/shared/src/__tests__/keys.test.ts @@ -1,6 +1,7 @@ import { buildPublishableKey, createDevOrStagingUrlCache, + getCookieSuffix, isDevelopmentFromPublishableKey, isDevelopmentFromSecretKey, isProductionFromPublishableKey, @@ -164,3 +165,22 @@ describe('isProductionFromSecretKey(key)', () => { expect(result).toEqual(expected); }); }); + +describe('getCookieSuffix(publishableKey)', () => { + const cases: Array<[string, string]> = [ + ['pk_live_Y2xlcmsuY2xlcmsuZGV2JA', '1Z8AzTQD'], + ['pk_test_Y2xlcmsuY2xlcmsuZGV2JA', 'QvfNY2dr'], + ]; + + test.each(cases)('given %p pk, returns %p cookie suffix', async (pk, expected) => { + expect(await getCookieSuffix(pk)).toEqual(expected); + }); + + test('omits special characters from the cookie suffix', async () => { + const pk = 'pk_test_ZW5vdWdoLWFscGFjYS04Mi5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; + expect(await getCookieSuffix(pk)).toEqual('jtYvyt_H'); + + const pk2 = 'pk_test_eHh4eHh4LXhhYWFhYS1hYS5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; + expect(await getCookieSuffix(pk2)).toEqual('tZJdb-5s'); + }); +}); diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 538e762842..9b8699a3c9 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -110,10 +110,14 @@ 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 async function getCookieSuffix(publishableKey: string): Promise { + const data = new TextEncoder().encode(publishableKey); + const digest = await globalThis.crypto.subtle.digest('sha-1', data); + const stringDigest = String.fromCharCode(...new Uint8Array(digest)); + // Base 64 Encoding with URL and Filename Safe Alphabet: https://datatracker.ietf.org/doc/html/rfc4648#section-5 + return isomorphicBtoa(stringDigest).replace(/\+/gi, '-').replace(/\//gi, '_').substring(0, 8); +} -export const getSuffixedCookieName = (cookieName: string, publishableKey: string): string => { - return `${cookieName}_${getCookieSuffix(publishableKey)}`; +export const getSuffixedCookieName = (cookieName: string, cookieSuffix: string): string => { + return `${cookieName}_${cookieSuffix}`; }; From 8399c1501b88b9b1b15744b62c938c62ea7b01e4 Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Thu, 20 Jun 2024 16:04:11 +0300 Subject: [PATCH 08/29] fix(backend): Update tests pk & avoid using suffixed cookies in some edge cases --- .../src/tokens/__tests__/request.test.ts | 6 +++--- .../backend/src/tokens/authenticateContext.ts | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 16f1aaefde..669111a151 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -9,8 +9,8 @@ import { AuthErrorReason, type AuthReason, AuthStatus, type RequestState } from import { authenticateRequest } from '../request'; import type { AuthenticateRequestOptions } from '../types'; -const PK_TEST = 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; -const PK_LIVE = 'pk_live_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; +const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; +const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; function assertSignedOut( assert, @@ -362,7 +362,7 @@ export default (QUnit: QUnit) => { const requestState = await authenticateRequest( mockRequestWithCookies(), mockOptions({ - publishableKey: 'pk_live_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', + publishableKey: 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA', secretKey: 'live_deadbeef', }), ); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 8ce07700bd..da7e6924ce 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -140,8 +140,10 @@ class AuthenticateContext { const suffixedSession = this.getSuffixedCookie(constants.Cookies.Session) || ''; const session = this.getCookie(constants.Cookies.Session) || ''; - // If there is no suffixed cookies use un-suffixed - if (!suffixedClientUat && !suffixedSession) { + // In the case of malformed session cookies (eg missing the iss claim), we should + // use the un-suffixed cookies to return signed-out state instead of triggering + // handshake + if (session && !this.tokenHasIssuer(session)) { return false; } @@ -151,6 +153,11 @@ class AuthenticateContext { return true; } + // If there is no suffixed cookies use un-suffixed + if (!suffixedClientUat && !suffixedSession) { + return false; + } + const { data: sessionData } = decodeJwt(session); const sessionIat = sessionData?.payload.iat || 0; const { data: suffixedSessionData } = decodeJwt(suffixedSession); @@ -208,6 +215,14 @@ class AuthenticateContext { return true; } + private tokenHasIssuer(token: string): boolean { + const { data, errors } = decodeJwt(token); + if (errors) { + return false; + } + return !!data.payload.iss; + } + private tokenBelongsToInstance(token: string): boolean { if (!token) { return false; From c7f8750c34d9e691074e1a9e73fe2387a4006cea Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Thu, 20 Jun 2024 17:23:28 +0300 Subject: [PATCH 09/29] fix(shared,backend): Pass subtle as getCookieSuffix arg to support node@18 missing crypto.subtle --- .../src/tokens/__tests__/authenticateContext.test.ts | 11 ++++++----- packages/backend/src/tokens/authenticateContext.ts | 5 ++++- packages/shared/src/__tests__/keys.test.ts | 2 +- packages/shared/src/keys.ts | 7 +++++-- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 802e320e71..9ea9602bb3 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -2,6 +2,7 @@ import type QUnit from 'qunit'; import sinon from 'sinon'; import { createCookieHeader, createJwt, mockJwtPayload, pkLive, pkTest } from '../../fixtures'; +import runtime from '../../runtime'; import { getCookieSuffix } from '../../util/shared'; import { createAuthenticateContext } from '../authenticateContext'; import { createClerkRequest } from '../clerkRequest'; @@ -221,20 +222,20 @@ export default (QUnit: QUnit) => { // Added these tests to verify that the generated sha-1 is the same as the one used in cookie assignment // Tests copied from packages/shared/src/__tests__/keys.test.ts - module('getCookieSuffix(publishableKey)', () => { + module('getCookieSuffix(publishableKey, subtle)', () => { test('given `pk_live_Y2xlcmsuY2xlcmsuZGV2JA` pk, returns `1Z8AzTQD` cookie suffix', async assert => { - assert.equal(await getCookieSuffix('pk_live_Y2xlcmsuY2xlcmsuZGV2JA'), '1Z8AzTQD'); + assert.equal(await getCookieSuffix('pk_live_Y2xlcmsuY2xlcmsuZGV2JA', runtime.crypto.subtle), '1Z8AzTQD'); }); test('given `pk_test_Y2xlcmsuY2xlcmsuZGV2JA` pk, returns `QvfNY2dr` cookie suffix', async assert => { - assert.equal(await getCookieSuffix('pk_test_Y2xlcmsuY2xlcmsuZGV2JA'), 'QvfNY2dr'); + assert.equal(await getCookieSuffix('pk_test_Y2xlcmsuY2xlcmsuZGV2JA', runtime.crypto.subtle), 'QvfNY2dr'); }); test('omits special characters from the cookie suffix', async assert => { const pk = 'pk_test_ZW5vdWdoLWFscGFjYS04Mi5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; - assert.equal(await getCookieSuffix(pk), 'jtYvyt_H'); + assert.equal(await getCookieSuffix(pk, runtime.crypto.subtle), 'jtYvyt_H'); const pk2 = 'pk_test_eHh4eHh4LXhhYWFhYS1hYS5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; - assert.equal(await getCookieSuffix(pk2), 'tZJdb-5s'); + assert.equal(await getCookieSuffix(pk2, runtime.crypto.subtle), 'tZJdb-5s'); }); }); }; diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index da7e6924ce..010c83508d 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -2,6 +2,7 @@ import type { Jwt } from '@clerk/types'; import { constants } from '../constants'; import { decodeJwt } from '../jwt/verifyJwt'; +import runtime from '../runtime'; import { assertValidPublishableKey } from '../util/optionsAssertions'; import { getCookieSuffix, getSuffixedCookieName, parsePublishableKey } from '../util/shared'; import type { ClerkRequest } from './clerkRequest'; @@ -247,6 +248,8 @@ export const createAuthenticateContext = async ( clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ): Promise => { - const cookieSuffix = options.publishableKey ? await getCookieSuffix(options.publishableKey) : ''; + const cookieSuffix = options.publishableKey + ? await getCookieSuffix(options.publishableKey, runtime.crypto.subtle) + : ''; return new AuthenticateContext(cookieSuffix, clerkRequest, options); }; diff --git a/packages/shared/src/__tests__/keys.test.ts b/packages/shared/src/__tests__/keys.test.ts index 6112a610db..25a588a7cf 100644 --- a/packages/shared/src/__tests__/keys.test.ts +++ b/packages/shared/src/__tests__/keys.test.ts @@ -166,7 +166,7 @@ describe('isProductionFromSecretKey(key)', () => { }); }); -describe('getCookieSuffix(publishableKey)', () => { +describe('getCookieSuffix(publishableKey, subtle?)', () => { const cases: Array<[string, string]> = [ ['pk_live_Y2xlcmsuY2xlcmsuZGV2JA', '1Z8AzTQD'], ['pk_test_Y2xlcmsuY2xlcmsuZGV2JA', 'QvfNY2dr'], diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 9b8699a3c9..efeb0b4e11 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -110,9 +110,12 @@ export function isProductionFromSecretKey(apiKey: string): boolean { return apiKey.startsWith('live_') || apiKey.startsWith('sk_live_'); } -export async function getCookieSuffix(publishableKey: string): Promise { +export async function getCookieSuffix( + publishableKey: string, + subtle: SubtleCrypto = globalThis.crypto.subtle, +): Promise { const data = new TextEncoder().encode(publishableKey); - const digest = await globalThis.crypto.subtle.digest('sha-1', data); + const digest = await subtle.digest('sha-1', data); const stringDigest = String.fromCharCode(...new Uint8Array(digest)); // Base 64 Encoding with URL and Filename Safe Alphabet: https://datatracker.ietf.org/doc/html/rfc4648#section-5 return isomorphicBtoa(stringDigest).replace(/\+/gi, '-').replace(/\//gi, '_').substring(0, 8); From b059081911c3bfd7cd1e03589a9dd0543017864b Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Tue, 25 Jun 2024 16:53:56 +0300 Subject: [PATCH 10/29] fix(clerk-js): Use dev browser id instead of dev browser jwt in dev browser creation --- packages/clerk-js/src/core/auth/devBrowser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/auth/devBrowser.ts b/packages/clerk-js/src/core/auth/devBrowser.ts index f6674dfb8e..2d5e3cb644 100644 --- a/packages/clerk-js/src/core/auth/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/devBrowser.ts @@ -96,7 +96,7 @@ export function createDevBrowser({ cookieSuffix, frontendApi, fapiClient }: Crea } const data = await response.json(); - setDevBrowserJWT(data?.token); + setDevBrowserJWT(data?.id); } return { From 51a4011a4a28b4186e2ca1c650cbe436d8c7387f Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Wed, 26 Jun 2024 12:41:11 +0300 Subject: [PATCH 11/29] fix(clerk-js): Use crypto-js sha1 for cookie suffix generation on in-secure context To avoid bundling the whole crypto-js library we used dynamic imports to load the dependency only if required and subpath imports to allow treeshake add only the required code parts from the crypto-js --- package-lock.json | 1 + packages/clerk-js/package.json | 1 + .../src/core/auth/AuthCookieService.ts | 2 +- .../core/auth/__tests__/cookieSuffix.test.ts | 43 +++++++++++++++++++ .../clerk-js/src/core/auth/cookieSuffix.ts | 24 +++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/clerk-js/src/core/auth/__tests__/cookieSuffix.test.ts create mode 100644 packages/clerk-js/src/core/auth/cookieSuffix.ts diff --git a/package-lock.json b/package-lock.json index a033541e4f..c77ec7d14c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48773,6 +48773,7 @@ "browser-tabs-lock": "1.2.15", "copy-to-clipboard": "3.3.3", "core-js": "3.26.1", + "crypto-js": "^4.2.0", "dequal": "2.0.3", "qrcode.react": "3.1.0", "regenerator-runtime": "0.13.11" diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 79b466595a..ef0dd016a9 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -62,6 +62,7 @@ "browser-tabs-lock": "1.2.15", "copy-to-clipboard": "3.3.3", "core-js": "3.26.1", + "crypto-js": "^4.2.0", "dequal": "2.0.3", "qrcode.react": "3.1.0", "regenerator-runtime": "0.13.11" diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index d901296e42..60af8d5100 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -1,6 +1,5 @@ import { setDevBrowserJWTInURL } from '@clerk/shared/devBrowser'; import { is4xxError, isClerkAPIResponseError, isNetworkError } from '@clerk/shared/error'; -import { getCookieSuffix } from '@clerk/shared/keys'; import type { Clerk, EnvironmentResource } from '@clerk/types'; import { clerkCoreErrorTokenRefreshFailed, clerkMissingDevBrowserJwt } from '../errors'; @@ -10,6 +9,7 @@ import type { ClientUatCookieHandler } from './cookies/clientUat'; import { createClientUatCookie } from './cookies/clientUat'; import type { SessionCookieHandler } from './cookies/session'; import { createSessionCookie } from './cookies/session'; +import { getCookieSuffix } from './cookieSuffix'; import type { DevBrowser } from './devBrowser'; import { createDevBrowser } from './devBrowser'; import { SessionCookiePoller } from './SessionCookiePoller'; diff --git a/packages/clerk-js/src/core/auth/__tests__/cookieSuffix.test.ts b/packages/clerk-js/src/core/auth/__tests__/cookieSuffix.test.ts new file mode 100644 index 0000000000..1adb90dfbb --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/cookieSuffix.test.ts @@ -0,0 +1,43 @@ +jest.mock('@clerk/shared/keys', () => { + return { getCookieSuffix: jest.fn() }; +}); +jest.mock('@clerk/shared/logger', () => { + return { logger: { logOnce: jest.fn() } }; +}); +import { getCookieSuffix as getSharedCookieSuffix } from '@clerk/shared/keys'; +import { logger } from '@clerk/shared/logger'; + +import { getCookieSuffix } from '../cookieSuffix'; + +describe('getCookieSuffix', () => { + beforeEach(() => { + (getSharedCookieSuffix as jest.Mock).mockRejectedValue(new Error('mocked error for insecure context')); + }); + + afterEach(() => { + (getSharedCookieSuffix as jest.Mock).mockReset(); + (logger.logOnce as jest.Mock).mockReset(); + }); + + describe('getCookieSuffix(publishableKey, subtle?)', () => { + const cases: Array<[string, string]> = [ + ['pk_live_Y2xlcmsuY2xlcmsuZGV2JA', '1Z8AzTQD'], + ['pk_test_Y2xlcmsuY2xlcmsuZGV2JA', 'QvfNY2dr'], + ]; + + test.each(cases)('given %p pk, returns %p cookie suffix', async (pk, expected) => { + expect(await getCookieSuffix(pk)).toEqual(expected); + expect(logger.logOnce).toHaveBeenCalledTimes(1); + }); + + test('omits special characters from the cookie suffix', async () => { + const pk = 'pk_test_ZW5vdWdoLWFscGFjYS04Mi5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; + expect(await getCookieSuffix(pk)).toEqual('jtYvyt_H'); + + const pk2 = 'pk_test_eHh4eHh4LXhhYWFhYS1hYS5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; + expect(await getCookieSuffix(pk2)).toEqual('tZJdb-5s'); + + expect(logger.logOnce).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/clerk-js/src/core/auth/cookieSuffix.ts b/packages/clerk-js/src/core/auth/cookieSuffix.ts new file mode 100644 index 0000000000..287e6ea0e7 --- /dev/null +++ b/packages/clerk-js/src/core/auth/cookieSuffix.ts @@ -0,0 +1,24 @@ +import { getCookieSuffix as getSharedCookieSuffix } from '@clerk/shared/keys'; +import { logger } from '@clerk/shared/logger'; + +export async function getCookieSuffix(publishableKey: string) { + let cookieSuffix; + try { + cookieSuffix = await getSharedCookieSuffix(publishableKey); + } catch (err) { + // Most common case of getCookieSuffix failing is for in-secure context + logger.logOnce( + `Suffixed cookie failed due to ${err.message} (secure-context: ${window.isSecureContext}, url: ${window.location.href})`, + ); + + // lazy load the crypto-js deps to avoid increasing the default clerk-js browser bundle size + // Since this change AuthCookieService is only used in browser (clerk.browser.js) and not in the happy path + // i would expect no change in the clerk-js bundle size + const { default: hashSha1 } = await import(/* webpackChunkName: "cookieSuffix" */ 'crypto-js/sha1'); + const { default: base64 } = await import(/* webpackChunkName: "cookieSuffix" */ 'crypto-js/enc-base64'); + const hash = hashSha1(publishableKey); + cookieSuffix = base64.stringify(hash).replace(/\+/gi, '-').replace(/\//gi, '_').substring(0, 8); + } + + return cookieSuffix; +} From ef163abaee1520ae207e3d4509f7003d6428f76a Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 9 Jul 2024 01:39:12 +0300 Subject: [PATCH 12/29] feat(e2e): Introduce multiple-apps-same-domain e2e tests (#3672) --- .github/workflows/ci.yml | 26 +- integration/README.md | 4 + integration/certs/README.md | 44 +++ integration/constants.ts | 1 + integration/models/application.ts | 33 +- integration/models/applicationConfig.ts | 17 +- integration/models/environment.ts | 5 - integration/presets/envs.ts | 88 ++++-- integration/presets/index.ts | 5 +- integration/presets/longRunningApps.ts | 10 + integration/scripts/proxyServer.ts | 33 ++ integration/testUtils/appPageObject.ts | 50 ++- integration/testUtils/index.ts | 11 + integration/testUtils/usersService.ts | 4 +- integration/tests/appearance.test.ts | 2 +- .../tests/next-account-portal/common.ts | 6 +- integration/tests/next-quickstart.test.ts | 8 +- integration/tests/non-secure-context.test.ts | 64 ++++ ...-different-port-different-instance.test.ts | 106 +++++++ ...lhost-different-port-same-instance.test.ts | 97 ++++++ .../localhost-switch-instance.test.ts | 58 ++++ .../root-subdomain-prod-instances.test.ts | 290 ++++++++++++++++++ integration/tests/sessions/utils.ts | 15 + integration/tests/sign-in-flow.test.ts | 1 - integration/tests/sign-out-smoke.test.ts | 2 +- package-lock.json | 4 +- package.json | 2 + turbo.json | 2 +- 28 files changed, 909 insertions(+), 79 deletions(-) create mode 100644 integration/certs/README.md create mode 100644 integration/scripts/proxyServer.ts create mode 100644 integration/tests/non-secure-context.test.ts create mode 100644 integration/tests/sessions/localhost-different-port-different-instance.test.ts create mode 100644 integration/tests/sessions/localhost-different-port-same-instance.test.ts create mode 100644 integration/tests/sessions/localhost-switch-instance.test.ts create mode 100644 integration/tests/sessions/root-subdomain-prod-instances.test.ts create mode 100644 integration/tests/sessions/utils.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78ad210e98..de6c6d602c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,7 @@ jobs: strategy: matrix: - test-name: ['generic', 'express', 'quickstart', 'ap-flows', 'elements', 'astro'] + test-name: ['generic', 'express', 'quickstart', 'ap-flows', 'elements', 'astro', 'sessions'] test-project: ['chrome'] include: - test-name: 'nextjs' @@ -196,10 +196,31 @@ jobs: if: ${{ steps.task-status.outputs.affected == '1' }} working-directory: ${{runner.temp}} run: mkdir clerk-js && cd clerk-js && npm init -y && npm install @clerk/clerk-js - + - name: Copy components @clerk/astro if: ${{ matrix.test-name == 'astro' }} run: cd packages/astro && npm run copy:components + + - name: Write all ENV certificates to files in integration/certs + if: ${{ steps.task-status.outputs.affected == '1' }} + uses: actions/github-script@v7 + working-directory: ./integration/certs + env: + INTEGRATION_CERTS: '${{secrets.INTEGRATION_CERTS}}' + with: + script: | + const fs = require('fs'); + const path = require('path'); + const certs = JSON.parse(process.env.INTEGRATION_CERTS); + for (const [name, cert] of Object.entries(certs)) { + // write file to current dir + fs.writeFileSync(path.join(__dirname, name), cert, 'utf8'); + } + + - name: LS certs + if: ${{ steps.task-status.outputs.affected == '1' }} + working-directory: ./integration/certs + run: ls -la && cat sessions-key.pem - name: Run Integration Tests if: ${{ steps.task-status.outputs.affected == '1' }} @@ -213,6 +234,7 @@ jobs: E2E_CLERK_ENCRYPTION_KEY: ${{ matrix.clerk-encryption-key }} INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }} + NODE_EXTRA_CA_CERTS: ${{ secrets.INTEGRATION_ROOT_CA }} - name: Upload test-results if: ${{ cancelled() || failure() }} diff --git a/integration/README.md b/integration/README.md index 9a6fc33b27..d044886f7f 100644 --- a/integration/README.md +++ b/integration/README.md @@ -577,6 +577,10 @@ This is why you created the `.keys.json` file in the [initial setup](#initial-se They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization. +### Test isolation + +Before writing tests, it's important to understand how Playwright handles test isolation. Refer to the [Playwright documentation](https://playwright.dev/docs/browser-contexts) for more details. + > [!NOTE] > The test suite also uses these environment variables to run some tests: > diff --git a/integration/certs/README.md b/integration/certs/README.md new file mode 100644 index 0000000000..31ae04ec2a --- /dev/null +++ b/integration/certs/README.md @@ -0,0 +1,44 @@ +### Introduction + +Some of our e2e test suites require self-signed SSL certificates to be installed on the local machine. This short guide will walk you through the process of generating self-signed SSL certificates using `mkcert`. + +### Prerequisites + +Good news! If you've set up your local development environment for Clerk, you've already installed `mkcert` as part of our `make deps` command. If you haven't, you can install it by following the instructions [here](https://github.com/FiloSottile/mkcert) + +### Generate SSL Certificates + +To generate a new cert/key pair, you can simply run the following command: + +```bash +mkcert -cert-file example.pem -key-file example-key.pem "example.com" "*.example.com" +``` + +The command above will create a `example.pem` and a `example-key.pem` file in the current directory. The certificate will be valid for `example.com` and all subdomains of `example.com`. + +### Using the Certificates + +During installation, `mkcert` automatically adds its root CA to your machine's trust store. All certificates generated by `mkcert` from that point on, will you that specific root CA. This means that you can use the generated certificates in your local development environment without any additional configuration. There's an important caveat though: `node` does not use the system root store, so it won't accept mkcert certificates automatically. Instead, you will have to set the `NODE_EXTRA_CA_CERTS` environment variable. + +```shell +export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" +``` + +or provide the `NODE_EXTRA_CA_CERTS` when runnning your tests: + +```shell +NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" playwright test... +``` + +For more details, see [here](https://github.com/FiloSottile/mkcert?tab=readme-ov-file#changing-the-location-of-the-ca-files) + +### Github actions + +In order to avoid install mkcert and generating self-signed certificates in our CI/CD pipeline, we have added the generated certificates and the root CA to the repository's secrets: + +```shell +secrets.INTEGRATION_ROOT_CA +secrets.INTEGRATION_CERTS +``` + +During the CICD run, the certificates are loaded from the ENV and written to the `ingration/certs` directory. diff --git a/integration/constants.ts b/integration/constants.ts index d58e396ed1..d4b681f3b5 100644 --- a/integration/constants.ts +++ b/integration/constants.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; export const constants = { TMP_DIR: path.join(os.tmpdir(), '.temp_integration'), + CERTS_DIR: path.join(process.cwd(), 'integration/certs'), APPS_STATE_FILE: path.join(os.tmpdir(), '.temp_integration', 'state.json'), /** * A URL to a running app that will be used to run the tests against. diff --git a/integration/models/application.ts b/integration/models/application.ts index 264da6a93f..2bb8f017ed 100644 --- a/integration/models/application.ts +++ b/integration/models/application.ts @@ -6,7 +6,12 @@ import type { EnvironmentConfig } from './environment.js'; export type Application = ReturnType; -export const application = (config: ApplicationConfig, appDirPath: string, appDirName: string) => { +export const application = ( + config: ApplicationConfig, + appDirPath: string, + appDirName: string, + serverUrl: string | undefined, +) => { const { name, scripts, envWriter } = config; const logger = createLogger({ prefix: `${appDirName}` }); const state = { completedSetup: false, serverUrl: '', env: {} as EnvironmentConfig }; @@ -39,18 +44,24 @@ export const application = (config: ApplicationConfig, appDirPath: string, appDi state.completedSetup = true; } }, - dev: async (opts: { port?: number; manualStart?: boolean; detached?: boolean } = {}) => { + dev: async (opts: { port?: number; manualStart?: boolean; detached?: boolean; serverUrl?: string } = {}) => { const log = logger.child({ prefix: 'dev' }).info; const port = opts.port || (await getPort()); - const serverUrl = `http://localhost:${port}`; - log(`Will try to serve app at ${serverUrl}`); + const getServerUrl = () => { + if (opts.serverUrl) { + return opts.serverUrl.includes(':') ? opts.serverUrl : `${opts.serverUrl}:${port}`; + } + return serverUrl || `http://localhost:${port}`; + }; + const runtimeServerUrl = getServerUrl(); + log(`Will try to serve app at ${runtimeServerUrl}`); if (opts.manualStart) { // for debugging, you can start the dev server manually by cd'ing into the temp dir // and running the corresponding dev command // this allows the test to run as normally, while setup is controlled by you, // so you can inspect the running up outside the PW lifecycle - state.serverUrl = serverUrl; - return { port, serverUrl }; + state.serverUrl = runtimeServerUrl; + return { port, serverUrl: runtimeServerUrl }; } const proc = run(scripts.dev, { @@ -61,12 +72,13 @@ export const application = (config: ApplicationConfig, appDirPath: string, appDi stderr: opts.detached ? fs.openSync(stderrFilePath, 'a') : undefined, log: opts.detached ? undefined : log, }); + const shouldExit = () => !!proc.exitCode && proc.exitCode !== 0; - await waitForServer(serverUrl, { log, maxAttempts: Infinity, shouldExit }); - log(`Server started at ${serverUrl}, pid: ${proc.pid}`); + await waitForServer(runtimeServerUrl, { log, maxAttempts: Infinity, shouldExit }); + log(`Server started at ${runtimeServerUrl}, pid: ${proc.pid}`); cleanupFns.push(() => awaitableTreekill(proc.pid, 'SIGKILL')); - state.serverUrl = serverUrl; - return { port, serverUrl, pid: proc.pid }; + state.serverUrl = runtimeServerUrl; + return { port, serverUrl: runtimeServerUrl, pid: proc.pid }; }, build: async () => { const log = logger.child({ prefix: 'build' }).info; @@ -83,6 +95,7 @@ export const application = (config: ApplicationConfig, appDirPath: string, appDi }, serve: async (opts: { port?: number; manualStart?: boolean } = {}) => { const port = opts.port || (await getPort()); + // TODO: get serverUrl as in dev() const serverUrl = `http://localhost:${port}`; // If this is ever used as a background process, we need to make sure // it's not using the log function. See the dev() method above diff --git a/integration/models/applicationConfig.ts b/integration/models/applicationConfig.ts index 87695cdf8e..511f81c40e 100644 --- a/integration/models/applicationConfig.ts +++ b/integration/models/applicationConfig.ts @@ -12,6 +12,7 @@ type Scripts = { dev: string; build: string; setup: string; serve: string }; export const applicationConfig = () => { let name = ''; + let serverUrl = ''; const templates: string[] = []; const files = new Map(); const scripts: Scripts = { dev: 'npm run dev', serve: 'npm run serve', build: 'npm run build', setup: 'npm i' }; @@ -35,6 +36,10 @@ export const applicationConfig = () => { name = _name; return self; }, + setServerUrl: (_serverUrl: string) => { + serverUrl = _serverUrl; + return self; + }, addFile: (filePath: string, cbOrPath: (helpers: Helpers) => string) => { files.set(filePath, cbOrPath(helpers)); return self; @@ -96,7 +101,7 @@ export const applicationConfig = () => { await fs.writeJSON(packageJsonPath, contents, { spaces: 2 }); } - return application(self, appDirPath, appDirName); + return application(self, appDirPath, appDirName, serverUrl); }, setEnvWriter: () => { throw new Error('not implemented'); @@ -115,9 +120,15 @@ export const applicationConfig = () => { logger.info(`Creating env file ".env" -> ${envDest}`); await fs.writeFile( path.join(appDir, '.env'), - [...env.publicVariables].map(([k, v]) => `${envFormatters.public(k)}=${v}`).join('\n') + + [...env.publicVariables] + .filter(([_, v]) => v) + .map(([k, v]) => `${envFormatters.public(k)}=${v}`) + .join('\n') + '\n' + - [...env.privateVariables].map(([k, v]) => `${envFormatters.private(k)}=${v}`).join('\n'), + [...env.privateVariables] + .filter(([_, v]) => v) + .map(([k, v]) => `${envFormatters.private(k)}=${v}`) + .join('\n'), ); }; return defaultWriter; diff --git a/integration/models/environment.ts b/integration/models/environment.ts index a4b2752d1f..5aa6a3a39a 100644 --- a/integration/models/environment.ts +++ b/integration/models/environment.ts @@ -7,7 +7,6 @@ export type EnvironmentConfig = { get id(): string; setId(newId: string): EnvironmentConfig; setEnvVariable(type: keyof EnvironmentVariables, name: string, value: any): EnvironmentConfig; - removeEnvVariable(type: keyof EnvironmentVariables, name: string): EnvironmentConfig; get publicVariables(): EnvironmentVariables['public']; get privateVariables(): EnvironmentVariables['private']; toJson(): { public: Record; private: Record }; @@ -34,10 +33,6 @@ export const environmentConfig = () => { envVars[type].set(name, value); return self; }, - removeEnvVariable: (type, name) => { - envVars[type].delete(name); - return self; - }, get publicVariables() { return envVars.public; }, diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 5d62e88205..fada0105a8 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -17,79 +17,101 @@ const getInstanceKeys = () => { if (!keys) { throw new Error('Missing instance keys. Is your env or .keys.json file populated?'); } - return keys; + return new Map(Object.entries(keys)); }; -const envKeys = getInstanceKeys(); +export const instanceKeys = getInstanceKeys(); -const withEmailCodes = environmentConfig() - .setId('withEmailCodes') +const base = environmentConfig() .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-email-codes'].sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-email-codes'].pk) .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up') - .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js') + .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js'); + +const withEmailCodes = base + .clone() + .setId('withEmailCodes') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk) .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY); -const withEmailLinks = environmentConfig() +const withEmailLinks = base + .clone() .setId('withEmailLinks') - .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-email-links'].sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-email-links'].pk) - .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') - .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up') - .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js'); + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-links').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-links').pk); -const withCustomRoles = environmentConfig() +const withCustomRoles = base + .clone() .setId('withCustomRoles') - .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) // Temporarily use the stage api until the custom roles feature is released to prod .setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev') - .setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk) - .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') - .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up') - .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js'); + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-custom-roles').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-custom-roles').pk); const withEmailCodesQuickstart = withEmailCodes .clone() - .removeEnvVariable('public', 'CLERK_SIGN_IN_URL') - .removeEnvVariable('public', 'CLERK_SIGN_UP_URL'); + .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '') + .setEnvVariable('public', 'CLERK_SIGN_UP_URL', ''); const withAPCore1ClerkLatest = environmentConfig() .setId('withAPCore1ClerkLatest') .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-email-codes'].sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-email-codes'].pk) + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk) .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js'); const withAPCore1ClerkV4 = environmentConfig() .setId('withAPCore1ClerkV4') .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-email-codes'].sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-email-codes'].pk); + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk); const withAPCore2ClerkLatest = environmentConfig() .setId('withAPCore2ClerkLatest') .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['core-2-all-enabled'].sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['core-2-all-enabled'].pk) + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('core-2-all-enabled').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('core-2-all-enabled').pk) .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js'); const withAPCore2ClerkV4 = environmentConfig() .setId('withAPCore2ClerkV4') .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['core-2-all-enabled'].sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['core-2-all-enabled'].pk); + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('core-2-all-enabled').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('core-2-all-enabled').pk); + +// TODO: Delete +const multipleAppsSameDomainProd1 = environmentConfig() + .setId('multipleAppsSameDomainProd1') + .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) + .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') + .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up') + .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js') + // TODO: Remove this once the apps are deployed + .setEnvVariable('public', 'CLERK_API_URL', 'https://api.lclclerk.com') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('sessions-prod-1').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('sessions-prod-1').pk); + +// TODO: Delete +const multipleAppsSameDomainProd2 = environmentConfig() + .setId('multipleAppsSameDomainProd2') + .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) + .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') + .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up') + .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js') + // TODO: Remove this once the apps are deployed + .setEnvVariable('public', 'CLERK_API_URL', 'https://api.lclclerk.com') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('sessions-prod-2').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('sessions-prod-2').pk); const withDynamicKeys = withEmailCodes .clone() .setId('withDynamicKeys') .setEnvVariable('private', 'CLERK_SECRET_KEY', '') - .setEnvVariable('private', 'CLERK_DYNAMIC_SECRET_KEY', envKeys['with-email-codes'].sk); + .setEnvVariable('private', 'CLERK_DYNAMIC_SECRET_KEY', instanceKeys.get('with-email-codes').sk); export const envs = { + base, withEmailCodes, withEmailLinks, withCustomRoles, @@ -98,5 +120,7 @@ export const envs = { withAPCore1ClerkV4, withAPCore2ClerkLatest, withAPCore2ClerkV4, + multipleAppsSameDomainProd1, + multipleAppsSameDomainProd2, withDynamicKeys, } as const; diff --git a/integration/presets/index.ts b/integration/presets/index.ts index 08987f3292..97dfab2814 100644 --- a/integration/presets/index.ts +++ b/integration/presets/index.ts @@ -1,6 +1,6 @@ import { astro } from './astro'; import { elements } from './elements'; -import { envs } from './envs'; +import { envs, instanceKeys } from './envs'; import { express } from './express'; import { createLongRunningApps } from './longRunningApps'; import { next } from './next'; @@ -16,4 +16,7 @@ export const appConfigs = { remix, elements, astro, + secrets: { + instanceKeys, + }, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index b13eafeefa..036251f87a 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -24,6 +24,16 @@ export const createLongRunningApps = () => { { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withEmailCodes', config: astro.node, env: envs.withEmailCodes }, + { + id: 'next.appRouter.multipleApps.prod.1', + config: next.appRouter.clone().setServerUrl('https://multiple-apps.dev'), + env: envs.multipleAppsSameDomainProd1, + }, + { + id: 'next.appRouter.multipleApps.prod.2', + config: next.appRouter.clone().setServerUrl('https://stg.multiple-apps.dev'), + env: envs.multipleAppsSameDomainProd2, + }, ] as const; const apps = configs.map(longRunningApplication); diff --git a/integration/scripts/proxyServer.ts b/integration/scripts/proxyServer.ts new file mode 100644 index 0000000000..fc5c99df74 --- /dev/null +++ b/integration/scripts/proxyServer.ts @@ -0,0 +1,33 @@ +import http from 'node:http'; +import type { createServer as _createServer, Server, ServerOptions } from 'node:https'; +import https from 'node:https'; +import * as process from 'node:process'; + +import { default as httpProxy } from 'http-proxy'; + +type ProxyServerOptions = { + targets: Record; + ssl?: Pick; +}; + +/** + * Creates a local proxy server that forwards requests to different targets based on the host header. + * The server will listen on port 80 (http) or 443 (https) depending on whether SSL options are provided. + */ +export const createProxyServer = (opts: ProxyServerOptions) => { + const proxy = httpProxy.createProxyServer(); + const usingSSL = !!opts.ssl; + const createServer: typeof _createServer = usingSSL ? https.createServer.bind(https) : http.createServer.bind(http); + + return createServer({ ca: process.env.NODE_EXTRA_CA_CERTS, ...opts.ssl }, (req, res) => { + const hostHeader = req.headers.host || ''; + if (opts.targets[hostHeader]) { + proxy.web(req, res, { target: opts.targets[hostHeader] }); + } else { + res.writeHead(404); + res.end(); + } + }).listen(usingSSL ? 443 : 80); +}; + +export type { Server }; diff --git a/integration/testUtils/appPageObject.ts b/integration/testUtils/appPageObject.ts index 5d3a9d75bb..24d0311f55 100644 --- a/integration/testUtils/appPageObject.ts +++ b/integration/testUtils/appPageObject.ts @@ -6,7 +6,7 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) const { page } = testArgs; const appPage = Object.create(page) as Page; const helpers = { - goToStart: async () => { + goToAppHome: async () => { try { await page.goto(app.serverUrl); } catch (e) { @@ -14,21 +14,25 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) } }, goToRelative: async (path: string, opts: { searchParams?: URLSearchParams } = {}) => { - const url = new URL(path, app.serverUrl); + let url: URL; + + try { + // When testing applications using real domains we want to manually navigate to the domain first + // and not follow serverUrl (localhost) by default, as this is usually proxied + url = new URL(path, page.url()); + } catch (e) { + // However, in most tests we don't need to manually navigate to the domain + // as the test is using a localhost app directly + // This handles the case where the page is at about:blank + // and instead it uses the serverUrl + url = new URL(path, app.serverUrl); + } + if (opts.searchParams) { url.search = opts.searchParams.toString(); } await page.goto(url.toString(), { timeout: 10000 }); }, - goToSignIn: (searchParams: URLSearchParams) => { - return helpers.goToRelative('/sign-in', { searchParams }); - }, - goToSignUp: (searchParams: URLSearchParams) => { - return helpers.goToRelative('/sign-up', { searchParams }); - }, - goToUserProfile: () => { - return helpers.goToRelative('/user'); - }, waitForClerkJsLoaded: async () => { return page.waitForFunction(() => { return window.Clerk?.loaded; @@ -43,7 +47,29 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) return page.waitForSelector('.cl-rootBox', { state: 'attached' }); }, waitForAppUrl: async (relativePath: string) => { - return page.waitForURL(new URL(relativePath, app.serverUrl).toString()); + return page.waitForURL(new URL(relativePath, page.url()).toString()); + }, + /** + * Get the cookies for the URL the page is currently at. + * Suffixed cookies can be accessed by using the wildcard character `*` at the end of the cookie name, + * eg `get('__session')` and `get('__session_*')`. + */ + cookies: async () => { + const array = await page.context().cookies(); + const map = array.reduce((acc, cookie) => { + // If a suffixed cookie is found, we usually don't care about the suffix itself + // Instead, simply replace the suffix with _* so we can easily read it + // TODO: deal with collisions if neede + // TODO: might be too much magic here + // Maybe extract this into a different helper? + if (cookie.name.match(/^(__.*_)(.{8})$/)) { + acc.set(cookie.name.replace(/^(__.*_)(.{8})$/, '$1*'), cookie); + } else { + acc.set(cookie.name, cookie); + } + return acc; + }, new Map()); + return Object.assign(map, { raw: () => array }); }, }; return Object.assign(appPage, helpers); diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index f8632e55dc..6721e0d2ba 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -37,6 +37,16 @@ const createExpectPageObject = ({ page }: TestArgs) => { }; }; +const createClerkUtils = ({ page }: TestArgs) => { + return { + getClientSideUser: () => { + return page.evaluate(() => { + return window.Clerk?.user; + }); + }, + }; +}; + type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser }; export const createTestUtils = < @@ -72,6 +82,7 @@ export const createTestUtils = < organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs), userButton: createUserButtonPageObject(testArgs), expect: createExpectPageObject(testArgs), + clerk: createClerkUtils(testArgs), }; const browserHelpers = { diff --git a/integration/testUtils/usersService.ts b/integration/testUtils/usersService.ts index 025e8ffead..5c1b33bdd7 100644 --- a/integration/testUtils/usersService.ts +++ b/integration/testUtils/usersService.ts @@ -38,7 +38,7 @@ export type FakeUser = { firstName: string; lastName: string; email: string; - password?: string; + password: string; username?: string; phoneNumber?: string; deleteIfExists: () => Promise; @@ -70,7 +70,7 @@ export const createUserService = (clerkClient: ClerkClient) => { const self: UserService = { createFakeUser: (options?: FakeUserOptions) => { const { - fictionalEmail = false, + fictionalEmail = true, withPassword = true, withPhoneNumber = false, withUsername = false, diff --git a/integration/tests/appearance.test.ts b/integration/tests/appearance.test.ts index bd09d411c3..589d6eff67 100644 --- a/integration/tests/appearance.test.ts +++ b/integration/tests/appearance.test.ts @@ -46,7 +46,7 @@ test.describe('appearance prop', () => { test('all @clerk/themes render', async ({ page }) => { const u = createTestUtils({ app, page }); - await u.page.goToStart(); + await u.page.goToAppHome(); await u.po.signIn.waitForMounted(); await u.po.signUp.waitForMounted(); await expect(page).toHaveScreenshot({ fullPage: true }); diff --git a/integration/tests/next-account-portal/common.ts b/integration/tests/next-account-portal/common.ts index 01aaca6572..14913af5a2 100644 --- a/integration/tests/next-account-portal/common.ts +++ b/integration/tests/next-account-portal/common.ts @@ -18,7 +18,7 @@ type TestParams = { export const testSignIn = async ({ app, page, context, fakeUser }: TestParams) => { const u = createTestUtils({ app, page, context }); // Begin in localhost - await u.page.goToStart(); + await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); @@ -83,7 +83,7 @@ export const testSignUp = async ({ app, page, context }: TestParams) => { const tempUser = u.services.users.createFakeUser({ fictionalEmail: true }); // Begin in localhost - await u.page.goToStart(); + await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); @@ -151,7 +151,7 @@ export const testSSR = async ({ app, page, context, fakeUser }: TestParams) => { const u = createTestUtils({ app, page, context }); // Begin in localhost - await u.page.goToStart(); + await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); diff --git a/integration/tests/next-quickstart.test.ts b/integration/tests/next-quickstart.test.ts index 260e1b2afb..9e5539f99a 100644 --- a/integration/tests/next-quickstart.test.ts +++ b/integration/tests/next-quickstart.test.ts @@ -22,7 +22,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesQuickstart] })( test('Clerk client loads on first visit and Sign In button renders', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await u.page.goToStart(); + await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); @@ -33,7 +33,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesQuickstart] })( test('can sign in with email and password', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await u.page.goToStart(); + await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); @@ -53,7 +53,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesQuickstart] })( test('user button is functional after sign in', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await u.page.goToStart(); + await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); @@ -81,7 +81,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesQuickstart] })( test('can sign out through user button', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await u.page.goToStart(); + await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); diff --git a/integration/tests/non-secure-context.test.ts b/integration/tests/non-secure-context.test.ts new file mode 100644 index 0000000000..483f7f1ab5 --- /dev/null +++ b/integration/tests/non-secure-context.test.ts @@ -0,0 +1,64 @@ +/** + * This test ensures that Clerk can still operate in a non-secure context. + * Especially useful for developing in local environments using custom domains pointing to localhost + * but without using self-signed certificates. + * + * No special requirements are needed for this test to run, as we will not use TLS. + * + * The test will: + * 1. Use a dev instance created from clerkstage.dev + * 2. Create and run a single app + * 3. Start a local server that proxies requests to the app running locally + * 4. Perform a simple sign-in flow + */ + +import type { Server } from 'node:http'; + +import { test } from '@playwright/test'; + +import { createProxyServer } from '../scripts/proxyServer'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +// This NEEDS to be a domain that points to localhost +// and is not listed in the HSTS preload list +// For more info, refer to https://hstspreload.org/ +// and https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security +const APP_HOST = 'lclclerk.com'; + +testAgainstRunningApps({ withPattern: ['next.appRouter.withEmailCodes'] })( + 'localhost non-secure context @generic', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let server: Server; + + test.beforeAll(async () => { + server = createProxyServer({ + targets: { + [APP_HOST]: app.serverUrl, + }, + }); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await Promise.all([await fakeUser.deleteIfExists(), await app.teardown()]); + server.close(); + }); + + test('sign-in flow', async ({ page }) => { + const u = createTestUtils({ app, page }); + await u.page.goto(`http://${APP_HOST}`); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword(fakeUser); + await u.po.expect.toBeSignedIn(); + await page.evaluate(() => window.Clerk.signOut()); + await u.po.expect.toBeSignedOut(); + }); + }, +); diff --git a/integration/tests/sessions/localhost-different-port-different-instance.test.ts b/integration/tests/sessions/localhost-different-port-different-instance.test.ts new file mode 100644 index 0000000000..61150cbb21 --- /dev/null +++ b/integration/tests/sessions/localhost-different-port-different-instance.test.ts @@ -0,0 +1,106 @@ +/** + * This test verifies that users can develop run multiple Clerk apps at the same time locally + * while using localhost and different ports. Most frameworks will try to listen to their default ports, + * but if the port is taken, they will try to use a free port. Also, by default, most frameworks use + * `localhost` (or a local IP pointing to 127.0.0.1). + * + * localhost:3000 <> clerk-instance-1 + * localhost:3001 <> clerk-instance-2 + * + */ + +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { prepareApplication } from './utils'; + +test.describe('multiple apps running on localhost using different Clerk instances @sessions', () => { + test.describe.configure({ mode: 'serial' }); + + let fakeUsers: FakeUser[]; + let apps: Array<{ app: Application; serverUrl: string }>; + + test.beforeAll(async () => { + apps = await Promise.all([prepareApplication('sessions-dev-1'), prepareApplication('sessions-dev-2')]); + + const u = apps.map(a => createTestUtils({ app: a.app })); + fakeUsers = await Promise.all(u.map(u => u.services.users.createFakeUser())); + await Promise.all([ + await u[0].services.users.createBapiUser(fakeUsers[0]), + await u[1].services.users.createBapiUser(fakeUsers[1]), + ]); + }); + + test.afterAll(async () => { + await Promise.all(fakeUsers.map(u => u.deleteIfExists())); + await Promise.all(apps.map(({ app }) => app.teardown())); + }); + + test('sessions are independent between the different apps', async ({ context }) => { + const pages = await Promise.all([context.newPage(), context.newPage()]); + const u = [ + createTestUtils({ app: apps[0].app, page: pages[0], context }), + createTestUtils({ app: apps[1].app, page: pages[1], context }), + ]; + + await u[0].po.signIn.goTo(); + await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]); + await u[0].po.expect.toBeSignedIn(); + const tab0User = await u[0].po.clerk.getClientSideUser(); + + // Check that the cookies are set as expected + let tab0Cookies = (await u[0].page.cookies()).raw(); + // 1 base cookie, 1 suffixed + expect(tab0Cookies.filter(c => c.name.startsWith('__session'))).toHaveLength(2); + expect(tab0Cookies.filter(c => c.name.startsWith('__clerk_db_jwt'))).toHaveLength(2); + expect(tab0Cookies.filter(c => c.name.startsWith('__client_uat'))).toHaveLength(2); + + await u[1].po.expect.toBeSignedOut(); + await u[1].po.signIn.goTo(); + await u[1].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[1]); + await u[1].po.expect.toBeSignedIn(); + + // Get the cookies again, now we have the cookies from the new tab as well + tab0Cookies = (await u[0].page.cookies()).raw(); + expect(tab0Cookies.filter(c => c.name.startsWith('__session'))).toHaveLength(3); + expect(tab0Cookies.filter(c => c.name.startsWith('__clerk_db_jwt'))).toHaveLength(3); + expect(tab0Cookies.filter(c => c.name.startsWith('__client_uat'))).toHaveLength(3); + + const tab1User = await u[1].po.clerk.getClientSideUser(); + expect(tab0User.id).not.toEqual(tab1User.id); + + // Reload tab 0 and make sure that the original user is still signed in + // This tests that signing-in from the second tab did not interfere with the original session + await u[0].page.reload(); + await u[0].po.expect.toBeSignedIn(); + expect(tab0User.id).toBe((await u[0].po.clerk.getClientSideUser()).id); + }); + + test('signing out from the root domains does not affect the sub domain', async ({ context }) => { + const pages = await Promise.all([context.newPage(), context.newPage()]); + const u = [ + createTestUtils({ app: apps[0].app, page: pages[0], context }), + createTestUtils({ app: apps[1].app, page: pages[1], context }), + ]; + + // signin in tab0 + await u[0].po.signIn.goTo(); + await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]); + await u[0].po.expect.toBeSignedIn(); + + // signin in tab1 + await u[1].po.signIn.goTo(); + await u[1].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[1]); + await u[1].po.expect.toBeSignedIn(); + + // singout from tab0 + await u[0].page.evaluate(() => window.Clerk.signOut()); + await u[0].po.expect.toBeSignedOut(); + + // ensure we're still logged in in tab1 + await u[1].page.reload(); + await u[1].po.expect.toBeSignedIn(); + }); +}); diff --git a/integration/tests/sessions/localhost-different-port-same-instance.test.ts b/integration/tests/sessions/localhost-different-port-same-instance.test.ts new file mode 100644 index 0000000000..b3098a0b64 --- /dev/null +++ b/integration/tests/sessions/localhost-different-port-same-instance.test.ts @@ -0,0 +1,97 @@ +/** + * This test is the development version of the test described in root-sub-same-instance-prod.test.ts + * Refer to that file for extra details. + * + * localhost:3000 <> clerk-instance-1 + * localhost:3001 <> clerk-instance-1 + * + */ + +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { prepareApplication } from './utils'; + +test.describe('multiple apps running on localhost using same Clerk instance @sessions', () => { + test.describe.configure({ mode: 'serial' }); + + let fakeUsers: FakeUser[]; + let apps: Array<{ app: Application; serverUrl: string }>; + + test.beforeAll(async () => { + apps = await Promise.all([prepareApplication('sessions-dev-1'), prepareApplication('sessions-dev-1')]); + + const u = apps.map(a => createTestUtils({ app: a.app })); + fakeUsers = await Promise.all(u.map(u => u.services.users.createFakeUser())); + await Promise.all([ + await u[0].services.users.createBapiUser(fakeUsers[0]), + await u[1].services.users.createBapiUser(fakeUsers[1]), + ]); + }); + + test.afterAll(async () => { + await Promise.all(fakeUsers.map(u => u.deleteIfExists())); + await Promise.all(apps.map(({ app }) => app.teardown())); + }); + + test('the cookies are aligned for the root and sub domains', async ({ context }) => { + const pages = await Promise.all([context.newPage(), context.newPage()]); + const u = [ + createTestUtils({ app: apps[0].app, page: pages[0], context }), + createTestUtils({ app: apps[1].app, page: pages[1], context }), + ]; + + await u[0].po.signIn.goTo(); + await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]); + await u[0].po.expect.toBeSignedIn(); + const tab0User = await u[0].po.clerk.getClientSideUser(); + + // Check that the cookies are set as expected + let tab0Cookies = (await u[0].page.cookies()).raw(); + // 1 base cookie, 1 suffixed + expect(tab0Cookies.filter(c => c.name.startsWith('__session'))).toHaveLength(2); + expect(tab0Cookies.filter(c => c.name.startsWith('__clerk_db_jwt'))).toHaveLength(2); + expect(tab0Cookies.filter(c => c.name.startsWith('__client_uat'))).toHaveLength(2); + + await u[1].page.goToAppHome(); + await u[1].po.expect.toBeSignedIn(); + + // We should have the same number of cookies here as this is the same instance running + tab0Cookies = (await u[0].page.cookies()).raw(); + expect(tab0Cookies.filter(c => c.name.startsWith('__session'))).toHaveLength(2); + expect(tab0Cookies.filter(c => c.name.startsWith('__clerk_db_jwt'))).toHaveLength(2); + expect(tab0Cookies.filter(c => c.name.startsWith('__client_uat'))).toHaveLength(2); + + const tab1User = await u[1].po.clerk.getClientSideUser(); + expect(tab0User.id).toEqual(tab1User.id); + + // Reload tab 0 and make sure that the original user is still signed in + // This tests that signing-in from the second tab did not interfere with the original session + await u[0].page.reload(); + await u[0].po.expect.toBeSignedIn(); + expect(tab0User.id).toBe((await u[0].po.clerk.getClientSideUser()).id); + }); + + test('signing out from the root domain affects the sub domain', async ({ context }) => { + const pages = await Promise.all([context.newPage(), context.newPage()]); + const u = [ + createTestUtils({ app: apps[0].app, page: pages[0], context }), + createTestUtils({ app: apps[1].app, page: pages[1], context }), + ]; + + // sign tab0 + await u[0].po.signIn.goTo(); + await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]); + await u[0].po.expect.toBeSignedIn(); + + // sign out from tab1 + await u[1].page.goToAppHome(); + await u[1].page.evaluate(() => window.Clerk.signOut()); + await u[1].po.expect.toBeSignedOut(); + + await u[0].page.reload(); + await u[0].po.expect.toBeSignedOut(); + }); +}); diff --git a/integration/tests/sessions/localhost-switch-instance.test.ts b/integration/tests/sessions/localhost-switch-instance.test.ts new file mode 100644 index 0000000000..d8ea9f28cd --- /dev/null +++ b/integration/tests/sessions/localhost-switch-instance.test.ts @@ -0,0 +1,58 @@ +/** + * This tests the scenario where a user is running an app on localhost:3000 and after stopping it, they start another + * app on the same port with a different instance key. This is a common scenario for agencies using Clerk, developing multiple *unrelated* applications + * one after the other. + * + * localhost:3000 <> clerk-instance-1 + * localhost:3000 <> clerk-instance-2 + * + */ + +import { expect, test } from '@playwright/test'; + +import { getPort } from '../../scripts'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { prepareApplication } from './utils'; + +test.describe('switching instances on localhost same port @sessions', () => { + test.describe.configure({ mode: 'serial' }); + const fakeUsers: FakeUser[] = []; + + test.afterAll(async () => { + await Promise.all(fakeUsers.map(u => u.deleteIfExists())); + }); + + test('apps can be used without clearing the cookies after instance switch', async ({ context }) => { + // We need both apps to run on the same port + const port = await getPort(); + // Create app and user for the 1st app + let app = await prepareApplication('sessions-dev-1', port); + let page = await context.newPage(); + let u = createTestUtils({ app: app.app, page: page, context }); + let fakeUser = u.services.users.createFakeUser(); + fakeUsers.push(fakeUser); + await u.services.users.createBapiUser(fakeUser); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword(fakeUser); + await u.po.expect.toBeSignedIn(); + expect((await u.po.clerk.getClientSideUser()).primaryEmailAddress.emailAddress).toBe(fakeUser.email); + await app.app.teardown(); + + // Create app and user for the 2nd app with a different instance key + app = await prepareApplication('sessions-dev-2', port); + page = await context.newPage(); + u = createTestUtils({ app: app.app, page: page, context }); + fakeUser = u.services.users.createFakeUser(); + fakeUsers.push(fakeUser); + await u.services.users.createBapiUser(fakeUser); + await u.page.pause(); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword(fakeUser); + await u.po.expect.toBeSignedIn(); + expect((await u.po.clerk.getClientSideUser()).primaryEmailAddress.emailAddress).toBe(fakeUser.email); + await app.app.teardown(); + }); +}); diff --git a/integration/tests/sessions/root-subdomain-prod-instances.test.ts b/integration/tests/sessions/root-subdomain-prod-instances.test.ts new file mode 100644 index 0000000000..a49bb2e934 --- /dev/null +++ b/integration/tests/sessions/root-subdomain-prod-instances.test.ts @@ -0,0 +1,290 @@ +import type { Server, ServerOptions } from 'node:https'; + +import { expect, test } from '@playwright/test'; + +import { constants } from '../../constants'; +import type { Application } from '../../models/application'; +import { fs } from '../../scripts'; +import { createProxyServer } from '../../scripts/proxyServer'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { prepareApplication } from './utils'; + +const ssl: Pick = { + cert: fs.readFileSync(constants.CERTS_DIR + '/sessions.pem'), + key: fs.readFileSync(constants.CERTS_DIR + '/sessions-key.pem'), +}; + +/** + * These two suites need to run in serial mode because they are both using a local proxy server + * that listens to port 443. We can't run them in parallel because they would conflict with each other, unless + * we use more custom domains to avoid collision. + */ +test.describe('multiple apps same domain for production instances @sessions', () => { + test.describe.configure({ mode: 'serial' }); + + /** + * This test verifies that the session is shared between different apps running on different subdomains + * but using the same instance. This covers the use case where a customer wants multiple apps sharing the same userbase and session. + * Our own setup with clerk.com and dashboard.clerk.com is the perfect example for such a use case. + * + * test.com <> clerk-instance-1 + * stg.test.com <> clerk-instance-1 + * + * Requirements: + * 1. This test assumes that the apps are deployed as production apps and expects that both + * are served using TLS. The local proxy server expects a `sessions.pem`/`sessions-key.pem` certificate/key pair to be available + * at the specified location (`integration/cert`). To learn how to generate a self-signed certificate, + * please refer to the README.md file in the `integration/cert` directory. + * + * The test will: + * 1. Use a production instance created from clerkstage.dev + * 2. Create two apps, both using the same instance key. + * 3. Start a local server that proxies requests to the two apps based on the host + * 4. The first app is going to be served on multiple-apps-e2e.clerk.app + * 5. The second app is going to be served on sub-1.multiple-apps-e2e.clerk.app + */ + test.describe('multiple apps same domain for production instances', () => { + const hosts = ['multiple-apps-e2e.clerk.app', 'sub-1.multiple-apps-e2e.clerk.app']; + + let fakeUser: FakeUser; + let server: Server; + let apps: Array<{ app: Application; serverUrl: string }>; + + test.beforeAll(async () => { + apps = await Promise.all([ + // first app + prepareApplication('sessions-prod-1'), + // second app using the same instance keys + prepareApplication('sessions-prod-1'), + ]); + + server = createProxyServer({ + ssl, + targets: { + [hosts[0]]: apps[0].serverUrl, + [hosts[1]]: apps[1].serverUrl, + }, + }); + + const u = createTestUtils({ app: apps[0].app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await Promise.all(apps.map(({ app }) => app.teardown())); + server.close(); + }); + + test('the cookies are aligned for the root and sub domains', async ({ context }) => { + const pages = await Promise.all([context.newPage(), context.newPage()]); + const u = [ + createTestUtils({ app: apps[0].app, page: pages[0], context }), + createTestUtils({ app: apps[1].app, page: pages[1], context }), + ]; + + await u[0].page.goto(`https://${hosts[0]}`); + await u[0].po.signIn.goTo(); + await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUser); + await u[0].po.expect.toBeSignedIn(); + const tab0User = await u[0].po.clerk.getClientSideUser(); + + // Check that the cookies are set as expected + const tab0Cookies = await u[0].page.cookies(); + expect(tab0Cookies.get('__session')).toBeDefined(); + expect(tab0Cookies.get('__session').domain).toEqual(hosts[0]); + expect(tab0Cookies.get('__session').value).toEqual(tab0Cookies.get('__session_*').value); + expect(tab0Cookies.get('__session_*').name.split('__session_')[1].length).toEqual(8); + + expect(tab0Cookies.get('__client_uat')).toBeDefined(); + expect(tab0Cookies.get('__client_uat').domain).toEqual('.' + hosts[0]); + expect(tab0Cookies.get('__client_uat').value).toEqual(tab0Cookies.get('__client_uat_*').value); + expect(tab0Cookies.get('__client_uat').domain).toEqual(tab0Cookies.get('__client_uat_*').domain); + expect(tab0Cookies.get('__client_uat_*').name.split('__client_uat_')[1].length).toEqual(8); + + await u[1].page.goto(`https://${hosts[1]}`); + // user should be signed in already + await u[1].po.expect.toBeSignedIn(); + const tab1User = await u[1].po.clerk.getClientSideUser(); + + // make sure we're signed in using the same user + expect(tab0User.id).toEqual(tab1User.id); + + const tab1Cookies = await u[1].page.cookies(); + + // both apps are using the same instance + // so the client cookie should be set on the same clerk.* domain + expect(tab0Cookies.get('__client').domain).toEqual(tab1Cookies.get('__client').domain); + // the client_uat cookie should be set on the root domain for both + // so, it can be shared between all subdomains + expect(tab0Cookies.get('__client_uat_*').domain).toEqual(tab1Cookies.get('__client_uat_*').domain); + // the session cookie should be set on the domain of the app + // so, it can be accessed by the host server + expect(tab1Cookies.get('__session').domain).toEqual(hosts[1]); + expect(tab1Cookies.get('__session').domain).not.toEqual(tab0Cookies.get('__session').domain); + }); + + test('signing out from the sub domains signs out the user from the root domain as well', async ({ context }) => { + const pages = await Promise.all([context.newPage(), context.newPage()]); + const u = [ + createTestUtils({ app: apps[0].app, page: pages[0], context }), + createTestUtils({ app: apps[1].app, page: pages[1], context }), + ]; + + await u[0].page.goto(`https://${hosts[0]}`); + await u[0].po.signIn.goTo(); + await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUser); + await u[0].po.expect.toBeSignedIn(); + + await u[1].page.goto(`https://${hosts[1]}`); + await u[1].po.expect.toBeSignedIn(); + await u[1].page.evaluate(() => window.Clerk.signOut()); + await u[1].po.expect.toBeSignedOut(); + + await u[0].page.reload(); + await u[0].po.expect.toBeSignedOut(); + }); + }); + + /** + * This test verifies that the session is not shared between different apps running on different subdomains, while + * using different Clerk instances. This covers the use case where a customer wants their prod app to be hosted on + * their root domain and possibly have a second Clerk instance that is a copy of their prod instance + * and a staging environment hosted on a subdomain. + * + * test.com <> clerk-instance-1 + * stg.test.com <> clerk-instance-2 + * + * Requirements: + * 1. This test assumes that the apps are deployed as production apps and expects that both + * are served using TLS. The local proxy server expects a `sessions.pem`/`sessions-key.pem` certificate/key pair to be available + * at the specified location (`integration/cert`). To learn how to generate a self-signed certificate, + * please refer to the README.md file in the `integration/cert` directory. + * + * The test will: + * 1. Use a production instance created from clerkstage.dev + * 2. Create two apps, each using its own instance key. + * 3. Start a local server that proxies requests to the two apps based on the host + * 4. The first app is going to be served on multiple-apps-e2e.clerk.app + * 5. The second app is going to be served on sub-1.multiple-apps-e2e.clerk.app + */ + test.describe('multiple apps same domain for different production instances', () => { + const hosts = ['multiple-apps-e2e.clerk.app', 'sub-2.multiple-apps-e2e.clerk.app']; + let fakeUsers: FakeUser[]; + let server: Server; + let apps: Array<{ app: Application; serverUrl: string }>; + + test.beforeAll(async () => { + apps = await Promise.all([prepareApplication('sessions-prod-1'), prepareApplication('sessions-prod-2')]); + + server = createProxyServer({ + ssl, + targets: { + [hosts[0]]: apps[0].serverUrl, + [hosts[1]]: apps[1].serverUrl, + }, + }); + + const u = apps.map(a => createTestUtils({ app: a.app })); + fakeUsers = await Promise.all(u.map(u => u.services.users.createFakeUser())); + await Promise.all([ + await u[0].services.users.createBapiUser(fakeUsers[0]), + await u[1].services.users.createBapiUser(fakeUsers[1]), + ]); + }); + + test.afterAll(async () => { + await Promise.all(fakeUsers.map(u => u.deleteIfExists())); + await Promise.all(apps.map(({ app }) => app.teardown())); + server.close(); + }); + + test('the cookies are independent for the root and sub domains', async ({ context }) => { + const pages = await Promise.all([context.newPage(), context.newPage()]); + const u = [ + createTestUtils({ app: apps[0].app, page: pages[0], context }), + createTestUtils({ app: apps[1].app, page: pages[1], context }), + ]; + + await u[0].page.goto(`https://${hosts[0]}`); + await u[0].po.signIn.goTo(); + await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]); + await u[0].po.expect.toBeSignedIn(); + const tab0User = await u[0].po.clerk.getClientSideUser(); + + // Check that the cookies are set as expected + const tab0Cookies = await u[0].page.cookies(); + expect(tab0Cookies.get('__client')).toBeDefined(); + expect(tab0Cookies.get('__client_*')).not.toBeDefined(); + expect(tab0Cookies.get('__client').domain).toBe(`.clerk.${hosts[0]}`); + expect(tab0Cookies.get('__client').httpOnly).toBeTruthy(); + + expect(tab0Cookies.get('__session')).toBeDefined(); + expect(tab0Cookies.get('__session').domain).toEqual(hosts[0]); + + // ensure that only 2 client_uat cookies (base and suffixed variant) is visible in this root domain + expect([...tab0Cookies.values()].filter(c => c.name.startsWith('__client_uat')).length).toEqual(2); + expect(tab0Cookies.get('__client_uat_*').domain).toEqual('.' + hosts[0]); + + await u[1].page.goto(`https://${hosts[1]}`); + await u[1].po.expect.toBeSignedOut(); + + await u[1].po.signIn.goTo(); + await u[1].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[1]); + await u[1].po.expect.toBeSignedIn(); + + const tab1User = await u[1].po.clerk.getClientSideUser(); + // We have two different users at this point + expect(tab0User.id).not.toEqual(tab1User.id); + + // Check that the cookies are set as expected + const tab1Cookies = await u[1].page.cookies(); + expect(tab1Cookies.get('__client')).toBeDefined(); + expect(tab1Cookies.get('__client_*')).not.toBeDefined(); + expect(tab1Cookies.get('__client').domain).toBe(`.clerk.${hosts[1]}`); + + expect(tab1Cookies.get('__session')).toBeDefined(); + expect(tab1Cookies.get('__session').domain).toEqual(hosts[1]); + + // ensure that all client_uat cookies (base and suffixed variant set on all subdomains) are visible in this root domain + expect(tab1Cookies.raw().filter(c => c.name.startsWith('__client_uat')).length).toEqual(4); + // a __client_uat and a __client_uat_* cookie should be set on the sub domain + expect( + tab1Cookies + .raw() + .filter(c => c.name.startsWith('__client_uat')) + .filter(c => c.domain === `.${hosts[1]}`).length, + ).toEqual(2); + }); + + test('signing out from the root domains does not affect the sub domain', async ({ context }) => { + const pages = await Promise.all([context.newPage(), context.newPage()]); + const u = [ + createTestUtils({ app: apps[0].app, page: pages[0], context }), + createTestUtils({ app: apps[1].app, page: pages[1], context }), + ]; + + // signin in tab0 + await u[0].page.goto(`https://${hosts[0]}`); + await u[0].po.signIn.goTo(); + await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]); + await u[0].po.expect.toBeSignedIn(); + + // signin in tab1 + await u[1].page.goto(`https://${hosts[1]}`); + await u[1].po.signIn.goTo(); + await u[1].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[1]); + await u[1].po.expect.toBeSignedIn(); + + // singout from tab0 + await u[0].page.evaluate(() => window.Clerk.signOut()); + await u[0].po.expect.toBeSignedOut(); + + // ensure we're still logged in in tab1 + await u[1].page.reload(); + await u[1].po.expect.toBeSignedIn(); + }); + }); +}); diff --git a/integration/tests/sessions/utils.ts b/integration/tests/sessions/utils.ts new file mode 100644 index 0000000000..be4a2827c1 --- /dev/null +++ b/integration/tests/sessions/utils.ts @@ -0,0 +1,15 @@ +import { appConfigs } from '../../presets'; + +export const prepareApplication = async (envKey: string, port?: number) => { + const app = await appConfigs.next.appRouter.clone().commit(); + await app.setup(); + await app.withEnv( + appConfigs.envs.base + .clone() + .setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev') + .setEnvVariable('private', 'CLERK_SECRET_KEY', appConfigs.secrets.instanceKeys.get(envKey).sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', appConfigs.secrets.instanceKeys.get(envKey).pk), + ); + const { serverUrl } = await app.dev({ port }); + return { app, serverUrl }; +}; diff --git a/integration/tests/sign-in-flow.test.ts b/integration/tests/sign-in-flow.test.ts index ddd2037bd7..0a251adbbc 100644 --- a/integration/tests/sign-in-flow.test.ts +++ b/integration/tests/sign-in-flow.test.ts @@ -12,7 +12,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f test.beforeAll(async () => { const u = createTestUtils({ app }); fakeUser = u.services.users.createFakeUser({ - fictionalEmail: true, withPhoneNumber: true, withUsername: true, }); diff --git a/integration/tests/sign-out-smoke.test.ts b/integration/tests/sign-out-smoke.test.ts index 3047b40e06..0a31743824 100644 --- a/integration/tests/sign-out-smoke.test.ts +++ b/integration/tests/sign-out-smoke.test.ts @@ -30,7 +30,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign out await mainTab.po.expect.toBeSignedIn(); await mainTab.tabs.runInNewTab(async m => { - await m.page.goToStart(); + await m.page.goToAppHome(); await m.page.waitForClerkJsLoaded(); diff --git a/package-lock.json b/package-lock.json index c77ec7d14c..ad452aebe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "fs-extra": "^11.1.1", "get-port": "^5.1.1", "globby": "^13.2.2", + "http-proxy": "^1.18.1", "http-server": "^14.1.1", "husky": "^8.0.3", "jest": "^29.7.0", @@ -29915,7 +29916,8 @@ }, "node_modules/http-proxy": { "version": "1.18.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", diff --git a/package.json b/package.json index e0f555ea5e..329d7d0dc0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:integration:nextjs": "E2E_APP_ID=next.appRouter.* npm run test:integration:base -- --grep @nextjs", "test:integration:astro": "E2E_APP_ID=astro.* npm run test:integration:base -- --grep @astro", "test:integration:quickstart": "E2E_APP_ID=quickstart.* npm run test:integration:base -- --grep @quickstart", + "test:integration:sessions": "npm run test:integration:base -- --grep @sessions", "test:integration:remix": "echo 'placeholder'", "turbo:clean": "turbo daemon clean", "update:lockfile": "npm run nuke && npm install -D --arch=x64 --platform=linux turbo && npm install -D --arch=arm64 --platform=darwin turbo", @@ -81,6 +82,7 @@ "fs-extra": "^11.1.1", "get-port": "^5.1.1", "globby": "^13.2.2", + "http-proxy": "^1.18.1", "http-server": "^14.1.1", "husky": "^8.0.3", "jest": "^29.7.0", diff --git a/turbo.json b/turbo.json index fcf2be44fe..59ad9d008b 100644 --- a/turbo.json +++ b/turbo.json @@ -27,7 +27,7 @@ "EXPO_PUBLIC_CLERK_*", "REACT_APP_CLERK_*" ], - "globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"], + "globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN", "NODE_EXTRA_CA_CERTS"], "tasks": { "build": { "dependsOn": ["^build"], From 43c76a25774e7d58f57bd37afefecc1dceab09f9 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 9 Jul 2024 10:42:12 +0300 Subject: [PATCH 13/29] Add test:integration:sessions turbo task --- turbo.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index 59ad9d008b..6d7dc69dce 100644 --- a/turbo.json +++ b/turbo.json @@ -27,7 +27,7 @@ "EXPO_PUBLIC_CLERK_*", "REACT_APP_CLERK_*" ], - "globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN", "NODE_EXTRA_CA_CERTS"], + "globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"], "tasks": { "build": { "dependsOn": ["^build"], @@ -189,6 +189,12 @@ "inputs": ["integration/**"], "outputLogs": "new-only" }, + "//#test:integration:sessions": { + "dependsOn": ["^@clerk/clerk-js#build", "^@clerk/backend#build", "^@clerk/nextjs#build"], + "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "NODE_EXTRA_CA_CERTS"], + "inputs": ["integration/**"], + "outputLogs": "new-only" + }, "//#test:integration:elements": { "cache": false, "dependsOn": [ From a6040d3e8c7c0314dfc31601140f45fe484ff99c Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Tue, 9 Jul 2024 12:50:54 +0300 Subject: [PATCH 14/29] Update .changeset/calm-readers-call.md Co-authored-by: Nikos Douvlis --- .changeset/calm-readers-call.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-readers-call.md b/.changeset/calm-readers-call.md index e75de4e85a..64d24a3807 100644 --- a/.changeset/calm-readers-call.md +++ b/.changeset/calm-readers-call.md @@ -5,4 +5,4 @@ --- 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. +The `__session`, `__clerk_db_jwt` and `__client_uat` cookies will now include a suffix derived from the instance's publishakeKey. The cookie name suffixes are used to prevent cookie collisions, effectively enabling support for multiple Clerk applications running on the same domain. From 99dc693ed11942f940cee7da4c0725457552d415 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 9 Jul 2024 15:42:59 +0300 Subject: [PATCH 15/29] Optimize cicd cache and run integration tests --- .github/actions/init/action.yml | 23 ++++++++++- .github/workflows/ci.yml | 40 +++++++++---------- integration/playwright.config.ts | 2 + integration/scripts/logger.ts | 7 +++- integration/scripts/proxyServer.ts | 3 +- .../next-app-router/src/app/api/me/route.ts | 6 +++ .../next-app-router/src/middleware.ts | 4 +- integration/testUtils/appPageObject.ts | 4 +- integration/testUtils/signInPageObject.ts | 4 +- ...-different-port-different-instance.test.ts | 11 +++-- ...lhost-different-port-same-instance.test.ts | 4 ++ .../localhost-switch-instance.test.ts | 24 ++++++----- .../root-subdomain-prod-instances.test.ts | 9 ++++- integration/tests/sessions/utils.ts | 16 ++++---- package-lock.json | 13 ++++++ package.json | 7 ++-- .../machines/sign-in/verification.machine.ts | 1 - turbo.json | 2 +- 18 files changed, 122 insertions(+), 58 deletions(-) create mode 100644 integration/templates/next-app-router/src/app/api/me/route.ts diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml index 16d3079576..c4590f88ed 100644 --- a/.github/actions/init/action.yml +++ b/.github/actions/init/action.yml @@ -106,14 +106,33 @@ runs: - name: Setup NodeJS ${{ inputs.node-version }} uses: actions/setup-node@v4 with: - cache: 'npm' node-version: ${{ inputs.node-version }} registry-url: ${{ inputs.registry-url }} + - name: NPM debug + shell: bash + run: npm config ls + + - name: Restore node_modules + uses: actions/cache/restore@v4 + id: cache-npm + with: + path: ./node_modules + key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v3 + restore-keys: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules- + - name: Install NPM Dependencies - run: mkdir node_modules && npm ci --cache /home/runner/.npm --audit=false --fund=false --prefer-offline + if: steps.cache-npm.outputs.cache-hit != 'true' + run: npm ci --prefer-offline --audit=false --fund=false --verbose shell: bash + - name: Cache node_modules + uses: actions/cache/save@v4 + if: steps.cache-npm.outputs.cache-hit != 'true' + with: + path: ./node_modules + key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v3 + - name: Get Playwright Version if: inputs.playwright-enabled == 'true' shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de6c6d602c..41f031b006 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,10 +37,6 @@ jobs: turbo-team: ${{ vars.TURBO_TEAM }} turbo-token: ${{ secrets.TURBO_TOKEN }} - - name: List node_modules - run: npm ls - shell: bash - - name: Require Changeset if: ${{ !(github.event_name == 'merge_group') }} run: if [[ "${{ github.event.pull_request.user.login }}" = "clerk-cookie" || "${{ github.event.pull_request.user.login }}" = "renovate[bot]" ]]; then echo 'Skipping' && exit 0; else npx changeset status --since=origin/main; fi @@ -135,7 +131,7 @@ jobs: integration-tests: name: Integration Tests needs: formatting-linting - runs-on: ${{ vars.RUNNER_MEDIUM || 'ubuntu-latest-m' }} + runs-on: ${{ vars.RUNNER_LARGE || 'ubuntu-latest-l' }} timeout-minutes: ${{ vars.TIMEOUT_MINUTES_LONG && fromJSON(vars.TIMEOUT_MINUTES_LONG) || 15 }} strategy: @@ -157,6 +153,15 @@ jobs: fetch-depth: 0 show-progress: false + - name: Setup + id: config + uses: ./.github/actions/init + with: + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + turbo-team: ${{ vars.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + playwright-enabled: true + - name: Task Status id: task-status env: @@ -170,16 +175,6 @@ jobs: (npx turbo-ignore --task=test:integration:${{ matrix.test-name }} --fallback=${{ github.base_ref || 'refs/heads/main' }}) || AFFECTED=1 echo "affected=${AFFECTED}" >> $GITHUB_OUTPUT - - name: Setup - if: ${{ steps.task-status.outputs.affected == '1' }} - id: config - uses: ./.github/actions/init - with: - turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} - turbo-team: ${{ vars.TURBO_TEAM }} - turbo-token: ${{ secrets.TURBO_TOKEN }} - playwright-enabled: true - - name: Verdaccio if: ${{ steps.task-status.outputs.affected == '1' }} uses: ./.github/actions/verdaccio @@ -204,28 +199,30 @@ jobs: - name: Write all ENV certificates to files in integration/certs if: ${{ steps.task-status.outputs.affected == '1' }} uses: actions/github-script@v7 - working-directory: ./integration/certs env: INTEGRATION_CERTS: '${{secrets.INTEGRATION_CERTS}}' + INTEGRATION_ROOT_CA: '${{secrets.INTEGRATION_ROOT_CA}}' with: script: | const fs = require('fs'); const path = require('path'); + const rootCa = process.env.INTEGRATION_ROOT_CA; + console.log('rootCa', rootCa); + fs.writeFileSync(path.join(process.env.GITHUB_WORKSPACE, 'integration/certs', 'rootCA.pem'), rootCa); const certs = JSON.parse(process.env.INTEGRATION_CERTS); for (const [name, cert] of Object.entries(certs)) { - // write file to current dir - fs.writeFileSync(path.join(__dirname, name), cert, 'utf8'); + fs.writeFileSync(path.join(process.env.GITHUB_WORKSPACE, 'integration/certs', name), cert); } - name: LS certs if: ${{ steps.task-status.outputs.affected == '1' }} working-directory: ./integration/certs - run: ls -la && cat sessions-key.pem + run: ls -la && pwd - name: Run Integration Tests if: ${{ steps.task-status.outputs.affected == '1' }} id: integration-tests - run: npx turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS --only -- --project=${{ matrix.test-project }} + run: sudo --preserve-env npx turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS --only -- --project=${{ matrix.test-project }} env: E2E_APP_CLERK_JS_DIR: ${{runner.temp}} E2E_CLERK_VERSION: 'latest' @@ -234,7 +231,8 @@ jobs: E2E_CLERK_ENCRYPTION_KEY: ${{ matrix.clerk-encryption-key }} INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }} - NODE_EXTRA_CA_CERTS: ${{ secrets.INTEGRATION_ROOT_CA }} + NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem + - name: Upload test-results if: ${{ cancelled() || failure() }} diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index 779f0f4409..4a29018488 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -21,6 +21,7 @@ export const common: PlaywrightTestConfig = { workers: process.env.CI ? numAvailableWorkers : '70%', reporter: process.env.CI ? 'line' : 'list', use: { + ignoreHTTPSErrors: true, trace: 'retain-on-failure', bypassCSP: true, // We probably need to limit this to specific tests }, @@ -28,6 +29,7 @@ export const common: PlaywrightTestConfig = { export default defineConfig({ ...common, + projects: [ { name: 'setup', diff --git a/integration/scripts/logger.ts b/integration/scripts/logger.ts index ca9522a0ba..8b690573bb 100644 --- a/integration/scripts/logger.ts +++ b/integration/scripts/logger.ts @@ -25,7 +25,12 @@ export const createLogger = (opts: CreateLoggerOptions) => { const prefixColor = color || getRandomChalkColor(); return { info: (msg: string) => { - if (process.env.DEBUG) { + if ( + process.env.E2E_DEBUG === 'true' || + process.env.E2E_DEBUG === '1' || + process.env.ACTIONS_STEP_DEBUG || + process.env.ACTIONS_RUNNER_DEBUG + ) { console.info(`${chalk[prefixColor](`[${prefix}]`)} ${msg}`); } }, diff --git a/integration/scripts/proxyServer.ts b/integration/scripts/proxyServer.ts index fc5c99df74..983008d4e5 100644 --- a/integration/scripts/proxyServer.ts +++ b/integration/scripts/proxyServer.ts @@ -1,7 +1,6 @@ import http from 'node:http'; import type { createServer as _createServer, Server, ServerOptions } from 'node:https'; import https from 'node:https'; -import * as process from 'node:process'; import { default as httpProxy } from 'http-proxy'; @@ -19,7 +18,7 @@ export const createProxyServer = (opts: ProxyServerOptions) => { const usingSSL = !!opts.ssl; const createServer: typeof _createServer = usingSSL ? https.createServer.bind(https) : http.createServer.bind(http); - return createServer({ ca: process.env.NODE_EXTRA_CA_CERTS, ...opts.ssl }, (req, res) => { + return createServer(opts.ssl, (req, res) => { const hostHeader = req.headers.host || ''; if (opts.targets[hostHeader]) { proxy.web(req, res, { target: opts.targets[hostHeader] }); diff --git a/integration/templates/next-app-router/src/app/api/me/route.ts b/integration/templates/next-app-router/src/app/api/me/route.ts new file mode 100644 index 0000000000..c6088c6a97 --- /dev/null +++ b/integration/templates/next-app-router/src/app/api/me/route.ts @@ -0,0 +1,6 @@ +import { auth } from '@clerk/nextjs/server'; + +export function GET() { + const { userId } = auth(); + return new Response(JSON.stringify({ userId })); +} diff --git a/integration/templates/next-app-router/src/middleware.ts b/integration/templates/next-app-router/src/middleware.ts index 5d176d53b3..1960ac44d8 100644 --- a/integration/templates/next-app-router/src/middleware.ts +++ b/integration/templates/next-app-router/src/middleware.ts @@ -3,7 +3,9 @@ import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; const isProtectedRoute = createRouteMatcher(['/protected(.*)', '/user(.*)', '/switcher(.*)']); export default clerkMiddleware((auth, req) => { - if (isProtectedRoute(req)) auth().protect(); + if (isProtectedRoute(req)) { + auth().protect(); + } }); export const config = { diff --git a/integration/testUtils/appPageObject.ts b/integration/testUtils/appPageObject.ts index 24d0311f55..233ddd301e 100644 --- a/integration/testUtils/appPageObject.ts +++ b/integration/testUtils/appPageObject.ts @@ -13,7 +13,7 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) // do not fail the test if interstitial is returned (401) } }, - goToRelative: async (path: string, opts: { searchParams?: URLSearchParams } = {}) => { + goToRelative: async (path: string, opts: { searchParams?: URLSearchParams; timeout?: number } = {}) => { let url: URL; try { @@ -31,7 +31,7 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) if (opts.searchParams) { url.search = opts.searchParams.toString(); } - await page.goto(url.toString(), { timeout: 10000 }); + await page.goto(url.toString(), { timeout: opts.timeout ?? 10000 }); }, waitForClerkJsLoaded: async () => { return page.waitForFunction(() => { diff --git a/integration/testUtils/signInPageObject.ts b/integration/testUtils/signInPageObject.ts index 98e731a460..f0509e81c7 100644 --- a/integration/testUtils/signInPageObject.ts +++ b/integration/testUtils/signInPageObject.ts @@ -11,8 +11,8 @@ export const createSignInComponentPageObject = (testArgs: TestArgs) => { const { page } = testArgs; const self = { ...common(testArgs), - goTo: async (opts?: { searchParams?: URLSearchParams; headlessSelector?: string }) => { - await page.goToRelative('/sign-in', { searchParams: opts?.searchParams }); + goTo: async (opts?: { searchParams?: URLSearchParams; headlessSelector?: string; timeout?: number }) => { + await page.goToRelative('/sign-in', opts); if (typeof opts?.headlessSelector !== 'undefined') { return self.waitForMounted(opts.headlessSelector); diff --git a/integration/tests/sessions/localhost-different-port-different-instance.test.ts b/integration/tests/sessions/localhost-different-port-different-instance.test.ts index 61150cbb21..3a4c002fa0 100644 --- a/integration/tests/sessions/localhost-different-port-different-instance.test.ts +++ b/integration/tests/sessions/localhost-different-port-different-instance.test.ts @@ -50,6 +50,9 @@ test.describe('multiple apps running on localhost using different Clerk instance await u[0].po.expect.toBeSignedIn(); const tab0User = await u[0].po.clerk.getClientSideUser(); + // make sure that the backend user now matches the user we signed in with on the client + expect((await u[0].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab0User.id); + // Check that the cookies are set as expected let tab0Cookies = (await u[0].page.cookies()).raw(); // 1 base cookie, 1 suffixed @@ -62,15 +65,17 @@ test.describe('multiple apps running on localhost using different Clerk instance await u[1].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[1]); await u[1].po.expect.toBeSignedIn(); + const tab1User = await u[1].po.clerk.getClientSideUser(); + expect(tab0User.id).not.toEqual(tab1User.id); + // make sure that the backend user now matches the user we signed in with on the client + expect((await u[1].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab1User.id); + // Get the cookies again, now we have the cookies from the new tab as well tab0Cookies = (await u[0].page.cookies()).raw(); expect(tab0Cookies.filter(c => c.name.startsWith('__session'))).toHaveLength(3); expect(tab0Cookies.filter(c => c.name.startsWith('__clerk_db_jwt'))).toHaveLength(3); expect(tab0Cookies.filter(c => c.name.startsWith('__client_uat'))).toHaveLength(3); - const tab1User = await u[1].po.clerk.getClientSideUser(); - expect(tab0User.id).not.toEqual(tab1User.id); - // Reload tab 0 and make sure that the original user is still signed in // This tests that signing-in from the second tab did not interfere with the original session await u[0].page.reload(); diff --git a/integration/tests/sessions/localhost-different-port-same-instance.test.ts b/integration/tests/sessions/localhost-different-port-same-instance.test.ts index b3098a0b64..605d23722e 100644 --- a/integration/tests/sessions/localhost-different-port-same-instance.test.ts +++ b/integration/tests/sessions/localhost-different-port-same-instance.test.ts @@ -47,6 +47,8 @@ test.describe('multiple apps running on localhost using same Clerk instance @ses await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]); await u[0].po.expect.toBeSignedIn(); const tab0User = await u[0].po.clerk.getClientSideUser(); + // make sure that the backend user now matches the user we signed in with on the client + expect((await u[0].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab0User.id); // Check that the cookies are set as expected let tab0Cookies = (await u[0].page.cookies()).raw(); @@ -66,6 +68,8 @@ test.describe('multiple apps running on localhost using same Clerk instance @ses const tab1User = await u[1].po.clerk.getClientSideUser(); expect(tab0User.id).toEqual(tab1User.id); + // make sure that the backend user now matches the user we signed in with on the client + expect((await u[1].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab1User.id); // Reload tab 0 and make sure that the original user is still signed in // This tests that signing-in from the second tab did not interfere with the original session diff --git a/integration/tests/sessions/localhost-switch-instance.test.ts b/integration/tests/sessions/localhost-switch-instance.test.ts index d8ea9f28cd..089c1226a3 100644 --- a/integration/tests/sessions/localhost-switch-instance.test.ts +++ b/integration/tests/sessions/localhost-switch-instance.test.ts @@ -13,10 +13,10 @@ import { expect, test } from '@playwright/test'; import { getPort } from '../../scripts'; import type { FakeUser } from '../../testUtils'; import { createTestUtils } from '../../testUtils'; -import { prepareApplication } from './utils'; +import { getEnvForMultiAppInstance, prepareApplication } from './utils'; test.describe('switching instances on localhost same port @sessions', () => { - test.describe.configure({ mode: 'serial' }); + test.describe.configure({ mode: 'serial', timeout: 5 * 60 * 1000 }); const fakeUsers: FakeUser[] = []; test.afterAll(async () => { @@ -27,32 +27,34 @@ test.describe('switching instances on localhost same port @sessions', () => { // We need both apps to run on the same port const port = await getPort(); // Create app and user for the 1st app - let app = await prepareApplication('sessions-dev-1', port); + const { app } = await prepareApplication('sessions-dev-1', port); let page = await context.newPage(); - let u = createTestUtils({ app: app.app, page: page, context }); + let u = createTestUtils({ app, page, context }); let fakeUser = u.services.users.createFakeUser(); fakeUsers.push(fakeUser); + await u.services.users.createBapiUser(fakeUser); - await u.po.signIn.goTo(); + await u.po.signIn.goTo({ timeout: 30000 }); await u.po.signIn.signInWithEmailAndInstantPassword(fakeUser); await u.po.expect.toBeSignedIn(); expect((await u.po.clerk.getClientSideUser()).primaryEmailAddress.emailAddress).toBe(fakeUser.email); - await app.app.teardown(); + await app.stop(); // Create app and user for the 2nd app with a different instance key - app = await prepareApplication('sessions-dev-2', port); + await app.withEnv(getEnvForMultiAppInstance('sessions-dev-2')); + await app.dev({ port }); + page = await context.newPage(); - u = createTestUtils({ app: app.app, page: page, context }); + u = createTestUtils({ app, page, context }); fakeUser = u.services.users.createFakeUser(); fakeUsers.push(fakeUser); await u.services.users.createBapiUser(fakeUser); - await u.page.pause(); - await u.po.signIn.goTo(); + await u.po.signIn.goTo({ timeout: 30000 }); await u.po.signIn.signInWithEmailAndInstantPassword(fakeUser); await u.po.expect.toBeSignedIn(); expect((await u.po.clerk.getClientSideUser()).primaryEmailAddress.emailAddress).toBe(fakeUser.email); - await app.app.teardown(); + await app.teardown(); }); }); diff --git a/integration/tests/sessions/root-subdomain-prod-instances.test.ts b/integration/tests/sessions/root-subdomain-prod-instances.test.ts index a49bb2e934..8f54516a33 100644 --- a/integration/tests/sessions/root-subdomain-prod-instances.test.ts +++ b/integration/tests/sessions/root-subdomain-prod-instances.test.ts @@ -90,6 +90,8 @@ test.describe('multiple apps same domain for production instances @sessions', () await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUser); await u[0].po.expect.toBeSignedIn(); const tab0User = await u[0].po.clerk.getClientSideUser(); + // make sure that the backend user now matches the user we signed in with on the client + expect((await u[0].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab0User.id); // Check that the cookies are set as expected const tab0Cookies = await u[0].page.cookies(); @@ -111,6 +113,8 @@ test.describe('multiple apps same domain for production instances @sessions', () // make sure we're signed in using the same user expect(tab0User.id).toEqual(tab1User.id); + // make sure that the backend user now matches the user we signed in with on the client + expect((await u[1].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab1User.id); const tab1Cookies = await u[1].page.cookies(); @@ -213,6 +217,8 @@ test.describe('multiple apps same domain for production instances @sessions', () await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]); await u[0].po.expect.toBeSignedIn(); const tab0User = await u[0].po.clerk.getClientSideUser(); + // make sure that the backend user now matches the user we signed in with on the client + expect((await u[0].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab0User.id); // Check that the cookies are set as expected const tab0Cookies = await u[0].page.cookies(); @@ -234,8 +240,9 @@ test.describe('multiple apps same domain for production instances @sessions', () await u[1].po.signIn.goTo(); await u[1].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[1]); await u[1].po.expect.toBeSignedIn(); - const tab1User = await u[1].po.clerk.getClientSideUser(); + // make sure that the backend user now matches the user we signed in with on the client + expect((await u[1].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab1User.id); // We have two different users at this point expect(tab0User.id).not.toEqual(tab1User.id); diff --git a/integration/tests/sessions/utils.ts b/integration/tests/sessions/utils.ts index be4a2827c1..7bd3b796da 100644 --- a/integration/tests/sessions/utils.ts +++ b/integration/tests/sessions/utils.ts @@ -1,15 +1,17 @@ import { appConfigs } from '../../presets'; +export const getEnvForMultiAppInstance = (envKey: string) => { + return appConfigs.envs.base + .clone() + .setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev') + .setEnvVariable('private', 'CLERK_SECRET_KEY', appConfigs.secrets.instanceKeys.get(envKey).sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', appConfigs.secrets.instanceKeys.get(envKey).pk); +}; + export const prepareApplication = async (envKey: string, port?: number) => { const app = await appConfigs.next.appRouter.clone().commit(); await app.setup(); - await app.withEnv( - appConfigs.envs.base - .clone() - .setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev') - .setEnvVariable('private', 'CLERK_SECRET_KEY', appConfigs.secrets.instanceKeys.get(envKey).sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', appConfigs.secrets.instanceKeys.get(envKey).pk), - ); + await app.withEnv(getEnvForMultiAppInstance(envKey)); const { serverUrl } = await app.dev({ port }); return { app, serverUrl }; }; diff --git a/package-lock.json b/package-lock.json index ad452aebe8..92ec17f353 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "ts-jest": "^29.0.3", "tsup": "^8.0.1", "turbo": "^2.0.6", + "turbo-ignore": "^2.0.6", "typescript": "^5.4.5", "verdaccio": "^5.26.3", "zx": "^7.2.3" @@ -44992,6 +44993,18 @@ "darwin" ] }, + "node_modules/turbo-ignore": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/turbo-ignore/-/turbo-ignore-2.0.6.tgz", + "integrity": "sha512-UW8dSPIiydPz4SNfEPbRg2Flz7nHOCcy26cpqnv6uvZRULtZwJiAw63O00zjspiC+P7mwBCZQ8VqPZBS/ZVBHw==", + "dev": true, + "dependencies": { + "json5": "^2.2.3" + }, + "bin": { + "turbo-ignore": "dist/cli.js" + } + }, "node_modules/turbo-linux-64": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-2.0.6.tgz", diff --git a/package.json b/package.json index 329d7d0dc0..e3b9c28cc3 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,9 @@ "test": "FORCE_COLOR=1 turbo test --concurrency=${TURBO_CONCURRENCY:-80%}", "test:cache:clear": "FORCE_COLOR=1 turbo test:cache:clear --continue --concurrency=${TURBO_CONCURRENCY:-80%}", "test:integration:ap-flows": "npm run test:integration:base -- --grep @ap-flows", - "test:integration:base": "DEBUG=1 npx playwright test --config integration/playwright.config.ts", - "test:integration:cleanup": "DEBUG=1 npx playwright test --config integration/playwright.cleanup.config.ts", - "test:integration:deployment:nextjs": "DEBUG=1 npx playwright test --config integration/playwright.deployments.config.ts", + "test:integration:base": "npx playwright test --config integration/playwright.config.ts", + "test:integration:cleanup": "npx playwright test --config integration/playwright.cleanup.config.ts", + "test:integration:deployment:nextjs": "npx playwright test --config integration/playwright.deployments.config.ts", "test:integration:elements": "E2E_APP_ID=elements.* npm run test:integration:base -- --grep @elements", "test:integration:express": "E2E_APP_ID=express.* npm run test:integration:base -- --grep @express", "test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes npm run test:integration:base -- --grep @generic", @@ -100,6 +100,7 @@ "ts-jest": "^29.0.3", "tsup": "^8.0.1", "turbo": "^2.0.6", + "turbo-ignore": "^2.0.6", "typescript": "^5.4.5", "verdaccio": "^5.26.3", "zx": "^7.2.3" diff --git a/packages/elements/src/internals/machines/sign-in/verification.machine.ts b/packages/elements/src/internals/machines/sign-in/verification.machine.ts index b941385be5..259390fa14 100644 --- a/packages/elements/src/internals/machines/sign-in/verification.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/verification.machine.ts @@ -378,7 +378,6 @@ export const SignInFirstFactorMachine = SignInVerificationMachine.provide({ } assertIsDefined(params, 'First factor params'); - return await clerk.client.signIn.prepareFirstFactor(params); }), attempt: fromPromise(async ({ input }) => { diff --git a/turbo.json b/turbo.json index 6d7dc69dce..8b5e89940f 100644 --- a/turbo.json +++ b/turbo.json @@ -27,7 +27,7 @@ "EXPO_PUBLIC_CLERK_*", "REACT_APP_CLERK_*" ], - "globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"], + "globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN", "ACTIONS_RUNNER_DEBUG", "ACTIONS_STEP_DEBUG"], "tasks": { "build": { "dependsOn": ["^build"], From c82fe12f2bbf7e199a2d305b51b8cc5be24af794 Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Wed, 10 Jul 2024 16:40:04 +0300 Subject: [PATCH 16/29] fix(integration): Update test to cover the suffixed session cookie --- integration/tests/user-profile.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/user-profile.test.ts b/integration/tests/user-profile.test.ts index d79375879d..b97174a6f7 100644 --- a/integration/tests/user-profile.test.ts +++ b/integration/tests/user-profile.test.ts @@ -307,7 +307,7 @@ export default function Page() { await u.page.waitForAppUrl('/'); // Make sure that the session cookie is deleted - const sessionCookieList = (await u.page.context().cookies()).filter(cookie => cookie.name === '__session'); + const sessionCookieList = (await u.page.context().cookies()).filter(cookie => cookie.name.startsWith('__session')); expect(sessionCookieList.length).toBe(0); }); From a53db0cff6b8efc1f9a87eb22fe5654c66f00883 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 10 Jul 2024 20:40:57 +0300 Subject: [PATCH 17/29] Optimize cicd cache and run integration tests --- .github/actions/init/action.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml index c4590f88ed..3489cdc46b 100644 --- a/.github/actions/init/action.yml +++ b/.github/actions/init/action.yml @@ -117,8 +117,8 @@ runs: uses: actions/cache/restore@v4 id: cache-npm with: - path: ./node_modules - key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v3 + path: /home/runner/.npm + key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v4 restore-keys: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules- - name: Install NPM Dependencies @@ -131,7 +131,7 @@ runs: if: steps.cache-npm.outputs.cache-hit != 'true' with: path: ./node_modules - key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v3 + key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v4 - name: Get Playwright Version if: inputs.playwright-enabled == 'true' From 0eba496531e1026b46f7fb905416532b4068b97a Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 10 Jul 2024 20:40:57 +0300 Subject: [PATCH 18/29] Optimize cicd cache and run integration tests --- .github/actions/init/action.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml index c4590f88ed..9c64da58e4 100644 --- a/.github/actions/init/action.yml +++ b/.github/actions/init/action.yml @@ -117,8 +117,8 @@ runs: uses: actions/cache/restore@v4 id: cache-npm with: - path: ./node_modules - key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v3 + path: /home/runner/.npm + key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v5 restore-keys: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules- - name: Install NPM Dependencies @@ -130,8 +130,8 @@ runs: uses: actions/cache/save@v4 if: steps.cache-npm.outputs.cache-hit != 'true' with: - path: ./node_modules - key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v3 + path: /home/runner/.npm + key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v5 - name: Get Playwright Version if: inputs.playwright-enabled == 'true' From e891a2a5f506fc4a8a85d52dfb3a048a2a1e4708 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 10 Jul 2024 20:56:30 +0300 Subject: [PATCH 19/29] Optimize cicd cache and run integration tests --- .github/actions/init/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml index 9c64da58e4..b7f21a694c 100644 --- a/.github/actions/init/action.yml +++ b/.github/actions/init/action.yml @@ -111,7 +111,7 @@ runs: - name: NPM debug shell: bash - run: npm config ls + run: npm config ls -l - name: Restore node_modules uses: actions/cache/restore@v4 From 9ab44d3e73e190a14421a94677b44e491e6586c9 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 10 Jul 2024 21:10:55 +0300 Subject: [PATCH 20/29] Optimize cicd cache and run integration tests --- .github/actions/init/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml index b7f21a694c..c8169ac8ce 100644 --- a/.github/actions/init/action.yml +++ b/.github/actions/init/action.yml @@ -122,7 +122,7 @@ runs: restore-keys: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules- - name: Install NPM Dependencies - if: steps.cache-npm.outputs.cache-hit != 'true' + # if: steps.cache-npm.outputs.cache-hit != 'true' run: npm ci --prefer-offline --audit=false --fund=false --verbose shell: bash From 2865b880c199976000785a81487f951d7dc56d44 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 10 Jul 2024 21:17:40 +0300 Subject: [PATCH 21/29] Optimize cicd cache and run integration tests --- .github/actions/init/action.yml | 8 ++++---- integration/presets/envs.ts | 26 -------------------------- integration/presets/longRunningApps.ts | 10 ---------- integration/tests/astro.test.ts | 2 +- integration/tests/dynamic-keys.test.ts | 2 +- integration/tests/handshake.test.ts | 4 ++-- 6 files changed, 8 insertions(+), 44 deletions(-) diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml index c8169ac8ce..15022693d2 100644 --- a/.github/actions/init/action.yml +++ b/.github/actions/init/action.yml @@ -117,8 +117,8 @@ runs: uses: actions/cache/restore@v4 id: cache-npm with: - path: /home/runner/.npm - key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v5 + path: ./node_modules + key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v6 restore-keys: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules- - name: Install NPM Dependencies @@ -130,8 +130,8 @@ runs: uses: actions/cache/save@v4 if: steps.cache-npm.outputs.cache-hit != 'true' with: - path: /home/runner/.npm - key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v5 + path: ./node_modules + key: ${{ runner.os }}-node-${{ inputs.node-version }}-node-modules-${{ hashFiles('**/package-lock.json') }}-v6 - name: Get Playwright Version if: inputs.playwright-enabled == 'true' diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index fada0105a8..d8c23c5fed 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -80,30 +80,6 @@ const withAPCore2ClerkV4 = environmentConfig() .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('core-2-all-enabled').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('core-2-all-enabled').pk); -// TODO: Delete -const multipleAppsSameDomainProd1 = environmentConfig() - .setId('multipleAppsSameDomainProd1') - .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') - .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up') - .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js') - // TODO: Remove this once the apps are deployed - .setEnvVariable('public', 'CLERK_API_URL', 'https://api.lclclerk.com') - .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('sessions-prod-1').sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('sessions-prod-1').pk); - -// TODO: Delete -const multipleAppsSameDomainProd2 = environmentConfig() - .setId('multipleAppsSameDomainProd2') - .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') - .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up') - .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js') - // TODO: Remove this once the apps are deployed - .setEnvVariable('public', 'CLERK_API_URL', 'https://api.lclclerk.com') - .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('sessions-prod-2').sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('sessions-prod-2').pk); - const withDynamicKeys = withEmailCodes .clone() .setId('withDynamicKeys') @@ -120,7 +96,5 @@ export const envs = { withAPCore1ClerkV4, withAPCore2ClerkLatest, withAPCore2ClerkV4, - multipleAppsSameDomainProd1, - multipleAppsSameDomainProd2, withDynamicKeys, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 036251f87a..b13eafeefa 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -24,16 +24,6 @@ export const createLongRunningApps = () => { { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withEmailCodes', config: astro.node, env: envs.withEmailCodes }, - { - id: 'next.appRouter.multipleApps.prod.1', - config: next.appRouter.clone().setServerUrl('https://multiple-apps.dev'), - env: envs.multipleAppsSameDomainProd1, - }, - { - id: 'next.appRouter.multipleApps.prod.2', - config: next.appRouter.clone().setServerUrl('https://stg.multiple-apps.dev'), - env: envs.multipleAppsSameDomainProd2, - }, ] as const; const apps = configs.map(longRunningApplication); diff --git a/integration/tests/astro.test.ts b/integration/tests/astro.test.ts index aca306ee72..409bf1c2c8 100644 --- a/integration/tests/astro.test.ts +++ b/integration/tests/astro.test.ts @@ -20,7 +20,7 @@ testAgainstRunningApps({ withPattern: ['astro.node.withEmailCodes'] })('test ast test('Clerk client loads on first visit and Sign In button renders', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await u.page.goToStart(); + await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); diff --git a/integration/tests/dynamic-keys.test.ts b/integration/tests/dynamic-keys.test.ts index 953219c437..e034146c64 100644 --- a/integration/tests/dynamic-keys.test.ts +++ b/integration/tests/dynamic-keys.test.ts @@ -65,7 +65,7 @@ test.describe('dynamic keys @nextjs', () => { test('redirects to `signInUrl` on `auth().protect()`', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await u.page.goToStart(); + await u.page.goToAppHome(); await u.po.expect.toBeSignedOut(); diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts index cbf2f960b5..de8a5f1232 100644 --- a/integration/tests/handshake.test.ts +++ b/integration/tests/handshake.test.ts @@ -72,7 +72,7 @@ test.describe('Client handshake @generic', () => { await new Promise(resolve => jwksServer.close(() => resolve())); }); - test('Test standard signed-in - dev', async () => { + test.skip('Test standard signed-in - dev', async () => { const config = generateConfig({ mode: 'test', }); @@ -90,7 +90,7 @@ test.describe('Client handshake @generic', () => { expect(res.status).toBe(200); }); - test('Test standard signed-in - authorization header - dev', async () => { + test.skip('Test standard signed-in - authorization header - dev', async () => { const config = generateConfig({ mode: 'test', }); From af14054eef89daeaf540227dce36630d209f7726 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 11 Jul 2024 20:18:20 +0300 Subject: [PATCH 22/29] Optimize cicd cache and run integration tests --- integration/presets/envs.ts | 2 +- integration/tests/handshake.test.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index d8c23c5fed..d9fe90b09e 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -33,7 +33,7 @@ const withEmailCodes = base .setId('withEmailCodes') .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk) - .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY); + .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'); const withEmailLinks = base .clone() diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts index de8a5f1232..ffc2b63819 100644 --- a/integration/tests/handshake.test.ts +++ b/integration/tests/handshake.test.ts @@ -72,10 +72,8 @@ test.describe('Client handshake @generic', () => { await new Promise(resolve => jwksServer.close(() => resolve())); }); - test.skip('Test standard signed-in - dev', async () => { - const config = generateConfig({ - mode: 'test', - }); + test('Test standard signed-in - dev', async () => { + const config = generateConfig({ mode: 'test' }); const { token, claims } = config.generateToken({ state: 'active' }); const clientUat = claims.iat; const res = await fetch(app.serverUrl + '/', { @@ -90,7 +88,7 @@ test.describe('Client handshake @generic', () => { expect(res.status).toBe(200); }); - test.skip('Test standard signed-in - authorization header - dev', async () => { + test('Test standard signed-in - authorization header - dev', async () => { const config = generateConfig({ mode: 'test', }); From 1192df83d90ee3eafc37d5375f32eb4c47f82b1a Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 11 Jul 2024 23:11:24 +0300 Subject: [PATCH 23/29] Fix waitForAppUrl race condition and use 50% workers --- .github/workflows/ci.yml | 6 +++--- integration/playwright.config.ts | 5 +---- integration/testUtils/appPageObject.ts | 2 +- integration/tests/next-account-portal/common.ts | 2 +- integration/tests/sign-in-flow.test.ts | 6 ------ 5 files changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41f031b006..0d030f95ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: integration-tests: name: Integration Tests - needs: formatting-linting +# needs: formatting-linting runs-on: ${{ vars.RUNNER_LARGE || 'ubuntu-latest-l' }} timeout-minutes: ${{ vars.TIMEOUT_MINUTES_LONG && fromJSON(vars.TIMEOUT_MINUTES_LONG) || 15 }} @@ -191,11 +191,11 @@ jobs: if: ${{ steps.task-status.outputs.affected == '1' }} working-directory: ${{runner.temp}} run: mkdir clerk-js && cd clerk-js && npm init -y && npm install @clerk/clerk-js - + - name: Copy components @clerk/astro if: ${{ matrix.test-name == 'astro' }} run: cd packages/astro && npm run copy:components - + - name: Write all ENV certificates to files in integration/certs if: ${{ steps.task-status.outputs.affected == '1' }} uses: actions/github-script@v7 diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index 4a29018488..583757c9d5 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -1,5 +1,4 @@ /* eslint-disable turbo/no-undeclared-env-vars */ -import os from 'node:os'; import type { PlaywrightTestConfig } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test'; @@ -8,8 +7,6 @@ import * as path from 'path'; config({ path: path.resolve(__dirname, '.env.local') }); -const numAvailableWorkers = os.cpus().length - 1; - export const common: PlaywrightTestConfig = { testDir: './tests', snapshotDir: './tests/snapshots', @@ -18,7 +15,7 @@ export const common: PlaywrightTestConfig = { retries: process.env.CI ? 2 : 0, timeout: 90000, maxFailures: process.env.CI ? 1 : undefined, - workers: process.env.CI ? numAvailableWorkers : '70%', + workers: process.env.CI ? '50%' : '70%', reporter: process.env.CI ? 'line' : 'list', use: { ignoreHTTPSErrors: true, diff --git a/integration/testUtils/appPageObject.ts b/integration/testUtils/appPageObject.ts index 233ddd301e..b20d117231 100644 --- a/integration/testUtils/appPageObject.ts +++ b/integration/testUtils/appPageObject.ts @@ -47,7 +47,7 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) return page.waitForSelector('.cl-rootBox', { state: 'attached' }); }, waitForAppUrl: async (relativePath: string) => { - return page.waitForURL(new URL(relativePath, page.url()).toString()); + return page.waitForURL(new URL(relativePath, app.serverUrl).toString()); }, /** * Get the cookies for the URL the page is currently at. diff --git a/integration/tests/next-account-portal/common.ts b/integration/tests/next-account-portal/common.ts index 4c8ee7dcdd..50ad8010b7 100644 --- a/integration/tests/next-account-portal/common.ts +++ b/integration/tests/next-account-portal/common.ts @@ -31,10 +31,10 @@ export const testSignIn = async ({ app, page, context, fakeUser }: TestParams) = await u.page.getByRole('button', { name: /Sign in/i }).click(); await u.po.signIn.waitForMounted(); - // Check that the DevBrowser JWT between localhost and AP is the same const accountPortalURL = page.url(); // Check that we are in Account Portal expect(accountPortalURL).toContain('.accounts.dev'); + // Check that the DevBrowser JWT between localhost and AP is the same const accountPortalDbJwt = await context .cookies(accountPortalURL) .then(cookies => cookies.find(c => c.name === CLERK_DB_JWT_COOKIE_NAME)?.value); diff --git a/integration/tests/sign-in-flow.test.ts b/integration/tests/sign-in-flow.test.ts index 0a251adbbc..e04b5e0012 100644 --- a/integration/tests/sign-in-flow.test.ts +++ b/integration/tests/sign-in-flow.test.ts @@ -23,12 +23,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f await app.teardown(); }); - test.afterEach(async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.signOut(); - await u.page.context().clearCookies(); - }); - test('sign in with email and password', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); From d2178b5fb2a266963c24c2055776bd0b45dff34b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 12 Jul 2024 13:22:27 +0300 Subject: [PATCH 24/29] Fix goToAppHome --- integration/tests/astro/components.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/astro/components.test.ts b/integration/tests/astro/components.test.ts index d9394cff5a..52afca22eb 100644 --- a/integration/tests/astro/components.test.ts +++ b/integration/tests/astro/components.test.ts @@ -87,7 +87,7 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f test('SignedIn, SignedOut SSR', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await u.page.goToStart(); + await u.page.goToAppHome(); await expect(u.page.getByText('Go to this page to log in')).toBeVisible(); await u.page.goToRelative('/sign-in'); await u.po.signIn.waitForMounted(); From 2a6f6455434f84b4ab9c7772c5f0b1445f889eb1 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 12 Jul 2024 14:34:04 +0300 Subject: [PATCH 25/29] Fix goToRelative --- integration/testUtils/appPageObject.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/integration/testUtils/appPageObject.ts b/integration/testUtils/appPageObject.ts index b20d117231..0d5f14b374 100644 --- a/integration/testUtils/appPageObject.ts +++ b/integration/testUtils/appPageObject.ts @@ -19,7 +19,11 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) try { // When testing applications using real domains we want to manually navigate to the domain first // and not follow serverUrl (localhost) by default, as this is usually proxied - url = new URL(path, page.url()); + if (page.url().includes('about:blank')) { + url = new URL(path, app.serverUrl); + } else { + url = new URL(path, page.url()); + } } catch (e) { // However, in most tests we don't need to manually navigate to the domain // as the test is using a localhost app directly From 201f62c4477f623bd9214e88575fac02114a29e0 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 16 Jul 2024 10:45:37 +0300 Subject: [PATCH 26/29] chore(repo): Revert --- .husky/pre-commit | 42 ------------------------------------------ 1 file changed, 42 deletions(-) delete mode 100755 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 5d2103ba28..0000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -. "$(dirname "$0")/_/husky.sh" - -############################### -## Determine Staged Packages -############################### -# -#staged_files=$(git diff --name-only --cached --diff-filter=ACM) -# -## Initialize an empty string to hold the unique folder paths -#unique_folders="" -# -## Loop through each staged file -#for file in $staged_files; do -# # Extract the first two folders from the file path -# folder=$(echo $file | awk -F'/' '{print $1 "/" $2}') -# -# # Filter files which end with .mjs, .js, .jsx, .ts, or .tsx [NOTE: Should match ./.lintstagedrc.json] -# if [[ $file =~ \.(mjs|js|jsx|ts|tsx)$ ]]; then -# # Check if this folder is already in the list of unique folders -# if [[ $folder == packages/* ]] && [[ ! " $unique_folders " =~ "$folder" ]]; then -# # Append the folder to the list of unique folders -# unique_folders="$unique_folders --filter={./$folder}^..." -# fi -# fi -#done -# -############################### -## Build Staged Packages -############################### -# -#if [ -n "$unique_folders" ]; then -# npx turbo run build --output-logs=errors-only $unique_folders -#else -# echo "SKIPPING: No packages to build" -#fi - -############################## -# Run Lint Staged -############################## - -npx lint-staged From 32b83ca50c26343d1487d003111c02cba37fd575 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 16 Jul 2024 10:46:33 +0300 Subject: [PATCH 27/29] fix(integration): Fix client_uat tests --- .husky/pre-commit | 42 +++++++++++++++++++ .../root-subdomain-prod-instances.test.ts | 17 +++----- 2 files changed, 48 insertions(+), 11 deletions(-) create mode 100755 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..5d2103ba28 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,42 @@ +#!/bin/bash +. "$(dirname "$0")/_/husky.sh" + +############################### +## Determine Staged Packages +############################### +# +#staged_files=$(git diff --name-only --cached --diff-filter=ACM) +# +## Initialize an empty string to hold the unique folder paths +#unique_folders="" +# +## Loop through each staged file +#for file in $staged_files; do +# # Extract the first two folders from the file path +# folder=$(echo $file | awk -F'/' '{print $1 "/" $2}') +# +# # Filter files which end with .mjs, .js, .jsx, .ts, or .tsx [NOTE: Should match ./.lintstagedrc.json] +# if [[ $file =~ \.(mjs|js|jsx|ts|tsx)$ ]]; then +# # Check if this folder is already in the list of unique folders +# if [[ $folder == packages/* ]] && [[ ! " $unique_folders " =~ "$folder" ]]; then +# # Append the folder to the list of unique folders +# unique_folders="$unique_folders --filter={./$folder}^..." +# fi +# fi +#done +# +############################### +## Build Staged Packages +############################### +# +#if [ -n "$unique_folders" ]; then +# npx turbo run build --output-logs=errors-only $unique_folders +#else +# echo "SKIPPING: No packages to build" +#fi + +############################## +# Run Lint Staged +############################## + +npx lint-staged diff --git a/integration/tests/sessions/root-subdomain-prod-instances.test.ts b/integration/tests/sessions/root-subdomain-prod-instances.test.ts index 8f54516a33..9374261984 100644 --- a/integration/tests/sessions/root-subdomain-prod-instances.test.ts +++ b/integration/tests/sessions/root-subdomain-prod-instances.test.ts @@ -20,7 +20,7 @@ const ssl: Pick = { * that listens to port 443. We can't run them in parallel because they would conflict with each other, unless * we use more custom domains to avoid collision. */ -test.describe('multiple apps same domain for production instances @sessions', () => { +test.describe('root and subdomain production apps @sessions', () => { test.describe.configure({ mode: 'serial' }); /** @@ -230,7 +230,7 @@ test.describe('multiple apps same domain for production instances @sessions', () expect(tab0Cookies.get('__session')).toBeDefined(); expect(tab0Cookies.get('__session').domain).toEqual(hosts[0]); - // ensure that only 2 client_uat cookies (base and suffixed variant) is visible in this root domain + // ensure that only 2 client_uat cookies (base and suffixed variant) are visible here expect([...tab0Cookies.values()].filter(c => c.name.startsWith('__client_uat')).length).toEqual(2); expect(tab0Cookies.get('__client_uat_*').domain).toEqual('.' + hosts[0]); @@ -255,15 +255,10 @@ test.describe('multiple apps same domain for production instances @sessions', () expect(tab1Cookies.get('__session')).toBeDefined(); expect(tab1Cookies.get('__session').domain).toEqual(hosts[1]); - // ensure that all client_uat cookies (base and suffixed variant set on all subdomains) are visible in this root domain - expect(tab1Cookies.raw().filter(c => c.name.startsWith('__client_uat')).length).toEqual(4); - // a __client_uat and a __client_uat_* cookie should be set on the sub domain - expect( - tab1Cookies - .raw() - .filter(c => c.name.startsWith('__client_uat')) - .filter(c => c.domain === `.${hosts[1]}`).length, - ).toEqual(2); + // ensure that all client_uat cookies are still set on the root domain + expect(tab1Cookies.get('__client_uat_*').domain).toEqual('.' + hosts[0]); + // we have 3 client_uat cookies here: 1 base and 2 suffixed variants + expect(tab1Cookies.raw().filter(c => c.name.startsWith('__client_uat')).length).toEqual(3); }); test('signing out from the root domains does not affect the sub domain', async ({ context }) => { From 6e91c3bc9eaa3a13dfddb98f9f6cea0ceb45513f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 16 Jul 2024 18:51:26 +0300 Subject: [PATCH 28/29] Add e2e test covering the old -> new sdk migration --- .../tests/sessions/prod-app-migration.test.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 integration/tests/sessions/prod-app-migration.test.ts diff --git a/integration/tests/sessions/prod-app-migration.test.ts b/integration/tests/sessions/prod-app-migration.test.ts new file mode 100644 index 0000000000..581e344a53 --- /dev/null +++ b/integration/tests/sessions/prod-app-migration.test.ts @@ -0,0 +1,102 @@ +import type { Server, ServerOptions } from 'node:https'; + +import { expect, test } from '@playwright/test'; + +import { constants } from '../../constants'; +import { appConfigs } from '../../presets'; +import { fs, getPort } from '../../scripts'; +import { createProxyServer } from '../../scripts/proxyServer'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { getEnvForMultiAppInstance } from './utils'; + +const ssl: Pick = { + cert: fs.readFileSync(constants.CERTS_DIR + '/sessions.pem'), + key: fs.readFileSync(constants.CERTS_DIR + '/sessions-key.pem'), +}; + +test.describe('root and subdomain production apps @manual-run', () => { + test.describe.configure({ mode: 'serial' }); + + test.describe('multiple apps same domain for production instances', () => { + const host = 'multiple-apps-e2e.clerk.app'; + const fakeUsers: FakeUser[] = []; + + let server: Server; + + test.afterAll(async () => { + await Promise.all(fakeUsers.map(u => u.deleteIfExists())); + server.close(); + }); + + test('apps can be used without clearing the cookies after instance switch', async ({ context }) => { + // We need both apps to run on the same port + const port = await getPort(); + + const apps = await Promise.all([ + // Last version before multi-app-same-domain support + await appConfigs.next.appRouter.clone().addDependency('@clerk/nextjs', '5.2.4').commit(), + // Locally-built SDKs + await appConfigs.next.appRouter.clone().commit(), + ]); + + // Write both apps to the disk and install dependencies + await Promise.all(apps.map(a => a.setup())); + + // Start the app with the older SDK version and let it hotload clerkjs from the CF worker + let app = apps[0]; + await app.withEnv(getEnvForMultiAppInstance('sessions-prod-1').setEnvVariable('public', 'CLERK_JS_URL', '')); + await app.dev({ port }); + + // Prepare the proxy server tha maps from the prod domain to the local apps + // We don't need to restart this one as the serverUrl will be the same for both apps + server = createProxyServer({ ssl, targets: { [host]: apps[0].serverUrl } }); + + const page = await context.newPage(); + let u = createTestUtils({ app, page, context }); + + const fakeUser = u.services.users.createFakeUser(); + fakeUsers.push(fakeUser); + await u.services.users.createBapiUser(fakeUser); + + await u.page.goto(`https://${host}`); + await u.po.signIn.goTo({ timeout: 30000 }); + await u.po.signIn.signInWithEmailAndInstantPassword(fakeUser); + await u.po.expect.toBeSignedIn(); + + expect((await u.po.clerk.getClientSideUser()).primaryEmailAddress.emailAddress).toBe(fakeUser.email); + expect((await u.page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe( + (await u.po.clerk.getClientSideUser()).id, + ); + + await u.page.pause(); + // TODO + // Add cookie checks + // ... + + await app.stop(); + + // Switch to and start the app with the latest SDK version + app = apps[1]; + await app.withEnv(getEnvForMultiAppInstance('sessions-prod-1')); + await app.dev({ port }); + + await page.reload(); + u = createTestUtils({ app, page, context }); + + await u.po.expect.toBeSignedIn(); + + expect((await u.po.clerk.getClientSideUser()).primaryEmailAddress.emailAddress).toBe(fakeUser.email); + expect((await u.page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe( + (await u.po.clerk.getClientSideUser()).id, + ); + + await u.page.pause(); + // TODO + // Add cookie checks + // ... + + await Promise.all(apps.map(a => a.teardown())); + }); + }); +}); From 4b3e2e1a5ce40c16db248e77bc55bcbb67e29be9 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 17 Jul 2024 18:11:57 +0300 Subject: [PATCH 29/29] Minor e2e test fixes --- .github/workflows/ci.yml | 2 +- .../tests/sessions/root-subdomain-prod-instances.test.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d030f95ca..253d0880e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: integration-tests: name: Integration Tests -# needs: formatting-linting + needs: formatting-linting runs-on: ${{ vars.RUNNER_LARGE || 'ubuntu-latest-l' }} timeout-minutes: ${{ vars.TIMEOUT_MINUTES_LONG && fromJSON(vars.TIMEOUT_MINUTES_LONG) || 15 }} diff --git a/integration/tests/sessions/root-subdomain-prod-instances.test.ts b/integration/tests/sessions/root-subdomain-prod-instances.test.ts index 9374261984..a185d246e5 100644 --- a/integration/tests/sessions/root-subdomain-prod-instances.test.ts +++ b/integration/tests/sessions/root-subdomain-prod-instances.test.ts @@ -29,7 +29,7 @@ test.describe('root and subdomain production apps @sessions', () => { * Our own setup with clerk.com and dashboard.clerk.com is the perfect example for such a use case. * * test.com <> clerk-instance-1 - * stg.test.com <> clerk-instance-1 + * dashboard.test.com <> clerk-instance-1 * * Requirements: * 1. This test assumes that the apps are deployed as production apps and expects that both @@ -44,7 +44,7 @@ test.describe('root and subdomain production apps @sessions', () => { * 4. The first app is going to be served on multiple-apps-e2e.clerk.app * 5. The second app is going to be served on sub-1.multiple-apps-e2e.clerk.app */ - test.describe('multiple apps same domain for production instances', () => { + test.describe('multiple apps same domain for the same production instances', () => { const hosts = ['multiple-apps-e2e.clerk.app', 'sub-1.multiple-apps-e2e.clerk.app']; let fakeUser: FakeUser; @@ -101,6 +101,7 @@ test.describe('root and subdomain production apps @sessions', () => { expect(tab0Cookies.get('__session_*').name.split('__session_')[1].length).toEqual(8); expect(tab0Cookies.get('__client_uat')).toBeDefined(); + // The client_uat cookie should always be set on etld+1 expect(tab0Cookies.get('__client_uat').domain).toEqual('.' + hosts[0]); expect(tab0Cookies.get('__client_uat').value).toEqual(tab0Cookies.get('__client_uat_*').value); expect(tab0Cookies.get('__client_uat').domain).toEqual(tab0Cookies.get('__client_uat_*').domain); @@ -123,7 +124,10 @@ test.describe('root and subdomain production apps @sessions', () => { expect(tab0Cookies.get('__client').domain).toEqual(tab1Cookies.get('__client').domain); // the client_uat cookie should be set on the root domain for both // so, it can be shared between all subdomains + // The client_uat cookie should always be set on etld+1 expect(tab0Cookies.get('__client_uat_*').domain).toEqual(tab1Cookies.get('__client_uat_*').domain); + // There should be 1 base client_uat cookie and 1 suffixed variants + expect(tab0Cookies.raw().filter(c => c.name.startsWith('__client_uat')).length).toEqual(2); // the session cookie should be set on the domain of the app // so, it can be accessed by the host server expect(tab1Cookies.get('__session').domain).toEqual(hosts[1]); @@ -232,6 +236,7 @@ test.describe('root and subdomain production apps @sessions', () => { // ensure that only 2 client_uat cookies (base and suffixed variant) are visible here expect([...tab0Cookies.values()].filter(c => c.name.startsWith('__client_uat')).length).toEqual(2); + // The client_uat cookie should always be set on etld+1 expect(tab0Cookies.get('__client_uat_*').domain).toEqual('.' + hosts[0]); await u[1].page.goto(`https://${hosts[1]}`);