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..4710a02404 --- /dev/null +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -0,0 +1,238 @@ +import type QUnit from 'qunit'; +import sinon from 'sinon'; + +import { createJwt, mockJwtPayload, pkLive, pkTest } from '../../fixtures'; +import { createAuthenticateContext } from '../authenticateContext'; +import { createClerkRequest } from '../clerkRequest'; + +function createCookieHeader(cookies: Record): string { + return Object.keys(cookies) + .reduce((result: string[], cookieName: string) => { + return [...result, `${cookieName}=${cookies[cookieName]}`]; + }, []) + .join('; '); +} +/** + * https://many-ghoul-34.accounts.lclclerk.com/sign-in?redirect_url=http%3A%2F%2Flocalhost%3A3001%2Fuser + * - [x] backend SDK with suffixed cookies & ClerkJS with suffixed cookies + * - [x] backend SDK with suffixed cookies & ClerkJS with un-suffixed cookies + * - [x] backend SDK with suffixed cookies & downgrade from ClerkJS with suffixed to un-suffixed cookies + * - [x] multiple apps for the same instance deployed in subdomains (eg [foo.example.com](http://foo.example.com/) and [bar.example.com](http://bar.example.com/) with pkA/skA) + * - [x] multiple apps for the same development instance in localhost (eg localhost:3001 and localhost:3002 with pkA/skA) + * - [x] backend SDK with suffixed cookies & ClerkJS with un-suffixed cookies and sign-in from AP of development instance (devBrowser is passed) + * + * - multiple apps for the same instance deployed in subdomains (eg [foo.example.com](http://foo.example.com/) and [bar.example.com](http://bar.example.com/) with pkA/skA) with multi-session app + */ +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 15d047bb42..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'; @@ -132,11 +134,70 @@ class AuthenticateContext { return this.getCookie(cookieName); } - private shouldUseSuffixed() { + 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) || ''; - return suffixedClientUat === clientUat; + // 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/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,