Skip to content

Commit

Permalink
Merge pull request #91 from jeroenptrs/feat/clerk-plugin
Browse files Browse the repository at this point in the history
feat(plugins): add Clerk plugin to manage users with webhooks
  • Loading branch information
Brayden authored Feb 27, 2025
2 parents 931269e + 8caa9fc commit 354d80a
Show file tree
Hide file tree
Showing 15 changed files with 524 additions and 77 deletions.
1 change: 1 addition & 0 deletions dist/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@
"@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",
"mongodb": "^6.11.0",
"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"
},
Expand Down
87 changes: 87 additions & 0 deletions plugins/clerk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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 clerkPlugin = new ClerkPlugin({
dataSource,
clerkInstanceId: 'ins_**********',
clerkSigningSecret: 'whsec_**********',
clerkSessionPublicKey: '-----BEGIN PUBLIC KEY***'
})
const plugins = [
clerkPlugin,
// ... other plugins
] 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 ...
- if (!payload.sub) {
+ if (!payload.sub || !await clerkPlugin.sessionExistsInDb(payload)) {
throw new Error(
'Invalid JWT payload, subject not found.'
)
}

context = payload
} else {
+ 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 |
| ----------------------- | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `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.

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://<your-starbase-instance-url>/clerk/webhook`
- 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
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.
263 changes: 263 additions & 0 deletions plugins/clerk/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { parse } from 'cookie'
import { jwtVerify, importSPKI, JWTPayload } from 'jose'
import { Webhook } from 'svix'
import { StarbaseApp } from '../../src/handler'
import { StarbasePlugin } from '../../src/plugin'
import { DataSource } from '../../src/types'
import { createResponse } from '../../src/utils'
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
} & (
| {
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 }
}
| {
type: 'session.created' | 'session.ended' | 'session.removed' | 'session.revoked'
data: {
id: string
user_id: string
}
}
)

const SQL_QUERIES = {
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 {
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[]
dataSource: DataSource
}) {
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 ?? []
this.dataSource = opts.dataSource
}

override async register(app: StarbaseApp) {
app.use(async (_, next) => {
// Create user table if it doesn't exist
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()
})

// 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()

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 this.dataSource?.rpc.executeQuery({
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'
) {
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 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)
} catch (error: any) {
console.error('Webhook processing error:', error)
return createResponse(
undefined,
`Webhook processing failed: ${error.message}`,
400
)
}
})
}

/**
* Authenticates a request using the Clerk session public key.
* heavily references https://clerk.com/docs/backend-requests/handling/manual-jwt
* @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({ cookie, token: tokenCrossOrigin }: { cookie?: string | null, token?: string }) {
if (!this.verifySessions || !this.clerkSessionPublicKey) {
console.error('Public key or session verification is not enabled.')
return false
}

const COOKIE_NAME = "__session"
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<{ sid: string; sub: string }>(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 sessionExists = await this.sessionExistsInDb(decoded.payload)
if (!sessionExists) {
console.error("Session not found")
return false
}

return decoded.payload
} catch (error) {
console.error('Authentication error:', 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<boolean> {
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
}
}
}
Loading

0 comments on commit 354d80a

Please sign in to comment.