From 1603ca1bd41e680a5ee072c78b880f71a2e3d982 Mon Sep 17 00:00:00 2001 From: Jeroen Peeters Date: Sun, 23 Feb 2025 22:07:28 +0100 Subject: [PATCH 1/3] feat(plugins): add Clerk plugin to manage users with webhooks chore: update pnpm-lock.yaml with new package resolutions --- dist/plugins.ts | 1 + package.json | 1 + plugins/clerk/README.md | 39 +++++ plugins/clerk/index.ts | 141 +++++++++++++++++ plugins/clerk/meta.json | 25 +++ plugins/clerk/sql/create-table.sql | 9 ++ plugins/clerk/sql/delete-user.sql | 3 + plugins/clerk/sql/get-user-information.sql | 2 + plugins/clerk/sql/upsert-user.sql | 8 + pnpm-lock.yaml | 171 +++++++++++---------- src/index.ts | 1 + 11 files changed, 324 insertions(+), 77 deletions(-) create mode 100644 plugins/clerk/README.md create mode 100644 plugins/clerk/index.ts create mode 100644 plugins/clerk/meta.json create mode 100644 plugins/clerk/sql/create-table.sql create mode 100644 plugins/clerk/sql/delete-user.sql create mode 100644 plugins/clerk/sql/get-user-information.sql create mode 100644 plugins/clerk/sql/upsert-user.sql diff --git a/dist/plugins.ts b/dist/plugins.ts index 52e2731..7dd252a 100644 --- a/dist/plugins.ts +++ b/dist/plugins.ts @@ -5,3 +5,4 @@ export { StripeSubscriptionPlugin } from '../plugins/stripe' export { ChangeDataCapturePlugin } from '../plugins/cdc' export { QueryLogPlugin } from '../plugins/query-log' export { ResendPlugin } from '../plugins/resend' +export { ClerkPlugin } from '../plugins/clerk' diff --git a/package.json b/package.json index d4e455a..6b9b2eb 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "mysql2": "^3.11.4", "node-sql-parser": "^4.18.0", "pg": "^8.13.1", + "svix": "^1.59.2", "tailwind-merge": "^2.6.0", "vite": "^5.4.11" }, diff --git a/plugins/clerk/README.md b/plugins/clerk/README.md new file mode 100644 index 0000000..f63661e --- /dev/null +++ b/plugins/clerk/README.md @@ -0,0 +1,39 @@ +# Clerk Plugin + +The Clerk Plugin for Starbase provides a quick and simple way for applications to add Clerk user information to their database. + +For more information on how to setup webhooks for your Clerk instance, please refer to their excellent [guide](https://clerk.com/docs/webhooks/sync-data). + +## Usage + +Add the ClerkPlugin plugin to your Starbase configuration: + +```typescript +import { ClerkPlugin } from './plugins/clerk' +const plugins = [ + // ... other plugins + new ClerkPlugin({ + clerkInstanceId: 'ins_**********', + clerkSigningSecret: 'whsec_**********', + }), +] satisfies StarbasePlugin[] +``` + +## Configuration Options + +| Option | Type | Default | Description | +| -------------------- | ------ | ------- | --------------------------------------------------------------------------------------- | +| `clerkInstanceId` | string | `null` | Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) | +| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) | + +## How To Use + +### Webhook Setup + +For our Starbase instance to receive webhook events when user information changes, we need to add our plugin endpoint to Clerk. + +1. Visit the Webhooks page for your Clerk instance: https://dashboard.clerk.com/last-active?path=webhooks +2. Add a new endpoint with the following settings: + - URL: `https:///clerk/webhook` + - Events: `User` +3. Save by clicking "Create" diff --git a/plugins/clerk/index.ts b/plugins/clerk/index.ts new file mode 100644 index 0000000..bd02ea1 --- /dev/null +++ b/plugins/clerk/index.ts @@ -0,0 +1,141 @@ +import { Webhook } from 'svix' +import { StarbaseApp, StarbaseContext } from '../../src/handler' +import { StarbasePlugin } from '../../src/plugin' +import { createResponse } from '../../src/utils' +import CREATE_TABLE from './sql/create-table.sql' +import UPSERT_USER from './sql/upsert-user.sql' +import GET_USER_INFORMATION from './sql/get-user-information.sql' +import DELETE_USER from './sql/delete-user.sql' + +type ClerkEvent = { + instance_id: string +} & ( + | { + type: 'user.created' | 'user.updated' + data: { + id: string + first_name: string + last_name: string + email_addresses: Array<{ + id: string + email_address: string + }> + primary_email_address_id: string + } + } + | { + type: 'user.deleted' + data: { id: string } + } +) + +const SQL_QUERIES = { + CREATE_TABLE, + UPSERT_USER, + GET_USER_INFORMATION, // Currently not used, but can be turned into an endpoint + DELETE_USER, +} + +export class ClerkPlugin extends StarbasePlugin { + context?: StarbaseContext + pathPrefix: string = '/clerk' + clerkInstanceId?: string + clerkSigningSecret: string + + constructor(opts?: { + clerkInstanceId?: string + clerkSigningSecret: string + }) { + super('starbasedb:clerk', { + // The `requiresAuth` is set to false to allow for the webhooks sent by Clerk to be accessible + requiresAuth: false, + }) + if (!opts?.clerkSigningSecret) { + throw new Error('A signing secret is required for this plugin.') + } + this.clerkInstanceId = opts.clerkInstanceId + this.clerkSigningSecret = opts.clerkSigningSecret + } + + override async register(app: StarbaseApp) { + app.use(async (c, next) => { + this.context = c + const dataSource = c?.get('dataSource') + + // Create user table if it doesn't exist + await dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.CREATE_TABLE, + params: [], + }) + + await next() + }) + + // Webhook to handle Clerk events + app.post(`${this.pathPrefix}/webhook`, async (c) => { + const wh = new Webhook(this.clerkSigningSecret) + const svix_id = c.req.header('svix-id') + const svix_signature = c.req.header('svix-signature') + const svix_timestamp = c.req.header('svix-timestamp') + + if (!svix_id || !svix_signature || !svix_timestamp) { + return createResponse( + undefined, + 'Missing required headers: svix-id, svix-signature, svix-timestamp', + 400 + ) + } + + const body = await c.req.text() + const dataSource = this.context?.get('dataSource') + + try { + const event = wh.verify(body, { + 'svix-id': svix_id, + 'svix-timestamp': svix_timestamp, + 'svix-signature': svix_signature, + }) as ClerkEvent + + if (this.clerkInstanceId && 'instance_id' in event && event.instance_id !== this.clerkInstanceId) { + return createResponse( + undefined, + 'Invalid instance ID', + 401 + ) + } + + if (event.type === 'user.deleted') { + const { id } = event.data + + await dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.DELETE_USER, + params: [id], + }) + } else if ( + event.type === 'user.updated' || + event.type === 'user.created' + ) { + const { id, first_name, last_name, email_addresses, primary_email_address_id } = event.data + + const email = email_addresses.find( + (email: any) => email.id === primary_email_address_id + )?.email_address + + await dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.UPSERT_USER, + params: [id, email, first_name, last_name], + }) + } + + return createResponse({ success: true }, undefined, 200) + } catch (error: any) { + console.error('Webhook processing error:', error) + return createResponse( + undefined, + `Webhook processing failed: ${error.message}`, + 400 + ) + } + }) + } +} diff --git a/plugins/clerk/meta.json b/plugins/clerk/meta.json new file mode 100644 index 0000000..6a3949a --- /dev/null +++ b/plugins/clerk/meta.json @@ -0,0 +1,25 @@ +{ + "version": "1.0.0", + "resources": { + "tables": { + "user": [ + "user_id", + "email", + "first_name", + "last_name", + "created_at", + "updated_at", + "deleted_at" + ] + }, + "secrets": {}, + "variables": {} + }, + "dependencies": { + "tables": { + "*": ["user_id"] + }, + "secrets": {}, + "variables": {} + } +} diff --git a/plugins/clerk/sql/create-table.sql b/plugins/clerk/sql/create-table.sql new file mode 100644 index 0000000..a779c23 --- /dev/null +++ b/plugins/clerk/sql/create-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS user ( + user_id TEXT PRIMARY KEY, + email TEXT NOT NULL, + first_name TEXT, + last_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME DEFAULT NULL +) \ No newline at end of file diff --git a/plugins/clerk/sql/delete-user.sql b/plugins/clerk/sql/delete-user.sql new file mode 100644 index 0000000..8818ded --- /dev/null +++ b/plugins/clerk/sql/delete-user.sql @@ -0,0 +1,3 @@ +UPDATE user +SET deleted_at = CURRENT_TIMESTAMP +WHERE user_id = ? AND deleted_at IS NULL \ No newline at end of file diff --git a/plugins/clerk/sql/get-user-information.sql b/plugins/clerk/sql/get-user-information.sql new file mode 100644 index 0000000..a681bc3 --- /dev/null +++ b/plugins/clerk/sql/get-user-information.sql @@ -0,0 +1,2 @@ +SELECT email, first_name, last_name FROM user +WHERE user_id = ? AND deleted_at IS NULL \ No newline at end of file diff --git a/plugins/clerk/sql/upsert-user.sql b/plugins/clerk/sql/upsert-user.sql new file mode 100644 index 0000000..50c0ee1 --- /dev/null +++ b/plugins/clerk/sql/upsert-user.sql @@ -0,0 +1,8 @@ +INSERT INTO user (user_id, email, first_name, last_name) +VALUES (?, ?, ?, ?) +ON CONFLICT(user_id) DO UPDATE SET +email = excluded.email, +first_name = excluded.first_name, +last_name = excluded.last_name, +updated_at = CURRENT_TIMESTAMP, +deleted_at = NULL \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 146fa1c..e719e89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@outerbase/sdk': specifier: 2.0.0-rc.3 version: 2.0.0-rc.3 - '@phosphor-icons/react': - specifier: ^2.1.7 - version: 2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -41,6 +38,9 @@ importers: pg: specifier: ^8.13.1 version: 8.13.1 + svix: + specifier: ^1.59.2 + version: 1.59.2 tailwind-merge: specifier: ^2.6.0 version: 2.6.0 @@ -600,13 +600,6 @@ packages: resolution: {integrity: sha512-bmV4hlzs5sz01IDWNHdJC2ZD4ezM4UEwG1fEQi59yByHRtPOVDjK7Z5iQ8e1MbR0814vdhv9hMcUKP8SJDA7vQ==} hasBin: true - '@phosphor-icons/react@2.1.7': - resolution: {integrity: sha512-g2e2eVAn1XG2a+LI09QU3IORLhnFNAFkNbo2iwbX6NOKSLOwvEMmTa7CgOzEbgNWR47z8i8kwjdvYZ5fkGx1mQ==} - engines: {node: '>=10'} - peerDependencies: - react: '>= 16.8' - react-dom: '>= 16.8' - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -706,6 +699,9 @@ packages: cpu: [x64] os: [win32] + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@tailwindcss/node@4.0.6': resolution: {integrity: sha512-jb6E0WeSq7OQbVYcIJ6LxnZTeC4HjMvbzFBMCrQff4R50HBlo/obmYNk6V2GCUXDeqiXtvtrQgcIbT+/boB03Q==} @@ -875,9 +871,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -956,10 +949,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -1005,10 +994,6 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -1048,6 +1033,9 @@ packages: es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + esbuild@0.17.19: resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} engines: {node: '>=12'} @@ -1087,6 +1075,9 @@ packages: resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} engines: {node: '>=12.0.0'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -1099,10 +1090,6 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} - form-data@4.0.1: - resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} - engines: {node: '>= 6'} - formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -1391,14 +1378,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -1485,6 +1464,15 @@ packages: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1654,19 +1642,16 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - react-dom@19.0.0: - resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==} - peerDependencies: - react: ^19.0.0 - - react@19.0.0: - resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} - engines: {node: '>=0.10.0'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} readdirp@4.0.2: resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} engines: {node: '>= 14.16.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve@1.22.9: resolution: {integrity: sha512-QxrmX1DzraFIi9PxdG5VkRfRwIgjwyud+z/iBwfRRrVmHc+P9Q7u2lSSpQ6bjr2gy5lrqIiU9vb6iAeGf2400A==} hasBin: true @@ -1696,9 +1681,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.25.0: - resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} - selfsigned@2.4.1: resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} engines: {node: '>=10'} @@ -1810,6 +1792,12 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix-fetch@3.0.0: + resolution: {integrity: sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw==} + + svix@1.59.2: + resolution: {integrity: sha512-LYdryeUpJsPCAiNb5c+lZcPTDf/hEgQ+KegRFed1INR2loD+uE6JKyhW32AEjaQquNdhaCK4ed2P1GrOtySa5Q==} + tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -1846,6 +1834,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.0.0: resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} engines: {node: '>=18'} @@ -1882,6 +1873,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + vite-node@2.1.8: resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1947,14 +1941,23 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-url@14.1.1: resolution: {integrity: sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==} engines: {node: '>=18'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2421,11 +2424,6 @@ snapshots: dependencies: handlebars: 4.7.8 - '@phosphor-icons/react@2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': - dependencies: - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - '@pkgjs/parseargs@0.11.0': optional: true @@ -2486,6 +2484,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.30.1': optional: true + '@stablelib/base64@1.0.1': {} + '@tailwindcss/node@4.0.6': dependencies: enhanced-resolve: 5.18.1 @@ -2655,8 +2655,6 @@ snapshots: assertion-error@2.0.1: {} - asynckit@0.4.0: {} - aws-ssl-profiles@1.1.2: {} balanced-match@1.0.2: {} @@ -2728,10 +2726,6 @@ snapshots: colorette@2.0.20: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@12.1.0: {} convert-source-map@2.0.0: {} @@ -2762,8 +2756,6 @@ snapshots: defu@6.1.4: {} - delayed-stream@1.0.0: {} - denque@2.1.0: {} detect-libc@1.0.3: {} @@ -2789,6 +2781,8 @@ snapshots: es-module-lexer@1.6.0: {} + es6-promise@4.2.8: {} + esbuild@0.17.19: optionalDependencies: '@esbuild/android-arm': 0.17.19 @@ -2868,6 +2862,8 @@ snapshots: expect-type@1.1.0: {} + fast-sha256@1.3.0: {} + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -2882,12 +2878,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.1: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -3160,12 +3150,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime@3.0.0: {} mimic-fn@4.0.0: {} @@ -3236,6 +3220,10 @@ snapshots: node-domexception@1.0.0: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -3374,15 +3362,12 @@ snapshots: punycode@2.3.1: {} - react-dom@19.0.0(react@19.0.0): - dependencies: - react: 19.0.0 - scheduler: 0.25.0 - - react@19.0.0: {} + querystringify@2.2.0: {} readdirp@4.0.2: {} + requires-port@1.0.0: {} + resolve@1.22.9: dependencies: is-core-module: 2.16.0 @@ -3437,8 +3422,6 @@ snapshots: safer-buffer@2.1.2: {} - scheduler@0.25.0: {} - selfsigned@2.4.1: dependencies: '@types/node-forge': 1.3.11 @@ -3531,6 +3514,24 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix-fetch@3.0.0: + dependencies: + node-fetch: 2.7.0 + whatwg-fetch: 3.6.20 + transitivePeerDependencies: + - encoding + + svix@1.59.2: + dependencies: + '@stablelib/base64': 1.0.1 + '@types/node': 22.10.2 + es6-promise: 4.2.8 + fast-sha256: 1.3.0 + svix-fetch: 3.0.0 + url-parse: 1.5.10 + transitivePeerDependencies: + - encoding + tailwind-merge@2.6.0: {} tailwindcss@4.0.6: {} @@ -3557,6 +3558,8 @@ snapshots: dependencies: is-number: 7.0.0 + tr46@0.0.3: {} + tr46@5.0.0: dependencies: punycode: 2.3.1 @@ -3589,6 +3592,11 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + vite-node@2.1.8(@types/node@22.10.2)(lightningcss@1.29.1): dependencies: cac: 6.7.14 @@ -3654,13 +3662,22 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + whatwg-fetch@3.6.20: {} + whatwg-url@14.1.1: dependencies: tr46: 5.0.0 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/src/index.ts b/src/index.ts index 2713b73..8df0b3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { QueryLogPlugin } from '../plugins/query-log' import { StatsPlugin } from '../plugins/stats' import { CronPlugin } from '../plugins/cron' import { InterfacePlugin } from '../plugins/interface' +import { ClerkPlugin } from '../plugins/clerk' export { StarbaseDBDurableObject } from './do' From b56d6ea6e6ed1ccfbab1e3717ed3b708805a7256 Mon Sep 17 00:00:00 2001 From: Jeroen Peeters Date: Mon, 24 Feb 2025 21:17:02 +0100 Subject: [PATCH 2/3] feat: add session verification to the Clerk plugin --- package.json | 1 + plugins/clerk/README.md | 49 +++++-- plugins/clerk/index.ts | 134 ++++++++++++++++-- plugins/clerk/meta.json | 7 + plugins/clerk/sql/create-session-table.sql | 7 + ...create-table.sql => create-user-table.sql} | 0 plugins/clerk/sql/delete-session.sql | 1 + plugins/clerk/sql/get-session.sql | 1 + plugins/clerk/sql/upsert-session.sql | 4 + pnpm-lock.yaml | 9 ++ 10 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 plugins/clerk/sql/create-session-table.sql rename plugins/clerk/sql/{create-table.sql => create-user-table.sql} (100%) create mode 100644 plugins/clerk/sql/delete-session.sql create mode 100644 plugins/clerk/sql/get-session.sql create mode 100644 plugins/clerk/sql/upsert-session.sql diff --git a/package.json b/package.json index 6b9b2eb..417babc 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@libsql/client": "^0.14.0", "@outerbase/sdk": "2.0.0-rc.3", "clsx": "^2.1.1", + "cookie": "^1.0.2", "cron-parser": "^4.9.0", "hono": "^4.6.14", "jose": "^5.9.6", diff --git a/plugins/clerk/README.md b/plugins/clerk/README.md index f63661e..e4535f9 100644 --- a/plugins/clerk/README.md +++ b/plugins/clerk/README.md @@ -10,21 +10,44 @@ Add the ClerkPlugin plugin to your Starbase configuration: ```typescript import { ClerkPlugin } from './plugins/clerk' +const clerkPlugin = new ClerkPlugin({ + clerkInstanceId: 'ins_**********', + clerkSigningSecret: 'whsec_**********', + clerkSessionPublicKey: '-----BEGIN PUBLIC KEY***' +}) const plugins = [ + clerkPlugin, // ... other plugins - new ClerkPlugin({ - clerkInstanceId: 'ins_**********', - clerkSigningSecret: 'whsec_**********', - }), ] satisfies StarbasePlugin[] ``` +If you want to use the Clerk plugin to verify sessions, change the function `authenticate` in `src/index.ts` to the following: + +```diff +... existing code ... +} else { ++ try { ++ const authenticated = await clerkPlugin.authenticate(request, dataSource) ++ if (!authenticated) { ++ throw new Error('Unauthorized request') ++ } ++ } catch (error) { + // If no JWT secret or JWKS endpoint is provided, then the request has no authorization. + throw new Error('Unauthorized request') + } +} +... existing code ... +``` + ## Configuration Options -| Option | Type | Default | Description | -| -------------------- | ------ | ------- | --------------------------------------------------------------------------------------- | -| `clerkInstanceId` | string | `null` | Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) | -| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) | +| Option | Type | Default | Description | +| ----------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------ | +| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) | +| `clerkInstanceId` | string | `null` | (optional) Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) | +| `verifySessions` | boolean | `true` | (optional) Verify sessions | +| `clerkSessionPublicKey` | string | `null` | (optional) Access your public key from (https://dashboard.clerk.com/last-active?path=api-keys) | +| `permittedOrigins` | string[] | `[]` | (optional) A list of allowed origins | ## How To Use @@ -35,5 +58,11 @@ For our Starbase instance to receive webhook events when user information change 1. Visit the Webhooks page for your Clerk instance: https://dashboard.clerk.com/last-active?path=webhooks 2. Add a new endpoint with the following settings: - URL: `https:///clerk/webhook` - - Events: `User` -3. Save by clicking "Create" + - Events: + - `User`, + - `Session` if you also want to verify sessions ("session.pending" does not appear to be sent by Clerk, so you can keep it deselected) +3. Save by clicking "Create" and copy the signing secret into the Clerk plugin +4. If you want to verify sessions, you will need to add a public key to your Clerk instance: + - Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys + - Click the copy icon next to `JWKS Public Key` +5. Copy the public key into the Clerk plugin diff --git a/plugins/clerk/index.ts b/plugins/clerk/index.ts index bd02ea1..6bc61af 100644 --- a/plugins/clerk/index.ts +++ b/plugins/clerk/index.ts @@ -1,11 +1,18 @@ +import { parse } from 'cookie' +import { jwtVerify, importSPKI } from 'jose' import { Webhook } from 'svix' -import { StarbaseApp, StarbaseContext } from '../../src/handler' +import { StarbaseApp } from '../../src/handler' import { StarbasePlugin } from '../../src/plugin' +import { DataSource } from '../../src/types' import { createResponse } from '../../src/utils' -import CREATE_TABLE from './sql/create-table.sql' +import CREATE_USER_TABLE from './sql/create-user-table.sql' +import CREATE_SESSION_TABLE from './sql/create-session-table.sql' import UPSERT_USER from './sql/upsert-user.sql' import GET_USER_INFORMATION from './sql/get-user-information.sql' import DELETE_USER from './sql/delete-user.sql' +import UPSERT_SESSION from './sql/upsert-session.sql' +import DELETE_SESSION from './sql/delete-session.sql' +import GET_SESSION from './sql/get-session.sql' type ClerkEvent = { instance_id: string @@ -27,47 +34,75 @@ type ClerkEvent = { type: 'user.deleted' data: { id: string } } + | { + type: 'session.created' | 'session.ended' | 'session.removed' | 'session.revoked' + data: { + id: string + user_id: string + } + } ) const SQL_QUERIES = { - CREATE_TABLE, + CREATE_USER_TABLE, + CREATE_SESSION_TABLE, UPSERT_USER, GET_USER_INFORMATION, // Currently not used, but can be turned into an endpoint DELETE_USER, + UPSERT_SESSION, + DELETE_SESSION, + GET_SESSION, } export class ClerkPlugin extends StarbasePlugin { - context?: StarbaseContext + private dataSource?: DataSource pathPrefix: string = '/clerk' clerkInstanceId?: string clerkSigningSecret: string - + clerkSessionPublicKey?: string + permittedOrigins: string[] + verifySessions: boolean constructor(opts?: { clerkInstanceId?: string clerkSigningSecret: string + clerkSessionPublicKey?: string + verifySessions?: boolean + permittedOrigins?: string[] }) { super('starbasedb:clerk', { // The `requiresAuth` is set to false to allow for the webhooks sent by Clerk to be accessible requiresAuth: false, }) + if (!opts?.clerkSigningSecret) { throw new Error('A signing secret is required for this plugin.') } + this.clerkInstanceId = opts.clerkInstanceId this.clerkSigningSecret = opts.clerkSigningSecret + this.clerkSessionPublicKey = opts.clerkSessionPublicKey + this.verifySessions = opts.verifySessions ?? true + this.permittedOrigins = opts.permittedOrigins ?? [] } override async register(app: StarbaseApp) { app.use(async (c, next) => { - this.context = c - const dataSource = c?.get('dataSource') + this.dataSource = c?.get('dataSource') // Create user table if it doesn't exist - await dataSource?.rpc.executeQuery({ - sql: SQL_QUERIES.CREATE_TABLE, + await this.dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.CREATE_USER_TABLE, params: [], }) + if (this.verifySessions) { + // Create session table if it doesn't exist + await this.dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.CREATE_SESSION_TABLE, + params: [], + }) + } + await next() }) @@ -87,7 +122,6 @@ export class ClerkPlugin extends StarbasePlugin { } const body = await c.req.text() - const dataSource = this.context?.get('dataSource') try { const event = wh.verify(body, { @@ -107,7 +141,7 @@ export class ClerkPlugin extends StarbasePlugin { if (event.type === 'user.deleted') { const { id } = event.data - await dataSource?.rpc.executeQuery({ + await this.dataSource?.rpc.executeQuery({ sql: SQL_QUERIES.DELETE_USER, params: [id], }) @@ -121,10 +155,24 @@ export class ClerkPlugin extends StarbasePlugin { (email: any) => email.id === primary_email_address_id )?.email_address - await dataSource?.rpc.executeQuery({ + await this.dataSource?.rpc.executeQuery({ sql: SQL_QUERIES.UPSERT_USER, params: [id, email, first_name, last_name], }) + } else if (event.type === 'session.created') { + const { id, user_id } = event.data + + await this.dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.UPSERT_SESSION, + params: [id, user_id], + }) + } else if (event.type === 'session.ended' || event.type === 'session.removed' || event.type === 'session.revoked') { + const { id, user_id } = event.data + + await this.dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.DELETE_SESSION, + params: [id, user_id], + }) } return createResponse({ success: true }, undefined, 200) @@ -138,4 +186,66 @@ export class ClerkPlugin extends StarbasePlugin { } }) } + + /** + * Authenticates a request using the Clerk session public key. + * heavily references https://clerk.com/docs/backend-requests/handling/manual-jwt + * @param request The request to authenticate. + * @param dataSource The data source to use for the authentication. Must be passed as a param as this can be called before the plugin is registered. + * @returns {boolean} True if authenticated, false if not, undefined if the public key is not present. + */ + public async authenticate(request: Request, dataSource: DataSource): Promise { + if (!this.verifySessions || !this.clerkSessionPublicKey) { + throw new Error('Public key or session verification is not enabled.') + } + + const COOKIE_NAME = "__session" + const cookie = parse(request.headers.get("Cookie") || "") + const tokenSameOrigin = cookie[COOKIE_NAME] + const tokenCrossOrigin = request.headers.get("Authorization")?.replace('Bearer ', '') ?? null + + if (!tokenSameOrigin && !tokenCrossOrigin) { + return false + } + + try { + const publicKey = await importSPKI(this.clerkSessionPublicKey, 'RS256') + const token = tokenSameOrigin || tokenCrossOrigin + const decoded = await jwtVerify(token!, publicKey) + + const currentTime = Math.floor(Date.now() / 1000) + if ( + (decoded.payload.exp && decoded.payload.exp < currentTime) + || (decoded.payload.nbf && decoded.payload.nbf > currentTime) + ) { + console.error('Token is expired or not yet valid') + return false + } + + if (this.permittedOrigins.length > 0 && decoded.payload.azp + && !this.permittedOrigins.includes(decoded.payload.azp as string) + ) { + console.error("Invalid 'azp' claim") + return false + } + + const sessionId = decoded.payload.sid + const userId = decoded.payload.sub + + const result: any = await dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.GET_SESSION, + params: [sessionId, userId], + }) + + if (!result?.length) { + console.error("Session not found") + return false + } + + return true + } catch (error) { + console.error('Authentication error:', error) + throw error + } + } } diff --git a/plugins/clerk/meta.json b/plugins/clerk/meta.json index 6a3949a..863cbf3 100644 --- a/plugins/clerk/meta.json +++ b/plugins/clerk/meta.json @@ -10,6 +10,13 @@ "created_at", "updated_at", "deleted_at" + ], + "session": [ + "session_id", + "user_id", + "created_at", + "updated_at", + "deleted_at" ] }, "secrets": {}, diff --git a/plugins/clerk/sql/create-session-table.sql b/plugins/clerk/sql/create-session-table.sql new file mode 100644 index 0000000..8133496 --- /dev/null +++ b/plugins/clerk/sql/create-session-table.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS user_session ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + status TEXT DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +) \ No newline at end of file diff --git a/plugins/clerk/sql/create-table.sql b/plugins/clerk/sql/create-user-table.sql similarity index 100% rename from plugins/clerk/sql/create-table.sql rename to plugins/clerk/sql/create-user-table.sql diff --git a/plugins/clerk/sql/delete-session.sql b/plugins/clerk/sql/delete-session.sql new file mode 100644 index 0000000..689b185 --- /dev/null +++ b/plugins/clerk/sql/delete-session.sql @@ -0,0 +1 @@ +DELETE FROM user_session WHERE session_id = ? AND user_id = ? \ No newline at end of file diff --git a/plugins/clerk/sql/get-session.sql b/plugins/clerk/sql/get-session.sql new file mode 100644 index 0000000..e8d1e6a --- /dev/null +++ b/plugins/clerk/sql/get-session.sql @@ -0,0 +1 @@ +SELECT * FROM user_session WHERE session_id = ? AND user_id = ? \ No newline at end of file diff --git a/plugins/clerk/sql/upsert-session.sql b/plugins/clerk/sql/upsert-session.sql new file mode 100644 index 0000000..7128632 --- /dev/null +++ b/plugins/clerk/sql/upsert-session.sql @@ -0,0 +1,4 @@ +INSERT INTO user_session (session_id, user_id) +VALUES (?, ?) +ON CONFLICT(session_id) DO UPDATE SET +updated_at = CURRENT_TIMESTAMP \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e719e89..62ef9be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cookie: + specifier: ^1.0.2 + version: 1.0.2 cron-parser: specifier: ^4.9.0 version: 4.9.0 @@ -960,6 +963,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} @@ -2732,6 +2739,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + cron-parser@4.9.0: dependencies: luxon: 3.5.0 From 8caa9fc028f2bb6e58b224d1286f421ffb21b8e0 Mon Sep 17 00:00:00 2001 From: Jeroen Peeters Date: Wed, 26 Feb 2025 00:05:07 +0100 Subject: [PATCH 3/3] feat(clerk): add support for JWKS endpoint verification --- plugins/clerk/README.md | 51 +++++++++++++++++++++---------- plugins/clerk/index.ts | 68 ++++++++++++++++++++++++----------------- 2 files changed, 75 insertions(+), 44 deletions(-) diff --git a/plugins/clerk/README.md b/plugins/clerk/README.md index e4535f9..0337a56 100644 --- a/plugins/clerk/README.md +++ b/plugins/clerk/README.md @@ -11,6 +11,7 @@ Add the ClerkPlugin plugin to your Starbase configuration: ```typescript import { ClerkPlugin } from './plugins/clerk' const clerkPlugin = new ClerkPlugin({ + dataSource, clerkInstanceId: 'ins_**********', clerkSigningSecret: 'whsec_**********', clerkSessionPublicKey: '-----BEGIN PUBLIC KEY***' @@ -25,32 +26,45 @@ If you want to use the Clerk plugin to verify sessions, change the function `aut ```diff ... existing code ... +- if (!payload.sub) { ++ if (!payload.sub || !await clerkPlugin.sessionExistsInDb(payload)) { + throw new Error( + 'Invalid JWT payload, subject not found.' + ) + } + + context = payload } else { -+ try { -+ const authenticated = await clerkPlugin.authenticate(request, dataSource) -+ if (!authenticated) { -+ throw new Error('Unauthorized request') -+ } -+ } catch (error) { - // If no JWT secret or JWKS endpoint is provided, then the request has no authorization. - throw new Error('Unauthorized request') - } ++ const authenticated = await clerkPlugin.authenticate({ ++ cookie: request.headers.get("Cookie"), ++ token, ++ }) + // If no JWT secret or JWKS endpoint is provided, then the request has no authorization. +- throw new Error('Unauthorized request') ++ if (!authenticated) throw new Error('Unauthorized request') ++ context = authenticated } ... existing code ... ``` ## Configuration Options -| Option | Type | Default | Description | -| ----------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------ | -| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) | -| `clerkInstanceId` | string | `null` | (optional) Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) | -| `verifySessions` | boolean | `true` | (optional) Verify sessions | -| `clerkSessionPublicKey` | string | `null` | (optional) Access your public key from (https://dashboard.clerk.com/last-active?path=api-keys) | -| `permittedOrigins` | string[] | `[]` | (optional) A list of allowed origins | +| Option | Type | Default | Description | +| ----------------------- | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `dataSource` | DataSource | `null` | dataSource is needed to create tables and execute queries. | +| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) | +| `clerkInstanceId` | string | `null` | (optional) Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) | +| `clerkSessionPublicKey` | string | `null` | (optional) Access your public key from (https://dashboard.clerk.com/last-active?path=api-keys) if you want to verify using a public key | +| `verifySessions` | boolean | `true` | (optional) Verify sessions, this creates a user_session table to store session data | +| `permittedOrigins` | string[] | `[]` | (optional) A list of allowed origins | ## How To Use +### Available Methods + +- `authenticate` - Authenticates a request using the Clerk session public key, returns the payload if authenticated, false in any other case. +- `sessionExistsInDb` - Checks if a user session exists in the database, returns true if it does, false in any other case. + ### Webhook Setup For our Starbase instance to receive webhook events when user information changes, we need to add our plugin endpoint to Clerk. @@ -66,3 +80,8 @@ For our Starbase instance to receive webhook events when user information change - Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys - Click the copy icon next to `JWKS Public Key` 5. Copy the public key into the Clerk plugin +6. Alternatively, you can use a JWKS endpoint instead of a public key. + - Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys + - Click the copy icon next to `JWKS URL` + - Paste the URL under `AUTH_JWKS_ENDPOINT` in your `wrangler.toml` + - Tweak the `authenticate` function in `src/index.ts` to check whether the session exists in the database, as shown in the [Usage](#usage) section. diff --git a/plugins/clerk/index.ts b/plugins/clerk/index.ts index 6bc61af..1d9c4f3 100644 --- a/plugins/clerk/index.ts +++ b/plugins/clerk/index.ts @@ -1,5 +1,5 @@ import { parse } from 'cookie' -import { jwtVerify, importSPKI } from 'jose' +import { jwtVerify, importSPKI, JWTPayload } from 'jose' import { Webhook } from 'svix' import { StarbaseApp } from '../../src/handler' import { StarbasePlugin } from '../../src/plugin' @@ -68,6 +68,7 @@ export class ClerkPlugin extends StarbasePlugin { clerkSessionPublicKey?: string verifySessions?: boolean permittedOrigins?: string[] + dataSource: DataSource }) { super('starbasedb:clerk', { // The `requiresAuth` is set to false to allow for the webhooks sent by Clerk to be accessible @@ -83,12 +84,11 @@ export class ClerkPlugin extends StarbasePlugin { this.clerkSessionPublicKey = opts.clerkSessionPublicKey this.verifySessions = opts.verifySessions ?? true this.permittedOrigins = opts.permittedOrigins ?? [] + this.dataSource = opts.dataSource } override async register(app: StarbaseApp) { - app.use(async (c, next) => { - this.dataSource = c?.get('dataSource') - + app.use(async (_, next) => { // Create user table if it doesn't exist await this.dataSource?.rpc.executeQuery({ sql: SQL_QUERIES.CREATE_USER_TABLE, @@ -145,6 +145,8 @@ export class ClerkPlugin extends StarbasePlugin { sql: SQL_QUERIES.DELETE_USER, params: [id], }) + + // todo if user is deleted, delete all sessions for that user } else if ( event.type === 'user.updated' || event.type === 'user.created' @@ -190,28 +192,24 @@ export class ClerkPlugin extends StarbasePlugin { /** * Authenticates a request using the Clerk session public key. * heavily references https://clerk.com/docs/backend-requests/handling/manual-jwt - * @param request The request to authenticate. - * @param dataSource The data source to use for the authentication. Must be passed as a param as this can be called before the plugin is registered. - * @returns {boolean} True if authenticated, false if not, undefined if the public key is not present. + * @param cookie The cookie to authenticate. + * @param token The token to authenticate. + * @returns {JWTPayload | false} The decoded payload if authenticated, false if not. */ - public async authenticate(request: Request, dataSource: DataSource): Promise { + public async authenticate({ cookie, token: tokenCrossOrigin }: { cookie?: string | null, token?: string }) { if (!this.verifySessions || !this.clerkSessionPublicKey) { - throw new Error('Public key or session verification is not enabled.') + console.error('Public key or session verification is not enabled.') + return false } const COOKIE_NAME = "__session" - const cookie = parse(request.headers.get("Cookie") || "") - const tokenSameOrigin = cookie[COOKIE_NAME] - const tokenCrossOrigin = request.headers.get("Authorization")?.replace('Bearer ', '') ?? null - - if (!tokenSameOrigin && !tokenCrossOrigin) { - return false - } + const tokenSameOrigin = cookie ? parse(cookie)[COOKIE_NAME] : undefined + if (!tokenSameOrigin && !tokenCrossOrigin) return false try { const publicKey = await importSPKI(this.clerkSessionPublicKey, 'RS256') const token = tokenSameOrigin || tokenCrossOrigin - const decoded = await jwtVerify(token!, publicKey) + const decoded = await jwtVerify<{ sid: string; sub: string }>(token!, publicKey) const currentTime = Math.floor(Date.now() / 1000) if ( @@ -229,23 +227,37 @@ export class ClerkPlugin extends StarbasePlugin { return false } - const sessionId = decoded.payload.sid - const userId = decoded.payload.sub - - const result: any = await dataSource?.rpc.executeQuery({ - sql: SQL_QUERIES.GET_SESSION, - params: [sessionId, userId], - }) - - if (!result?.length) { + const sessionExists = await this.sessionExistsInDb(decoded.payload) + if (!sessionExists) { console.error("Session not found") return false } - return true + return decoded.payload } catch (error) { console.error('Authentication error:', error) - throw error + return false + } + } + + /** + * Checks if a user session exists in the database. + * @param sessionId The session ID to check. + * @param userId The user ID to check. + * @param dataSource The data source to use for the check. + * @returns {boolean} True if the session exists, false if not. + */ + public async sessionExistsInDb(payload: { sub: string, sid: string }): Promise { + try { + const result: any = await this.dataSource?.rpc.executeQuery({ + sql: SQL_QUERIES.GET_SESSION, + params: [payload.sid, payload.sub], + }) + + return result?.length > 0 + } catch (error) { + console.error('db error while fetching session:', error) + return false } } }