diff --git a/.changeset/nice-doors-fail.md b/.changeset/nice-doors-fail.md new file mode 100644 index 00000000000..50e348c22d2 --- /dev/null +++ b/.changeset/nice-doors-fail.md @@ -0,0 +1,9 @@ +--- +'@clerk/backend': major +--- + +- Refactor the `authenticateRequest()` flow to use the new client handshake endpoint. This replaces the previous "interstitial"-based flow. This should improve performance and overall reliability of Clerk's server-side request authentication functionality. +- `authenticateRequest()` now accepts two arguments, a `Request` object to authenticate and options: + ```ts + authenticateRequest(new Request(...), { secretKey: '...' }) + ``` diff --git a/handshake.test.ts b/handshake.test.ts new file mode 100644 index 00000000000..f3c92b0542f --- /dev/null +++ b/handshake.test.ts @@ -0,0 +1,787 @@ +// @ts-ignore ignore types +import * as http from 'http'; +import { generateConfig, getJwksFromSecretKey } from './handshakeTestConfigs'; + +const urlArg = process.argv.find(x => x.startsWith('--url='))?.replace('--url=', ''); +if (!urlArg) { + throw new Error('Must pass URL like: --url=http://localhost:4011'); +} + +// Strip trailing slash +const url = new URL(urlArg).origin; +const devBrowserCookie = '__clerk_db_jwt=needstobeset;'; +const devBrowserQuery = '&__clerk_db_jwt=needstobeset'; + +//create a server object: +const server = 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))); //write a response to the client + res.end(); //end the response +}); + +beforeAll(() => { + console.log( + 'Starting jwks service on 127.0.0.1:4199.\nMake sure the framework has CLERK_API_URL set to http://localhost:4199', + ); + server.listen(4199); + + console.log('Running tests against ', url); +}); + +afterAll(() => { + server.close(); + setImmediate(function () { + server.emit('close'); + }); +}); + +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(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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(`${url}/`)}${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(url + '/', { + 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(`${url}/`)}`, + ); +}); + +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(url + '/', { + 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(`${url}/`)}`, + ); +}); + +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(url + '/', { + 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(`${url}/`)}${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(url + '/', { + 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(`${url}/`)}${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(url + '/', { + 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(`${url}/`)}${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(url + '/', { + 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(`${url}/`)}`, + ); +}); + +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(url + '/', { + 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(`${url}/`)}${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(url + '/', { + 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(`${url}/`)}`, + ); +}); + +test('Test missing session token, positive uat - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/', { + 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(`${url}/`)}${devBrowserQuery}`, + ); +}); + +test('Test missing session token, positive uat - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(url + '/', { + 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(`${url}/`)}`, + ); +}); + +test('Test missing session token, 0 uat (indicating signed out) - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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(url + '/')}`, + ); +}); + +test('Test signed out satellite - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/', { + 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(url + '/', { + 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(url + '/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( + `${url}/`, + )}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(url + '/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(`${url}/`)}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(url + '/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(url + '/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(url + '/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(url + '/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(url + '/?__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(url + '/?__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(url + '/?__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(url + '/?__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(url + '/?__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(url + '/?__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(`${url}/`)}&__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(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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(url + '/', { + 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/handshakeTestConfigs.ts b/handshakeTestConfigs.ts new file mode 100644 index 00000000000..6144c07c79e --- /dev/null +++ b/handshakeTestConfigs.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' }) => { + let 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/templates/next-app-router/package.json b/integration/templates/next-app-router/package.json index 5e298c001bd..eef4565f52c 100644 --- a/integration/templates/next-app-router/package.json +++ b/integration/templates/next-app-router/package.json @@ -9,6 +9,11 @@ "start": "next start" }, "dependencies": { + "@clerk/backend": "file:.yalc/@clerk/backend", + "@clerk/clerk-react": "file:.yalc/@clerk/clerk-react", + "@clerk/nextjs": "file:.yalc/@clerk/nextjs", + "@clerk/shared": "file:.yalc/@clerk/shared", + "@clerk/types": "file:.yalc/@clerk/types", "@types/node": "^18.17.0", "@types/react": "18.2.14", "@types/react-dom": "18.2.6", diff --git a/jest.config.handshake.js b/jest.config.handshake.js new file mode 100644 index 00000000000..e5897f4fe9e --- /dev/null +++ b/jest.config.handshake.js @@ -0,0 +1,19 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + extensionsToTreatAsEsm: ['.ts'], + testRegex: ['handshake.test.tsx?$'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` + // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` + '^.+\\.tsx?$': [ + 'ts-jest', + { + diagnostics: false, + useESM: true, + }, + ], + }, +}; diff --git a/packages/backend/README.md b/packages/backend/README.md index 797b8b788a5..b8f7f821a5d 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -40,7 +40,6 @@ This package provides Clerk Backend API resources and low-level authentication u - Support multiple CLERK_API_KEY for multiple instance REST access. - Align JWT key resolution algorithm across all environments (Function param > Environment variable > JWKS from API). - Tested automatically across different runtimes (Node, CF Workers, Vercel Edge middleware.) -- Clean up Clerk interstitial logic. - Refactor the Rest Client API to return `{data, errors}` instead of throwing errors. - Export a generic verifyToken for Clerk JWTs verification. - Align AuthData interface for SSR. @@ -80,7 +79,6 @@ clerk.allowlistIdentifiers; clerk.clients; clerk.emailAddresses; clerk.emails; -clerk.interstitial; clerk.invitations; clerk.organizations; clerk.phoneNumbers; @@ -96,12 +94,6 @@ clerk.authenticateRequest(options); // Build debug payload of the request state. clerk.debugRequestState(requestState); - -// Load clerk interstitial from this package -clerk.localInterstitial(options); - -// Load clerk interstitial from the public Private API endpoint (Deprecated) -clerk.remotePrivateInterstitial(options); ``` #### verifyToken(token: string, options: VerifyTokenOptions) @@ -161,20 +153,6 @@ import { debugRequestState } from '@clerk/backend'; debugRequestState(requestState); ``` -#### loadInterstitialFromLocal(options) - -Generates a debug payload for the request state. The debug payload is available via `window.__clerk_debug`. - -```js -import { loadInterstitialFromLocal } from '@clerk/backend'; - -loadInterstitialFromLocal({ - frontendApi: '...', - clerkJSVersion: '...', - debugData: {}, -}); -``` - #### signedInAuthObject(sessionClaims, options) Builds the AuthObject when the user is signed in. diff --git a/packages/backend/src/api/endpoints/InterstitialApi.ts b/packages/backend/src/api/endpoints/InterstitialApi.ts deleted file mode 100644 index 2ce6cc967c3..00000000000 --- a/packages/backend/src/api/endpoints/InterstitialApi.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { deprecated } from '../../util/shared'; -import { AbstractAPI } from './AbstractApi'; -/** - * @deprecated Switch to the public interstitial endpoint from Clerk Backend API. - */ -export class InterstitialAPI extends AbstractAPI { - public async getInterstitial() { - deprecated( - 'getInterstitial()', - 'Switch to `Clerk(...).localInterstitial(...)` from `import { Clerk } from "@clerk/backend"`.', - ); - - return this.request({ - path: 'internal/interstitial', - method: 'GET', - headerParams: { - 'Content-Type': 'text/html', - }, - }); - } -} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index e4f743550ad..9d823d9de1a 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -4,7 +4,6 @@ export * from './ClientApi'; export * from './DomainApi'; export * from './EmailAddressApi'; export * from './EmailApi'; -export * from './InterstitialApi'; export * from './InvitationApi'; export * from './OrganizationApi'; export * from './PhoneNumberApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index aa8f94c5c42..6a9e63b4c6c 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -4,7 +4,6 @@ import { DomainAPI, EmailAddressAPI, EmailAPI, - InterstitialAPI, InvitationAPI, OrganizationAPI, PhoneNumberAPI, @@ -27,7 +26,6 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { clients: new ClientAPI(request), emailAddresses: new EmailAddressAPI(request), emails: new EmailAPI(request), - interstitial: new InterstitialAPI(request), invitations: new InvitationAPI(request), organizations: new OrganizationAPI(request), phoneNumbers: new PhoneNumberAPI(request), diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index ce7a8d7c5c3..55bb8897239 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -15,6 +15,13 @@ const Attributes = { const Cookies = { Session: '__session', ClientUat: '__client_uat', + Handshake: '__clerk_handshake', + DevBrowser: '__clerk_db_jwt', +} as const; + +const QueryParameters = { + ClerkSynced: '__clerk_synced', + ClerkRedirectUrl: '__clerk_redirect_url', } as const; const Headers = { @@ -33,6 +40,7 @@ const Headers = { Origin: 'origin', Host: 'host', ContentType: 'content-type', + SecFetchDest: 'sec-fetch-dest', } as const; const SearchParams = { @@ -49,4 +57,5 @@ export const constants = { Headers, SearchParams, ContentTypes, + QueryParameters, } as const; diff --git a/packages/backend/src/exports.test.ts b/packages/backend/src/exports.test.ts index 2b8bca5c84a..97bed0993d5 100644 --- a/packages/backend/src/exports.test.ts +++ b/packages/backend/src/exports.test.ts @@ -42,7 +42,6 @@ export default (QUnit: QUnit) => { 'decodeJwt', 'deserialize', 'hasValidSignature', - 'loadInterstitialFromLocal', 'makeAuthObjectSerializable', 'prunePrivateMetadata', 'redirect', diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 401113e61be..a44e67f3a94 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -8,8 +8,8 @@ import type { TokenVerificationErrorReason } from './errors'; export enum AuthStatus { SignedIn = 'signed-in', SignedOut = 'signed-out', - Interstitial = 'interstitial', - Unknown = 'unknown', + Handshake = 'handshake', + SessionTokenOutdated = 'SessionTokenOutdated', } export type SignedInState = { @@ -25,9 +25,8 @@ export type SignedInState = { afterSignInUrl: string; afterSignUpUrl: string; isSignedIn: true; - isInterstitial: false; - isUnknown: false; toAuth: () => SignedInAuthObject; + headers: Headers; }; export type SignedOutState = { @@ -43,43 +42,31 @@ export type SignedOutState = { afterSignInUrl: string; afterSignUpUrl: string; isSignedIn: false; - isInterstitial: false; - isUnknown: false; toAuth: () => SignedOutAuthObject; + headers: Headers; }; -export type InterstitialState = Omit & { - status: AuthStatus.Interstitial; - isInterstitial: true; +export type HandshakeState = Omit & { + status: AuthStatus.Handshake; + headers: Headers; toAuth: () => null; }; -export type UnknownState = Omit & { - status: AuthStatus.Unknown; - isInterstitial: false; - isUnknown: true; -}; - export enum AuthErrorReason { - CookieAndUATMissing = 'cookie-and-uat-missing', - CookieMissing = 'cookie-missing', - CookieOutDated = 'cookie-outdated', - CookieUATMissing = 'uat-missing', - CrossOriginReferrer = 'cross-origin-referrer', - HeaderMissingCORS = 'header-missing-cors', - HeaderMissingNonBrowser = 'header-missing-non-browser', - SatelliteCookieNeedsSyncing = 'satellite-needs-syncing', - SatelliteReturnsFromPrimary = 'satellite-returns-from-primary', + ClientUATWithoutSessionToken = 'client-uat-but-no-session-token', + DevBrowserSync = 'dev-browser-sync', PrimaryRespondsToSyncing = 'primary-responds-to-syncing', - StandardSignedIn = 'standard-signed-in', - StandardSignedOut = 'standard-signed-out', + SatelliteCookieNeedsSyncing = 'satellite-needs-syncing', + SessionTokenAndUATMissing = 'session-token-and-uat-missing', + SessionTokenMissing = 'session-token-missing', + SessionTokenOutdated = 'session-token-outdated', + SessionTokenWithoutClientUAT = 'session-token-but-no-client-uat', UnexpectedError = 'unexpected-error', - Unknown = 'unknown', } export type AuthReason = AuthErrorReason | TokenVerificationErrorReason; -export type RequestState = SignedInState | SignedOutState | InterstitialState | UnknownState; +export type RequestState = SignedInState | SignedOutState | HandshakeState; type LoadResourcesOptions = { loadSession?: boolean; @@ -99,12 +86,12 @@ type RequestStateParams = { }; type AuthParams = { - /* Client token cookie value */ - cookieToken?: string; + /* Session token cookie value */ + sessionTokenInCookie?: string; + /* Client token header value */ + sessionTokenInHeader?: string; /* Client uat cookie value */ clientUat?: string; - /* Client token header value */ - headerToken?: string; }; export type AuthStatusOptionsType = LoadResourcesOptions & @@ -115,6 +102,7 @@ export type AuthStatusOptionsType = LoadResourcesOptions & export async function signedIn( options: T, sessionClaims: JwtPayload, + headers: Headers = new Headers(), ): Promise { const { publishableKey = '', @@ -128,8 +116,8 @@ export async function signedIn( secretKey, apiUrl, apiVersion, - cookieToken, - headerToken, + sessionTokenInCookie, + sessionTokenInHeader, loadSession, loadUser, loadOrganization, @@ -159,7 +147,7 @@ export async function signedIn( secretKey, apiUrl, apiVersion, - token: cookieToken || headerToken || '', + token: sessionTokenInCookie || sessionTokenInHeader || '', session, user, organization, @@ -180,15 +168,15 @@ export async function signedIn( afterSignInUrl, afterSignUpUrl, isSignedIn: true, - isInterstitial: false, - isUnknown: false, toAuth: () => authObject, + headers, }; } export function signedOut( options: T, reason: AuthReason, message = '', + headers: Headers = new Headers(), ): SignedOutState { const { publishableKey = '', @@ -214,48 +202,17 @@ export function signedOut( afterSignInUrl, afterSignUpUrl, isSignedIn: false, - isInterstitial: false, - isUnknown: false, + headers, toAuth: () => signedOutAuthObject({ ...options, status: AuthStatus.SignedOut, reason, message }), }; } -export function interstitial( +export function handshake( options: T, reason: AuthReason, message = '', -): InterstitialState { - const { - publishableKey = '', - proxyUrl = '', - isSatellite = false, - domain = '', - signInUrl = '', - signUpUrl = '', - afterSignInUrl = '', - afterSignUpUrl = '', - } = options; - - return { - status: AuthStatus.Interstitial, - reason, - message, - publishableKey, - isSatellite, - domain, - proxyUrl, - signInUrl, - signUpUrl, - afterSignInUrl, - afterSignUpUrl, - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => null, - }; -} - -export function unknownState(options: AuthStatusOptionsType, reason: AuthReason, message = ''): UnknownState { + headers: Headers, +): HandshakeState { const { publishableKey = '', proxyUrl = '', @@ -268,7 +225,7 @@ export function unknownState(options: AuthStatusOptionsType, reason: AuthReason, } = options; return { - status: AuthStatus.Unknown, + status: AuthStatus.Handshake, reason, message, publishableKey, @@ -280,8 +237,7 @@ export function unknownState(options: AuthStatusOptionsType, reason: AuthReason, afterSignInUrl, afterSignUpUrl, isSignedIn: false, - isInterstitial: false, - isUnknown: true, + headers, toAuth: () => null, }; } diff --git a/packages/backend/src/tokens/errors.ts b/packages/backend/src/tokens/errors.ts index 8c3f8a02494..15a7580f465 100644 --- a/packages/backend/src/tokens/errors.ts +++ b/packages/backend/src/tokens/errors.ts @@ -9,7 +9,6 @@ export enum TokenVerificationErrorReason { TokenInvalid = 'token-invalid', TokenInvalidAlgorithm = 'token-invalid-algorithm', TokenInvalidAuthorizedParties = 'token-invalid-authorized-parties', - TokenInvalidIssuer = 'token-invalid-issuer', TokenInvalidSignature = 'token-invalid-signature', TokenNotActiveYet = 'token-not-active-yet', TokenVerificationFailed = 'token-verification-failed', @@ -22,8 +21,6 @@ export enum TokenVerificationErrorReason { RemoteJWKMissing = 'jwk-remote-missing', JWKFailedToResolve = 'jwk-failed-to-resolve', - - RemoteInterstitialFailedToLoad = 'interstitial-remote-failed-to-load', } export enum TokenVerificationErrorAction { diff --git a/packages/backend/src/tokens/factory.test.ts b/packages/backend/src/tokens/factory.test.ts index ced8f12a71d..91831161076 100644 --- a/packages/backend/src/tokens/factory.test.ts +++ b/packages/backend/src/tokens/factory.test.ts @@ -3,6 +3,8 @@ import type QUnit from 'qunit'; import type { ApiClient } from '../api'; import { createAuthenticateRequest } from './factory'; +const TEST_PK = 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; + export default (QUnit: QUnit) => { const { module, test } = QUnit; @@ -19,7 +21,7 @@ export default (QUnit: QUnit) => { apiUrl: 'apiUrl', apiVersion: 'apiVersion', proxyUrl: 'proxyUrl', - publishableKey: 'pk', + publishableKey: TEST_PK, isSatellite: false, domain: 'domain', audience: 'domain', @@ -30,7 +32,7 @@ export default (QUnit: QUnit) => { apiClient: {} as ApiClient, }); - const requestState = await authenticateRequest({ request: new Request('http://example.com/') }); + const requestState = await authenticateRequest(new Request('http://example.com/')); assert.propContains(requestState.toAuth()?.debug(), buildTimeOptions); }); @@ -41,7 +43,7 @@ export default (QUnit: QUnit) => { apiUrl: 'apiUrl', apiVersion: 'apiVersion', proxyUrl: 'proxyUrl', - publishableKey: 'pk', + publishableKey: TEST_PK, isSatellite: false, domain: 'domain', audience: 'domain', @@ -54,10 +56,9 @@ export default (QUnit: QUnit) => { const overrides = { secretKey: 'r-sk', - publishableKey: 'r-pk', + publishableKey: TEST_PK, }; - const requestState = await authenticateRequest({ - request: new Request('http://example.com/'), + const requestState = await authenticateRequest(new Request('http://example.com/'), { ...overrides, }); assert.propContains(requestState.toAuth()?.debug(), { @@ -73,7 +74,7 @@ export default (QUnit: QUnit) => { apiUrl: 'apiUrl', apiVersion: 'apiVersion', proxyUrl: 'proxyUrl', - publishableKey: 'pk', + publishableKey: TEST_PK, isSatellite: false, domain: 'domain', audience: 'domain', @@ -84,8 +85,7 @@ export default (QUnit: QUnit) => { apiClient: {} as ApiClient, }); - const requestState = await authenticateRequest({ - request: new Request('http://example.com/'), + const requestState = await authenticateRequest(new Request('http://example.com/'), { // @ts-expect-error is used to check runtime code apiUrl: 'r-apiUrl', apiVersion: 'r-apiVersion', diff --git a/packages/backend/src/tokens/factory.ts b/packages/backend/src/tokens/factory.ts index f24821ad634..a59681e7d9d 100644 --- a/packages/backend/src/tokens/factory.ts +++ b/packages/backend/src/tokens/factory.ts @@ -1,7 +1,5 @@ import type { ApiClient } from '../api'; import { mergePreDefinedOptions } from '../util/mergePreDefinedOptions'; -import type { LoadInterstitialOptions } from './interstitial'; -import { loadInterstitialFromLocal } from './interstitial'; import type { AuthenticateRequestOptions } from './request'; import { authenticateRequest as authenticateRequestOriginal, debugRequestState } from './request'; @@ -40,13 +38,12 @@ export type CreateAuthenticateRequestOptions = { }; export function createAuthenticateRequest(params: CreateAuthenticateRequestOptions) { - const { apiClient } = params; const buildTimeOptions = mergePreDefinedOptions(defaultOptions, params.options); - const authenticateRequest = (options: RunTimeOptions) => { + const authenticateRequest = (request: Request, options: RunTimeOptions = {}) => { const { apiUrl, apiVersion } = buildTimeOptions; const runTimeOptions = mergePreDefinedOptions(buildTimeOptions, options); - return authenticateRequestOriginal({ + return authenticateRequestOriginal(request, { ...options, ...runTimeOptions, // We should add all the omitted props from options here (eg apiUrl / apiVersion) @@ -56,18 +53,8 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio }); }; - const localInterstitial = (options: Omit) => { - const runTimeOptions = mergePreDefinedOptions(buildTimeOptions, options); - return loadInterstitialFromLocal({ ...options, ...runTimeOptions }); - }; - - // TODO: Replace this function with remotePublicInterstitial - const remotePrivateInterstitial = () => apiClient.interstitial.getInterstitial(); - return { authenticateRequest, - localInterstitial, - remotePrivateInterstitial, debugRequestState, }; } diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts new file mode 100644 index 00000000000..5c867e4a804 --- /dev/null +++ b/packages/backend/src/tokens/handshake.ts @@ -0,0 +1,69 @@ +import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from './errors'; +import { decodeJwt, hasValidSignature, type VerifyJwtOptions } from './jwt'; +import { assertHeaderAlgorithm, assertHeaderType } from './jwt/assertions'; +import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; +import type { VerifyTokenOptions } from './verify'; + +async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Promise<{ handshake: string[] }> { + const decoded = decodeJwt(token); + + const { header, payload } = decoded; + + // Header verifications + const { typ, alg } = header; + + assertHeaderType(typ); + assertHeaderAlgorithm(alg); + + let signatureValid: boolean; + + try { + signatureValid = await hasValidSignature(decoded, key); + } catch (err) { + throw new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenVerificationFailed, + message: `Error verifying handshake token. ${err}`, + }); + } + + if (!signatureValid) { + throw new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenInvalidSignature, + message: 'Handshake signature is invalid.', + }); + } + + return payload as unknown as { handshake: string[] }; +} + +/** + * Similar to our verifyToken flow for Clerk-issued JWTs, but this verification flow is for our signed handshake payload. The handshake payload requires fewer verification steps. + */ +export async function verifyHandshakeToken( + token: string, + options: VerifyTokenOptions, +): Promise<{ handshake: string[] }> { + const { secretKey, apiUrl, apiVersion, jwksCacheTtlInMs, jwtKey, skipJwksCache } = options; + + const { header } = decodeJwt(token); + const { kid } = header; + + let key; + + if (jwtKey) { + key = loadClerkJWKFromLocal(jwtKey); + } else if (secretKey) { + // Fetch JWKS from Backend API using the key + key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache }); + } else { + throw new TokenVerificationError({ + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during handshake verification.', + reason: TokenVerificationErrorReason.JWKFailedToResolve, + }); + } + + return await verifyHandshakeJwt(token, { + key, + }); +} diff --git a/packages/backend/src/tokens/index.ts b/packages/backend/src/tokens/index.ts index 926783d53ba..50642216e4b 100644 --- a/packages/backend/src/tokens/index.ts +++ b/packages/backend/src/tokens/index.ts @@ -3,6 +3,5 @@ export { AuthStatus } from './authStatus'; export type { RequestState } from './authStatus'; export { TokenVerificationError, TokenVerificationErrorReason } from './errors'; export * from './factory'; -export { loadInterstitialFromLocal } from './interstitial'; export { debugRequestState } from './request'; export type { AuthenticateRequestOptions, OptionalVerifyTokenOptions } from './request'; diff --git a/packages/backend/src/tokens/interstitial.ts b/packages/backend/src/tokens/interstitial.ts deleted file mode 100644 index 2e9b444d290..00000000000 --- a/packages/backend/src/tokens/interstitial.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { MultiDomainAndOrProxyPrimitives } from '@clerk/types'; - -// 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 { addClerkPrefix, getScriptUrl, isDevOrStagingUrl, parsePublishableKey } from '../util/shared'; -import type { DebugRequestSate } from './request'; - -export type LoadInterstitialOptions = { - apiUrl: string; - publishableKey: string; - clerkJSUrl?: string; - clerkJSVersion?: string; - userAgent?: string; - debugData?: DebugRequestSate; - isSatellite?: boolean; - signInUrl?: string; -} & MultiDomainAndOrProxyPrimitives; - -export function loadInterstitialFromLocal(options: Omit) { - const frontendApi = parsePublishableKey(options.publishableKey)?.frontendApi || ''; - const domainOnlyInProd = !isDevOrStagingUrl(frontendApi) ? addClerkPrefix(options.domain) : ''; - const { - debugData, - clerkJSUrl, - clerkJSVersion, - publishableKey, - proxyUrl, - isSatellite = false, - domain, - signInUrl, - } = options; - return ` - - - - - - - -`; -} diff --git a/packages/backend/src/tokens/interstitialRule.ts b/packages/backend/src/tokens/interstitialRule.ts deleted file mode 100644 index cfc92670081..00000000000 --- a/packages/backend/src/tokens/interstitialRule.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { checkCrossOrigin } from '../util/request'; -import { isDevelopmentFromSecretKey, isProductionFromSecretKey } from '../util/shared'; -import type { AuthStatusOptionsType, RequestState } from './authStatus'; -import { AuthErrorReason, interstitial, signedIn, signedOut } from './authStatus'; -import { verifyToken } from './verify'; - -export type InterstitialRuleOptions = AuthStatusOptionsType & { - /* Request origin header value */ - origin?: string; - /* Request host header value */ - host?: string; - /* Request forwarded host value */ - forwardedHost?: string; - /* Request forwarded proto value */ - forwardedProto?: string; - /* Request referrer */ - referrer?: string; - /* Request user-agent value */ - userAgent?: string; - /* Client token cookie value */ - cookieToken?: string; - /* Client uat cookie value */ - clientUat?: string; - /* Client token header value */ - headerToken?: string; - /* Request search params value */ - searchParams?: URLSearchParams; -}; - -type InterstitialRuleResult = RequestState | undefined; -type InterstitialRule = (opts: InterstitialRuleOptions) => Promise | InterstitialRuleResult; - -const shouldRedirectToSatelliteUrl = (qp?: URLSearchParams) => !!qp?.get('__clerk_satellite_url'); -const hasJustSynced = (qp?: URLSearchParams) => qp?.get('__clerk_synced') === 'true'; - -const VALID_USER_AGENTS = /^Mozilla\/|(Amazon CloudFront)/; - -const isBrowser = (userAgent: string | undefined) => VALID_USER_AGENTS.test(userAgent || ''); - -// In development or staging environments only, based on the request's -// User Agent, detect non-browser requests (e.g. scripts). Since there -// is no Authorization header, consider the user as signed out and -// prevent interstitial rendering -// In production, script requests will be missing both uat and session cookies, which will be -// automatically treated as signed out. This exception is needed for development, because the any // missing uat throws an interstitial in development. -export const nonBrowserRequestInDevRule: InterstitialRule = options => { - const { secretKey, userAgent } = options; - if (isDevelopmentFromSecretKey(secretKey || '') && !isBrowser(userAgent)) { - return signedOut(options, AuthErrorReason.HeaderMissingNonBrowser); - } - return undefined; -}; - -export const crossOriginRequestWithoutHeader: InterstitialRule = options => { - const { origin, host, forwardedHost, forwardedProto } = options; - const isCrossOrigin = - origin && - checkCrossOrigin({ - originURL: new URL(origin), - host, - forwardedHost, - forwardedProto, - }); - - if (isCrossOrigin) { - return signedOut(options, AuthErrorReason.HeaderMissingCORS); - } - return undefined; -}; - -export const isPrimaryInDevAndRedirectsToSatellite: InterstitialRule = options => { - const { secretKey = '', isSatellite, searchParams } = options; - const isDev = isDevelopmentFromSecretKey(secretKey); - - if (isDev && !isSatellite && shouldRedirectToSatelliteUrl(searchParams)) { - return interstitial(options, AuthErrorReason.PrimaryRespondsToSyncing); - } - return undefined; -}; - -export const potentialFirstLoadInDevWhenUATMissing: InterstitialRule = options => { - const { secretKey = '', clientUat } = options; - const res = isDevelopmentFromSecretKey(secretKey); - if (res && !clientUat) { - return interstitial(options, AuthErrorReason.CookieUATMissing); - } - return undefined; -}; - -/** - * NOTE: Exclude any satellite app that has just synced from throwing an interstitial. - * It is expected that a primary app will trigger a redirect back to the satellite app. - */ -export const potentialRequestAfterSignInOrOutFromClerkHostedUiInDev: InterstitialRule = options => { - const { secretKey = '', referrer, host, forwardedHost, forwardedProto } = options; - const crossOriginReferrer = - referrer && checkCrossOrigin({ originURL: new URL(referrer), host, forwardedHost, forwardedProto }); - - if (isDevelopmentFromSecretKey(secretKey) && crossOriginReferrer) { - return interstitial(options, AuthErrorReason.CrossOriginReferrer); - } - return undefined; -}; - -export const potentialFirstRequestOnProductionEnvironment: InterstitialRule = options => { - const { secretKey = '', clientUat, cookieToken } = options; - - if (isProductionFromSecretKey(secretKey) && !clientUat && !cookieToken) { - return signedOut(options, AuthErrorReason.CookieAndUATMissing); - } - return undefined; -}; - -// TBD: Can enable if we do not want the __session cookie to be inspected. -// const signedOutOnDifferentSubdomainButCookieNotRemovedYet: AuthStateRule = (options, key) => { -// if (isProduction(key) && !options.clientUat && !options.cookieToken) { -// return { status: AuthStatus.Interstitial, errorReason: '' as any }; -// } -// }; -export const isNormalSignedOutState: InterstitialRule = options => { - const { clientUat } = options; - if (clientUat === '0') { - return signedOut(options, AuthErrorReason.StandardSignedOut); - } - return undefined; -}; - -// This happens when a signed in user visits a new subdomain for the first time. The uat will be available because it's set on naked domain, but session will be missing. It can also happen if the cookieToken is manually removed during development. -export const hasPositiveClientUatButCookieIsMissing: InterstitialRule = options => { - const { clientUat, cookieToken } = options; - - if (clientUat && Number.parseInt(clientUat) > 0 && !cookieToken) { - return interstitial(options, AuthErrorReason.CookieMissing); - } - return undefined; -}; - -export const hasValidHeaderToken: InterstitialRule = async options => { - const { headerToken } = options; - const sessionClaims = await verifyRequestState(options, headerToken as string); - return await signedIn(options, sessionClaims); -}; - -export const hasValidCookieToken: InterstitialRule = async options => { - const { cookieToken, clientUat } = options; - const sessionClaims = await verifyRequestState(options, cookieToken as string); - const state = await signedIn(options, sessionClaims); - - const jwt = state.toAuth().sessionClaims; - const cookieTokenIsOutdated = jwt.iat < Number.parseInt(clientUat as string); - - if (!clientUat || cookieTokenIsOutdated) { - return interstitial(options, AuthErrorReason.CookieOutDated); - } - - return state; -}; - -export async function runInterstitialRules( - opts: T, - rules: InterstitialRule[], -): Promise { - for (const rule of rules) { - const res = await rule(opts); - if (res) { - return res; - } - } - - return signedOut(opts, AuthErrorReason.UnexpectedError); -} - -async function verifyRequestState(options: InterstitialRuleOptions, token: string) { - const { isSatellite, proxyUrl } = options; - let issuer; - if (isSatellite) { - issuer = null; - } else if (proxyUrl) { - issuer = proxyUrl; - } else { - issuer = (iss: string) => iss.startsWith('https://clerk.') || iss.includes('.clerk.accounts'); - } - - return verifyToken(token, { ...options, issuer }); -} - -/** - * Avoid throwing this rule for development instances - * Let the next rule for UatMissing to fire if needed - */ -export const isSatelliteAndNeedsSyncing: InterstitialRule = options => { - const { clientUat, isSatellite, searchParams, userAgent } = options; - - const isSignedOut = !clientUat || clientUat === '0'; - - if (isSatellite && isSignedOut && !isBrowser(userAgent)) { - return signedOut(options, AuthErrorReason.SatelliteCookieNeedsSyncing); - } - - if (isSatellite && isSignedOut && !hasJustSynced(searchParams)) { - return interstitial(options, AuthErrorReason.SatelliteCookieNeedsSyncing); - } - - return undefined; -}; diff --git a/packages/backend/src/tokens/jwt/assertions.test.ts b/packages/backend/src/tokens/jwt/assertions.test.ts index 71b95e7066d..97296209867 100644 --- a/packages/backend/src/tokens/jwt/assertions.test.ts +++ b/packages/backend/src/tokens/jwt/assertions.test.ts @@ -9,7 +9,6 @@ import { assertHeaderAlgorithm, assertHeaderType, assertIssuedAtClaim, - assertIssuerClaim, assertSubClaim, } from './assertions'; @@ -232,42 +231,6 @@ export default (QUnit: QUnit) => { }); }); - module('assertIssuerClaim(iss, issuer)', () => { - test('does not throw if issuer is null', assert => { - assert.equal(undefined, assertIssuerClaim('', null)); - }); - - test('throws error if iss does not match with issuer string', assert => { - assert.raises( - () => assertIssuerClaim('issuer', ''), - new Error(`Invalid JWT issuer claim (iss) "issuer". Expected "".`), - ); - assert.raises( - () => assertIssuerClaim('issuer', 'issuer-2'), - new Error(`Invalid JWT issuer claim (iss) "issuer". Expected "issuer-2".`), - ); - }); - - test('throws error if iss does not match with issuer function result', assert => { - assert.raises( - () => assertIssuerClaim('issuer', () => false), - new Error(`Failed JWT issuer resolver. Make sure that the resolver returns a truthy value.`), - ); - }); - - test('does not throw if iss matches issuer ', assert => { - assert.equal(undefined, assertIssuerClaim('issuer', 'issuer')); - assert.equal( - undefined, - assertIssuerClaim('issuer', s => s === 'issuer'), - ); - assert.equal( - undefined, - assertIssuerClaim('issuer', () => true), - ); - }); - }); - module('assertExpirationClaim(exp, clockSkewInMs)', hooks => { let fakeClock; hooks.beforeEach(() => { diff --git a/packages/backend/src/tokens/jwt/assertions.ts b/packages/backend/src/tokens/jwt/assertions.ts index aeb4029fe45..f91e9b2636f 100644 --- a/packages/backend/src/tokens/jwt/assertions.ts +++ b/packages/backend/src/tokens/jwt/assertions.ts @@ -94,20 +94,6 @@ export const assertAuthorizedPartiesClaim = (azp?: string, authorizedParties?: s } }; -export const assertIssuerClaim = (iss: string, issuer: IssuerResolver | null) => { - if (typeof issuer === 'function' && !issuer(iss)) { - throw new TokenVerificationError({ - reason: TokenVerificationErrorReason.TokenInvalidIssuer, - message: 'Failed JWT issuer resolver. Make sure that the resolver returns a truthy value.', - }); - } else if (typeof issuer === 'string' && iss && iss !== issuer) { - throw new TokenVerificationError({ - reason: TokenVerificationErrorReason.TokenInvalidIssuer, - message: `Invalid JWT issuer claim (iss) ${JSON.stringify(iss)}. Expected "${issuer}".`, - }); - } -}; - export const assertExpirationClaim = (exp: number, clockSkewInMs: number) => { if (typeof exp !== 'number') { throw new TokenVerificationError({ diff --git a/packages/backend/src/tokens/jwt/signJwt.test.ts b/packages/backend/src/tokens/jwt/signJwt.test.ts index 5e97ec823cc..36c7db01f7e 100644 --- a/packages/backend/src/tokens/jwt/signJwt.test.ts +++ b/packages/backend/src/tokens/jwt/signJwt.test.ts @@ -31,7 +31,7 @@ export default (QUnit: QUnit) => { header: mockJwtHeader, }); - const verifiedPayload = await verifyJwt(jwt, { key: publicJwks, issuer: mockJwtPayload.iss }); + const verifiedPayload = await verifyJwt(jwt, { key: publicJwks }); assert.deepEqual(verifiedPayload, payload); }); @@ -41,7 +41,7 @@ export default (QUnit: QUnit) => { header: mockJwtHeader, }); - const verifiedPayload = await verifyJwt(jwt, { key: pemEncodedPublicKey, issuer: mockJwtPayload.iss }); + const verifiedPayload = await verifyJwt(jwt, { key: pemEncodedPublicKey }); assert.deepEqual(verifiedPayload, payload); }); }); diff --git a/packages/backend/src/tokens/jwt/verifyJwt.ts b/packages/backend/src/tokens/jwt/verifyJwt.ts index 874d616210b..a0b4d218082 100644 --- a/packages/backend/src/tokens/jwt/verifyJwt.ts +++ b/packages/backend/src/tokens/jwt/verifyJwt.ts @@ -6,7 +6,6 @@ import runtime from '../../runtime'; import { base64url } from '../../util/rfc4648'; import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; import { getCryptoAlgorithm } from './algorithms'; -import type { IssuerResolver } from './assertions'; import { assertActivationClaim, assertAudienceClaim, @@ -15,7 +14,6 @@ import { assertHeaderAlgorithm, assertHeaderType, assertIssuedAtClaim, - assertIssuerClaim, assertSubClaim, } from './assertions'; import { importKey } from './cryptoKeys'; @@ -82,13 +80,12 @@ export type VerifyJwtOptions = { audience?: string | string[]; authorizedParties?: string[]; clockSkewInMs?: number; - issuer: IssuerResolver | string | null; key: JsonWebKey | string; }; export async function verifyJwt( token: string, - { audience, authorizedParties, clockSkewInMs, issuer, key }: VerifyJwtOptions, + { audience, authorizedParties, clockSkewInMs, key }: VerifyJwtOptions, ): Promise { const clockSkew = clockSkewInMs || DEFAULT_CLOCK_SKEW_IN_SECONDS; @@ -103,12 +100,11 @@ export async function verifyJwt( assertHeaderAlgorithm(alg); // Payload verifications - const { azp, sub, aud, iss, iat, exp, nbf } = payload; + const { azp, sub, aud, iat, exp, nbf } = payload; assertSubClaim(sub); assertAudienceClaim([aud], [audience]); assertAuthorizedPartiesClaim(azp, authorizedParties); - assertIssuerClaim(iss, issuer); assertExpirationClaim(exp, clockSkew); assertActivationClaim(nbf, clockSkew); assertIssuedAtClaim(iat, clockSkew); diff --git a/packages/backend/src/tokens/keys.test.ts b/packages/backend/src/tokens/keys.test.ts index bde80cc2d1f..682d95d7cf1 100644 --- a/packages/backend/src/tokens/keys.test.ts +++ b/packages/backend/src/tokens/keys.test.ts @@ -65,24 +65,6 @@ export default (QUnit: QUnit) => { assert.propEqual(jwk, mockRsaJwk); }); - test('loads JWKS from Frontend API when issuer is provided', async assert => { - fakeFetch.onCall(0).returns(jsonOk(mockJwks)); - const jwk = await loadClerkJWKFromRemote({ - issuer: 'https://clerk.inspired.puma-74.lcl.dev', - kid: mockRsaJwkKid, - skipJwksCache: true, - }); - - fakeFetch.calledOnceWith('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', - }, - }); - assert.propEqual(jwk, mockRsaJwk); - }); - test('loads JWKS from Backend API using the provided apiUrl', async assert => { fakeFetch.onCall(0).returns(jsonOk(mockJwks)); const jwk = await loadClerkJWKFromRemote({ @@ -169,30 +151,6 @@ export default (QUnit: QUnit) => { } }); - test('throws an error when JWKS can not be fetched from Backend or Frontend API and cache updated less than 5 minutes ago', async assert => { - const kid = 'ins_whatever'; - try { - await loadClerkJWKFromRemote({ - secretKey: 'deadbeef', - kid, - }); - assert.false(true); - } catch (err) { - if (err instanceof Error) { - assert.propEqual(err, { - reason: 'jwk-remote-missing', - action: 'Contact support@clerk.com', - }); - assert.propContains(err, { - message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: ${mockRsaJwkKid}, local`, - }); - } else { - // This should never be reached. If it does, the suite should fail - assert.false(true); - } - } - }); - test('throws an error when no JWK matches the provided kid', async assert => { fakeFetch.onCall(0).returns(jsonOk(mockJwks)); const kid = 'ins_whatever'; diff --git a/packages/backend/src/tokens/keys.ts b/packages/backend/src/tokens/keys.ts index 41030f2287f..f6de7713b23 100644 --- a/packages/backend/src/tokens/keys.ts +++ b/packages/backend/src/tokens/keys.ts @@ -98,7 +98,6 @@ export type LoadClerkJWKFromRemoteOptions = { secretKey?: string; apiUrl?: string; apiVersion?: string; - issuer?: string; }; /** @@ -108,7 +107,6 @@ export type LoadClerkJWKFromRemoteOptions = { * The cache lasts 1 hour by default. * * @param {Object} options - * @param {string} options.issuer - The issuer origin of the JWT * @param {string} options.kid - The id of the key that the JWT was signed with * @param {string} options.alg - The algorithm of the JWT * @param {number} options.jwksCacheTtlInMs - The TTL of the jwks cache (defaults to 1 hour) @@ -118,27 +116,20 @@ export async function loadClerkJWKFromRemote({ secretKey, apiUrl = API_URL, apiVersion = API_VERSION, - issuer, kid, jwksCacheTtlInMs = JWKS_CACHE_TTL_MS, skipJwksCache, }: LoadClerkJWKFromRemoteOptions): Promise { - const shouldRefreshCache = !getFromCache(kid) && reachedMaxCacheUpdatedAt(); - if (skipJwksCache || shouldRefreshCache) { - let fetcher; - - if (secretKey) { - fetcher = () => fetchJWKSFromBAPI(apiUrl, secretKey, apiVersion); - } else if (issuer) { - fetcher = () => fetchJWKSFromFAPI(issuer); - } else { + const needsFetch = !getFromCache(kid) || cacheHasExpired(); + if (skipJwksCache || needsFetch) { + if (!secretKey) { throw new TokenVerificationError({ action: TokenVerificationErrorAction.ContactSupport, message: 'Failed to load JWKS from Clerk Backend or Frontend API.', reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad, }); } - + const fetcher = () => fetchJWKSFromBAPI(apiUrl, secretKey, apiVersion); const { keys } = await callWithRetry<{ keys: JsonWebKeyWithKid[] }>(fetcher); if (!keys || !keys.length) { @@ -170,23 +161,6 @@ export async function loadClerkJWKFromRemote({ return jwk; } -async function fetchJWKSFromFAPI(issuer: string) { - const url = new URL(issuer); - url.pathname = joinPaths(url.pathname, '.well-known/jwks.json'); - - const response = await runtime.fetch(url.href); - - if (!response.ok) { - throw new TokenVerificationError({ - action: TokenVerificationErrorAction.ContactSupport, - message: `Error loading Clerk JWKS from ${url.href} with code=${response.status}`, - reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad, - }); - } - - return response.json(); -} - async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string) { if (!key) { throw new TokenVerificationError({ @@ -231,6 +205,6 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string return response.json(); } -function reachedMaxCacheUpdatedAt() { +function cacheHasExpired() { return Date.now() - lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000; } diff --git a/packages/backend/src/tokens/request.test.ts b/packages/backend/src/tokens/request.test.ts index f2b24dc5ff2..66dc24b3e36 100644 --- a/packages/backend/src/tokens/request.test.ts +++ b/packages/backend/src/tokens/request.test.ts @@ -20,13 +20,11 @@ function assertSignedOut( message?: string; }, ) { - assert.propEqual(requestState, { + assert.propContains(requestState, { publishableKey: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', proxyUrl: '', status: AuthStatus.SignedOut, isSignedIn: false, - isInterstitial: false, - isUnknown: false, isSatellite: false, signInUrl: '', signUpUrl: '', @@ -54,7 +52,7 @@ function assertSignedOutToAuth(assert, requestState: RequestState) { }); } -function assertInterstitial( +function assertHandshake( assert, requestState: RequestState, expectedState: { @@ -67,10 +65,8 @@ function assertInterstitial( assert.propContains(requestState, { publishableKey: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', proxyUrl: '', - status: AuthStatus.Interstitial, + status: AuthStatus.Handshake, isSignedIn: false, - isInterstitial: true, - isUnknown: false, isSatellite: false, signInUrl: '', signUpUrl: '', @@ -82,24 +78,6 @@ function assertInterstitial( }); } -function assertUnknown(assert, requestState: RequestState, reason: AuthReason) { - assert.propContains(requestState, { - publishableKey: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', - status: AuthStatus.Unknown, - isSignedIn: false, - isInterstitial: false, - isUnknown: true, - isSatellite: false, - signInUrl: '', - signUpUrl: '', - afterSignInUrl: '', - afterSignUpUrl: '', - domain: '', - reason, - toAuth: {}, - }); -} - function assertSignedInToAuth(assert, requestState: RequestState) { assert.propContains(requestState.toAuth(), { sessionClaims: mockJwtPayload, @@ -129,8 +107,6 @@ function assertSignedIn( proxyUrl: '', status: AuthStatus.SignedIn, isSignedIn: true, - isInterstitial: false, - isUnknown: false, isSatellite: false, signInUrl: '', signUpUrl: '', @@ -142,15 +118,19 @@ function assertSignedIn( } export default (QUnit: QUnit) => { - const { module, test, skip } = QUnit; + const { module, test } = QUnit; const defaultHeaders: Record = { host: 'example.com', 'user-agent': 'Mozilla/TestAgent', }; + const mockRequest = (headers = {}, requestUrl = 'http://clerk.com/path') => { + return new Request(requestUrl, { headers: { ...defaultHeaders, ...headers } }); + }; + /* An otherwise bare state on a request. */ - const defaultMockAuthenticateRequestOptions = (headers = defaultHeaders, requestUrl = 'http://clerk.com/path') => + const mockOptions = (options?) => ({ secretKey: 'deadbeef', apiUrl: 'https://api.clerk.test', @@ -164,34 +144,19 @@ export default (QUnit: QUnit) => { afterSignInUrl: '', afterSignUpUrl: '', domain: '', - request: new Request(requestUrl, { headers }), + ...options, } satisfies AuthenticateRequestOptions); - const defaultMockHeaderAuthOptions = (headers = defaultHeaders, requestUrl?) => { - return { - ...defaultMockAuthenticateRequestOptions( - { - authorization: mockJwt, - ...headers, - }, - requestUrl, - ), - }; + const mockRequestWithHeaderAuth = (headers?, requestUrl?) => { + return mockRequest({ authorization: mockJwt, ...headers }, requestUrl); }; - const defaultMockCookieAuthOptions = (headers = defaultHeaders, cookies = {}, requestUrl?) => { + const mockRequestWithCookies = (headers?, cookies = {}, requestUrl?) => { const cookieStr = Object.entries(cookies) .map(([k, v]) => `${k}=${v}`) .join(';'); - return { - ...defaultMockAuthenticateRequestOptions( - { - cookie: cookieStr, - ...headers, - }, - requestUrl, - ), - }; + + return mockRequest({ cookie: cookieStr, ...headers }, requestUrl); }; module('tokens.authenticateRequest(options)', hooks => { @@ -216,10 +181,12 @@ export default (QUnit: QUnit) => { test('returns signed out state if jwk fails to load from remote', async assert => { fakeFetch.onCall(0).returns(jsonOk({})); - const requestState = await authenticateRequest({ - ...defaultMockHeaderAuthOptions(), - skipJwksCache: false, - }); + const requestState = await authenticateRequest( + mockRequestWithHeaderAuth(), + mockOptions({ + skipJwksCache: false, + }), + ); const errMessage = 'The JWKS endpoint did not contain any signing keys. Contact support@clerk.com. Contact support@clerk.com (reason=jwk-remote-failed-to-load, token-carrier=header)'; @@ -231,7 +198,7 @@ export default (QUnit: QUnit) => { }); test('headerToken: returns signed in state when a valid token [1y.2y]', async assert => { - const requestState = await authenticateRequest(defaultMockHeaderAuthOptions()); + const requestState = await authenticateRequest(mockRequestWithHeaderAuth(), mockOptions()); assertSignedIn(assert, requestState); assertSignedInToAuth(assert, requestState); @@ -245,10 +212,12 @@ export default (QUnit: QUnit) => { // ); test('headerToken: returns signed out state when a token with invalid authorizedParties [1y.2n]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockHeaderAuthOptions(), - authorizedParties: ['whatever'], - }); + const requestState = await authenticateRequest( + mockRequestWithHeaderAuth(), + mockOptions({ + authorizedParties: ['whatever'], + }), + ); const errMessage = 'Invalid JWT Authorized party claim (azp) "https://accounts.inspired.puma-74.lcl.dev". Expected "whatever". (reason=token-invalid-authorized-parties, token-carrier=header)'; @@ -259,22 +228,22 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - test('headerToken: returns interstitial state when token expired [1y.2n]', async assert => { + test('headerToken: returns handshake state when token expired [1y.2n]', async assert => { // advance clock for 1 hour fakeClock.tick(3600 * 1000); - const requestState = await authenticateRequest(defaultMockHeaderAuthOptions()); + const requestState = await authenticateRequest(mockRequestWithHeaderAuth(), mockOptions()); - assertUnknown(assert, requestState, TokenVerificationErrorReason.TokenExpired); + assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); assert.strictEqual(requestState.toAuth(), null); }); test('headerToken: returns signed out state when invalid signature [1y.2n]', async assert => { const requestState = await authenticateRequest( - defaultMockHeaderAuthOptions({ - ...defaultHeaders, + mockRequestWithHeaderAuth({ authorization: mockInvalidSignatureJwt, }), + mockOptions(), ); const errMessage = 'JWT signature is invalid. (reason=token-invalid-signature, token-carrier=header)'; @@ -287,10 +256,8 @@ export default (QUnit: QUnit) => { test('headerToken: returns signed out state when an malformed token [1y.1n]', async assert => { const requestState = await authenticateRequest( - defaultMockHeaderAuthOptions({ - ...defaultHeaders, - authorization: 'test_header_token', - }), + mockRequestWithHeaderAuth({ authorization: 'test_header_token' }), + mockOptions(), ); const errMessage = @@ -302,61 +269,26 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - // - // HTTP Authorization does NOT exist and __session cookie exists - // - - test('cookieToken: returns signed out state when cross-origin request [2y]', async assert => { + test('cookieToken: returns handshake when clientUat is missing or equals to 0 and is satellite and not is synced [11y]', async assert => { const requestState = await authenticateRequest( - defaultMockCookieAuthOptions( + mockRequestWithCookies( { - ...defaultHeaders, - origin: 'https://clerk.com', - 'x-forwarded-proto': 'http', + 'Sec-Fetch-Dest': 'document', }, - { __session: mockJwt }, - ), - ); - - assertSignedOut(assert, requestState, { - reason: AuthErrorReason.HeaderMissingCORS, - }); - assertSignedOutToAuth(assert, requestState); - }); - - test('cookieToken: returns signed out when non browser requests in development [3y]', async assert => { - const nonBrowserUserAgent = 'curl'; - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( { - ...defaultHeaders, - 'user-agent': nonBrowserUserAgent, + __client_uat: '0', }, - { __client_uat: '12345', __session: mockJwt }, ), - secretKey: 'test_deadbeef', - }); - - assertSignedOut(assert, requestState, { reason: AuthErrorReason.HeaderMissingNonBrowser }); - assertSignedOutToAuth(assert, requestState); - }); - - test('cookieToken: returns interstitial when clientUat is missing or equals to 0 and is satellite and not is synced [11y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( - { - ...defaultHeaders, - }, - { __client_uat: '0' }, - ), - secretKey: 'deadbeef', - clientUat: '0', - isSatellite: true, - signInUrl: 'https://primary.dev/sign-in', - domain: 'satellite.dev', - }); + mockOptions({ + secretKey: 'deadbeef', + clientUat: '0', + isSatellite: true, + signInUrl: 'https://primary.dev/sign-in', + domain: 'satellite.dev', + }), + ); - assertInterstitial(assert, requestState, { + assertHandshake(assert, requestState, { reason: AuthErrorReason.SatelliteCookieNeedsSyncing, isSatellite: true, signInUrl: 'https://primary.dev/sign-in', @@ -367,22 +299,24 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns signed out is satellite but a non-browser request [11y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( + const requestState = await authenticateRequest( + mockRequestWithCookies( { ...defaultHeaders, 'user-agent': '[some-agent]', }, { __client_uat: '0' }, ), - secretKey: 'deadbeef', - isSatellite: true, - signInUrl: 'https://primary.dev/sign-in', - domain: 'satellite.dev', - }); + mockOptions({ + secretKey: 'deadbeef', + isSatellite: true, + signInUrl: 'https://primary.dev/sign-in', + domain: 'satellite.dev', + }), + ); assertSignedOut(assert, requestState, { - reason: AuthErrorReason.SatelliteCookieNeedsSyncing, + reason: AuthErrorReason.SessionTokenAndUATMissing, isSatellite: true, signInUrl: 'https://primary.dev/sign-in', domain: 'satellite.dev', @@ -390,17 +324,19 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - test('cookieToken: returns interstitial when app is satellite, returns from primary and is dev instance [13y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(), - secretKey: 'sk_test_deadbeef', - signInUrl: 'http://primary.example/sign-in', - isSatellite: true, - domain: 'satellite.example', - }); + test('cookieToken: returns handshake when app is satellite, returns from primary and is dev instance [13y]', async assert => { + const requestState = await authenticateRequest( + mockRequestWithCookies({}, {}, `http://satellite.example/path?__clerk_synced=true&__clerk_db_jwt=${mockJwt}`), + mockOptions({ + secretKey: 'sk_test_deadbeef', + signInUrl: 'http://primary.example/sign-in', + isSatellite: true, + domain: 'satellite.example', + }), + ); - assertInterstitial(assert, requestState, { - reason: AuthErrorReason.SatelliteCookieNeedsSyncing, + assertHandshake(assert, requestState, { + reason: AuthErrorReason.DevBrowserSync, isSatellite: true, domain: 'satellite.example', signInUrl: 'http://primary.example/sign-in', @@ -409,17 +345,20 @@ export default (QUnit: QUnit) => { assert.strictEqual(requestState.toAuth(), null); }); - test('cookieToken: returns interstitial when app is not satellite and responds to syncing on dev instances[12y]', async assert => { + test('cookieToken: returns handshake when app is not satellite and responds to syncing on dev instances[12y]', async assert => { const sp = new URLSearchParams(); - sp.set('__clerk_satellite_url', 'http://localhost:3000'); + sp.set('__clerk_redirect_url', 'http://localhost:3000'); const requestUrl = `http://clerk.com/path?${sp.toString()}`; - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '12345', __session: mockJwt }, requestUrl), - secretKey: 'sk_test_deadbeef', - isSatellite: false, - }); + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { __client_uat: '12345', __session: mockJwt }, + requestUrl, + ), + mockOptions({ secretKey: 'sk_test_deadbeef', isSatellite: false }), + ); - assertInterstitial(assert, requestState, { + assertHandshake(assert, requestState, { reason: AuthErrorReason.PrimaryRespondsToSyncing, }); assert.equal(requestState.message, ''); @@ -427,63 +366,36 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns signed out when no cookieToken and no clientUat in production [4y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(), - secretKey: 'live_deadbeef', - }); + const requestState = await authenticateRequest( + mockRequestWithCookies(), + mockOptions({ + secretKey: 'live_deadbeef', + }), + ); assertSignedOut(assert, requestState, { - reason: AuthErrorReason.CookieAndUATMissing, + reason: AuthErrorReason.SessionTokenAndUATMissing, }); assertSignedOutToAuth(assert, requestState); }); - test('cookieToken: returns interstitial when no clientUat in development [5y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __session: mockJwt }), - secretKey: 'test_deadbeef', - }); - - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CookieUATMissing }); - assert.equal(requestState.message, ''); - assert.strictEqual(requestState.toAuth(), null); - }); - - // Omit because it caused view-source to always returns the interstitial in development mode (there's no referrer for view-source) - skip('cookieToken: returns interstitial when no referrer in development [6y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '12345', __session: mockJwt }), - secretKey: 'test_deadbeef', - }); - - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CrossOriginReferrer }); - assert.equal(requestState.message, ''); - assert.strictEqual(requestState.toAuth(), null); - }); - - test('cookieToken: returns interstitial when crossOriginReferrer in development [6y]', async assert => { - // Scenario: after auth action on Clerk-hosted UIs - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( - { - ...defaultHeaders, - // this is not a typo, it's intentional to be `referer` to match HTTP header key - referer: 'https://clerk.com', - }, - { __client_uat: '12345', __session: mockJwt }, - ), - secretKey: 'test_deadbeef', - }); + test('cookieToken: returns handshake when no clientUat in development [5y]', async assert => { + const requestState = await authenticateRequest( + mockRequestWithCookies({}, { __session: mockJwt }), + mockOptions({ + secretKey: 'test_deadbeef', + }), + ); - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CrossOriginReferrer }); + assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenWithoutClientUAT }); assert.equal(requestState.message, ''); assert.strictEqual(requestState.toAuth(), null); }); test('cookieToken: returns undefined when crossOriginReferrer in development and is satellite [6n]', async assert => { // Scenario: after auth action on Clerk-hosted UIs - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( + const requestState = await authenticateRequest( + mockRequestWithCookies( { ...defaultHeaders, // this is not a typo, it's intentional to be `referer` to match HTTP header key @@ -491,11 +403,13 @@ export default (QUnit: QUnit) => { }, { __client_uat: '12345', __session: mockJwt }, ), - secretKey: 'pk_test_deadbeef', - isSatellite: true, - signInUrl: 'https://localhost:3000/sign-in/', - domain: 'localhost:3001', - }); + mockOptions({ + secretKey: 'pk_test_deadbeef', + isSatellite: true, + signInUrl: 'https://localhost:3000/sign-in/', + domain: 'localhost:3001', + }), + ); assertSignedIn(assert, requestState, { isSatellite: true, @@ -505,54 +419,53 @@ export default (QUnit: QUnit) => { assertSignedInToAuth(assert, requestState); }); - // // Note: The user is definitely signed out here so this interstitial can be - // // eliminated. We can keep it if we're worried about something inspecting - // // the __session cookie manually - skip('cookieToken: returns interstitial when clientUat = 0 [7y]', assert => { - assert.true(true); - }); - - test('cookieToken: returns interstitial when clientUat > 0 and no cookieToken [8y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '12345' }), - secretKey: 'deadbeef', - }); + test('cookieToken: returns handshake when clientUat > 0 and no cookieToken [8y]', async assert => { + const requestState = await authenticateRequest( + mockRequestWithCookies({}, { __client_uat: '12345' }), + mockOptions({ secretKey: 'deadbeef' }), + ); - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CookieMissing }); + assertHandshake(assert, requestState, { reason: AuthErrorReason.ClientUATWithoutSessionToken }); assert.equal(requestState.message, ''); assert.strictEqual(requestState.toAuth(), null); }); test('cookieToken: returns signed out when clientUat = 0 and no cookieToken [9y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '0' }), - }); + const requestState = await authenticateRequest(mockRequestWithCookies({}, { __client_uat: '0' }), mockOptions()); assertSignedOut(assert, requestState, { - reason: AuthErrorReason.StandardSignedOut, + reason: AuthErrorReason.SessionTokenAndUATMissing, }); assertSignedOutToAuth(assert, requestState); }); - test('cookieToken: returns interstitial when clientUat > cookieToken.iat [10n]', async assert => { + test('cookieToken: returns handshake when clientUat > cookieToken.iat [10n]', async assert => { const requestState = await authenticateRequest( - defaultMockCookieAuthOptions(defaultHeaders, { - __client_uat: `${mockJwtPayload.iat + 10}`, - __session: mockJwt, - }), + mockRequestWithCookies( + {}, + { + __client_uat: `${mockJwtPayload.iat + 10}`, + __session: mockJwt, + }, + ), + mockOptions(), ); - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CookieOutDated }); + assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); assert.equal(requestState.message, ''); assert.strictEqual(requestState.toAuth(), null); }); test('cookieToken: returns signed out when cookieToken.iat >= clientUat and malformed token [10y.1n]', async assert => { const requestState = await authenticateRequest( - defaultMockCookieAuthOptions(defaultHeaders, { - __client_uat: `${mockJwtPayload.iat - 10}`, - __session: mockMalformedJwt, - }), + mockRequestWithCookies( + {}, + { + __client_uat: `${mockJwtPayload.iat - 10}`, + __session: mockMalformedJwt, + }, + ), + mockOptions(), ); const errMessage = @@ -566,10 +479,14 @@ export default (QUnit: QUnit) => { test('cookieToken: returns signed in when cookieToken.iat >= clientUat and valid token [10y.2y]', async assert => { const requestState = await authenticateRequest( - defaultMockCookieAuthOptions(defaultHeaders, { - __client_uat: `${mockJwtPayload.iat - 10}`, - __session: mockJwt, - }), + mockRequestWithCookies( + {}, + { + __client_uat: `${mockJwtPayload.iat - 10}`, + __session: mockJwt, + }, + ), + mockOptions(), ); assertSignedIn(assert, requestState); @@ -583,33 +500,37 @@ export default (QUnit: QUnit) => { // }, // ); - test('cookieToken: returns interstitial when cookieToken.iat >= clientUat and expired token [10y.2n.1n]', async assert => { + test('cookieToken: returns handshake when cookieToken.iat >= clientUat and expired token [10y.2n.1n]', async assert => { // advance clock for 1 hour fakeClock.tick(3600 * 1000); const requestState = await authenticateRequest( - defaultMockCookieAuthOptions(defaultHeaders, { - __client_uat: `${mockJwtPayload.iat - 10}`, - __session: mockJwt, - }), + mockRequestWithCookies( + {}, + { + __client_uat: `${mockJwtPayload.iat - 10}`, + __session: mockJwt, + }, + ), + mockOptions(), ); - assertInterstitial(assert, requestState, { reason: TokenVerificationErrorReason.TokenExpired }); + assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); assert.true(/^JWT is expired/.test(requestState.message || '')); assert.strictEqual(requestState.toAuth(), null); }); test('cookieToken: returns signed in for Amazon Cloudfront userAgent', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( + const requestState = await authenticateRequest( + mockRequestWithCookies( { ...defaultHeaders, 'user-agent': 'Amazon CloudFront', }, { __client_uat: `12345`, __session: mockJwt }, ), - secretKey: 'test_deadbeef', - }); + mockOptions({ secretKey: 'test_deadbeef' }), + ); assertSignedIn(assert, requestState); assertSignedInToAuth(assert, requestState); @@ -617,22 +538,11 @@ export default (QUnit: QUnit) => { }); module('tokens.loadOptionsFromHeaders', () => { - const defaultOptions = { - headerToken: '', - origin: '', - host: '', - forwardedHost: '', - forwardedProto: '', - referrer: '', - userAgent: '', - }; - test('returns forwarded headers from headers', assert => { const headersData = { 'x-forwarded-proto': 'http', 'x-forwarded-port': '80', 'x-forwarded-host': 'example.com' }; const headers = key => headersData[key] || ''; - assert.propEqual(loadOptionsFromHeaders(headers), { - ...defaultOptions, + assert.propContains(loadOptionsFromHeaders(headers), { forwardedProto: 'http', forwardedHost: 'example.com', }); @@ -647,8 +557,7 @@ export default (QUnit: QUnit) => { }; const headers = key => headersData[key] || ''; - assert.propEqual(loadOptionsFromHeaders(headers), { - ...defaultOptions, + assert.propContains(loadOptionsFromHeaders(headers), { forwardedProto: 'https', forwardedHost: 'example.com', }); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index bb61419175d..e84e12fc4a2 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,27 +1,18 @@ +import { parsePublishableKey } from '@clerk/shared/keys'; +import type { JwtPayload } from '@clerk/types'; + import { constants } from '../constants'; import { assertValidSecretKey } from '../util/assertValidSecretKey'; import { buildRequest, stripAuthorizationHeader } from '../util/IsomorphicRequest'; import { isDevelopmentFromSecretKey } from '../util/shared'; import type { AuthStatusOptionsType, RequestState } from './authStatus'; -import { AuthErrorReason, interstitial, signedOut, unknownState } from './authStatus'; +import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; import type { TokenCarrier } from './errors'; import { TokenVerificationError, TokenVerificationErrorReason } from './errors'; -import type { InterstitialRuleOptions } from './interstitialRule'; -import { - crossOriginRequestWithoutHeader, - hasPositiveClientUatButCookieIsMissing, - hasValidCookieToken, - hasValidHeaderToken, - isNormalSignedOutState, - isPrimaryInDevAndRedirectsToSatellite, - isSatelliteAndNeedsSyncing, - nonBrowserRequestInDevRule, - potentialFirstLoadInDevWhenUATMissing, - potentialFirstRequestOnProductionEnvironment, - potentialRequestAfterSignInOrOutFromClerkHostedUiInDev, - runInterstitialRules, -} from './interstitialRule'; -import type { VerifyTokenOptions } from './verify'; +import { verifyHandshakeToken } from './handshake'; +import { decodeJwt } from './jwt'; +import { verifyToken, type VerifyTokenOptions } from './verify'; + export type OptionalVerifyTokenOptions = Partial< Pick< VerifyTokenOptions, @@ -29,7 +20,7 @@ export type OptionalVerifyTokenOptions = Partial< > >; -export type AuthenticateRequestOptions = AuthStatusOptionsType & OptionalVerifyTokenOptions & { request: Request }; +export type AuthenticateRequestOptions = AuthStatusOptionsType & OptionalVerifyTokenOptions; function assertSignInUrlExists(signInUrl: string | undefined, key: string): asserts signInUrl is string { if (!signInUrl && isDevelopmentFromSecretKey(key)) { @@ -56,85 +47,266 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { } } -export async function authenticateRequest(options: AuthenticateRequestOptions): Promise { - const { cookies, headers, searchParams } = buildRequest(options?.request); +export async function authenticateRequest( + request: Request, + options: AuthenticateRequestOptions, +): Promise { + const { cookies, headers, searchParams, derivedRequestUrl } = buildRequest(request); - const ruleOptions = { + const authenticateContext = { ...options, ...loadOptionsFromHeaders(headers), ...loadOptionsFromCookies(cookies), searchParams, - } satisfies InterstitialRuleOptions; + derivedRequestUrl, + }; - assertValidSecretKey(ruleOptions.secretKey); + const devBrowserToken = + searchParams?.get(constants.Cookies.DevBrowser) || cookies(constants.Cookies.DevBrowser) || ''; + const handshakeToken = searchParams?.get(constants.Cookies.Handshake) || cookies(constants.Cookies.Handshake) || ''; - if (ruleOptions.isSatellite) { - assertSignInUrlExists(ruleOptions.signInUrl, ruleOptions.secretKey); - if (ruleOptions.signInUrl && ruleOptions.origin) { - assertSignInUrlFormatAndOrigin(ruleOptions.signInUrl, ruleOptions.origin); + assertValidSecretKey(authenticateContext.secretKey); + + if (authenticateContext.isSatellite) { + assertSignInUrlExists(authenticateContext.signInUrl, authenticateContext.secretKey); + if (authenticateContext.signInUrl && authenticateContext.origin) { + assertSignInUrlFormatAndOrigin(authenticateContext.signInUrl, authenticateContext.origin); } - assertProxyUrlOrDomain(ruleOptions.proxyUrl || ruleOptions.domain); + assertProxyUrlOrDomain(authenticateContext.proxyUrl || authenticateContext.domain); } + function buildRedirectToHandshake() { + const redirectUrl = new URL(derivedRequestUrl); + redirectUrl.searchParams.delete('__clerk_db_jwt'); + const frontendApiNoProtocol = pk.frontendApi.replace(/http(s)?:\/\//, ''); + + const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); + url.searchParams.append('redirect_url', redirectUrl?.href || ''); + + if (pk?.instanceType === 'development' && devBrowserToken) { + url.searchParams.append('__clerk_db_jwt', devBrowserToken); + } + + return new Headers({ location: url.href }); + } + + async function resolveHandshake() { + const { derivedRequestUrl } = authenticateContext; + + const headers = new Headers({ + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Credentials': 'true', + }); + + const handshakePayload = await verifyHandshakeToken(handshakeToken, authenticateContext); + const cookiesToSet = handshakePayload.handshake; + + let sessionToken = ''; + cookiesToSet.forEach((x: string) => { + headers.append('Set-Cookie', x); + if (x.startsWith('__session=')) { + sessionToken = x.split(';')[0].substring(10); + } + }); + + if (instanceType === 'development') { + const newUrl = new URL(derivedRequestUrl); + newUrl.searchParams.delete('__clerk_handshake'); + newUrl.searchParams.delete('__clerk_help'); + headers.append('Location', newUrl.toString()); + } + + if (sessionToken === '') { + return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); + } + + let verifyResult: JwtPayload; + + try { + verifyResult = await verifyToken(sessionToken, authenticateContext); + } catch (err) { + if (err instanceof TokenVerificationError) { + 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. + +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 }); + } + } else { + throw err; + } + } + + return signedIn(authenticateContext, verifyResult!, headers); + } + + const pk = parsePublishableKey(options.publishableKey, { + fatal: true, + proxyUrl: options.proxyUrl, + domain: options.domain, + }); + + const instanceType = pk.instanceType; + async function authenticateRequestWithTokenInHeader() { + const { sessionTokenInHeader } = authenticateContext; + try { - const state = await runInterstitialRules(ruleOptions, [hasValidHeaderToken]); - return state; + const verifyResult = await verifyToken(sessionTokenInHeader!, authenticateContext); + return await signedIn(options, verifyResult); } catch (err) { return handleError(err, 'header'); } } async function authenticateRequestWithTokenInCookie() { + const { + derivedRequestUrl, + isSatellite, + secFetchDest, + signInUrl, + clientUat: clientUatRaw, + sessionTokenInCookie: sessionToken, + } = authenticateContext; + + const clientUat = parseInt(clientUatRaw || '', 10) || 0; + const hasActiveClient = clientUat > 0; + const hasSessionToken = !!sessionToken; + + const isRequestEligibleForMultiDomainSync = + isSatellite && + secFetchDest === 'document' && + !derivedRequestUrl.searchParams.has(constants.QueryParameters.ClerkSynced); + + /** + * If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state. + */ + if (handshakeToken) { + return resolveHandshake(); + } + + /** + * Otherwise, check for "known unknown" auth states that we can resolve with a handshake. + */ + if (instanceType === 'development' && derivedRequestUrl.searchParams.has(constants.Cookies.DevBrowser)) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.DevBrowserSync, '', headers); + } + + /** + * Begin multi-domain sync flows + */ + if (instanceType === 'production' && isRequestEligibleForMultiDomainSync) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + } + + // Multi-domain development sync flow + if (instanceType === 'development' && isRequestEligibleForMultiDomainSync) { + // initiate MD sync + + // signInUrl exists, checked at the top of `authenticateRequest` + const redirectURL = new URL(signInUrl!); + redirectURL.searchParams.append(constants.QueryParameters.ClerkRedirectUrl, derivedRequestUrl.toString()); + + const headers = new Headers({ location: redirectURL.toString() }); + return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + } + + // Multi-domain development sync flow + const redirectUrl = new URL(derivedRequestUrl).searchParams.get(constants.QueryParameters.ClerkRedirectUrl); + if (instanceType === 'development' && !isSatellite && redirectUrl) { + // Dev MD sync from primary, redirect back to satellite w/ __clerk_db_jwt + const redirectBackToSatelliteUrl = new URL(redirectUrl); + + if (devBrowserToken) { + redirectBackToSatelliteUrl.searchParams.append(constants.Cookies.DevBrowser, devBrowserToken); + } + redirectBackToSatelliteUrl.searchParams.append(constants.QueryParameters.ClerkSynced, 'true'); + + const headers = new Headers({ location: redirectBackToSatelliteUrl.toString() }); + return handshake(authenticateContext, AuthErrorReason.PrimaryRespondsToSyncing, '', headers); + } + /** + * End multi-domain sync flows + */ + + if (!hasActiveClient && !hasSessionToken) { + return signedOut(authenticateContext, AuthErrorReason.SessionTokenAndUATMissing); + } + + // This can eagerly run handshake since client_uat is SameSite=Strict in dev + if (!hasActiveClient && hasSessionToken) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SessionTokenWithoutClientUAT, '', headers); + } + + if (hasActiveClient && !hasSessionToken) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.ClientUATWithoutSessionToken, '', headers); + } + + const decodeResult = decodeJwt(sessionToken!); + + if (decodeResult.payload.iat < clientUat) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SessionTokenOutdated, '', headers); + } + try { - const state = await runInterstitialRules(ruleOptions, [ - crossOriginRequestWithoutHeader, - nonBrowserRequestInDevRule, - isSatelliteAndNeedsSyncing, - isPrimaryInDevAndRedirectsToSatellite, - potentialFirstRequestOnProductionEnvironment, - potentialFirstLoadInDevWhenUATMissing, - potentialRequestAfterSignInOrOutFromClerkHostedUiInDev, - hasPositiveClientUatButCookieIsMissing, - isNormalSignedOutState, - hasValidCookieToken, - ]); - - return state; + const verifyResult = await verifyToken(sessionToken!, authenticateContext); + if (verifyResult) { + return signedIn(authenticateContext, verifyResult); + } } catch (err) { return handleError(err, 'cookie'); } + + return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); } function handleError(err: unknown, tokenCarrier: TokenCarrier) { if (err instanceof TokenVerificationError) { err.tokenCarrier = tokenCarrier; - const reasonToReturnInterstitial = [ + const reasonToHandshake = [ TokenVerificationErrorReason.TokenExpired, TokenVerificationErrorReason.TokenNotActiveYet, ].includes(err.reason); - if (reasonToReturnInterstitial) { - if (tokenCarrier === 'header') { - return unknownState(ruleOptions, err.reason, err.getFullMessage()); - } - return interstitial(ruleOptions, err.reason, err.getFullMessage()); + if (reasonToHandshake) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SessionTokenOutdated, err.getFullMessage(), headers); } - return signedOut(ruleOptions, err.reason, err.getFullMessage()); + return signedOut(authenticateContext, err.reason, err.getFullMessage()); } - return signedOut(ruleOptions, AuthErrorReason.UnexpectedError, (err as Error).message); + + return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); } - if (ruleOptions.headerToken) { + if (authenticateContext.sessionTokenInHeader) { return authenticateRequestWithTokenInHeader(); } return authenticateRequestWithTokenInCookie(); } export const debugRequestState = (params: RequestState) => { - const { isSignedIn, proxyUrl, isInterstitial, reason, message, publishableKey, isSatellite, domain } = params; - return { isSignedIn, proxyUrl, isInterstitial, reason, message, publishableKey, isSatellite, domain }; + const { isSignedIn, proxyUrl, reason, message, publishableKey, isSatellite, domain } = params; + return { isSignedIn, proxyUrl, reason, message, publishableKey, isSatellite, domain }; }; export type DebugRequestSate = ReturnType; @@ -148,13 +320,14 @@ export const loadOptionsFromHeaders = (headers: ReturnType[ } return { - headerToken: stripAuthorizationHeader(headers(constants.Headers.Authorization)), + sessionTokenInHeader: stripAuthorizationHeader(headers(constants.Headers.Authorization)), origin: headers(constants.Headers.Origin), host: headers(constants.Headers.Host), forwardedHost: headers(constants.Headers.ForwardedHost), forwardedProto: headers(constants.Headers.CloudFrontForwardedProto) || headers(constants.Headers.ForwardedProto), referrer: headers(constants.Headers.Referrer), userAgent: headers(constants.Headers.UserAgent), + secFetchDest: headers(constants.Headers.SecFetchDest), }; }; @@ -167,7 +340,7 @@ export const loadOptionsFromCookies = (cookies: ReturnType[ } return { - cookieToken: cookies?.(constants.Cookies.Session), + sessionTokenInCookie: cookies?.(constants.Cookies.Session), clientUat: cookies?.(constants.Cookies.ClientUat), }; }; diff --git a/packages/backend/src/tokens/verify.test.ts b/packages/backend/src/tokens/verify.test.ts index a5557cffb97..c9d89e279a3 100644 --- a/packages/backend/src/tokens/verify.test.ts +++ b/packages/backend/src/tokens/verify.test.ts @@ -30,7 +30,6 @@ export default (QUnit: QUnit) => { apiUrl: 'https://api.clerk.test', secretKey: 'a-valid-key', authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], - issuer: 'https://clerk.inspired.puma-74.lcl.dev', skipJwksCache: true, }); @@ -42,7 +41,6 @@ export default (QUnit: QUnit) => { const payload = await verifyToken(mockJwt, { secretKey: 'a-valid-key', authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], - issuer: 'https://clerk.inspired.puma-74.lcl.dev', skipJwksCache: true, }); @@ -56,44 +54,5 @@ export default (QUnit: QUnit) => { }); assert.propEqual(payload, mockJwtPayload); }); - - test('verifies the token by fetching the JWKs from Frontend API when issuer (string) is provided ', async assert => { - const payload = await verifyToken(mockJwt, { - authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], - issuer: 'https://clerk.inspired.puma-74.lcl.dev', - skipJwksCache: true, - }); - - fakeFetch.calledOnceWith('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', - }, - }); - assert.propEqual(payload, mockJwtPayload); - }); - - test('throws an error when the verification fails', async assert => { - try { - await verifyToken(mockJwt, { - secretKey: 'a-valid-key', - issuer: 'https://clerk.whatever.lcl.dev', - skipJwksCache: true, - }); - // This should never be reached. If it does, the suite should fail - assert.false(true); - } catch (err) { - if (err instanceof Error) { - assert.equal( - err.message, - 'Invalid JWT issuer claim (iss) "https://clerk.inspired.puma-74.lcl.dev". Expected "https://clerk.whatever.lcl.dev".', - ); - } else { - // This should never be reached. If it does, the suite should fail - assert.false(true); - } - } - }); }); }; diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index dafe984910f..9801ed30e42 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -9,13 +9,9 @@ import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; /** * */ -export type VerifyTokenOptions = Pick< - VerifyJwtOptions, - 'authorizedParties' | 'audience' | 'issuer' | 'clockSkewInMs' -> & { jwtKey?: string; proxyUrl?: string } & Pick< - LoadClerkJWKFromRemoteOptions, - 'secretKey' | 'apiUrl' | 'apiVersion' | 'jwksCacheTtlInMs' | 'skipJwksCache' - >; +export type VerifyTokenOptions = Pick & { + jwtKey?: string; +} & Pick; export async function verifyToken(token: string, options: VerifyTokenOptions): Promise { const { @@ -25,7 +21,6 @@ export async function verifyToken(token: string, options: VerifyTokenOptions): P audience, authorizedParties, clockSkewInMs, - issuer, jwksCacheTtlInMs, jwtKey, skipJwksCache, @@ -38,9 +33,6 @@ export async function verifyToken(token: string, options: VerifyTokenOptions): P if (jwtKey) { key = loadClerkJWKFromLocal(jwtKey); - } else if (typeof issuer === 'string') { - // Fetch JWKS from Frontend API if an issuer of type string has been provided - key = await loadClerkJWKFromRemote({ issuer, kid, jwksCacheTtlInMs, skipJwksCache }); } else if (secretKey) { // Fetch JWKS from Backend API using the key key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache }); @@ -57,6 +49,5 @@ export async function verifyToken(token: string, options: VerifyTokenOptions): P authorizedParties, clockSkewInMs, key, - issuer, }); } diff --git a/packages/backend/src/util/IsomorphicRequest.ts b/packages/backend/src/util/IsomorphicRequest.ts index 00028a4076d..7ac783560c1 100644 --- a/packages/backend/src/util/IsomorphicRequest.ts +++ b/packages/backend/src/util/IsomorphicRequest.ts @@ -11,18 +11,17 @@ export const createIsomorphicRequest = (cb: IsomorphicRequestOptions): Request = return new runtime.Request(headersGeneratedURL, req); }; -export const buildRequest = (req?: Request) => { - if (!req) { - return {}; - } +export const buildRequest = (req: Request) => { const cookies = parseIsomorphicRequestCookies(req); const headers = getHeaderFromIsomorphicRequest(req); const searchParams = getSearchParamsFromIsomorphicRequest(req); + const derivedRequestUrl = buildRequestUrl(req); return { cookies, headers, searchParams, + derivedRequestUrl, }; }; diff --git a/packages/backend/src/utils.test.ts b/packages/backend/src/utils.test.ts index 9114d8cb815..d76615d66a6 100644 --- a/packages/backend/src/utils.test.ts +++ b/packages/backend/src/utils.test.ts @@ -105,11 +105,6 @@ export default (QUnit: QUnit) => { assert.equal(buildRequestUrl(req), 'https://example.com/path'); }); - test('with path', assert => { - const req = new Request('http://localhost:3000/path'); - assert.equal(buildRequestUrl(req, '/other-path'), 'http://localhost:3000/other-path'); - }); - test('with query params in request', assert => { const req = new Request('http://localhost:3000/path'); assert.equal(buildRequestUrl(req), 'http://localhost:3000/path'); diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 85e656074c5..62857cc5b95 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -3,18 +3,19 @@ import { constants } from './constants'; const getHeader = (req: Request, key: string) => req.headers.get(key); const getFirstValueFromHeader = (value?: string | null) => value?.split(',')[0]; -type BuildRequestUrl = (request: Request, path?: string) => URL; -export const buildRequestUrl: BuildRequestUrl = (request, path) => { +type BuildRequestUrl = (request: Request) => URL; +export const buildRequestUrl: BuildRequestUrl = request => { const initialUrl = new URL(request.url); const forwardedProto = getHeader(request, constants.Headers.ForwardedProto); const forwardedHost = getHeader(request, constants.Headers.ForwardedHost); + const host = getHeader(request, constants.Headers.Host); const protocol = initialUrl.protocol; const base = buildOrigin({ protocol, forwardedProto, forwardedHost, host: host || initialUrl.host }); - return new URL(path || initialUrl.pathname, base); + return new URL(initialUrl.pathname + initialUrl.search, base); }; type BuildOriginParams = { diff --git a/packages/chrome-extension/src/index.ts b/packages/chrome-extension/src/index.ts index e16785a2c57..58facee8907 100644 --- a/packages/chrome-extension/src/index.ts +++ b/packages/chrome-extension/src/index.ts @@ -1,6 +1,5 @@ -// eslint-disable-next-line import/export export * from '@clerk/clerk-react'; // order matters since we want override @clerk/clerk-react ClerkProvider -// eslint-disable-next-line import/export + export { ClerkProvider } from './ClerkProvider'; diff --git a/packages/clerk-js/src/core/clerk.redirects.test.ts b/packages/clerk-js/src/core/clerk.redirects.test.ts index ae53212d0a9..360e0a7ada5 100644 --- a/packages/clerk-js/src/core/clerk.redirects.test.ts +++ b/packages/clerk-js/src/core/clerk.redirects.test.ts @@ -293,7 +293,7 @@ describe('Clerk singleton - Redirects', () => { }); }); - it('redirects to the provided url without __dev_session in the url', async () => { + it('redirects to the provided url without __clerk_db_jwt in the url', async () => { await clerkForProductionInstance.redirectWithAuth('https://app.example.com'); expect(mockHref).toHaveBeenNthCalledWith(1, 'https://app.example.com/'); @@ -326,7 +326,7 @@ describe('Clerk singleton - Redirects', () => { }); }); - it('redirects to the provided url with __dev_session in the url', async () => { + it('redirects to the provided url with __clerk_db_jwt in the url', async () => { await clerkForProductionInstance.redirectWithAuth('https://app.example.com'); expect(mockHref).toHaveBeenNthCalledWith(1, 'https://app.example.com/'); diff --git a/packages/clerk-js/src/core/clerk.test.ts b/packages/clerk-js/src/core/clerk.test.ts index 0bd743f51df..ad04b50a72b 100644 --- a/packages/clerk-js/src/core/clerk.test.ts +++ b/packages/clerk-js/src/core/clerk.test.ts @@ -1894,7 +1894,7 @@ describe('Clerk singleton', () => { await sut.load(); const url = sut.buildUrlWithAuth('https://example.com/some-path', { useQueryParam: true }); - expect(url).toBe('https://example.com/some-path?__dev_session=deadbeef'); + expect(url).toBe('https://example.com/some-path?__clerk_db_jwt=deadbeef'); }); it('uses the query param to propagate the dev_browser JWT to Account Portal pages on dev - non-kima', async () => { @@ -1903,7 +1903,7 @@ describe('Clerk singleton', () => { await sut.load(); const url = sut.buildUrlWithAuth('https://accounts.abcef.12345.dev.lclclerk.com'); - expect(url).toBe('https://accounts.abcef.12345.dev.lclclerk.com/?__dev_session=deadbeef'); + expect(url).toBe('https://accounts.abcef.12345.dev.lclclerk.com/?__clerk_db_jwt=deadbeef'); }); it('uses the query param to propagate the dev_browser JWT to Account Portal pages on dev - kima', async () => { @@ -1912,7 +1912,7 @@ describe('Clerk singleton', () => { await sut.load(); const url = sut.buildUrlWithAuth('https://rested-anemone-14.accounts.dev'); - expect(url).toBe('https://rested-anemone-14.accounts.dev/?__dev_session=deadbeef'); + expect(url).toBe('https://rested-anemone-14.accounts.dev/?__clerk_db_jwt=deadbeef'); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 446a7eff995..f831e805d07 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -134,7 +134,6 @@ const defaultOptions: ClerkOptions = { signUpUrl: undefined, afterSignInUrl: undefined, afterSignUpUrl: undefined, - isInterstitial: false, }; export class Clerk implements ClerkInterface { @@ -1204,9 +1203,7 @@ export class Clerk implements ClerkInterface { #shouldSyncWithPrimary = (): boolean => { if (this.#hasJustSynced()) { - if (!this.#options.isInterstitial) { - this.#clearJustSynced(); - } + this.#clearJustSynced(); return false; } diff --git a/packages/clerk-js/src/core/devBrowserHandler.test.ts b/packages/clerk-js/src/core/devBrowserHandler.test.ts index 6701c19e1a5..05ccba60f00 100644 --- a/packages/clerk-js/src/core/devBrowserHandler.test.ts +++ b/packages/clerk-js/src/core/devBrowserHandler.test.ts @@ -1,35 +1,8 @@ -import type { CreateDevBrowserHandlerOptions } from './devBrowserHandler'; -import { createDevBrowserHandler } from './devBrowserHandler'; - -describe('detBrowserHandler', () => { - const { getDevBrowserJWT, setDevBrowserJWT, removeDevBrowserJWT } = createDevBrowserHandler( - {} as CreateDevBrowserHandlerOptions, - ); - - describe('localStorage', () => { - beforeEach(() => { - Object.defineProperty(window, 'localStorage', { - value: { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - }, - writable: true, - }); - }); - - it('stores and retrieves the DevBrowser JWT in localStorage', () => { - const mockJWT = 'cafebabe'; - - expect(setDevBrowserJWT(mockJWT)).toBeUndefined(); - expect(window.localStorage.setItem).toHaveBeenNthCalledWith(1, 'clerk-db-jwt', mockJWT); - - getDevBrowserJWT(); - expect(window.localStorage.getItem).toHaveBeenCalledTimes(1); - - expect(removeDevBrowserJWT()).toBeUndefined(); - getDevBrowserJWT(); - expect(window.localStorage.getItem).toHaveBeenCalledTimes(2); +describe.skip('detBrowserHandler', () => { + // TODO: Add devbrowser tests + describe('get', () => { + it('todo', () => { + expect(true).toBeTruthy(); }); }); }); diff --git a/packages/clerk-js/src/core/devBrowserHandler.ts b/packages/clerk-js/src/core/devBrowserHandler.ts index 90851a2575a..95216066cb1 100644 --- a/packages/clerk-js/src/core/devBrowserHandler.ts +++ b/packages/clerk-js/src/core/devBrowserHandler.ts @@ -14,7 +14,7 @@ export interface DevBrowserHandler { setup(): Promise; - getDevBrowserJWT(): string | null; + getDevBrowserJWT(): string | undefined; setDevBrowserJWT(jwt: string): void; @@ -39,11 +39,10 @@ export function createDevBrowserHandler({ let usesUrlBasedSessionSyncing = true; function getDevBrowserJWT() { - return localStorage.getItem(key); + return cookieHandler.getDevBrowserCookie(); } function setDevBrowserJWT(jwt: string) { - localStorage.setItem(key, jwt); // Append dev browser JWT to cookies, because server-side redirects (e.g. middleware) has no access to local storage cookieHandler.setDevBrowserCookie(jwt); } @@ -116,7 +115,7 @@ export function createDevBrowserHandler({ } // 2. If no JWT is found in the first step, check if a JWT is already available in the local cache - if (getDevBrowserJWT() !== null) { + if (getDevBrowserJWT()) { return; } diff --git a/packages/clerk-js/src/core/fapiClient.test.ts b/packages/clerk-js/src/core/fapiClient.test.ts index 05354ef2801..7e4094919a3 100644 --- a/packages/clerk-js/src/core/fapiClient.test.ts +++ b/packages/clerk-js/src/core/fapiClient.test.ts @@ -170,12 +170,12 @@ describe('request', () => { }); describe('for production instances', () => { - it.todo('does not append the __dev_session cookie value to the query string'); - it.todo('does not set the __dev_session cookie from the response Clerk-Cookie header'); + it.todo('does not append the __clerk_db_jwt cookie value to the query string'); + it.todo('does not set the __clerk_db_jwt cookie from the response Clerk-Cookie header'); }); describe('for staging or development instances', () => { - it.todo('appends the __dev_session cookie value to the query string'); - it.todo('sets the __dev_session cookie from the response Clerk-Cookie header'); + it.todo('appends the __clerk_db_jwt cookie value to the query string'); + it.todo('sets the __clerk_db_jwt cookie from the response Clerk-Cookie header'); }); }); diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 415cbebeda6..4ab04ab01c0 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -87,7 +87,7 @@ export class Organization extends BaseResource implements OrganizationResource { { path: `/organizations/${this.id}/roles`, method: 'GET', - search: convertPageToOffset(getRolesParams) as any, + search: convertPageToOffset(getRolesParams), }, { forceUpdateClient: true, @@ -109,7 +109,7 @@ export class Organization extends BaseResource implements OrganizationResource { { path: `/organizations/${this.id}/domains`, method: 'GET', - search: convertPageToOffset(getDomainParams) as any, + search: convertPageToOffset(getDomainParams), }, { forceUpdateClient: true, @@ -146,7 +146,7 @@ export class Organization extends BaseResource implements OrganizationResource { return await BaseResource._fetch({ path: `/organizations/${this.id}/membership_requests`, method: 'GET', - search: convertPageToOffset(getRequestParam) as any, + search: convertPageToOffset(getRequestParam), }) .then(res => { const { data: requests, total_count } = @@ -173,7 +173,7 @@ export class Organization extends BaseResource implements OrganizationResource { method: 'GET', // `paginated` is used in some legacy endpoints to support clerk paginated responses // The parameter will be dropped in FAPI v2 - search: convertPageToOffset({ ...getMembershipsParams, paginated: true }) as any, + search: convertPageToOffset({ ...getMembershipsParams, paginated: true }), }) .then(res => { const { data: suggestions, total_count } = @@ -199,7 +199,7 @@ export class Organization extends BaseResource implements OrganizationResource { { path: `/organizations/${this.id}/invitations`, method: 'GET', - search: convertPageToOffset(getInvitationsParams) as any, + search: convertPageToOffset(getInvitationsParams), }, { forceUpdateClient: true, diff --git a/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts b/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts index 879ba46719f..41e31855d56 100644 --- a/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts +++ b/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts @@ -29,7 +29,7 @@ export class OrganizationSuggestion extends BaseResource implements Organization return await BaseResource._fetch({ path: '/me/organization_suggestions', method: 'GET', - search: convertPageToOffset(params) as any, + search: convertPageToOffset(params), }) .then(res => { const { data: suggestions, total_count } = diff --git a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index 40e4fa7fc06..074a5af4d8d 100644 --- a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts +++ b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts @@ -27,7 +27,7 @@ export class UserOrganizationInvitation extends BaseResource implements UserOrga return await BaseResource._fetch({ path: '/me/organization_invitations', method: 'GET', - search: convertPageToOffset(params) as any, + search: convertPageToOffset(params), }) .then(res => { const { data: invites, total_count } = diff --git a/packages/fastify/src/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__snapshots__/constants.test.ts.snap index b653b78cd85..7e3776bd19f 100644 --- a/packages/fastify/src/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__snapshots__/constants.test.ts.snap @@ -6,6 +6,8 @@ exports[`constants from environment variables 1`] = ` "API_VERSION": "CLERK_API_VERSION", "Cookies": { "ClientUat": "__client_uat", + "DevBrowser": "__clerk_db_jwt", + "Handshake": "__clerk_handshake", "Session": "__session", }, "Headers": { @@ -23,6 +25,7 @@ exports[`constants from environment variables 1`] = ` "Host": "host", "Origin": "origin", "Referrer": "referer", + "SecFetchDest": "sec-fetch-dest", "UserAgent": "user-agent", }, "JWT_KEY": "CLERK_JWT_KEY", diff --git a/packages/fastify/src/utils.ts b/packages/fastify/src/utils.ts new file mode 100644 index 00000000000..cdc923b4726 --- /dev/null +++ b/packages/fastify/src/utils.ts @@ -0,0 +1,25 @@ +import type { FastifyRequest } from 'fastify'; + +export const fastifyRequestToRequest = (req: FastifyRequest): Request => { + const headers = new Headers( + Object.keys(req.headers).reduce((acc, key) => { + const value = req.headers[key]; + if (!value) { + return acc; + } + + if (typeof value === 'string') { + acc.set(key, value); + } else { + acc.set(key, value.join(',')); + } + return acc; + }, new Headers()), + ); + + // Making some manual tests it seems that FastifyRequest populates the req protocol / hostname + // based on the forwarded headers. Nevertheless, we are gonna use a dummy base and the request + // will be fixed by the createIsomorphicRequest. + const dummyOriginReqUrl = new URL(req.url || '', `${req.protocol}://clerk-dummy`); + return new Request(dummyOriginReqUrl, { method: req.method, headers }); +}; diff --git a/packages/fastify/src/withClerkMiddleware.test.ts b/packages/fastify/src/withClerkMiddleware.test.ts index 39490e244c0..5ab4654ef8a 100644 --- a/packages/fastify/src/withClerkMiddleware.test.ts +++ b/packages/fastify/src/withClerkMiddleware.test.ts @@ -4,7 +4,6 @@ import Fastify from 'fastify'; import { clerkPlugin, getAuth } from './index'; const authenticateRequestMock = jest.fn(); -const localInterstitialMock = jest.fn(); jest.mock('@clerk/backend', () => { return { @@ -12,7 +11,6 @@ jest.mock('@clerk/backend', () => { createClerkClient: () => { return { authenticateRequest: (...args: any) => authenticateRequestMock(...args), - localInterstitial: (...args: any) => localInterstitialMock(...args), }; }, }; @@ -26,9 +24,6 @@ describe('withClerkMiddleware(options)', () => { test('handles signin with Authorization Bearer', async () => { authenticateRequestMock.mockResolvedValue({ - isUnknown: false, - isInterstitial: false, - isSignedIn: true, toAuth: () => 'mockedAuth', }); const fastify = Fastify(); @@ -56,18 +51,15 @@ describe('withClerkMiddleware(options)', () => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); expect(authenticateRequestMock).toBeCalledWith( + expect.any(Request), expect.objectContaining({ secretKey: 'TEST_SECRET_KEY', - request: expect.any(Request), }), ); }); test('handles signin with cookie', async () => { authenticateRequestMock.mockResolvedValue({ - isUnknown: false, - isInterstitial: false, - isSignedIn: true, toAuth: () => 'mockedAuth', }); const fastify = Fastify(); @@ -95,82 +87,44 @@ describe('withClerkMiddleware(options)', () => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); expect(authenticateRequestMock).toBeCalledWith( + expect.any(Request), expect.objectContaining({ secretKey: 'TEST_SECRET_KEY', - request: expect.any(Request), }), ); }); - test('handles unknown case by terminating the request with empty response and 401 http code', async () => { - authenticateRequestMock.mockResolvedValue({ - isUnknown: true, - isInterstitial: false, - isSignedIn: false, - reason: 'auth-reason', - message: 'auth-message', - toAuth: () => 'mockedAuth', - }); - const fastify = Fastify(); - await fastify.register(clerkPlugin); - - fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { - const auth = getAuth(request); - reply.send({ auth }); - }); - - const response = await fastify.inject({ - method: 'GET', - path: '/', - headers: { - cookie: '_gcl_au=value1; ko_id=value2; __session=deadbeef; __client_uat=1675692233', - }, - }); - - expect(response.statusCode).toEqual(401); - expect(response.headers['x-clerk-auth-reason']).toEqual('auth-reason'); - expect(response.headers['x-clerk-auth-message']).toEqual('auth-message'); - expect(response.body).toEqual(''); - }); - - test('handles interstitial case by terminating the request with interstitial html page and 401 http code', async () => { - authenticateRequestMock.mockResolvedValue({ - isUnknown: false, - isInterstitial: true, - isSignedIn: false, - reason: 'auth-reason', - message: 'auth-message', - toAuth: () => 'mockedAuth', - }); - localInterstitialMock.mockReturnValue('Interstitial'); - const fastify = Fastify(); - await fastify.register(clerkPlugin); - - fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { - const auth = getAuth(request); - reply.send({ auth }); - }); - - const response = await fastify.inject({ - method: 'GET', - path: '/', - headers: { - cookie: '_gcl_au=value1; ko_id=value2; __session=deadbeef; __client_uat=1675692233', - }, - }); - - expect(response.statusCode).toEqual(401); - expect(response.headers['content-type']).toEqual('text/html'); - expect(response.headers['x-clerk-auth-reason']).toEqual('auth-reason'); - expect(response.headers['x-clerk-auth-message']).toEqual('auth-message'); - expect(response.body).toEqual('Interstitial'); - }); + // @TODO handshake + // test('handles handshake case by redirecting the request to fapi', async () => { + // authenticateRequestMock.mockResolvedValue({ + // reason: 'auth-reason', + // message: 'auth-message', + // toAuth: () => 'mockedAuth', + // }); + // const fastify = Fastify(); + // await fastify.register(clerkPlugin); + // + // fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { + // const auth = getAuth(request); + // reply.send({ auth }); + // }); + // + // const response = await fastify.inject({ + // method: 'GET', + // path: '/', + // headers: { + // cookie: '_gcl_au=value1; ko_id=value2; __session=deadbeef; __client_uat=1675692233', + // }, + // }); + // + // expect(response.statusCode).toEqual(401); + // expect(response.headers['content-type']).toEqual('text/html'); + // expect(response.headers['x-clerk-auth-reason']).toEqual('auth-reason'); + // expect(response.headers['x-clerk-auth-message']).toEqual('auth-message'); + // }); test('handles signout case by populating the req.auth', async () => { authenticateRequestMock.mockResolvedValue({ - isUnknown: false, - isInterstitial: false, - isSignedIn: false, toAuth: () => 'mockedAuth', }); const fastify = Fastify(); @@ -190,9 +144,9 @@ describe('withClerkMiddleware(options)', () => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); expect(authenticateRequestMock).toBeCalledWith( + expect.any(Request), expect.objectContaining({ secretKey: 'TEST_SECRET_KEY', - request: expect.any(Request), }), ); }); diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 2c629da369b..c3fe1c1ade5 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -1,57 +1,34 @@ -import { createIsomorphicRequest } from '@clerk/backend'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import { AuthStatus } from '@clerk/backend'; +import type { FastifyRequest } from 'fastify'; import { clerkClient } from './clerkClient'; import * as constants from './constants'; import type { ClerkFastifyOptions } from './types'; +import { fastifyRequestToRequest } from './utils'; export const withClerkMiddleware = (options: ClerkFastifyOptions) => { - return async (req: FastifyRequest, reply: FastifyReply) => { + return async (fastifyRequest: FastifyRequest) => { const secretKey = options.secretKey || constants.SECRET_KEY; const publishableKey = options.publishableKey || constants.PUBLISHABLE_KEY; + const req = fastifyRequestToRequest(fastifyRequest); - const requestState = await clerkClient.authenticateRequest({ + const requestState = await clerkClient.authenticateRequest(req, { ...options, secretKey, publishableKey, - request: createIsomorphicRequest((Request, Headers) => { - const requestHeaders = Object.keys(req.headers).reduce( - (acc, key) => Object.assign(acc, { [key]: req?.headers[key] }), - {}, - ); - const headers = new Headers(requestHeaders); - // Making some manual tests it seems that FastifyRequest populates the req protocol / hostname - // based on the forwarded headers. Nevertheless, we are gonna use a dummy base and the request - // will be fixed by the createIsomorphicRequest. - const dummyOriginReqUrl = new URL(req.url || '', `${req.protocol}://clerk-dummy`); - return new Request(dummyOriginReqUrl, { - method: req.method, - headers, - }); - }), }); - // Interstitial cases - if (requestState.isUnknown) { - return reply - .code(401) - .header(constants.Headers.AuthReason, requestState.reason) - .header(constants.Headers.AuthMessage, requestState.message) - .send(); + if (requestState.status === AuthStatus.Handshake) { + // @TODO handshake + // return reply + // .code(401) + // .header(constants.Headers.AuthReason, requestState.reason) + // .header(constants.Headers.AuthMessage, requestState.message) + // .type('text/html') + // .send(...); } - if (requestState.isInterstitial) { - const interstitialHtmlPage = clerkClient.localInterstitial({ publishableKey }); - - return reply - .code(401) - .header(constants.Headers.AuthReason, requestState.reason) - .header(constants.Headers.AuthMessage, requestState.message) - .type('text/html') - .send(interstitialHtmlPage); - } - - // @ts-ignore - req.auth = requestState.toAuth(); + // @ts-expect-error Inject auth so getAuth can read it + fastifyRequest.auth = requestState.toAuth(); }; }; diff --git a/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx b/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx index e36a9c982d2..50de7845722 100644 --- a/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx +++ b/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx @@ -1,9 +1,5 @@ import type { ClerkProviderProps } from '@clerk/clerk-react'; -import { - __internal__setErrorThrowerOptions, - ClerkLoaded, - ClerkProvider as ReactClerkProvider, -} from '@clerk/clerk-react'; +import { __internal__setErrorThrowerOptions, ClerkProvider as ReactClerkProvider } from '@clerk/clerk-react'; import { navigate } from 'gatsby'; import React from 'react'; @@ -17,7 +13,7 @@ export type GatsbyClerkProviderProps = { export function ClerkProvider({ children, ...rest }: GatsbyClerkProviderProps) { const { clerkState, ...restProps } = rest; - const { __clerk_ssr_state, __clerk_ssr_interstitial_html } = clerkState?.__internal_clerk_state || {}; + const { __clerk_ssr_state } = clerkState?.__internal_clerk_state || {}; return ( - {__clerk_ssr_interstitial_html ? ( - - - - ) : ( - children - )} + {children} ); } - -export function Interstitial({ html }: { html: string }) { - return ; -} diff --git a/packages/gatsby-plugin-clerk/src/ssr/authenticateRequest.ts b/packages/gatsby-plugin-clerk/src/ssr/authenticateRequest.ts deleted file mode 100644 index 16e2d71c668..00000000000 --- a/packages/gatsby-plugin-clerk/src/ssr/authenticateRequest.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { GetServerDataProps } from 'gatsby'; - -import { PUBLISHABLE_KEY, SECRET_KEY } from '../constants'; -import { clerkClient, constants, createIsomorphicRequest } from './clerkClient'; -import type { WithServerAuthOptions } from './types'; - -export function authenticateRequest(context: GetServerDataProps, options: WithServerAuthOptions) { - return clerkClient.authenticateRequest({ - ...options, - secretKey: SECRET_KEY, - publishableKey: PUBLISHABLE_KEY, - request: createIsomorphicRequest((Request, Headers) => { - const headers = new Headers(Object.fromEntries(context.headers) as Record); - headers.set( - constants.Headers.ForwardedHost, - returnReferrerAsXForwardedHostToFixLocalDevGatsbyProxy(context.headers), - ); - return new Request(context.url, { - method: context.method, - headers, - }); - }), - }); -} - -const returnReferrerAsXForwardedHostToFixLocalDevGatsbyProxy = (headers: Map) => { - if (process.env.NODE_ENV !== 'development') { - return headers.get(constants.Headers.ForwardedHost) as string; - } - - const forwardedHost = headers.get(constants.Headers.ForwardedHost) as string; - if (forwardedHost) { - return forwardedHost; - } - - const referrerUrl = new URL(headers.get(constants.Headers.Referrer) as string); - const hostUrl = new URL('https://' + (headers.get(constants.Headers.Host) as string)); - - if (isDevelopmentOrStaging(SECRET_KEY || '') && hostUrl.hostname === referrerUrl.hostname) { - return referrerUrl.host; - } - - return forwardedHost; -}; - -function isDevelopmentOrStaging(apiKey: string): boolean { - return apiKey.startsWith('test_') || apiKey.startsWith('sk_test_'); -} diff --git a/packages/gatsby-plugin-clerk/src/ssr/utils.ts b/packages/gatsby-plugin-clerk/src/ssr/utils.ts index 4d305d2228c..fed608ad9ff 100644 --- a/packages/gatsby-plugin-clerk/src/ssr/utils.ts +++ b/packages/gatsby-plugin-clerk/src/ssr/utils.ts @@ -3,6 +3,9 @@ import { prunePrivateMetadata } from '@clerk/backend'; import cookie from 'cookie'; import type { GetServerDataProps } from 'gatsby'; +import { SECRET_KEY } from '../constants'; +import { constants } from './clerkClient'; + /** * @internal */ @@ -38,19 +41,43 @@ export const wrapWithClerkState = (data: any) => { return { clerkState: { __internal_clerk_state: { ...data } } }; }; -/** - * @internal - */ export const parseCookies = (headers: any) => { return cookie.parse(headers.get('cookie') || ''); }; -/** - * @internal - */ export function injectSSRStateIntoProps(callbackResult: any, data: any) { return { ...callbackResult, props: { ...callbackResult.props, ...wrapWithClerkState(data) }, }; } + +export const gatsbyPropsToRequest = (context: GetServerDataProps): Request => { + const headers = new Headers(Object.fromEntries(context.headers) as Record); + headers.set(constants.Headers.ForwardedHost, returnReferrerAsXForwardedHostToFixLocalDevGatsbyProxy(context.headers)); + return new Request(context.url, { method: context.method, headers }); +}; + +const returnReferrerAsXForwardedHostToFixLocalDevGatsbyProxy = (headers: Map) => { + if (process.env.NODE_ENV !== 'development') { + return headers.get(constants.Headers.ForwardedHost) as string; + } + + const forwardedHost = headers.get(constants.Headers.ForwardedHost) as string; + if (forwardedHost) { + return forwardedHost; + } + + const referrerUrl = new URL(headers.get(constants.Headers.Referrer) as string); + const hostUrl = new URL('https://' + (headers.get(constants.Headers.Host) as string)); + + if (isDevelopmentOrStaging(SECRET_KEY || '') && hostUrl.hostname === referrerUrl.hostname) { + return referrerUrl.host; + } + + return forwardedHost; +}; + +function isDevelopmentOrStaging(apiKey: string): boolean { + return apiKey.startsWith('test_') || apiKey.startsWith('sk_test_'); +} diff --git a/packages/gatsby-plugin-clerk/src/ssr/withServerAuth.ts b/packages/gatsby-plugin-clerk/src/ssr/withServerAuth.ts index 3d2cce8cb13..f69e290ddde 100644 --- a/packages/gatsby-plugin-clerk/src/ssr/withServerAuth.ts +++ b/packages/gatsby-plugin-clerk/src/ssr/withServerAuth.ts @@ -1,10 +1,10 @@ +import { AuthStatus } from '@clerk/backend'; import type { GetServerDataProps, GetServerDataReturn } from 'gatsby'; -import { PUBLISHABLE_KEY } from '../constants'; -import { authenticateRequest } from './authenticateRequest'; -import { clerkClient, constants } from './clerkClient'; +import { PUBLISHABLE_KEY, SECRET_KEY } from '../constants'; +import { clerkClient } from './clerkClient'; import type { WithServerAuthCallback, WithServerAuthOptions, WithServerAuthResult } from './types'; -import { injectAuthIntoContext, injectSSRStateIntoProps, sanitizeAuthObject } from './utils'; +import { gatsbyPropsToRequest, injectAuthIntoContext, injectSSRStateIntoProps, sanitizeAuthObject } from './utils'; interface WithServerAuth { ( @@ -19,16 +19,17 @@ export const withServerAuth: WithServerAuth = (cbOrOptions: any, options?: any): const opts = (options ? options : typeof cbOrOptions !== 'function' ? cbOrOptions : {}) || {}; return async (props: GetServerDataProps) => { - const requestState = await authenticateRequest(props, opts); - if (requestState.isInterstitial || requestState.isUnknown) { - const headers = { - [constants.Headers.AuthMessage]: requestState.message, - [constants.Headers.AuthStatus]: requestState.status, - }; - const interstitialHtml = clerkClient.localInterstitial({ - publishableKey: PUBLISHABLE_KEY, - }); - return injectSSRStateIntoProps({ headers }, { __clerk_ssr_interstitial_html: interstitialHtml }); + const req = gatsbyPropsToRequest(props); + const requestState = await clerkClient.authenticateRequest(req, { + ...opts, + secretKey: SECRET_KEY, + publishableKey: PUBLISHABLE_KEY, + request: req, + }); + + if (requestState.status === AuthStatus.Handshake) { + // @TODO handle handshake + return; } const contextWithAuth = injectAuthIntoContext(props, requestState.toAuth()); diff --git a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap index 6c43fefc75b..f14079b5887 100644 --- a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap @@ -42,7 +42,6 @@ exports[`/server public exports should not include a breaking change 1`] = ` "deserialize", "getAuth", "hasValidSignature", - "loadInterstitialFromLocal", "makeAuthObjectSerializable", "prunePrivateMetadata", "redirect", diff --git a/packages/nextjs/src/server/authMiddleware.test.ts b/packages/nextjs/src/server/authMiddleware.test.ts index de85fe73a8e..405f0fa535c 100644 --- a/packages/nextjs/src/server/authMiddleware.test.ts +++ b/packages/nextjs/src/server/authMiddleware.test.ts @@ -1,10 +1,24 @@ // There is no need to execute the complete authenticateRequest to test authMiddleware // This mock SHOULD exist before the import of authenticateRequest +import { AuthStatus } from '@clerk/backend'; import { expectTypeOf } from 'expect-type'; import { NextURL } from 'next/dist/server/web/next-url'; import type { NextFetchEvent, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +const authenticateRequestMock = jest.fn().mockResolvedValue({ + toAuth: () => ({}), +}); + +jest.mock('./clerkClient', () => { + return { + clerkClient: { + authenticateRequest: authenticateRequestMock, + telemetry: { record: jest.fn() }, + }, + }; +}); + const mockRedirectToSignIn = jest.fn().mockImplementation(() => { const res = NextResponse.redirect( 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', @@ -19,8 +33,6 @@ jest.mock('./redirect', () => { }); import { paths, setHeader } from '../utils'; -// used to assert the mock -import { authenticateRequest } from './authenticateRequest'; import { authMiddleware, createRouteMatcher, DEFAULT_CONFIG_MATCHER, DEFAULT_IGNORED_ROUTES } from './authMiddleware'; // used to assert the mock import { clerkClient } from './clerkClient'; @@ -37,17 +49,6 @@ afterAll(() => { global.console.warn = consoleWarn; }); -jest.mock('./authenticateRequest', () => { - const { handleInterstitialState, handleUnknownState } = jest.requireActual('./authenticateRequest'); - return { - authenticateRequest: jest.fn().mockResolvedValue({ - toAuth: () => ({}), - }), - handleInterstitialState, - handleUnknownState, - }; -}); - // Removing this mock will cause the authMiddleware tests to fail due to missing publishable key // This mock SHOULD exist before the imports jest.mock('./constants', () => { @@ -197,13 +198,8 @@ describe('default ignored routes matcher', () => { }); describe('authMiddleware(params)', () => { - beforeAll(() => { - clerkClient.localInterstitial = jest.fn().mockResolvedValue('interstitial'); - }); - beforeEach(() => { - (authenticateRequest as jest.Mock).mockClear(); - (clerkClient.localInterstitial as jest.Mock).mockClear(); + authenticateRequestMock.mockClear(); }); describe('without params', function () { @@ -244,7 +240,7 @@ describe('authMiddleware(params)', () => { })(mockRequest({ url: '/ignored' }), {} as NextFetchEvent); expect(resp?.status).toEqual(200); - expect(authenticateRequest).not.toBeCalled(); + expect(clerkClient.authenticateRequest).not.toBeCalled(); expect(beforeAuthSpy).not.toBeCalled(); expect(afterAuthSpy).not.toBeCalled(); }); @@ -259,7 +255,7 @@ describe('authMiddleware(params)', () => { })(mockRequest({ url: '/protected' }), {} as NextFetchEvent); expect(resp?.status).toEqual(200); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); expect(beforeAuthSpy).toBeCalled(); expect(afterAuthSpy).toBeCalled(); }); @@ -326,7 +322,7 @@ describe('authMiddleware(params)', () => { expect(resp?.status).toEqual(200); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('skip'); - expect(authenticateRequest).not.toBeCalled(); + expect(clerkClient.authenticateRequest).not.toBeCalled(); expect(afterAuthSpy).not.toBeCalled(); }); @@ -338,7 +334,7 @@ describe('authMiddleware(params)', () => { })(mockRequest({ url: '/protected' }), {} as NextFetchEvent); expect(resp?.status).toEqual(200); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); expect(afterAuthSpy).toBeCalled(); }); @@ -352,7 +348,7 @@ describe('authMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toEqual('https://www.clerk.com/custom-redirect'); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).not.toBeCalled(); + expect(clerkClient.authenticateRequest).not.toBeCalled(); expect(afterAuthSpy).not.toBeCalled(); }); @@ -375,7 +371,7 @@ describe('authMiddleware(params)', () => { expect(resp?.status).toEqual(200); expect(resp?.headers.get('x-before-auth-header')).toEqual('before'); expect(resp?.headers.get('x-after-auth-header')).toEqual('after'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); }); @@ -390,19 +386,18 @@ describe('authMiddleware(params)', () => { 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', ); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); it('uses authenticateRequest result as auth', async () => { const req = mockRequest({ url: '/protected' }); const event = {} as NextFetchEvent; - // @ts-ignore - authenticateRequest.mockResolvedValueOnce({ toAuth: () => ({ userId: null }) }); + authenticateRequestMock.mockResolvedValueOnce({ toAuth: () => ({ userId: null }) }); const afterAuthSpy = jest.fn(); await authMiddleware({ afterAuth: afterAuthSpy })(req, event); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); expect(afterAuthSpy).toBeCalledWith( { userId: null, @@ -416,37 +411,16 @@ describe('authMiddleware(params)', () => { }); describe('authenticateRequest', function () { - it('returns 401 with local interstitial for interstitial requestState', async () => { - // @ts-ignore - authenticateRequest.mockResolvedValueOnce({ isInterstitial: true }); - const resp = await authMiddleware()(mockRequest({ url: '/protected' }), {} as NextFetchEvent); - - expect(resp?.status).toEqual(401); - expect(resp?.headers.get('content-type')).toEqual('text/html'); - expect(clerkClient.localInterstitial).toBeCalled(); - }); - - it('returns 401 for unknown requestState', async () => { - // @ts-ignore - authenticateRequest.mockResolvedValueOnce({ isUnknown: true }); + it('returns 307 and starts the handshake flow for handshake requestState status', async () => { + const mockLocationUrl = 'https://example.com'; + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.Handshake, + headers: new Headers({ Location: mockLocationUrl }), + }); const resp = await authMiddleware()(mockRequest({ url: '/protected' }), {} as NextFetchEvent); - expect(resp?.status).toEqual(401); - expect(resp?.headers.get('content-type')).toEqual('application/json'); - expect(clerkClient.localInterstitial).not.toBeCalled(); - }); - - it('returns 401 for interstitial requestState in an API route', async () => { - // @ts-ignore - authenticateRequest.mockResolvedValueOnce({ isInterstitial: true }); - const resp = await authMiddleware({ apiRoutes: ['/api/items'] })( - mockRequest({ url: '/api/items' }), - {} as NextFetchEvent, - ); - - expect(resp?.status).toEqual(401); - expect(resp?.headers.get('content-type')).toEqual('application/json'); - expect(clerkClient.localInterstitial).not.toBeCalled(); + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('Location')).toEqual(mockLocationUrl); }); }); }); @@ -462,7 +436,7 @@ describe('Dev Browser JWT when redirecting to cross origin', function () { 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', ); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); it('appends the Dev Browser JWT to the search when cookie __clerk_db_jwt exists and location is an Account Portal URL', async () => { @@ -472,10 +446,10 @@ describe('Dev Browser JWT when redirecting to cross origin', function () { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toEqual( - 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected&__dev_session=test_jwt', + 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected&__clerk_db_jwt=test_jwt', ); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); it('does NOT append the Dev Browser JWT if x-clerk-redirect-to header is not set', async () => { @@ -493,7 +467,7 @@ describe('Dev Browser JWT when redirecting to cross origin', function () { 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', ); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); }); diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index 53ce94693da..d7483d24871 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -1,5 +1,5 @@ -import type { AuthObject, RequestState } from '@clerk/backend'; -import { buildRequestUrl, constants, TokenVerificationErrorReason } from '@clerk/backend'; +import type { AuthenticateRequestOptions, AuthObject } from '@clerk/backend'; +import { AuthStatus, buildRequestUrl, constants } from '@clerk/backend'; import { DEV_BROWSER_JWT_MARKER, setDevBrowserJWTInURL } from '@clerk/shared/devBrowser'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { eventMethodCalled } from '@clerk/shared/telemetry'; @@ -10,21 +10,17 @@ import { NextResponse } from 'next/server'; import { isRedirect, mergeResponses, paths, setHeader, stringifyHeaders } from '../utils'; import { withLogger } from '../utils/debugLogger'; -import { authenticateRequest, handleInterstitialState, handleUnknownState } from './authenticateRequest'; import { clerkClient } from './clerkClient'; -import { SECRET_KEY } from './constants'; -import { - clockSkewDetected, - infiniteRedirectLoopDetected, - informAboutProtectedRouteInfo, - receivedRequestForIgnoredRoute, -} from './errors'; +import { PUBLISHABLE_KEY, SECRET_KEY } from './constants'; +import { informAboutProtectedRouteInfo, receivedRequestForIgnoredRoute } from './errors'; import { redirectToSignIn } from './redirect'; import type { NextMiddlewareResult, WithAuthOptions } from './types'; import { isDevAccountPortalOrigin } from './url'; import { apiEndpointUnauthorizedNextResponse, decorateRequest, + decorateResponseWithObservabilityHeaders, + handleMultiDomainAndProxy, isCrossOrigin, setRequestHeadersOnNextResponse, } from './utils'; @@ -39,8 +35,6 @@ type RouteMatcherWithNextTypedRoutes = Autocomplete< WithPathPatternWildcard> | NextTypedRoute >; -const INFINITE_REDIRECTION_LOOP_COOKIE = '__clerk_redirection_loop'; - /** * The default ideal matcher that excludes the _next directory (internals) and all static files, * but it will match the root route (/) and any routes that start with /api or /trpc. @@ -191,20 +185,25 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { return setHeader(beforeAuthRes, constants.Headers.AuthReason, 'redirect'); } - const requestState = await authenticateRequest(req, options); - if (requestState.isUnknown) { - logger.debug('authenticateRequest state is unknown', requestState); - return handleUnknownState(requestState); - } else if (requestState.isInterstitial && isApiRoute(req)) { - logger.debug('authenticateRequest state is interstitial in an API route', requestState); - return handleUnknownState(requestState); - } else if (requestState.isInterstitial) { - logger.debug('authenticateRequest state is interstitial', requestState); - - assertClockSkew(requestState, options); - - const res = handleInterstitialState(requestState, options); - return assertInfiniteRedirectionLoop(req, res, options, requestState); + // TODO: fix type discrepancy between WithAuthOptions and AuthenticateRequestOptions + const authenticateRequestOptions = { + ...options, + secretKey: options.secretKey || SECRET_KEY, + publishableKey: options.publishableKey || PUBLISHABLE_KEY, + ...handleMultiDomainAndProxy(req, options as AuthenticateRequestOptions), + } 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'); + } + // triggering a handshake redirect + return decorateResponseWithObservabilityHeaders( + new Response(null, { status: 307, headers: requestState.headers }), + requestState, + ); } const auth = Object.assign(requestState.toAuth(), { @@ -227,7 +226,15 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { logger.debug(`Added ${constants.Headers.EnableDebug} on request`); } - return decorateRequest(req, finalRes, requestState); + const result = decorateRequest(req, finalRes, requestState) || NextResponse.next(); + + if (requestState.headers) { + requestState.headers.forEach((value, key) => { + result.headers.append(key, value); + }); + } + + return result; }); }; @@ -352,55 +359,6 @@ const isRequestMethodIndicatingApiRoute = (req: NextRequest): boolean => { return !['get', 'head', 'options'].includes(requestMethod); }; -/** - * In development, attempt to detect clock skew based on the requestState. This check should run when requestState.isInterstitial is true. If detected, we throw an error. - */ -const assertClockSkew = (requestState: RequestState, opts: AuthMiddlewareParams): void => { - if (!isDevelopmentFromSecretKey(opts.secretKey || SECRET_KEY)) { - return; - } - - if (requestState.reason === TokenVerificationErrorReason.TokenNotActiveYet) { - throw new Error(clockSkewDetected(requestState.message)); - } -}; - -// When in development, we want to prevent infinite interstitial redirection loops. -// We incrementally set a `__clerk_redirection_loop` cookie, and when it loops 6 times, we throw an error. -// We also utilize the `referer` header to skip the prefetch requests. -const assertInfiniteRedirectionLoop = ( - req: NextRequest, - res: NextResponse, - opts: AuthMiddlewareParams, - requestState: RequestState, -): NextResponse => { - if (!isDevelopmentFromSecretKey(opts.secretKey || SECRET_KEY)) { - return res; - } - - const infiniteRedirectsCounter = Number(req.cookies.get(INFINITE_REDIRECTION_LOOP_COOKIE)?.value) || 0; - if (infiniteRedirectsCounter === 6) { - // Infinite redirect detected, is it clock skew? - // We check for token-expired here because it can be a valid, recoverable scenario, but in a redirect loop a token-expired error likely indicates clock skew. - if (requestState.reason === TokenVerificationErrorReason.TokenExpired) { - throw new Error(clockSkewDetected(requestState.message)); - } - - // Not clock skew, return general error - throw new Error(infiniteRedirectLoopDetected()); - } - - // Skip the prefetch requests (when hovering a Next Link element) - if (req.headers.get('referer') === req.url) { - res.cookies.set({ - name: INFINITE_REDIRECTION_LOOP_COOKIE, - value: `${infiniteRedirectsCounter + 1}`, - maxAge: 3, - }); - } - return res; -}; - const withNormalizedClerkUrl = (req: NextRequest): WithClerkUrl => { const clerkUrl = req.nextUrl.clone(); diff --git a/packages/nextjs/src/server/authenticateRequest.ts b/packages/nextjs/src/server/authenticateRequest.ts deleted file mode 100644 index 8d995fb7d2d..00000000000 --- a/packages/nextjs/src/server/authenticateRequest.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { constants, debugRequestState } from '@clerk/backend'; -import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; - -import type { RequestState } from './clerkClient'; -import { clerkClient } from './clerkClient'; -import { CLERK_JS_URL, CLERK_JS_VERSION, PUBLISHABLE_KEY, SECRET_KEY } from './constants'; -import type { WithAuthOptions } from './types'; -import { apiEndpointUnauthorizedNextResponse, handleMultiDomainAndProxy } from './utils'; - -export const authenticateRequest = async (req: NextRequest, opts: WithAuthOptions) => { - const { isSatellite, domain, signInUrl, proxyUrl } = handleMultiDomainAndProxy(req, opts); - return await clerkClient.authenticateRequest({ - ...opts, - secretKey: opts.secretKey || SECRET_KEY, - publishableKey: opts.publishableKey || PUBLISHABLE_KEY, - isSatellite, - domain, - signInUrl, - proxyUrl, - request: req, - }); -}; - -const decorateResponseWithObservabilityHeaders = (res: NextResponse, requestState: RequestState) => { - requestState.message && res.headers.set(constants.Headers.AuthMessage, encodeURIComponent(requestState.message)); - requestState.reason && res.headers.set(constants.Headers.AuthReason, encodeURIComponent(requestState.reason)); - requestState.status && res.headers.set(constants.Headers.AuthStatus, encodeURIComponent(requestState.status)); -}; - -export const handleUnknownState = (requestState: RequestState) => { - const response = apiEndpointUnauthorizedNextResponse(); - decorateResponseWithObservabilityHeaders(response, requestState); - return response; -}; - -export const handleInterstitialState = (requestState: RequestState, opts: WithAuthOptions) => { - const response = new NextResponse( - clerkClient.localInterstitial({ - publishableKey: opts.publishableKey || PUBLISHABLE_KEY, - clerkJSUrl: CLERK_JS_URL, - clerkJSVersion: CLERK_JS_VERSION, - proxyUrl: requestState.proxyUrl, - isSatellite: requestState.isSatellite, - domain: requestState.domain, - debugData: debugRequestState(requestState), - signInUrl: requestState.signInUrl, - }), - { - status: 401, - headers: { - 'content-type': 'text/html', - }, - }, - ); - decorateResponseWithObservabilityHeaders(response, requestState); - return response; -}; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index de745f5e9b9..6f244db5963 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -1,4 +1,4 @@ -import type { RequestState } from '@clerk/backend'; +import type { AuthenticateRequestOptions, RequestState } from '@clerk/backend'; import { buildRequestUrl, constants } from '@clerk/backend'; import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; @@ -9,7 +9,7 @@ import { NextResponse } from 'next/server'; import { constants as nextConstants } from '../constants'; import { DOMAIN, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './constants'; import { missingDomainAndProxy, missingSignInUrlInDev } from './errors'; -import type { NextMiddlewareResult, RequestLike, WithAuthOptions } from './types'; +import type { NextMiddlewareResult, RequestLike } from './types'; type AuthKey = 'AuthStatus' | 'AuthMessage' | 'AuthReason'; @@ -221,7 +221,7 @@ export const isCrossOrigin = (from: string | URL, to: string | URL) => { return fromUrl.origin !== toUrl.origin; }; -export const handleMultiDomainAndProxy = (req: NextRequest, opts: WithAuthOptions) => { +export const handleMultiDomainAndProxy = (req: NextRequest, opts: AuthenticateRequestOptions) => { const requestURL = buildRequestUrl(req); const relativeOrAbsoluteProxyUrl = handleValueOrFn(opts?.proxyUrl, requestURL, PROXY_URL); let proxyUrl; @@ -239,7 +239,7 @@ export const handleMultiDomainAndProxy = (req: NextRequest, opts: WithAuthOption throw new Error(missingDomainAndProxy); } - if (isSatellite && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(SECRET_KEY)) { + if (isSatellite && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(opts.secretKey || SECRET_KEY)) { throw new Error(missingSignInUrlInDev); } @@ -250,3 +250,10 @@ export const handleMultiDomainAndProxy = (req: NextRequest, opts: WithAuthOption signInUrl, }; }; + +export const decorateResponseWithObservabilityHeaders = (res: Response, requestState: RequestState): Response => { + requestState.message && res.headers.set(constants.Headers.AuthMessage, encodeURIComponent(requestState.message)); + requestState.reason && res.headers.set(constants.Headers.AuthReason, encodeURIComponent(requestState.reason)); + requestState.status && res.headers.set(constants.Headers.AuthStatus, encodeURIComponent(requestState.status)); + return res; +}; diff --git a/packages/remix/src/client/ClerkErrorBoundary.tsx b/packages/remix/src/client/ClerkErrorBoundary.tsx deleted file mode 100644 index c1f1de184c2..00000000000 --- a/packages/remix/src/client/ClerkErrorBoundary.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { isRouteErrorResponse, useRouteError } from '@remix-run/react'; -import React from 'react'; - -import { Interstitial } from './Interstitial'; - -export function ClerkErrorBoundary(RootErrorBoundary?: React.ComponentType) { - return () => { - const error = useRouteError(); - - if (isRouteErrorResponse(error)) { - const { __clerk_ssr_interstitial_html } = error?.data?.clerkState?.__internal_clerk_state || {}; - if (__clerk_ssr_interstitial_html) { - /** - * In the (unlikely) case we trigger an interstitial during a client-side transition, we need to reload the page so the interstitial can properly trigger. Without a reload, the injected script tag does not get executed. - * Notably, this currently triggers for satellite domain syncing. - */ - if (typeof window !== 'undefined') { - window.location.reload(); - return; - } - - return ; - } - } - - if (!RootErrorBoundary) { - return undefined; - } - - return ; - }; -} diff --git a/packages/remix/src/client/Interstitial.tsx b/packages/remix/src/client/Interstitial.tsx deleted file mode 100644 index 927c0e4d820..00000000000 --- a/packages/remix/src/client/Interstitial.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export function Interstitial({ html }: { html: string }) { - return ; -} diff --git a/packages/remix/src/client/RemixClerkProvider.tsx b/packages/remix/src/client/RemixClerkProvider.tsx index effceea1283..2b87492996d 100644 --- a/packages/remix/src/client/RemixClerkProvider.tsx +++ b/packages/remix/src/client/RemixClerkProvider.tsx @@ -7,7 +7,6 @@ import { ClerkRemixOptionsProvider } from './RemixOptionsContext'; import type { ClerkState } from './types'; import { useAwaitableNavigate } from './useAwaitableNavigate'; -// eslint-disable-next-line import/export export * from '@clerk/clerk-react'; const SDK_METADATA = { @@ -32,7 +31,6 @@ export type RemixClerkProviderProps = { */ const awaitableNavigateRef: { current: ReturnType | undefined } = { current: undefined }; -// eslint-disable-next-line import/export export function ClerkProvider({ children, ...rest }: RemixClerkProviderProps): JSX.Element { const awaitableNavigate = useAwaitableNavigate(); diff --git a/packages/remix/src/client/index.ts b/packages/remix/src/client/index.ts index 1bd9d9e0adb..f4fc7a23ea4 100644 --- a/packages/remix/src/client/index.ts +++ b/packages/remix/src/client/index.ts @@ -1,5 +1,4 @@ export * from './RemixClerkProvider'; export { ClerkApp } from './ClerkApp'; -export { ClerkErrorBoundary } from './ClerkErrorBoundary'; export { WithClerkState } from './types'; export { SignIn, SignUp } from './uiComponents'; diff --git a/packages/remix/src/client/types.ts b/packages/remix/src/client/types.ts index b1f0706456e..327c07351fa 100644 --- a/packages/remix/src/client/types.ts +++ b/packages/remix/src/client/types.ts @@ -4,7 +4,6 @@ import type { InitialState } from '@clerk/types'; export type ClerkState = { __type: 'clerkState'; __internal_clerk_state: { - __clerk_ssr_interstitial: string; __clerk_ssr_state: InitialState; __publishableKey: string | undefined; __proxyUrl: string | undefined; diff --git a/packages/remix/src/ssr/authenticateRequest.ts b/packages/remix/src/ssr/authenticateRequest.ts index fb04d5f2e80..51d203da2e1 100644 --- a/packages/remix/src/ssr/authenticateRequest.ts +++ b/packages/remix/src/ssr/authenticateRequest.ts @@ -10,9 +10,6 @@ import { noSecretKeyError, satelliteAndMissingProxyUrlAndDomain, satelliteAndMis import { getEnvVariable } from '../utils'; import type { LoaderFunctionArgs, RootAuthLoaderOptions } from './types'; -/** - * @internal - */ export function authenticateRequest(args: LoaderFunctionArgs, opts: RootAuthLoaderOptions = {}): Promise { const { request, context } = args; const { loadSession, loadUser, loadOrganization } = opts; @@ -73,7 +70,7 @@ export function authenticateRequest(args: LoaderFunctionArgs, opts: RootAuthLoad throw new Error(satelliteAndMissingSignInUrl); } - return createClerkClient({ apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain }).authenticateRequest({ + return createClerkClient({ apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain }).authenticateRequest(request, { audience, secretKey, jwtKey, @@ -89,6 +86,5 @@ export function authenticateRequest(args: LoaderFunctionArgs, opts: RootAuthLoad signUpUrl, afterSignInUrl, afterSignUpUrl, - request, }); } diff --git a/packages/remix/src/ssr/getAuth.ts b/packages/remix/src/ssr/getAuth.ts index e14bfeb4112..8871a5cb1fd 100644 --- a/packages/remix/src/ssr/getAuth.ts +++ b/packages/remix/src/ssr/getAuth.ts @@ -1,9 +1,9 @@ -import { sanitizeAuthObject } from '@clerk/backend'; +import { AuthStatus, sanitizeAuthObject } from '@clerk/backend'; +import { redirect } from '@remix-run/server-runtime'; import { noLoaderArgsPassedInGetAuth } from '../errors'; import { authenticateRequest } from './authenticateRequest'; import type { GetAuthReturn, LoaderFunctionArgs, RootAuthLoaderOptions } from './types'; -import { interstitialJsonResponse, unknownResponse } from './utils'; type GetAuthOptions = Pick; @@ -13,12 +13,10 @@ export async function getAuth(args: LoaderFunctionArgs, opts?: GetAuthOptions): } const requestState = await authenticateRequest(args, opts); - if (requestState.isUnknown) { - throw unknownResponse(requestState); - } - - if (requestState.isInterstitial) { - throw interstitialJsonResponse(requestState, { loader: 'nested' }, args.context); + // TODO handle handshake + // this halts the execution of all nested loaders using getAuth + if (requestState.status === AuthStatus.Handshake) { + throw redirect(''); } return sanitizeAuthObject(requestState.toAuth()); diff --git a/packages/remix/src/ssr/rootAuthLoader.ts b/packages/remix/src/ssr/rootAuthLoader.ts index 23a8ccb83fc..f91094893aa 100644 --- a/packages/remix/src/ssr/rootAuthLoader.ts +++ b/packages/remix/src/ssr/rootAuthLoader.ts @@ -1,5 +1,6 @@ -import { sanitizeAuthObject } from '@clerk/backend'; +import { AuthStatus, sanitizeAuthObject } from '@clerk/backend'; import type { defer } from '@remix-run/server-runtime'; +import { redirect } from '@remix-run/server-runtime'; import { isDeferredData } from '@remix-run/server-runtime/dist/responses'; import { invalidRootLoaderCallbackReturn } from '../errors'; @@ -10,10 +11,8 @@ import { injectAuthIntoRequest, injectRequestStateIntoDeferredData, injectRequestStateIntoResponse, - interstitialJsonResponse, isRedirect, isResponse, - unknownResponse, } from './utils'; interface RootAuthLoader { @@ -51,12 +50,9 @@ export const rootAuthLoader: RootAuthLoader = async ( const requestState = await authenticateRequest(args, opts); - if (requestState.isUnknown) { - throw unknownResponse(requestState); - } - - if (requestState.isInterstitial) { - throw interstitialJsonResponse(requestState, { loader: 'root' }, args.context); + // TODO handle handshake + if (requestState.status === AuthStatus.Handshake) { + throw redirect(''); } if (!handler) { diff --git a/packages/remix/src/ssr/utils.ts b/packages/remix/src/ssr/utils.ts index a1d1e5a6097..dd48b5c3d81 100644 --- a/packages/remix/src/ssr/utils.ts +++ b/packages/remix/src/ssr/utils.ts @@ -1,5 +1,5 @@ import type { AuthObject, RequestState } from '@clerk/backend'; -import { constants, debugRequestState, loadInterstitialFromLocal } from '@clerk/backend'; +import { constants, debugRequestState } from '@clerk/backend'; import { isTruthy } from '@clerk/shared/underscore'; import type { AppLoadContext, defer } from '@remix-run/server-runtime'; import { json } from '@remix-run/server-runtime'; @@ -73,35 +73,6 @@ export const getClerkDebugHeaders = (headers: Headers) => { }; }; -export const unknownResponse = (requestState: RequestState) => { - return json(null, { status: 401, headers: observabilityHeadersFromRequestState(requestState) }); -}; - -export const interstitialJsonResponse = ( - requestState: RequestState, - opts: { loader: 'root' | 'nested' }, - context: AppLoadContext, -) => { - return json( - wrapWithClerkState({ - __loader: opts.loader, - __clerk_ssr_interstitial_html: loadInterstitialFromLocal({ - debugData: debugRequestState(requestState), - publishableKey: requestState.publishableKey, - // TODO: This needs to be the version of clerk/remix not clerk/react - // pkgVersion: LIB_VERSION, - clerkJSUrl: getEnvVariable('CLERK_JS', context), - clerkJSVersion: getEnvVariable('CLERK_JS_VERSION', context), - proxyUrl: requestState.proxyUrl, - isSatellite: requestState.isSatellite, - domain: requestState.domain, - signInUrl: requestState.signInUrl, - }), - }), - { status: 401, headers: observabilityHeadersFromRequestState(requestState) }, - ); -}; - export const injectRequestStateIntoResponse = async ( response: Response, requestState: RequestState, @@ -150,7 +121,7 @@ export function injectRequestStateIntoDeferredData( * @internal */ export function getResponseClerkState(requestState: RequestState, context: AppLoadContext) { - const { reason, message, isSignedIn, isInterstitial, ...rest } = requestState; + const { reason, message, isSignedIn, ...rest } = requestState; const clerkState = wrapWithClerkState({ __clerk_ssr_state: rest.toAuth(), __publishableKey: requestState.publishableKey, 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 4e57c549bbb..a06ef71aa5c 100644 --- a/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap @@ -48,7 +48,6 @@ exports[`module exports should not change unless explicitly set 1`] = ` "emails", "hasValidSignature", "invitations", - "loadInterstitialFromLocal", "makeAuthObjectSerializable", "organizations", "phoneNumbers", diff --git a/packages/sdk-node/src/__tests__/authenticateRequest.test.ts b/packages/sdk-node/src/__tests__/authenticateRequest.test.ts index 5ae3005c988..44b602357b3 100644 --- a/packages/sdk-node/src/__tests__/authenticateRequest.test.ts +++ b/packages/sdk-node/src/__tests__/authenticateRequest.test.ts @@ -1,5 +1,5 @@ -import { constants, createIsomorphicRequest } from '@clerk/backend'; -import type { Request } from 'express'; +import { constants } from '@clerk/backend'; +import { Request } from 'express'; import { authenticateRequest } from '../authenticateRequest'; @@ -39,14 +39,6 @@ describe('authenticateRequest', () => { const searchParams = new URLSearchParams(); searchParams.set('__query', 'true'); - const expectedIsomorphicRequest = createIsomorphicRequest((Request, Headers) => { - // @ts-ignore - return new Request(req.url, { - // @ts-ignore - headers: new Headers(req.headers), - }); - }); - await authenticateRequest({ clerkClient: clerkClient as any, secretKey, @@ -54,9 +46,10 @@ describe('authenticateRequest', () => { req, options, }); + expect(clerkClient.authenticateRequest).toHaveBeenCalledWith( + expect.any(Request), expect.objectContaining({ - authorizedParties: ['party1'], secretKey: secretKey, publishableKey: publishableKey, jwtKey: 'jwtKey', @@ -64,7 +57,6 @@ describe('authenticateRequest', () => { proxyUrl: '', signInUrl: '', domain: '', - request: expect.objectContaining(expectedIsomorphicRequest), }), ); }); diff --git a/packages/sdk-node/src/__tests__/middleware.test.ts b/packages/sdk-node/src/__tests__/middleware.test.ts index d35fe2c0966..417148b7a26 100644 --- a/packages/sdk-node/src/__tests__/middleware.test.ts +++ b/packages/sdk-node/src/__tests__/middleware.test.ts @@ -15,8 +15,6 @@ afterEach(() => { const mockClerkClient = () => ({ authenticateRequest: jest.fn(), - remotePrivateInterstitial: jest.fn(), - localInterstitial: jest.fn(), }); describe('ClerkExpressWithAuth', () => { @@ -26,8 +24,6 @@ describe('ClerkExpressWithAuth', () => { const clerkClient = mockClerkClient() as any; clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: false, toAuth: () => ({ sessionId: null }), } as RequestState); @@ -43,8 +39,6 @@ describe('ClerkExpressWithAuth', () => { const clerkClient = mockClerkClient() as any; clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: true, - isInterstitial: false, toAuth: () => ({ sessionId: '1' }), } as RequestState); @@ -52,72 +46,6 @@ describe('ClerkExpressWithAuth', () => { expect((req as WithAuthProp).auth.sessionId).toEqual('1'); expect(mockNext).toHaveBeenCalledWith(); }); - - it('should halt middleware execution and return empty response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: false, - isUnknown: true, - toAuth: () => ({ sessionId: '1' }), - } as unknown as RequestState); - - await createClerkExpressWithAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith(); - expect(mockNext).not.toBeCalled(); - }); - - it('should halt middleware execution and return remote private interstitial response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => ({ sessionId: '1' }), - } as unknown as RequestState); - clerkClient.remotePrivateInterstitial.mockReturnValue({ data: 'interstitial', errors: null }); - - await createClerkExpressWithAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith('interstitial'); - expect(mockNext).not.toBeCalled(); - }); - - it('should halt middleware execution and return local interstitial response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => ({ sessionId: '1' }), - publishableKey: 'pk_12345', - } as unknown as RequestState); - clerkClient.localInterstitial.mockReturnValue('interstitial'); - - await createClerkExpressWithAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith('interstitial'); - expect(mockNext).not.toBeCalled(); - }); }); describe('ClerkExpressRequireAuth', () => { @@ -127,8 +55,6 @@ describe('ClerkExpressRequireAuth', () => { const clerkClient = mockClerkClient() as any; clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: false, toAuth: () => ({ sessionId: null }), } as RequestState); @@ -145,79 +71,11 @@ describe('ClerkExpressRequireAuth', () => { const clerkClient = mockClerkClient() as any; clerkClient.authenticateRequest.mockReturnValue({ isSignedIn: true, - isInterstitial: false, toAuth: () => ({ sessionId: '1' }), } as RequestState); await createClerkExpressRequireAuth({ clerkClient })()(req, res, mockNext as NextFunction); - expect((req as WithAuthProp).auth.sessionId).toEqual('1'); expect(mockNext).toHaveBeenCalledWith(); }); - - it('should halt middleware execution and return empty response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: false, - isUnknown: true, - toAuth: () => ({ sessionId: '1' }), - } as unknown as RequestState); - - await createClerkExpressRequireAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith(); - expect(mockNext).not.toBeCalled(); - }); - - it('should halt middleware execution and return remote private interstitial response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => ({ sessionId: '1' }), - } as unknown as RequestState); - clerkClient.remotePrivateInterstitial.mockReturnValue({ data: 'interstitial', errors: null }); - - await createClerkExpressRequireAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith('interstitial'); - expect(mockNext).not.toBeCalled(); - }); - - it('should halt middleware execution and return local interstitial response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => ({ sessionId: '1' }), - publishableKey: 'pk_12345', - } as unknown as RequestState); - clerkClient.localInterstitial.mockReturnValue('interstitial'); - - await createClerkExpressWithAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith('interstitial'); - expect(mockNext).not.toBeCalled(); - }); }); diff --git a/packages/sdk-node/src/authenticateRequest.ts b/packages/sdk-node/src/authenticateRequest.ts index f7b6f6d72a0..17db4085c23 100644 --- a/packages/sdk-node/src/authenticateRequest.ts +++ b/packages/sdk-node/src/authenticateRequest.ts @@ -1,66 +1,20 @@ import type { RequestState } from '@clerk/backend'; -import { buildRequestUrl, constants, createIsomorphicRequest } from '@clerk/backend'; +import { buildRequestUrl, constants } from '@clerk/backend'; import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl } from '@clerk/shared/proxy'; -import type { ServerResponse } from 'http'; +import type { IncomingMessage, ServerResponse } from 'http'; -import type { AuthenticateRequestParams, ClerkClient } from './types'; +import type { AuthenticateRequestParams } from './types'; import { loadApiEnv, loadClientEnv } from './utils'; -export async function loadInterstitial({ - clerkClient, - requestState, -}: { - clerkClient: ClerkClient; - requestState: RequestState; -}) { - const { clerkJSVersion, clerkJSUrl } = loadClientEnv(); - /** - * When publishable key is present utilize the localInterstitial method - * and avoid the extra network call - */ - if (requestState.publishableKey) { - const data = clerkClient.localInterstitial({ - publishableKey: requestState.publishableKey, - proxyUrl: requestState.proxyUrl, - signInUrl: requestState.signInUrl, - isSatellite: requestState.isSatellite, - domain: requestState.domain, - clerkJSVersion, - clerkJSUrl, - }); - - return { - data, - errors: null, - }; - } - - return clerkClient.remotePrivateInterstitial(); -} - export const authenticateRequest = (opts: AuthenticateRequestParams) => { - const { clerkClient, secretKey, publishableKey, req, options } = opts; + const { clerkClient, secretKey, publishableKey, req: incomingMessage, options } = opts; const { jwtKey, authorizedParties, audience } = options || {}; + const req = incomingMessageToRequest(incomingMessage); const env = { ...loadApiEnv(), ...loadClientEnv() }; - - const isomorphicRequest = createIsomorphicRequest((Request, Headers) => { - const headers = Object.keys(req.headers).reduce((acc, key) => Object.assign(acc, { [key]: req?.headers[key] }), {}); - - // @ts-ignore Optimistic attempt to get the protocol in case - // req extends IncomingMessage in a useful way. No guarantee - // it'll work. - const protocol = req.connection?.encrypted ? 'https' : 'http'; - const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://clerk-dummy`); - return new Request(dummyOriginReqUrl, { - method: req.method, - headers: new Headers(headers), - }); - }); - - const requestUrl = buildRequestUrl(isomorphicRequest); + const requestUrl = buildRequestUrl(req); const isSatellite = handleValueOrFn(options?.isSatellite, requestUrl, env.isSatellite); const domain = handleValueOrFn(options?.domain, requestUrl) || env.domain; const signInUrl = options?.signInUrl || env.signInUrl; @@ -77,7 +31,7 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { throw new Error(satelliteAndMissingSignInUrl); } - return clerkClient.authenticateRequest({ + return clerkClient.authenticateRequest(req, { audience, secretKey, publishableKey, @@ -87,23 +41,23 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { isSatellite, domain, signInUrl, - request: isomorphicRequest, }); }; -export const handleUnknownCase = (res: ServerResponse, requestState: RequestState) => { - if (requestState.isUnknown) { - res.writeHead(401, { 'Content-Type': 'text/html' }); - res.end(); - } -}; -export const handleInterstitialCase = (res: ServerResponse, requestState: RequestState, interstitial: string) => { - if (requestState.isInterstitial) { - res.writeHead(401, { 'Content-Type': 'text/html' }); - res.end(interstitial); - } +const incomingMessageToRequest = (req: IncomingMessage): Request => { + const headers = Object.keys(req.headers).reduce((acc, key) => Object.assign(acc, { [key]: req?.headers[key] }), {}); + // @ts-ignore Optimistic attempt to get the protocol in case + // req extends IncomingMessage in a useful way. No guarantee + // it'll work. + const protocol = req.connection?.encrypted ? 'https' : 'http'; + const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://clerk-dummy`); + return new Request(dummyOriginReqUrl, { + method: req.method, + headers: new Headers(headers), + }); }; +// TODO: Move to backend export const decorateResponseWithObservabilityHeaders = (res: ServerResponse, requestState: RequestState) => { requestState.message && res.setHeader(constants.Headers.AuthMessage, encodeURIComponent(requestState.message)); requestState.reason && res.setHeader(constants.Headers.AuthReason, encodeURIComponent(requestState.reason)); diff --git a/packages/sdk-node/src/clerkClient.ts b/packages/sdk-node/src/clerkClient.ts index d92016af8dc..eed18896e94 100644 --- a/packages/sdk-node/src/clerkClient.ts +++ b/packages/sdk-node/src/clerkClient.ts @@ -1,14 +1,14 @@ -import type { ClerkOptions, VerifyTokenOptions } from '@clerk/backend'; -import { createClerkClient, verifyToken as _verifyToken } from '@clerk/backend'; +import type { ClerkOptions } from '@clerk/backend'; +import { createClerkClient, verifyToken } from '@clerk/backend'; import { createClerkExpressRequireAuth } from './clerkExpressRequireAuth'; import { createClerkExpressWithAuth } from './clerkExpressWithAuth'; import { loadApiEnv, loadClientEnv } from './utils'; -type ExtendedClerk = ReturnType & { +type ClerkClient = ReturnType & { expressWithAuth: ReturnType; expressRequireAuth: ReturnType; - verifyToken: (token: string, verifyOpts?: Parameters[1]) => ReturnType; + verifyToken: typeof verifyToken; }; /** @@ -16,14 +16,10 @@ type ExtendedClerk = 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): ExtendedClerk { +export function Clerk(options: ClerkOptions): ClerkClient { const clerkClient = createClerkClient(options); const expressWithAuth = createClerkExpressWithAuth({ ...options, clerkClient }); const expressRequireAuth = createClerkExpressRequireAuth({ ...options, clerkClient }); - const verifyToken = (token: string, verifyOpts?: VerifyTokenOptions) => { - const issuer = (iss: string) => iss.startsWith('https://clerk.') || iss.includes('.clerk.accounts'); - return _verifyToken(token, { issuer, ...options, ...verifyOpts }); - }; return Object.assign(clerkClient, { expressWithAuth, diff --git a/packages/sdk-node/src/clerkExpressRequireAuth.ts b/packages/sdk-node/src/clerkExpressRequireAuth.ts index 89632974c34..47f3eef0810 100644 --- a/packages/sdk-node/src/clerkExpressRequireAuth.ts +++ b/packages/sdk-node/src/clerkExpressRequireAuth.ts @@ -1,12 +1,7 @@ import type { createClerkClient } from '@clerk/backend'; +import { AuthStatus } from '@clerk/backend'; -import { - authenticateRequest, - decorateResponseWithObservabilityHeaders, - handleInterstitialCase, - handleUnknownCase, - loadInterstitial, -} from './authenticateRequest'; +import { authenticateRequest, decorateResponseWithObservabilityHeaders } from './authenticateRequest'; import type { ClerkMiddlewareOptions, MiddlewareRequireAuthProp, RequireAuthProp } from './types'; export type CreateClerkExpressMiddlewareOptions = { @@ -29,22 +24,11 @@ export const createClerkExpressRequireAuth = (createOpts: CreateClerkExpressMidd options, }); decorateResponseWithObservabilityHeaders(res, requestState); - if (requestState.isUnknown) { - return handleUnknownCase(res, requestState); - } - if (requestState.isInterstitial) { - const interstitial = await loadInterstitial({ - clerkClient, - requestState, - }); - if (interstitial.errors) { - // Temporarily return Unauthenticated instead of the interstitial errors since we don't - // want to expose any internal error (possible errors are http 401, 500 response from BAPI) - // It will be dropped with the removal of fetching remotePrivateInterstitial - next(new Error('Unauthenticated')); - return; - } - return handleInterstitialCase(res, requestState, interstitial.data); + + if (requestState.status === AuthStatus.Handshake) { + // TODO: Handle handshake + // This needs to be refactored and reused by clerkExpressWithAuth as well + return; } if (requestState.isSignedIn) { diff --git a/packages/sdk-node/src/clerkExpressWithAuth.ts b/packages/sdk-node/src/clerkExpressWithAuth.ts index 08c22297a89..aba87f14eba 100644 --- a/packages/sdk-node/src/clerkExpressWithAuth.ts +++ b/packages/sdk-node/src/clerkExpressWithAuth.ts @@ -1,10 +1,6 @@ -import { - authenticateRequest, - decorateResponseWithObservabilityHeaders, - handleInterstitialCase, - handleUnknownCase, - loadInterstitial, -} from './authenticateRequest'; +import { AuthStatus } from '@clerk/backend'; + +import { authenticateRequest, decorateResponseWithObservabilityHeaders } from './authenticateRequest'; import type { CreateClerkExpressMiddlewareOptions } from './clerkExpressRequireAuth'; import type { ClerkMiddlewareOptions, MiddlewareWithAuthProp, WithAuthProp } from './types'; @@ -20,22 +16,11 @@ export const createClerkExpressWithAuth = (createOpts: CreateClerkExpressMiddlew options, }); decorateResponseWithObservabilityHeaders(res, requestState); - if (requestState.isUnknown) { - return handleUnknownCase(res, requestState); - } - if (requestState.isInterstitial) { - const interstitial = await loadInterstitial({ - clerkClient, - requestState, - }); - if (interstitial.errors) { - // Temporarily return Unauthenticated instead of the interstitial errors since we don't - // want to expose any internal error (possible errors are http 401, 500 response from BAPI) - // It will be dropped with the removal of fetching remotePrivateInterstitial - next(new Error('Unauthenticated')); - return; - } - return handleInterstitialCase(res, requestState, interstitial.data); + + if (requestState.status === AuthStatus.Handshake) { + // TODO: Handle handshake + // This needs to be refactored and reused by clerkExpressRequireAuth as well + return; } (req as WithAuthProp).auth = { diff --git a/packages/shared/src/__tests__/devbrowser.test.ts b/packages/shared/src/__tests__/devbrowser.test.ts index 13a24e86beb..8c5a340ba60 100644 --- a/packages/shared/src/__tests__/devbrowser.test.ts +++ b/packages/shared/src/__tests__/devbrowser.test.ts @@ -11,8 +11,8 @@ describe('setDevBrowserJWTInURL(url, jwt)', () => { ['/foo?bar=42#qux', 'deadbeef', false, '/foo?bar=42#qux__clerk_db_jwt[deadbeef]'], ['/foo#__clerk_db_jwt[deadbeef]', 'deadbeef', false, '/foo#__clerk_db_jwt[deadbeef]'], ['/foo?bar=42#qux__clerk_db_jwt[deadbeef]', 'deadbeef', false, '/foo?bar=42#qux__clerk_db_jwt[deadbeef]'], - ['/foo', 'deadbeef', true, '/foo?__dev_session=deadbeef'], - ['/foo?bar=42', 'deadbeef', true, '/foo?bar=42&__dev_session=deadbeef'], + ['/foo', 'deadbeef', true, '/foo?__clerk_db_jwt=deadbeef'], + ['/foo?bar=42', 'deadbeef', true, '/foo?bar=42&__clerk_db_jwt=deadbeef'], ]; test.each(testCases)( diff --git a/packages/shared/src/__tests__/keys.test.ts b/packages/shared/src/__tests__/keys.test.ts index 598a7c0a0d1..dcc3226c259 100644 --- a/packages/shared/src/__tests__/keys.test.ts +++ b/packages/shared/src/__tests__/keys.test.ts @@ -36,6 +36,10 @@ describe('parsePublishableKey(key)', () => { 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', { instanceType: 'development', frontendApi: 'foo-bar-13.clerk.accounts.dev' }, ], + [ + 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', + { instanceType: 'development', frontendApi: 'foo-bar-13.clerk.accounts.dev' }, + ], ]; // @ts-ignore @@ -44,6 +48,24 @@ describe('parsePublishableKey(key)', () => { const result = parsePublishableKey(publishableKeyStr); expect(result).toEqual(expectedPublishableKey); }); + + it('throws an error if the key is not a valid publishable key, when fatal: true', () => { + expect(() => parsePublishableKey('fake_pk', { fatal: true })).toThrowError('Publishable key not valid.'); + }); + + it('applies the proxyUrl if provided', () => { + expect(parsePublishableKey('pk_live_Y2xlcmsuY2xlcmsuZGV2JA==', { proxyUrl: 'example.com/__clerk' })).toEqual({ + frontendApi: 'example.com/__clerk', + instanceType: 'production', + }); + }); + + it('applies the domain if provided for production keys', () => { + expect(parsePublishableKey('pk_live_Y2xlcmsuY2xlcmsuZGV2JA==', { domain: 'example.com' })).toEqual({ + frontendApi: 'clerk.example.com', + instanceType: 'production', + }); + }); }); describe('isPublishableKey(key)', () => { diff --git a/packages/shared/src/devBrowser.ts b/packages/shared/src/devBrowser.ts index 8f94b099856..ef78863342b 100644 --- a/packages/shared/src/devBrowser.ts +++ b/packages/shared/src/devBrowser.ts @@ -1,4 +1,4 @@ -export const DEV_BROWSER_SSO_JWT_PARAMETER = '__dev_session'; +export const DEV_BROWSER_SSO_JWT_PARAMETER = '__clerk_db_jwt'; export const DEV_BROWSER_JWT_MARKER = '__clerk_db_jwt'; export const DEV_BROWSER_SSO_JWT_KEY = 'clerk-db-jwt'; diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 9d8c056e3ed..238b512132b 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -4,6 +4,12 @@ import { DEV_OR_STAGING_SUFFIXES } from './constants'; import { isomorphicAtob } from './isomorphicAtob'; import { isomorphicBtoa } from './isomorphicBtoa'; +type ParsePublishableKeyOptions = { + fatal?: boolean; + domain?: string; + proxyUrl?: string; +}; + const PUBLISHABLE_KEY_LIVE_PREFIX = 'pk_live_'; const PUBLISHABLE_KEY_TEST_PREFIX = 'pk_test_'; @@ -17,10 +23,24 @@ export function buildPublishableKey(frontendApi: string): string { return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`; } -export function parsePublishableKey(key: string | undefined): PublishableKey | null { +export function parsePublishableKey( + key: string | undefined, + options: ParsePublishableKeyOptions & { fatal: true }, +): PublishableKey; +export function parsePublishableKey( + key: string | undefined, + options?: ParsePublishableKeyOptions, +): PublishableKey | null; +export function parsePublishableKey( + key: string | undefined, + options: { fatal?: boolean; domain?: string; proxyUrl?: string } = {}, +): PublishableKey | null { key = key || ''; - if (!isPublishableKey(key)) { + if (!key || !isPublishableKey(key)) { + if (options.fatal) { + throw new Error('Publishable key not valid.'); + } return null; } @@ -28,12 +48,15 @@ export function parsePublishableKey(key: string | undefined): PublishableKey | n let frontendApi = isomorphicAtob(key.split('_')[2]); - if (!frontendApi.endsWith('$')) { - return null; - } // TODO(@dimkl): validate packages/clerk-js/src/utils/instance.ts frontendApi = frontendApi.slice(0, -1); + if (options.proxyUrl) { + frontendApi = options.proxyUrl; + } else if (instanceType !== 'development' && options.domain) { + frontendApi = `clerk.${options.domain}`; + } + return { instanceType, frontendApi, diff --git a/packages/types/src/clerk.retheme.ts b/packages/types/src/clerk.retheme.ts index 0e4ae3c7c72..e58ce059064 100644 --- a/packages/types/src/clerk.retheme.ts +++ b/packages/types/src/clerk.retheme.ts @@ -532,12 +532,6 @@ export type ClerkOptions = ClerkOptionsNavigation & { afterSignInUrl?: string; afterSignUpUrl?: string; allowedRedirectOrigins?: Array; - - /** - * Indicates that clerk.js is will be loaded from interstitial - * Defaults to false - */ - isInterstitial?: boolean; isSatellite?: boolean | ((url: URL) => boolean); /** diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 78514012416..d18b3d26f58 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -526,12 +526,6 @@ export type ClerkOptions = ClerkOptionsNavigation & { afterSignInUrl?: string; afterSignUpUrl?: string; allowedRedirectOrigins?: Array; - - /** - * Indicates that clerk.js is will be loaded from interstitial - * Defaults to false - */ - isInterstitial?: boolean; isSatellite?: boolean | ((url: URL) => boolean); /** diff --git a/playground/nextjs/middleware.ts b/playground/nextjs/middleware.ts index e41136e55ed..1d485a96a31 100644 --- a/playground/nextjs/middleware.ts +++ b/playground/nextjs/middleware.ts @@ -3,9 +3,17 @@ 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 default authMiddleware({ - publicRoutes: publicPaths, -}); +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)(.*)'], diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index 4c304acd971..f2d9cf5c3d5 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -9,13 +9,13 @@ "lint": "next lint" }, "dependencies": { - "@clerk/backend": "latest", - "@clerk/clerk-react": "latest", + "@clerk/backend": "file:.yalc/@clerk/backend", + "@clerk/clerk-react": "file:.yalc/@clerk/clerk-react", "@clerk/clerk-sdk-node": "latest", - "@clerk/nextjs": "latest", - "@clerk/shared": "latest", + "@clerk/nextjs": "file:.yalc/@clerk/nextjs", + "@clerk/shared": "file:.yalc/@clerk/shared", "@clerk/themes": "latest", - "@clerk/types": "latest", + "@clerk/types": "file:.yalc/@clerk/types", "next": "^13.5.6", "react": "^18.2.0", "react-dom": "^18.2.0"