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/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/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/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..9ea9602bb3 --- /dev/null +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -0,0 +1,241 @@ +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'; + +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', async assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __session: session, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await 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', async assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_MqCvchyS: suffixedClientUat, + __session: newSession, + __session_MqCvchyS: suffixedSession, + __clerk_db_jwt: '__clerk_db_jwt', + __clerk_db_jwt_MqCvchyS: '__clerk_db_jwt-suffixed', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await 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', async assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_vWCgMp3A: '0', + __session: session, + __clerk_db_jwt: '__clerk_db_jwt', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await 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', async assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: '0', + __client_uat_MqCvchyS: '0', + __session: session, + __session_MqCvchyS: suffixedSession, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await 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', async assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_MqCvchyS: suffixedClientUat, + __session: session, + __session_MqCvchyS: suffixedSession, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await 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', async assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_MqCvchyS: suffixedClientUat, + __session: sessionWithInvalidIssuer, + __session_MqCvchyS: suffixedSession, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await 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', 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_vWCgMp3A: '0', + __client_uat_8HKF1r6W: '1717490193', + __client_uat_Rmi8c5i8: '1717490194', + __session: session, + __session_vWCgMp3A: suffixedSession, + __session_8HKF1r6W: blahSession, + __session_Rmi8c5i8: fooSession, + __clerk_db_jwt: '__clerk_db_jwt', + __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 = await 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', async assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: '0', + __client_uat_vWCgMp3A: '0', + __session: session, + __session_vWCgMp3A: suffixedSession, + __clerk_db_jwt: '__clerk_db_jwt', + __clerk_db_jwt_vWCgMp3A: '__clerk_db_jwt-suffixed', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await 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', async assert => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: '0', + __client_uat_MqCvchyS: '0', + __session: session, + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + + assert.true(context.suffixedCookies); + assert.strictEqual(context.sessionTokenInCookie, undefined); + assert.equal(context.clientUat, '0'); + }); + }); + }); + }); + + // 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, subtle)', () => { + test('given `pk_live_Y2xlcmsuY2xlcmsuZGV2JA` pk, returns `1Z8AzTQD` cookie suffix', async assert => { + 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', runtime.crypto.subtle), 'QvfNY2dr'); + }); + + test('omits special characters from the cookie suffix', async assert => { + const pk = 'pk_test_ZW5vdWdoLWFscGFjYS04Mi5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; + assert.equal(await getCookieSuffix(pk, runtime.crypto.subtle), 'jtYvyt_H'); + const pk2 = 'pk_test_eHh4eHh4LXhhYWFhYS1hYS5jbGVyay5hY2NvdW50cy5sY2xjbGVyay5jb20k'; + assert.equal(await getCookieSuffix(pk2, runtime.crypto.subtle), 'tZJdb-5s'); + }); + }); +}; 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 f1d3c6e43c..010c83508d 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,4 +1,10 @@ +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'; import type { AuthenticateRequestOptions } from './types'; @@ -16,6 +22,7 @@ interface AuthenticateContextInterface extends AuthenticateRequestOptions { // cookie-based values sessionTokenInCookie: string | undefined; clientUat: number; + suffixedCookies: boolean; // handshake-related values devBrowserToken: string | undefined; handshakeToken: string | undefined; @@ -23,6 +30,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 {} @@ -39,53 +50,206 @@ class AuthenticateContext { } 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 + this.initPublishableKeyValues(options); this.initHeaderValues(); + // initCookieValues should be used before initHandshakeValues because the it depends on suffixedCookies this.initCookieValues(); this.initHandshakeValues(); Object.assign(this, options); this.clerkUrl = this.clerkRequest.clerkUrl; } - private initHandshakeValues() { - this.devBrowserToken = - this.clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) || - this.clerkRequest.cookies.get(constants.Cookies.DevBrowser); - this.handshakeToken = - this.clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.Handshake) || - this.clerkRequest.cookies.get(constants.Cookies.Handshake); + private initPublishableKeyValues(options: AuthenticateRequestOptions) { + assertValidPublishableKey(options.publishableKey); + this.publishableKey = options.publishableKey; + + const pk = parsePublishableKey(this.publishableKey, { + fatal: true, + proxyUrl: options.proxyUrl, + domain: options.domain, + }); + this.instanceType = pk.instanceType; + this.frontendApi = pk.frontendApi; } private initHeaderValues() { - const get = (name: string) => this.clerkRequest.headers.get(name) || undefined; - this.sessionTokenInHeader = this.stripAuthorizationHeader(get(constants.Headers.Authorization)); - this.origin = get(constants.Headers.Origin); - this.host = get(constants.Headers.Host); - this.forwardedHost = get(constants.Headers.ForwardedHost); - this.forwardedProto = get(constants.Headers.CloudFrontForwardedProto) || get(constants.Headers.ForwardedProto); - this.referrer = get(constants.Headers.Referrer); - this.userAgent = get(constants.Headers.UserAgent); - this.secFetchDest = get(constants.Headers.SecFetchDest); - this.accept = get(constants.Headers.Accept); + this.sessionTokenInHeader = this.stripAuthorizationHeader(this.getHeader(constants.Headers.Authorization)); + this.origin = this.getHeader(constants.Headers.Origin); + this.host = this.getHeader(constants.Headers.Host); + this.forwardedHost = this.getHeader(constants.Headers.ForwardedHost); + this.forwardedProto = + this.getHeader(constants.Headers.CloudFrontForwardedProto) || this.getHeader(constants.Headers.ForwardedProto); + this.referrer = this.getHeader(constants.Headers.Referrer); + this.userAgent = this.getHeader(constants.Headers.UserAgent); + this.secFetchDest = this.getHeader(constants.Headers.SecFetchDest); + this.accept = this.getHeader(constants.Headers.Accept); } private initCookieValues() { - const get = (name: string) => this.clerkRequest.cookies.get(name) || undefined; - this.sessionTokenInCookie = get(constants.Cookies.Session); - this.clientUat = Number.parseInt(get(constants.Cookies.ClientUat) || '') || 0; + // suffixedCookies needs to be set first because it's used in getMultipleAppsCookie + this.suffixedCookies = this.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 { return authValue?.replace('Bearer ', ''); } + + private getQueryParam(name: string) { + return this.clerkRequest.clerkUrl.searchParams.get(name); + } + + private getHeader(name: string) { + return this.clerkRequest.headers.get(name) || undefined; + } + + private getCookie(name: string) { + return this.clerkRequest.cookies.get(name) || undefined; + } + + private getSuffixedCookie(name: string) { + return this.getCookie(getSuffixedCookieName(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) || ''; + + // 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; + } + + // 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; + } + + // 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); + 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 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; + } + + 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; + } } 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, runtime.crypto.subtle) + : ''; + return new AuthenticateContext(cookieSuffix, clerkRequest, options); }; 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 9248e2c887..aa017b2012 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,16 +1,15 @@ -import { parsePublishableKey } from '@clerk/shared/keys'; - import { constants } from '../constants'; import type { TokenCarrier } from '../errors'; import { TokenVerificationError, TokenVerificationErrorReason } from '../errors'; import { decodeJwt } from '../jwt/verifyJwt'; -import { assertValidSecretKey } from '../util/assertValidSecretKey'; +import { assertValidSecretKey } from '../util/optionsAssertions'; import { isDevelopmentFromSecretKey } from '../util/shared'; import type { AuthenticateContext } from './authenticateContext'; import { createAuthenticateContext } from './authenticateContext'; import type { RequestState } from './authStatus'; import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; import { createClerkRequest } from './clerkRequest'; +import { getCookieName, getCookieValue } from './cookie'; import { verifyHandshakeToken } from './handshake'; import type { AuthenticateRequestOptions } from './types'; import { verifyToken } from './verify'; @@ -63,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) { @@ -86,12 +85,13 @@ export async function authenticateRequest( function buildRedirectToHandshake() { const redirectUrl = removeDevBrowserFromURL(authenticateContext.clerkUrl); - const frontendApiNoProtocol = pk.frontendApi.replace(/http(s)?:\/\//, ''); + const frontendApiNoProtocol = authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); url.searchParams.append('redirect_url', redirectUrl?.href || ''); + url.searchParams.append('suffixed_cookies', authenticateContext.suffixedCookies.toString()); - if (pk?.instanceType === 'development' && authenticateContext.devBrowserToken) { + if (authenticateContext.instanceType === 'development' && authenticateContext.devBrowserToken) { url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken); } @@ -110,12 +110,12 @@ export async function authenticateRequest( let sessionToken = ''; cookiesToSet.forEach((x: string) => { headers.append('Set-Cookie', x); - if (x.startsWith(`${constants.Cookies.Session}=`)) { - sessionToken = x.split(';')[0].substring(10); + if (getCookieName(x).startsWith(constants.Cookies.Session)) { + sessionToken = getCookieValue(x); } }); - if (instanceType === 'development') { + if (authenticateContext.instanceType === 'development') { const newUrl = new URL(authenticateContext.clerkUrl); newUrl.searchParams.delete(constants.QueryParameters.Handshake); newUrl.searchParams.delete(constants.QueryParameters.HandshakeHelp); @@ -132,7 +132,7 @@ export async function authenticateRequest( } if ( - instanceType === 'development' && + authenticateContext.instanceType === 'development' && (error?.reason === TokenVerificationErrorReason.TokenExpired || error?.reason === TokenVerificationErrorReason.TokenNotActiveYet) ) { @@ -177,14 +177,6 @@ ${error.getFullMessage()}`, return signedOut(authenticateContext, reason, message, new Headers()); } - const pk = parsePublishableKey(options.publishableKey, { - fatal: true, - proxyUrl: options.proxyUrl, - domain: options.domain, - }); - - const instanceType = pk.instanceType; - async function authenticateRequestWithTokenInHeader() { const { sessionTokenInHeader } = authenticateContext; @@ -220,7 +212,7 @@ ${error.getFullMessage()}`, // If for some reason the handshake token is invalid or stale, we ignore it and continue trying to authenticate the request. // Worst case, the handshake will trigger again and return a refreshed token. if (error instanceof TokenVerificationError) { - if (instanceType === 'development') { + if (authenticateContext.instanceType === 'development') { if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) { throw new Error( `Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`, @@ -249,7 +241,7 @@ ${error.getFullMessage()}`, * Otherwise, check for "known unknown" auth states that we can resolve with a handshake. */ if ( - instanceType === 'development' && + authenticateContext.instanceType === 'development' && authenticateContext.clerkUrl.searchParams.has(constants.QueryParameters.DevBrowser) ) { return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.DevBrowserSync, ''); @@ -258,12 +250,12 @@ ${error.getFullMessage()}`, /** * Begin multi-domain sync flows */ - if (instanceType === 'production' && isRequestEligibleForMultiDomainSync) { + if (authenticateContext.instanceType === 'production' && isRequestEligibleForMultiDomainSync) { return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, ''); } // Multi-domain development sync flow - if (instanceType === 'development' && isRequestEligibleForMultiDomainSync) { + if (authenticateContext.instanceType === 'development' && isRequestEligibleForMultiDomainSync) { // initiate MD sync // signInUrl exists, checked at the top of `authenticateRequest` @@ -281,7 +273,7 @@ ${error.getFullMessage()}`, const redirectUrl = new URL(authenticateContext.clerkUrl).searchParams.get( constants.QueryParameters.ClerkRedirectUrl, ); - if (instanceType === 'development' && !authenticateContext.isSatellite && redirectUrl) { + if (authenticateContext.instanceType === 'development' && !authenticateContext.isSatellite && redirectUrl) { // Dev MD sync from primary, redirect back to satellite w/ dev browser query param const redirectBackToSatelliteUrl = new URL(redirectUrl); @@ -300,7 +292,7 @@ ${error.getFullMessage()}`, * End multi-domain sync flows */ - if (instanceType === 'development' && !hasDevBrowserToken) { + if (authenticateContext.instanceType === 'development' && !hasDevBrowserToken) { return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.DevBrowserMissing, ''); } diff --git a/packages/backend/src/util/assertValidSecretKey.ts b/packages/backend/src/util/optionsAssertions.ts similarity index 59% rename from packages/backend/src/util/assertValidSecretKey.ts rename to packages/backend/src/util/optionsAssertions.ts index eecd6db204..25be5f1d95 100644 --- a/packages/backend/src/util/assertValidSecretKey.ts +++ b/packages/backend/src/util/optionsAssertions.ts @@ -1,3 +1,5 @@ +import { parsePublishableKey } from './shared'; + export function assertValidSecretKey(val: unknown): asserts val is string { if (!val || typeof val !== 'string') { throw Error('Missing Clerk Secret Key. Go to https://dashboard.clerk.com and get your key for your instance.'); @@ -5,3 +7,7 @@ export function assertValidSecretKey(val: unknown): asserts val is string { //TODO: Check if the key is invalid and throw error } + +export function assertValidPublishableKey(val: unknown): asserts val is string { + parsePublishableKey(val as string | undefined, { fatal: true }); +} diff --git a/packages/backend/src/util/shared.ts b/packages/backend/src/util/shared.ts index c873c2822d..c5c941594e 100644 --- a/packages/backend/src/util/shared.ts +++ b/packages/backend/src/util/shared.ts @@ -1,6 +1,12 @@ 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, + getSuffixedCookieName, +} 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, 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 a82da9fe3c..60af8d5100 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -9,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'; @@ -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,11 +59,12 @@ export class AuthCookieService { this.refreshTokenOnVisibilityChange(); this.startPollingForToken(); - this.clientUat = createClientUatCookie(); - this.sessionCookie = createSessionCookie(); + this.clientUat = createClientUatCookie(cookieSuffix); + this.sessionCookie = createSessionCookie(cookieSuffix); this.devBrowser = createDevBrowser({ frontendApi: clerk.frontendApi, fapiClient, + cookieSuffix, }); } 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/__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/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; +} diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index f731ce9dab..0ba16d1ac1 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -1,5 +1,6 @@ import { createCookieHandler } from '@clerk/shared/cookie'; import { addYears } from '@clerk/shared/date'; +import { getSuffixedCookieName } from '@clerk/shared/keys'; import type { ClientResource } from '@clerk/types'; import { inCrossOriginIframe } from '../../../utils'; @@ -18,11 +19,13 @@ export type ClientUatCookieHandler = { * The cookie is used as hint from the Clerk Backend packages to identify * if the user is authenticated or not. */ -export const createClientUatCookie = (): ClientUatCookieHandler => { +export const createClientUatCookie = (cookieSuffix: string): ClientUatCookieHandler => { const clientUatCookie = createCookieHandler(CLIENT_UAT_COOKIE_NAME); + const suffixedClientUatCookie = createCookieHandler(getSuffixedCookieName(CLIENT_UAT_COOKIE_NAME, cookieSuffix)); 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..70e007f2c1 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -1,6 +1,7 @@ import { createCookieHandler } from '@clerk/shared/cookie'; import { addYears } from '@clerk/shared/date'; import { DEV_BROWSER_JWT_KEY } from '@clerk/shared/devBrowser'; +import { getSuffixedCookieName } from '@clerk/shared/keys'; import { inCrossOriginIframe } from '../../../utils'; @@ -16,24 +17,25 @@ export type DevBrowserCookieHandler = { * The cookie is used to authenticate FAPI requests and pass * authentication from AP to the app. */ -export const createDevBrowserCookie = (): DevBrowserCookieHandler => { +export const createDevBrowserCookie = (cookieSuffix: string): DevBrowserCookieHandler => { const devBrowserCookie = createCookieHandler(DEV_BROWSER_JWT_KEY); + const suffixedDevBrowserCookie = createCookieHandler(getSuffixedCookieName(DEV_BROWSER_JWT_KEY, cookieSuffix)); - 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..5a36dc87e3 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -1,5 +1,6 @@ import { createCookieHandler } from '@clerk/shared/cookie'; import { addYears } from '@clerk/shared/date'; +import { getSuffixedCookieName } from '@clerk/shared/keys'; import { inCrossOriginIframe } from '../../../utils'; @@ -15,21 +16,22 @@ export type SessionCookieHandler = { * The cookie is used by the Clerk backend SDKs to identify * the authenticated user. */ -export const createSessionCookie = (): SessionCookieHandler => { +export const createSessionCookie = (cookieSuffix: string): SessionCookieHandler => { const sessionCookie = createCookieHandler(SESSION_COOKIE_NAME); + const suffixedSessionCookie = createCookieHandler(getSuffixedCookieName(SESSION_COOKIE_NAME, cookieSuffix)); - 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..2d5e3cb644 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; + cookieSuffix: string; fapiClient: FapiClient; }; -export function createDevBrowser({ frontendApi, fapiClient }: CreateDevBrowserOptions): DevBrowser { - const devBrowserCookie = createDevBrowserCookie(); +export function createDevBrowser({ cookieSuffix, frontendApi, fapiClient }: CreateDevBrowserOptions): DevBrowser { + const devBrowserCookie = createDevBrowserCookie(cookieSuffix); function getDevBrowserJWT() { return devBrowserCookie.get(); @@ -95,7 +96,7 @@ export function createDevBrowser({ frontendApi, fapiClient }: CreateDevBrowserOp } const data = await response.json(); - setDevBrowserJWT(data?.token); + setDevBrowserJWT(data?.id); } return { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index cf047327ca..c205188d0f 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 @@ -1515,7 +1515,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..25a588a7cf 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, 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); + }); + + 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 1919299b49..efeb0b4e11 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -109,3 +109,18 @@ export function isDevelopmentFromSecretKey(apiKey: string): boolean { export function isProductionFromSecretKey(apiKey: string): boolean { return apiKey.startsWith('live_') || apiKey.startsWith('sk_live_'); } + +export async function getCookieSuffix( + publishableKey: string, + subtle: SubtleCrypto = globalThis.crypto.subtle, +): Promise { + const data = new TextEncoder().encode(publishableKey); + 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); +} + +export const getSuffixedCookieName = (cookieName: string, cookieSuffix: string): string => { + return `${cookieName}_${cookieSuffix}`; +};