From b56d6ea6e6ed1ccfbab1e3717ed3b708805a7256 Mon Sep 17 00:00:00 2001 From: Jeroen Peeters Date: Mon, 24 Feb 2025 21:17:02 +0100 Subject: [PATCH] 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