From 6a7c0659441d332228de98cebdd1fabdf261d1f0 Mon Sep 17 00:00:00 2001 From: Dimitris Klouvas Date: Wed, 5 Jun 2024 16:47:34 +0300 Subject: [PATCH] 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 | 22 ++ .../__tests__/authenticateContext.test.ts | 220 ++++++++++++++++++ .../backend/src/tokens/authenticateContext.ts | 110 +++++++-- 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, 399 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 23b1b3e48a..dde8a9f488 100644 --- a/integration/tests/handshake.test.ts +++ b/integration/tests/handshake.test.ts @@ -164,7 +164,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -185,7 +185,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -207,7 +209,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -230,7 +234,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -254,7 +258,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -278,7 +282,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -300,7 +304,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -324,7 +330,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -346,7 +352,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -367,7 +375,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}${devBrowserQuery}`, + )}&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -386,7 +394,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -485,7 +495,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(app.serverUrl + '/')}`, + `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent( + app.serverUrl + '/', + )}&suffixed_cookies=true`, ); }); @@ -520,7 +532,9 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&suffixed_cookies=true`, ); }); @@ -543,7 +557,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}hello%3Ffoo%3Dbar${devBrowserQuery}`, + )}hello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -566,7 +580,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}hello%3Ffoo%3Dbar`, + )}hello%3Ffoo%3Dbar&suffixed_cookies=true`, ); }); @@ -589,7 +603,7 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -612,7 +626,7 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true`, ); }); @@ -635,7 +649,7 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`, ); }); @@ -658,7 +672,7 @@ test.describe('Client handshake @generic', () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true`, ); }); @@ -787,7 +801,7 @@ test.describe('Client handshake @generic', () => { expect(res.headers.get('location')).toBe( `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( `${app.serverUrl}/`, - )}&__clerk_db_jwt=asdf`, + )}&suffixed_cookies=true&__clerk_db_jwt=asdf`, ); }); diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index 1ab0471e10..28fcd02f56 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,20 @@ 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('.'); +}; 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..96c65257db --- /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.false(context.suffixedCookies); + assert.equal(context.sessionTokenInCookie, session); + 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..ed286d0899 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,81 @@ 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; + } + + // In production, we can trust suffixed_uat because we don't need clerk.js to set it + if (this.instanceType === 'production') { + if (suffixedSession && suffixedClientUat === '0') { + return false; + } + } else { + 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,