diff --git a/.changeset/bright-ways-provide.md b/.changeset/bright-ways-provide.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/bright-ways-provide.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/testUtils/handshake.ts b/integration/testUtils/handshake.ts new file mode 100644 index 00000000000..63c4fbf2d64 --- /dev/null +++ b/integration/testUtils/handshake.ts @@ -0,0 +1,151 @@ +// @ts-ignore ignore types +import * as jwt from 'jsonwebtoken'; +// @ts-ignore ignore types +import * as uuid from 'uuid'; + +const rsaPairs = { + a: { + private: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAlRVgJiQJ0nfuctIVSLnFJlAC76YPKly8Y5xrY36ADo472G1w +FpeiykQRyDdGOwrkJBEVmLpAybV4yTgQFpQ0A4YzeDlKKkOxBhCmuANZXluAm2MW +3ehNAm0svievMfKtG6UjjYz6v67U9Om/oMt1ehsOmR8MrDYvs3Wy+dxYpZaxyn6w +ajL7GkICHxc8cGsI/MBZr9jKtKyzFY++r8TQKAJwn9TcSQljRivomz1wQvjtdLnq +ZSLP3BFQB7e7DuM6SBsodIHhkVEVK2EaGVLOY+ifAITt7MqEcvast14AP0rICBSq +vbQQjZuwLgIrlJgqvJ4YBRfIaIx/qQzs0+eFtwIDAQABAoIBAD1H4xTqfWsZR1fF +SWBylDqSaxKNRPCZ3ApqEq58IjFZf/oPyiJPRGg2IMUXC3RbnrnAmAsGjHkdcj/s +HpjZZKQKNv/1NKo41vxyPcWoAsVJgYzd51liEr2rmNe1QkuawFN7xyh5Sd0fBYSC +zPVQjMKbep2waKolP+hZui8AxyORLtu6aUQawaCdWFyiyHtqEnlcb/YSTtGl/3W/ +/LqYyv60dG0QdcAO37MAE42vp3R4GGcJelsFo/lxSKg+KiLn7NdsNr7bCJrqbVXz +93Fu5jgHQD9+BeVyvHJ/R2yg+utYEvIMiFvwX7z4MLh9PsWJbf9vbDNlw9ErWpf5 +r1xUiqECgYEA/lLJP+qla0vd+ocYNe3ufOG4kaUFsrqRoChiS1JxwQr/WGTmV8sT +ZyTPwyxnsHtbzn4lwuI6CpAeyvd9O6G3FfTzUqsyPaknsGlymf8LEwL4AVo0BVY0 +YGodRnDISBBU/yPQ2kvq6c72ouq5cQxWF45f8Z/Z+fFDjuHG6Q44hYcCgYEAlhD6 +sm8wTWVklMAxOnhQJoseWQ1vcl6VCxRVv4QBiX9CQm/4ANVOWun4KRC5qqTdoNHc +RyuiWpZVgGblqUu4sWSQgi3CZyyLbHOJ9wTPTeo0oDVaFa9MMwS8rq35HXjpgREz +JtTRi6c9WVsjBygYiE5IYO0FGbEjI9qIiD5CClECgYA+wtVRRamu0dkk0yPhYycg +gF+Y6Z1/XtVDLdQb/GuAFSOwf63sanwOTyJKavHntnmQesb80fE63BgNRIgOKDlT +XNCTTRYn60+VFGCoqizkcy4av1TpID3qsSUqVfjG9+jR0dffly6Qpnds+vnqcP3p +8EOzEByttqFSaFs69jxyjwKBgFCQbQa+isACXy08wTESxnTq2zAT9nEANiPsltxq +kiivGXNxiUNpQNeuJHxnbkYenJ1qDUhoNJFNhDmbBFEPReh2hN5ekq+xSmi+3qKv +AlxiED6yZdqecdoyANoGrGcWMsYH5d5DAvxmnJkMRJHjBMiovlLK7KIOZz8oY4RB +aFMBAoGBAJ8UoGHwz7AdOLAldGY3c0HzaH1NaGrZoocSH19XI3ZYr8Qvl2XUww9G +UC1OG4e2PtZ8PINQGIYPacUaab5Yg1tOmxBoAx4gUkpgyjtSm2ZPd4EUVOdylU3E +aFa08+0FF7mqqJTgz5XlvHMrCcUTsJ9u+e05rr1G1PHsATuuMD9m +-----END RSA PRIVATE KEY-----`, + public: { + kty: 'RSA', + n: 'lRVgJiQJ0nfuctIVSLnFJlAC76YPKly8Y5xrY36ADo472G1wFpeiykQRyDdGOwrkJBEVmLpAybV4yTgQFpQ0A4YzeDlKKkOxBhCmuANZXluAm2MW3ehNAm0svievMfKtG6UjjYz6v67U9Om_oMt1ehsOmR8MrDYvs3Wy-dxYpZaxyn6wajL7GkICHxc8cGsI_MBZr9jKtKyzFY--r8TQKAJwn9TcSQljRivomz1wQvjtdLnqZSLP3BFQB7e7DuM6SBsodIHhkVEVK2EaGVLOY-ifAITt7MqEcvast14AP0rICBSqvbQQjZuwLgIrlJgqvJ4YBRfIaIx_qQzs0-eFtw', + e: 'AQAB', + }, + }, + b: { + private: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAt9zSYl1hFhFKXvv8uJcT2X15iOqi1mTtxqVxNDnzPQSj1RSa +Jryjhkzpyd16c+PDo+FFtMgZTUv6Z2hr5QYMuAjlsM+apHmfE8MRMQRQHXNF0+sE +Bd1241W0mL7fId2ZChaGgufFOGFl2Obby56FH4Z86lCFi7Z4Ow7TBSpVSN598OKH +oVKwbYOVPKtmWBar0JeCPVpng4Ntx7kvuHGdFSoJ8z8+Uy5ybLlk1qSlQ5lsymfW +hxs0C9j7/x/h24n9jUbq51pzx2URcsEi0Wbuv26Ba0Q4v1ySl0I6IM5Xemwrzjo1 +H6kz6IYldqPtTwkzhJSnJvJFzKJZn3hH9N+rGwIDAQABAoIBAGLGdx/xGp9IWrP8 +nCBuyXMmPYyYwTJ8tmDpsI9mMo6tV3a5wrbc0NztpQuVuJtZ2VjJRTGB7lXgY336 +UzyOq3aTERKT9Xg2/ocXXL0AnCm2K+VVdKvR9nTbLlKA+E6xRe5te4YIDaPkb1q/ +a4VQfCQblDAtYhFUzfKsXCGCRJ8IPlhZxiA9RHfQTmQUSoBW+12IovyMdMxVLoPT +qjdnwL1TS3iARim+eV+buHW+8Drn8eldeSFoTJd0B9eRf7pMpRH/X8G7X0YYdDjF +ADWI770CQj45QQeuVsZYIuONIPmzai4nGiNQ85v+Yy0L47lYUp5XsDvwYO5tMCQK +v1og8cECgYEA7zIfBWM1AIY763FGJ6F5ym41A4i7WSRaWusfQx6fOdGTyHJ3hXu9 ++1kQS97IKElJ1V7oK69dJGxq+aupsd/AaRJb4oVZCBSby4Fo6VeJoKyJSdNSCks6 +tonT3hGUsJO1ER2ItWcgiCxgGY+vrK0rkacX/VgNZKGIjlGv8pQUpaUCgYEAxMeL +2jyx+5odGM51e/1G4Vn6t+ffNNC/03NbwZeJ93+0NPgdagIo1kErZQrFPEfoExOr +KMkwnAsnR/xfn4X21voK1pUc7VhzzoODb8l9LA9kB7efWtRZA79gcsbOH5wNkp9Z +i76AtaVU/p1grFKNcnes1lbFfcRUnO880g5dsb8CgYBacuuEEAWk0x2pZEYRCmCR +iacGVRfzF2oLY0mJCfVP2c42SAKmOSqX9w/QgMfTZBNFWgQVMNTZxx2Ul7Mtjdym +XsjcGWyXP6PCCodvZSin11Z60iv9tIDZMbkqCh/dvZ0EgdSGNB77HzyfrdPSShFl +nHfX1woJeYO3vW/5HMHJ+QKBgQCNema7pq3Ulq5a2n2vgp9GgJn5RXW+lGOG1Mbg +vmJMlv1qpAUJ5bmUqdBYWlEKkSxzIs4JifUwC/jXEcVyfS/GyommVBkzMEg672U9 +pyEe34Xs4oFpHYlOX3cprnQeV+WOSJFqHrKNZuxgD6ik3MmjxhV3GXXugYzQNFWH +NRr6IwKBgH9aN5mY4fcVL76mMEVZ5BIHE+JpPMZ6OOamOHAiA5jrWRX4aRMICq3t +cKVfcj/M4dyBuRV5EW1y1m2QhRECFPSKpScykpD9nyCb+XqbMSLH+f+j1BGfLKWl +t5o8u/dlwJ1fGGday48gs/hA4V/F9zDjecNkYWUB/wUwVStqZljn +-----END RSA PRIVATE KEY-----`, + public: { + kty: 'RSA', + n: 't9zSYl1hFhFKXvv8uJcT2X15iOqi1mTtxqVxNDnzPQSj1RSaJryjhkzpyd16c-PDo-FFtMgZTUv6Z2hr5QYMuAjlsM-apHmfE8MRMQRQHXNF0-sEBd1241W0mL7fId2ZChaGgufFOGFl2Obby56FH4Z86lCFi7Z4Ow7TBSpVSN598OKHoVKwbYOVPKtmWBar0JeCPVpng4Ntx7kvuHGdFSoJ8z8-Uy5ybLlk1qSlQ5lsymfWhxs0C9j7_x_h24n9jUbq51pzx2URcsEi0Wbuv26Ba0Q4v1ySl0I6IM5Xemwrzjo1H6kz6IYldqPtTwkzhJSnJvJFzKJZn3hH9N-rGw', + e: 'AQAB', + }, + }, +}; + +const allConfigs: any = []; + +export function generateConfig({ mode, matchedKeys = true }: { mode: 'test' | 'live'; matchedKeys?: boolean }) { + const ins_id = uuid.v4(); + const pkHost = `clerk.${uuid.v4()}.com`; + const pk = `pk_${mode}_${btoa(`${pkHost}$`)}`; + const sk = `sk_${mode}_${uuid.v4()}`; + const rsa = matchedKeys + ? rsaPairs.a + : { + private: rsaPairs.a.private, + public: rsaPairs.b.public, + }; + const jwks = { + keys: [ + { + ...rsa.public, + kid: ins_id, + use: 'sig', + alg: 'RS256', + }, + ], + }; + + type Claims = { + sub: string; + iat: number; + exp: number; + nbf: number; + }; + const generateToken = ({ state }: { state: 'active' | 'expired' | 'early' }) => { + const claims = { sub: 'user_12345' } as Claims; + + const now = Math.floor(Date.now() / 1000); + if (state === 'active') { + claims.iat = now; + claims.nbf = now - 10; + claims.exp = now + 60; + } else if (state === 'expired') { + claims.iat = now - 600; + claims.nbf = now - 10 - 600; + claims.exp = now + 60 - 600; + } else if (state === 'early') { + claims.iat = now + 600; + claims.nbf = now - 10 + 600; + claims.exp = now + 60 + 600; + } + return { + token: jwt.sign(claims, rsa.private, { + algorithm: 'RS256', + header: { kid: ins_id }, + }), + claims, + }; + }; + const config = Object.freeze({ + pk, + sk, + generateToken, + generateHandshakeToken(payload: string[]) { + return jwt.sign({ handshake: payload }, rsa.private, { + algorithm: 'RS256', + header: { kid: ins_id }, + }); + }, + jwks, + pkHost, + }); + allConfigs.push(config); + return config; +} + +export function getJwksFromSecretKey(sk: any) { + return allConfigs.find((x: any) => x.sk === sk)?.jwks; +} diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts new file mode 100644 index 00000000000..37d659752d7 --- /dev/null +++ b/integration/tests/handshake.test.ts @@ -0,0 +1,839 @@ +import * as http from 'node:http'; + +import { expect, test } from '@playwright/test'; +import { request as apiRequest } from 'playwright'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import { generateConfig, getJwksFromSecretKey } from '../testUtils/handshake'; + +const PORT = 4199; + +test.describe('Client handshake', () => { + test.describe.configure({ mode: 'serial' }); + + let app: Application; + const jwksServer = http.createServer(function (req, res) { + const sk = req.headers.authorization?.replace('Bearer ', ''); + if (!sk) { + console.log('No SK to', req.url, req.headers); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(JSON.stringify(getJwksFromSecretKey(sk))); + res.end(); + }); + // Strip trailing slash + const devBrowserCookie = '__clerk_db_jwt=needstobeset;'; + const devBrowserQuery = '&__clerk_db_jwt=needstobeset'; + + test.beforeAll('setup local Clerk API mock', async () => { + const env = appConfigs.envs.withEmailCodes + .clone() + .setEnvVariable('private', 'CLERK_API_URL', `http://localhost:${PORT}`); + + // Start the jwks server + await new Promise(resolve => jwksServer.listen(4199, resolve)); + + app = await appConfigs.next.appRouter + .clone() + .addFile( + 'src/middleware.ts', + () => `import { authMiddleware } from '@clerk/nextjs/server'; + + // Set the paths that don't require the user to be signed in + const publicPaths = ['/', /^(\\/(sign-in|sign-up|app-dir|custom)\\/*).*$/]; + + export const middleware = (req, evt) => { + return authMiddleware({ + publicRoutes: publicPaths, + publishableKey: req.headers.get("x-publishable-key"), + secretKey: req.headers.get("x-secret-key"), + proxyUrl: req.headers.get("x-proxy-url"), + domain: req.headers.get("x-domain"), + isSatellite: req.headers.get('x-satellite') === 'true', + signInUrl: req.headers.get("x-sign-in-url"), + })(req, evt) + }; + + export const config = { + matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'], + }; + `, + ) + .commit(); + + await app.setup(); + await app.withEnv(env); + await app.dev(); + + request = await apiRequest.newContext({ baseURL: app.serverUrl }); + }); + + test.afterAll(async () => { + await app.teardown(); + await new Promise(resolve => jwksServer.close(() => resolve())); + }); + + test('Test standard signed-in - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'active' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test standard signed-in - authorization header - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'active' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat};`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Authorization: `Bearer ${token}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test standard signed-in - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'active' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test standard signed-in - authorization header - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'active' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat};`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Authorization: `Bearer ${token}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test expired session token - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}${devBrowserQuery}`, + ); + }); + + test('Test expired session token - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + ); + }); + + test('Test expired session token - authorization header - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat};`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Authorization: `Bearer ${token}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + ); + }); + + test('Test early session token - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'early' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}${devBrowserQuery}`, + ); + }); + + test('Test early session token - authorization header - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'early' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat};`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Authorization: `Bearer ${token}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}${devBrowserQuery}`, + ); + }); + + test('Test proxyUrl - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Proxy-Url': 'https://example.com/clerk', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}${devBrowserQuery}`, + ); + }); + + test('Test proxyUrl - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Proxy-Url': 'https://example.com/clerk', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + ); + }); + + test('Test domain - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Domain': 'localhost:3000', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}${devBrowserQuery}`, + ); + }); + + test('Test domain - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Domain': 'example.com', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + ); + }); + + test('Test missing session token, positive uat - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=1`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}${devBrowserQuery}`, + ); + }); + + test('Test missing session token, positive uat - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `__client_uat=1`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`, + ); + }); + + test('Test missing session token, 0 uat (indicating signed out) - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=0`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test missing session token, 0 uat (indicating signed out) - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `__client_uat=0`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test missing session token, missing uat (indicating signed out) - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test missing session token, missing uat (indicating signed out) - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test signed out satellite no sec-fetch-dest=document - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test signed out satellite with sec-fetch-dest=document - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'Sec-Fetch-Dest': 'document', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(app.serverUrl + '/')}`, + ); + }); + + test('Test signed out satellite - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'X-Sign-In-Url': 'https://example.com/sign-in', + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test missing session token, missing uat (indicating signed out), missing devbrowser - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + }); + + test('Test redirect url - path and qs - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}hello%3Ffoo%3Dbar${devBrowserQuery}`, + ); + }); + + test('Test redirect url - path and qs - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}hello%3Ffoo%3Dbar`, + ); + }); + + test('Test redirect url - proxy - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Forwarded-Host': 'example.com', + 'X-Forwarded-Proto': 'https', + }), + redirect: 'manual', + }); + 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}`, + ); + }); + + test('Test redirect url - proxy - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Forwarded-Host': 'example.com', + 'X-Forwarded-Proto': 'https', + }), + redirect: 'manual', + }); + 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`, + ); + }); + + test('Test redirect url - proxy with port - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Forwarded-Host': 'example.com:3213', + 'X-Forwarded-Proto': 'https', + }), + redirect: 'manual', + }); + 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}`, + ); + }); + + test('Test redirect url - proxy with port - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(app.serverUrl + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Forwarded-Host': 'example.com:3213', + 'X-Forwarded-Proto': 'https', + }), + redirect: 'manual', + }); + 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`, + ); + }); + + test('Handshake result - dev - nominal', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe('/'); + const headers = [...res.headers.entries()]; + cookiesToSet.forEach(cookie => { + expect(headers).toContainEqual(['set-cookie', cookie]); + }); + }); + + test('Handshake result - dev - skew - clock behind', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token } = config.generateToken({ state: 'early' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe('/'); + }); + + test('Handshake result - dev - skew - clock ahead', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token } = config.generateToken({ state: 'expired' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + }); + + test('Handshake result - dev - mismatched keys', async () => { + const config = generateConfig({ + mode: 'test', + matchedKeys: false, + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); + }); + + // I don't know if we need this one? We might pass new devbrowser back in handshake + test('Handshake result - dev - new devbrowser', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, '__clerk_db_jwt=asdf;path=/']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe('/'); + const headers = [...res.headers.entries()]; + cookiesToSet.forEach(cookie => { + expect(headers).toContainEqual(['set-cookie', cookie]); + }); + }); + + test('External visit - new devbrowser', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(app.serverUrl + '/?__clerk_db_jwt=asdf', { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${app.serverUrl}/`, + )}&__clerk_db_jwt=asdf`, + ); + }); + + test('Handshake result - prod - nominal', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Cookie: `__clerk_handshake=${handshake}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + const headers = [...res.headers.entries()]; + cookiesToSet.forEach(cookie => { + expect(headers).toContainEqual(['set-cookie', cookie]); + }); + }); + + test('Handshake result - prod - skew - clock behind', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token } = config.generateToken({ state: 'early' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Cookie: `__clerk_handshake=${handshake}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); + }); + + test('Handshake result - prod - skew - clock ahead', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token } = config.generateToken({ state: 'expired' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Cookie: `__clerk_handshake=${handshake}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); + }); + + test('Handshake result - prod - mismatched keys', async () => { + const config = generateConfig({ + mode: 'live', + matchedKeys: false, + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = await config.generateHandshakeToken(cookiesToSet); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Cookie: `__clerk_handshake=${handshake}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); + }); +}); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index e84e12fc4a2..fa65c36cf25 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -125,27 +125,26 @@ export async function authenticateRequest( try { verifyResult = await verifyToken(sessionToken, authenticateContext); } catch (err) { - if (err instanceof TokenVerificationError) { + if ( + err instanceof TokenVerificationError && + instanceType === 'development' && + (err.reason === TokenVerificationErrorReason.TokenExpired || + err.reason === TokenVerificationErrorReason.TokenNotActiveYet) + ) { err.tokenCarrier = 'cookie'; - if ( - instanceType === 'development' && - (err.reason === TokenVerificationErrorReason.TokenExpired || - err.reason === TokenVerificationErrorReason.TokenNotActiveYet) - ) { - // This probably means we're dealing with clock skew - console.error( - `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. + // This probably means we're dealing with clock skew + console.error( + `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. To resolve this issue, make sure your system's clock is set to the correct time (e.g. turn off and on automatic time synchronization). --- ${err.getFullMessage()}`, - ); + ); - // Retry with a generous clock skew allowance (1 day) - verifyResult = await verifyToken(sessionToken, { ...authenticateContext, clockSkewInMs: 86_400_000 }); - } + // Retry with a generous clock skew allowance (1 day) + verifyResult = await verifyToken(sessionToken, { ...authenticateContext, clockSkewInMs: 86_400_000 }); } else { throw err; } diff --git a/packages/nextjs/src/server/authMiddleware.test.ts b/packages/nextjs/src/server/authMiddleware.test.ts index 405f0fa535c..ee6f08de283 100644 --- a/packages/nextjs/src/server/authMiddleware.test.ts +++ b/packages/nextjs/src/server/authMiddleware.test.ts @@ -8,6 +8,7 @@ import { NextResponse } from 'next/server'; const authenticateRequestMock = jest.fn().mockResolvedValue({ toAuth: () => ({}), + headers: new Headers(), }); jest.mock('./clerkClient', () => { @@ -392,7 +393,7 @@ describe('authMiddleware(params)', () => { it('uses authenticateRequest result as auth', async () => { const req = mockRequest({ url: '/protected' }); const event = {} as NextFetchEvent; - authenticateRequestMock.mockResolvedValueOnce({ toAuth: () => ({ userId: null }) }); + authenticateRequestMock.mockResolvedValueOnce({ toAuth: () => ({ userId: null }), headers: new Headers() }); const afterAuthSpy = jest.fn(); await authMiddleware({ afterAuth: afterAuthSpy })(req, event); diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index d7483d24871..b2a8474a275 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -194,11 +194,8 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { } as AuthenticateRequestOptions; const requestState = await clerkClient.authenticateRequest(req, authenticateRequestOptions); - if (requestState.status === AuthStatus.Handshake) { - const locationHeader = requestState.headers.get('location'); - if (!locationHeader) { - throw new Error('Unexpected handshake without redirect'); - } + const locationHeader = requestState.headers.get('location'); + if (locationHeader) { // triggering a handshake redirect return decorateResponseWithObservabilityHeaders( new Response(null, { status: 307, headers: requestState.headers }), @@ -206,6 +203,10 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { ); } + if (requestState.status === AuthStatus.Handshake) { + throw new Error('Unexpected handshake without redirect'); + } + const auth = Object.assign(requestState.toAuth(), { isPublicRoute: isPublicRoute(req), isApiRoute: isApiRoute(req), diff --git a/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap index a06ef71aa5c..f847f40c962 100644 --- a/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap @@ -4,7 +4,6 @@ exports[`module exports should not change unless explicitly set 1`] = ` [ "AllowlistIdentifier", "AuthStatus", - "Clerk", "ClerkExpressRequireAuth", "ClerkExpressWithAuth", "Client", diff --git a/packages/sdk-node/src/clerkClient.ts b/packages/sdk-node/src/clerkClient.ts index eed18896e94..f29ef9478ac 100644 --- a/packages/sdk-node/src/clerkClient.ts +++ b/packages/sdk-node/src/clerkClient.ts @@ -1,11 +1,11 @@ import type { ClerkOptions } from '@clerk/backend'; -import { createClerkClient, verifyToken } from '@clerk/backend'; +import { createClerkClient as _createClerkClient, verifyToken } from '@clerk/backend'; import { createClerkExpressRequireAuth } from './clerkExpressRequireAuth'; import { createClerkExpressWithAuth } from './clerkExpressWithAuth'; import { loadApiEnv, loadClientEnv } from './utils'; -type ClerkClient = ReturnType & { +type ClerkClient = ReturnType & { expressWithAuth: ReturnType; expressRequireAuth: ReturnType; verifyToken: typeof verifyToken; @@ -16,8 +16,8 @@ type ClerkClient = ReturnType & { * new Clerk() syntax for v4 compatibility. * Arrow functions can never be called with the new keyword because they do not have the [[Construct]] method */ -export function Clerk(options: ClerkOptions): ClerkClient { - const clerkClient = createClerkClient(options); +export function createClerkClient(options: ClerkOptions): ClerkClient { + const clerkClient = _createClerkClient(options); const expressWithAuth = createClerkExpressWithAuth({ ...options, clerkClient }); const expressRequireAuth = createClerkExpressRequireAuth({ ...options, clerkClient }); @@ -28,9 +28,7 @@ export function Clerk(options: ClerkOptions): ClerkClient { }); } -export { createClerkClient } from '@clerk/backend'; - -let clerkClientSingleton = {} as unknown as ReturnType; +let clerkClientSingleton = {} as unknown as ReturnType; export const clerkClient = new Proxy(clerkClientSingleton, { get(_target, property) { @@ -42,7 +40,7 @@ export const clerkClient = new Proxy(clerkClientSingleton, { const env = { ...loadApiEnv(), ...loadClientEnv() }; if (env.secretKey) { - clerkClientSingleton = Clerk({ ...env, userAgent: '@clerk/clerk-sdk-node' }); + clerkClientSingleton = createClerkClient({ ...env, userAgent: '@clerk/clerk-sdk-node' }); // @ts-expect-error - Element implicitly has an 'any' type because expression of type 'string | symbol' can't be used to index type 'ExtendedClerk'. return clerkClientSingleton[property]; } diff --git a/packages/sdk-node/src/index.ts b/packages/sdk-node/src/index.ts index 98483e5a29f..e235d41e9c0 100644 --- a/packages/sdk-node/src/index.ts +++ b/packages/sdk-node/src/index.ts @@ -1,4 +1,4 @@ -import { Clerk, clerkClient, ClerkExpressRequireAuth, ClerkExpressWithAuth, createClerkClient } from './clerkClient'; +import { clerkClient, ClerkExpressRequireAuth, ClerkExpressWithAuth, createClerkClient } from './clerkClient'; import { createClerkExpressRequireAuth } from './clerkExpressRequireAuth'; import { createClerkExpressWithAuth } from './clerkExpressWithAuth'; import type { @@ -18,7 +18,7 @@ export * from '@clerk/backend'; * 2 additional apis: clerk.expressWithAuth, clerk.expressRequireAuth */ // eslint-disable-next-line import/export -export { Clerk, clerkClient, ClerkExpressRequireAuth, ClerkExpressWithAuth, createClerkClient }; +export { clerkClient, ClerkExpressRequireAuth, ClerkExpressWithAuth, createClerkClient }; const { users,