Skip to content

Commit

Permalink
feat: add session verification to the Clerk plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
Jeroen Peeters committed Feb 24, 2025
1 parent 681b1db commit d79c314
Show file tree
Hide file tree
Showing 10 changed files with 191 additions and 23 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 39 additions & 10 deletions plugins/clerk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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://<your-starbase-instance-url>/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
135 changes: 122 additions & 13 deletions plugins/clerk/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { Webhook } from 'svix'
import { StarbaseApp, StarbaseContext } from '../../src/handler'
import { parse } from 'cookie'
import { jwtVerify, importSPKI } from 'jose'
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
} & (
Expand All @@ -27,47 +33,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()
})

Expand All @@ -87,7 +121,6 @@ export class ClerkPlugin extends StarbasePlugin {
}

const body = await c.req.text()
const dataSource = this.context?.get('dataSource')

try {
const event = wh.verify(body, {
Expand All @@ -107,7 +140,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],
})
Expand All @@ -121,10 +154,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)
Expand All @@ -138,4 +185,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<boolean | undefined> {
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")

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
}
}
}
7 changes: 7 additions & 0 deletions plugins/clerk/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
"created_at",
"updated_at",
"deleted_at"
],
"session": [
"session_id",
"user_id",
"created_at",
"updated_at",
"deleted_at"
]
},
"secrets": {},
Expand Down
7 changes: 7 additions & 0 deletions plugins/clerk/sql/create-session-table.sql
Original file line number Diff line number Diff line change
@@ -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
)
File renamed without changes.
1 change: 1 addition & 0 deletions plugins/clerk/sql/delete-session.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM user_session WHERE session_id = ? AND user_id = ?
1 change: 1 addition & 0 deletions plugins/clerk/sql/get-session.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SELECT * FROM user_session WHERE session_id = ? AND user_id = ?
4 changes: 4 additions & 0 deletions plugins/clerk/sql/upsert-session.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
INSERT INTO user_session (session_id, user_id)
VALUES (?, ?)
ON CONFLICT(session_id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit d79c314

Please sign in to comment.