Skip to content

Commit b56d6ea

Browse files
author
Jeroen Peeters
committed
feat: add session verification to the Clerk plugin
1 parent 1603ca1 commit b56d6ea

File tree

10 files changed

+191
-22
lines changed

10 files changed

+191
-22
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@libsql/client": "^0.14.0",
5252
"@outerbase/sdk": "2.0.0-rc.3",
5353
"clsx": "^2.1.1",
54+
"cookie": "^1.0.2",
5455
"cron-parser": "^4.9.0",
5556
"hono": "^4.6.14",
5657
"jose": "^5.9.6",

plugins/clerk/README.md

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,44 @@ Add the ClerkPlugin plugin to your Starbase configuration:
1010

1111
```typescript
1212
import { ClerkPlugin } from './plugins/clerk'
13+
const clerkPlugin = new ClerkPlugin({
14+
clerkInstanceId: 'ins_**********',
15+
clerkSigningSecret: 'whsec_**********',
16+
clerkSessionPublicKey: '-----BEGIN PUBLIC KEY***'
17+
})
1318
const plugins = [
19+
clerkPlugin,
1420
// ... other plugins
15-
new ClerkPlugin({
16-
clerkInstanceId: 'ins_**********',
17-
clerkSigningSecret: 'whsec_**********',
18-
}),
1921
] satisfies StarbasePlugin[]
2022
```
2123

24+
If you want to use the Clerk plugin to verify sessions, change the function `authenticate` in `src/index.ts` to the following:
25+
26+
```diff
27+
... existing code ...
28+
} else {
29+
+ try {
30+
+ const authenticated = await clerkPlugin.authenticate(request, dataSource)
31+
+ if (!authenticated) {
32+
+ throw new Error('Unauthorized request')
33+
+ }
34+
+ } catch (error) {
35+
// If no JWT secret or JWKS endpoint is provided, then the request has no authorization.
36+
throw new Error('Unauthorized request')
37+
}
38+
}
39+
... existing code ...
40+
```
41+
2242
## Configuration Options
2343

