Skip to content

Commit 5cb03b6

Browse files
author
Jeroen Peeters
committed
feat: add session verification to the Clerk plugin
1 parent 681b1db commit 5cb03b6

File tree

10 files changed

+197
-23
lines changed

10 files changed

+197
-23
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: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,50 @@ 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 `src/index.ts` to the following:
25+
26+
```diff
27+
// There must be some form of authentication token provided to proceed.
28+
- if (!authenticationToken) {
29+
- return createResponse(undefined, 'Unauthorized request', 401)
30+
- }
31+
-
32+
- try {
33+
- await authenticate(authenticationToken)
34+
+ try {
35+
+ const authenticated = await clerkPlugin.authenticate(request, dataSource)
36+
+ if (!authenticated) {
37+
+ return createResponse(undefined, 'Unauthorized request', 401)
38+
+ }
39+
+ } catch (error: any) {
40+
+ return createResponse(
41+
+ undefined,
42+
error?.message ?? 'Unable to process request.',
43+
400
44+
)
45+
}
46+
```
47+
2248
## Configuration Options
2349

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) |
50+
| Option | Type | Default | Description |
51+
| ----------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------ |
52+
| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) |
53+
| `clerkInstanceId` | string | `null` | (optional) Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) |
54+
| `verifySessions` | boolean | `true` | (optional) Verify sessions |
55+
| `clerkSessionPublicKey` | string | `null` | (optional) Access your public key from (https://dashboard.clerk.com/last-active?path=api-keys) |
56+
| `permittedOrigins` | string[] | `[]` | (optional) A list of allowed origins |
2857

2958
## How To Use
3059

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

plugins/clerk/index.ts

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { Webhook } from 'svix'
2-
import { StarbaseApp, StarbaseContext } from '../../src/handler'
2+
import { parse } from 'cookie'
3+
import { jwtVerify, importSPKI } from 'jose'
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'
9-
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'
1016
type ClerkEvent = {
1117
instance_id: string
1218
} & (
@@ -27,47 +33,75 @@ type ClerkEvent = {
2733
type: 'user.deleted'
2834
data: { id: string }
2935
}
36+
| {
37+
type: 'session.created' | 'session.ended' | 'session.removed' | 'session.revoked'
38+
data: {
39+
id: string
40+
user_id: string
41+
}
42+
}
3043
)
3144

3245
const SQL_QUERIES = {
33-
CREATE_TABLE,
46+
CREATE_USER_TABLE,
47+
CREATE_SESSION_TABLE,
3448
UPSERT_USER,
3549
GET_USER_INFORMATION, // Currently not used, but can be turned into an endpoint
3650
DELETE_USER,
51+
UPSERT_SESSION,
52+
DELETE_SESSION,
53+
GET_SESSION,
3754
}
3855

3956
export class ClerkPlugin extends StarbasePlugin {
40-
context?: StarbaseContext
57+
private dataSource?: DataSource
4158
pathPrefix: string = '/clerk'
4259
clerkInstanceId?: string
4360
clerkSigningSecret: string
44-
61+
clerkSessionPublicKey?: string
62+
permittedOrigins: string[]
63+
verifySessions: boolean
4564
constructor(opts?: {
4665
clerkInstanceId?: string
4766
clerkSigningSecret: string
67+
clerkSessionPublicKey?: string
68+
verifySessions?: boolean
69+
permittedOrigins?: string[]
4870
}) {
4971
super('starbasedb:clerk', {
5072
// The `requiresAuth` is set to false to allow for the webhooks sent by Clerk to be accessible
5173
requiresAuth: false,
5274
})
75+
5376
if (!opts?.clerkSigningSecret) {
5477
throw new Error('A signing secret is required for this plugin.')
5578
}
79+
5680
this.clerkInstanceId = opts.clerkInstanceId
5781
this.clerkSigningSecret = opts.clerkSigningSecret
82+
this.clerkSessionPublicKey = opts.clerkSessionPublicKey
83+
this.verifySessions = opts.verifySessions ?? true
84+
this.permittedOrigins = opts.permittedOrigins ?? []
5885
}
5986

6087
override async register(app: StarbaseApp) {
6188
app.use(async (c, next) => {
62-
this.context = c
63-
const dataSource = c?.get('dataSource')
89+
this.dataSource = c?.get('dataSource')
6490

6591
// Create user table if it doesn't exist
66-
await dataSource?.rpc.executeQuery({
67-
sql: SQL_QUERIES.CREATE_TABLE,
92+
await this.dataSource?.rpc.executeQuery({
93+
sql: SQL_QUERIES.CREATE_USER_TABLE,
6894
params: [],
6995
})
7096

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

@@ -87,7 +121,6 @@ export class ClerkPlugin extends StarbasePlugin {
87121
}
88122

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

92125
try {
93126
const event = wh.verify(body, {
@@ -107,7 +140,7 @@ export class ClerkPlugin extends StarbasePlugin {
107140
if (event.type === 'user.deleted') {
108141
const { id } = event.data
109142

110-
await dataSource?.rpc.executeQuery({
143+
await this.dataSource?.rpc.executeQuery({
111144
sql: SQL_QUERIES.DELETE_USER,
112145
params: [id],
113146
})
@@ -121,10 +154,24 @@ export class ClerkPlugin extends StarbasePlugin {
121154
(email: any) => email.id === primary_email_address_id
122155
)?.email_address
123156

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

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

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+
)
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)