24-
| Option | Type | Default | Description |
25-
| -------------------- | ------ | ------- | --------------------------------------------------------------------------------------- |
26-
| `clerkInstanceId` | string | `null` | Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) |
27-
| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) |
44+
| Option | Type | Default | Description |
45+
| ----------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------ |
46+
| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) |
47+
| `clerkInstanceId` | string | `null` | (optional) Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) |
48+
| `verifySessions` | boolean | `true` | (optional) Verify sessions |
49+
| `clerkSessionPublicKey` | string | `null` | (optional) Access your public key from (https://dashboard.clerk.com/last-active?path=api-keys) |
50+
| `permittedOrigins` | string[] | `[]` | (optional) A list of allowed origins |
2851

2952
## How To Use
3053

@@ -35,5 +58,11 @@ For our Starbase instance to receive webhook events when user information change
3558
1. Visit the Webhooks page for your Clerk instance: https://dashboard.clerk.com/last-active?path=webhooks
3659
2. Add a new endpoint with the following settings:
3760
- URL: `https://<your-starbase-instance-url>/clerk/webhook`
38-
- Events: `User`
39-
3. Save by clicking "Create"
61+
- Events:
62+
- `User`,
63+
- `Session` if you also want to verify sessions ("session.pending" does not appear to be sent by Clerk, so you can keep it deselected)
64+
3. Save by clicking "Create" and copy the signing secret into the Clerk plugin
65+
4. If you want to verify sessions, you will need to add a public key to your Clerk instance:
66+
- Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys
67+
- Click the copy icon next to `JWKS Public Key`
68+
5. Copy the public key into the Clerk plugin

plugins/clerk/index.ts

Lines changed: 122 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
import { parse } from 'cookie'
2+
import { jwtVerify, importSPKI } from 'jose'
13
import { Webhook } from 'svix'
2-
import { StarbaseApp, StarbaseContext } from '../../src/handler'
4+
import { StarbaseApp } from '../../src/handler'
35
import { StarbasePlugin } from '../../src/plugin'
6+
import { DataSource } from '../../src/types'
47
import { createResponse } from '../../src/utils'
5-
import CREATE_TABLE from './sql/create-table.sql'
8+
import CREATE_USER_TABLE from './sql/create-user-table.sql'
9+
import CREATE_SESSION_TABLE from './sql/create-session-table.sql'
610
import UPSERT_USER from './sql/upsert-user.sql'
711
import GET_USER_INFORMATION from './sql/get-user-information.sql'
812
import DELETE_USER from './sql/delete-user.sql'
13+
import UPSERT_SESSION from './sql/upsert-session.sql'
14+
import DELETE_SESSION from './sql/delete-session.sql'
15+
import GET_SESSION from './sql/get-session.sql'
916

1017
type ClerkEvent = {
1118
instance_id: string
@@ -27,47 +34,75 @@ type ClerkEvent = {
2734
type: 'user.deleted'
2835
data: { id: string }
2936
}
37+
| {
38+
type: 'session.created' | 'session.ended' | 'session.removed' | 'session.revoked'
39+
data: {
40+
id: string
41+
user_id: string
42+
}
43+
}
3044
)
3145

3246
const SQL_QUERIES = {
33-
CREATE_TABLE,
47+
CREATE_USER_TABLE,
48+
CREATE_SESSION_TABLE,
3449
UPSERT_USER,
3550
GET_USER_INFORMATION, // Currently not used, but can be turned into an endpoint
3651
DELETE_USER,
52+
UPSERT_SESSION,
53+
DELETE_SESSION,
54+
GET_SESSION,
3755
}
3856

3957
export class ClerkPlugin extends StarbasePlugin {
40-
context?: StarbaseContext
58+
private dataSource?: DataSource
4159
pathPrefix: string = '/clerk'
4260
clerkInstanceId?: string
4361
clerkSigningSecret: string
44-
62+
clerkSessionPublicKey?: string
63+
permittedOrigins: string[]
64+
verifySessions: boolean
4565
constructor(opts?: {
4666
clerkInstanceId?: string
4767
clerkSigningSecret: string
68+
clerkSessionPublicKey?: string
69+
verifySessions?: boolean
70+
permittedOrigins?: string[]
4871
}) {
4972
super('starbasedb:clerk', {
5073
// The `requiresAuth` is set to false to allow for the webhooks sent by Clerk to be accessible
5174
requiresAuth: false,
5275
})
76+
5377
if (!opts?.clerkSigningSecret) {
5478
throw new Error('A signing secret is required for this plugin.')
5579
}
80+
5681
this.clerkInstanceId = opts.clerkInstanceId
5782
this.clerkSigningSecret = opts.clerkSigningSecret
83+
this.clerkSessionPublicKey = opts.clerkSessionPublicKey
84+
this.verifySessions = opts.verifySessions ?? true
85+
this.permittedOrigins = opts.permittedOrigins ?? []
5886
}
5987

6088
override async register(app: StarbaseApp) {
6189
app.use(async (c, next) => {
62-
this.context = c
63-
const dataSource = c?.get('dataSource')
90+
this.dataSource = c?.get('dataSource')
6491

6592
// Create user table if it doesn't exist
66-
await dataSource?.rpc.executeQuery({
67-
sql: SQL_QUERIES.CREATE_TABLE,
93+
await this.dataSource?.rpc.executeQuery({
94+
sql: SQL_QUERIES.CREATE_USER_TABLE,
6895
params: [],
6996
})
7097

98+
if (this.verifySessions) {
99+
// Create session table if it doesn't exist
100+
await this.dataSource?.rpc.executeQuery({
101+
sql: SQL_QUERIES.CREATE_SESSION_TABLE,
102+
params: [],
103+
})
104+
}
105+
71106
await next()
72107
})
73108

@@ -87,7 +122,6 @@ export class ClerkPlugin extends StarbasePlugin {
87122
}
88123

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

92126
try {
93127
const event = wh.verify(body, {
@@ -107,7 +141,7 @@ export class ClerkPlugin extends StarbasePlugin {
107141
if (event.type === 'user.deleted') {
108142
const { id } = event.data
109143

110-
await dataSource?.rpc.executeQuery({
144+
await this.dataSource?.rpc.executeQuery({
111145
sql: SQL_QUERIES.DELETE_USER,
112146
params: [id],
113147
})
@@ -121,10 +155,24 @@ export class ClerkPlugin extends StarbasePlugin {
121155
(email: any) => email.id === primary_email_address_id
122156
)?.email_address
123157

124-
await dataSource?.rpc.executeQuery({
158+
await this.dataSource?.rpc.executeQuery({
125159
sql: SQL_QUERIES.UPSERT_USER,
126160
params: [id, email, first_name, last_name],
127161
})
162+
} else if (event.type === 'session.created') {
163+
const { id, user_id } = event.data
164+
165+
await this.dataSource?.rpc.executeQuery({
166+
sql: SQL_QUERIES.UPSERT_SESSION,
167+
params: [id, user_id],
168+
})
169+
} else if (event.type === 'session.ended' || event.type === 'session.removed' || event.type === 'session.revoked') {
170+
const { id, user_id } = event.data
171+
172+
await this.dataSource?.rpc.executeQuery({
173+
sql: SQL_QUERIES.DELETE_SESSION,
174+
params: [id, user_id],
175+
})
128176
}
129177

130178
return createResponse({ success: true }, undefined, 200)
@@ -138,4 +186,66 @@ export class ClerkPlugin extends StarbasePlugin {
138186
}
139187
})
140188
}
189+
190+
/**
191+
* Authenticates a request using the Clerk session public key.
192+
* heavily references https://clerk.com/docs/backend-requests/handling/manual-jwt
193+
* @param request The request to authenticate.
194+
* @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.
195+
* @returns {boolean} True if authenticated, false if not, undefined if the public key is not present.
196+
*/
197+
public async authenticate(request: Request, dataSource: DataSource): Promise<boolean | undefined> {
198+
if (!this.verifySessions || !this.clerkSessionPublicKey) {
199+
throw new Error('Public key or session verification is not enabled.')
200+
}
201+
202+
const COOKIE_NAME = "__session"
203+
const cookie = parse(request.headers.get("Cookie") || "")
204+
const tokenSameOrigin = cookie[COOKIE_NAME]
205+
const tokenCrossOrigin = request.headers.get("Authorization")?.replace('Bearer ', '') ?? null
206+
207+
if (!tokenSameOrigin && !tokenCrossOrigin) {
208+
return false
209+
}
210+
211+
try {
212+
const publicKey = await importSPKI(this.clerkSessionPublicKey, 'RS256')
213+
const token = tokenSameOrigin || tokenCrossOrigin
214+
const decoded = await jwtVerify(token!, publicKey)
215+
216+
const currentTime = Math.floor(Date.now() / 1000)
217+
if (
218+
(decoded.payload.exp && decoded.payload.exp < currentTime)
219+
|| (decoded.payload.nbf && decoded.payload.nbf > currentTime)
220+
) {
221+
console.error('Token is expired or not yet valid')
222+
return false
223+
}
224+
225+
if (this.permittedOrigins.length > 0 && decoded.payload.azp
226+
&& !this.permittedOrigins.includes(decoded.payload.azp as string)
227+
) {
228+
console.error("Invalid 'azp' claim")
229+
return false
230+
}
231+
232+
const sessionId = decoded.payload.sid
233+
const userId = decoded.payload.sub
234+
235+
const result: any = await dataSource?.rpc.executeQuery({
236+
sql: SQL_QUERIES.GET_SESSION,
237+
params: [sessionId, userId],
238+
})
239+
240+
if (!result?.length) {
241+
console.error("Session not found")
242+
return false
243+
}
244+
245+
return true
246+
} catch (error) {
247+
console.error('Authentication error:', error)
248+
throw error
249+
}
250+
}
141251
}

plugins/clerk/meta.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
"created_at",
1111
"updated_at",
1212
"deleted_at"
13+
],
14+
"session": [
15+
"session_id",
16+
"user_id",
17+
"created_at",
18+
"updated_at",
19+
"deleted_at"
1320
]
1421
},
1522
"secrets": {},
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE TABLE IF NOT EXISTS user_session (
2+
session_id TEXT PRIMARY KEY,
3+
user_id TEXT NOT NULL,
4+
status TEXT DEFAULT 'active',
5+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
6+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
7+
)
File renamed without changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DELETE FROM user_session WHERE session_id = ? AND user_id = ?

plugins/clerk/sql/get-session.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SELECT * FROM user_session WHERE session_id = ? AND user_id = ?
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
INSERT INTO user_session (session_id, user_id)
2+
VALUES (?, ?)
3+
ON CONFLICT(session_id) DO UPDATE SET
4+
updated_at = CURRENT_TIMESTAMP

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)