Skip to content

Commit 8caa9fc

Browse files
author
Jeroen Peeters
committed
feat(clerk): add support for JWKS endpoint verification
1 parent b56d6ea commit 8caa9fc

File tree

2 files changed

+75
-44
lines changed

2 files changed

+75
-44
lines changed

plugins/clerk/README.md

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Add the ClerkPlugin plugin to your Starbase configuration:
1111
```typescript
1212
import { ClerkPlugin } from './plugins/clerk'
1313
const clerkPlugin = new ClerkPlugin({
14+
dataSource,
1415
clerkInstanceId: 'ins_**********',
1516
clerkSigningSecret: 'whsec_**********',
1617
clerkSessionPublicKey: '-----BEGIN PUBLIC KEY***'
@@ -25,32 +26,45 @@ If you want to use the Clerk plugin to verify sessions, change the function `aut
2526

2627
```diff
2728
... existing code ...
29+
- if (!payload.sub) {
30+
+ if (!payload.sub || !await clerkPlugin.sessionExistsInDb(payload)) {
31+
throw new Error(
32+
'Invalid JWT payload, subject not found.'
33+
)
34+
}
35+
36+
context = payload
2837
} 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+
+ const authenticated = await clerkPlugin.authenticate({
39+
+ cookie: request.headers.get("Cookie"),
40+
+ token,
41+
+ })
42+
// If no JWT secret or JWKS endpoint is provided, then the request has no authorization.
43+
- throw new Error('Unauthorized request')
44+
+ if (!authenticated) throw new Error('Unauthorized request')
45+
+ context = authenticated
3846
}
3947
... existing code ...
4048
```
4149

4250
## Configuration Options
4351

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 |
52+
| Option | Type | Default | Description |
53+
| ----------------------- | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- |
54+
| `dataSource` | DataSource | `null` | dataSource is needed to create tables and execute queries. |
55+
| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) |
56+
| `clerkInstanceId` | string | `null` | (optional) Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) |
57+
| `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 |
58+
| `verifySessions` | boolean | `true` | (optional) Verify sessions, this creates a user_session table to store session data |
59+
| `permittedOrigins` | string[] | `[]` | (optional) A list of allowed origins |
5160

5261
## How To Use
5362

63+
### Available Methods
64+
65+
- `authenticate` - Authenticates a request using the Clerk session public key, returns the payload if authenticated, false in any other case.
66+
- `sessionExistsInDb` - Checks if a user session exists in the database, returns true if it does, false in any other case.
67+
5468
### Webhook Setup
5569

5670
For our Starbase instance to receive webhook events when user information changes, we need to add our plugin endpoint to Clerk.
@@ -66,3 +80,8 @@ For our Starbase instance to receive webhook events when user information change
6680
- Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys
6781
- Click the copy icon next to `JWKS Public Key`
6882
5. Copy the public key into the Clerk plugin
83+
6. Alternatively, you can use a JWKS endpoint instead of a public key.
84+
- Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys
85+
- Click the copy icon next to `JWKS URL`
86+
- Paste the URL under `AUTH_JWKS_ENDPOINT` in your `wrangler.toml`
87+
- Tweak the `authenticate` function in `src/index.ts` to check whether the session exists in the database, as shown in the [Usage](#usage) section.

plugins/clerk/index.ts

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { parse } from 'cookie'
2-
import { jwtVerify, importSPKI } from 'jose'
2+
import { jwtVerify, importSPKI, JWTPayload } from 'jose'
33
import { Webhook } from 'svix'
44
import { StarbaseApp } from '../../src/handler'
55
import { StarbasePlugin } from '../../src/plugin'
@@ -68,6 +68,7 @@ export class ClerkPlugin extends StarbasePlugin {
6868
clerkSessionPublicKey?: string
6969
verifySessions?: boolean
7070
permittedOrigins?: string[]
71+
dataSource: DataSource
7172
}) {
7273
super('starbasedb:clerk', {
7374
// The `requiresAuth` is set to false to allow for the webhooks sent by Clerk to be accessible
@@ -83,12 +84,11 @@ export class ClerkPlugin extends StarbasePlugin {
8384
this.clerkSessionPublicKey = opts.clerkSessionPublicKey
8485
this.verifySessions = opts.verifySessions ?? true
8586
this.permittedOrigins = opts.permittedOrigins ?? []
87+
this.dataSource = opts.dataSource
8688
}
8789

8890
override async register(app: StarbaseApp) {
89-
app.use(async (c, next) => {
90-
this.dataSource = c?.get('dataSource')
91-
91+
app.use(async (_, next) => {
9292
// Create user table if it doesn't exist
9393
await this.dataSource?.rpc.executeQuery({
9494
sql: SQL_QUERIES.CREATE_USER_TABLE,
@@ -145,6 +145,8 @@ export class ClerkPlugin extends StarbasePlugin {
145145
sql: SQL_QUERIES.DELETE_USER,
146146
params: [id],
147147
})
148+
149+
// todo if user is deleted, delete all sessions for that user
148150
} else if (
149151
event.type === 'user.updated' ||
150152
event.type === 'user.created'
@@ -190,28 +192,24 @@ export class ClerkPlugin extends StarbasePlugin {
190192
/**
191193
* Authenticates a request using the Clerk session public key.
192194
* 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.
195+
* @param cookie The cookie to authenticate.
196+
* @param token The token to authenticate.
197+
* @returns {JWTPayload | false} The decoded payload if authenticated, false if not.
196198
*/
197-
public async authenticate(request: Request, dataSource: DataSource): Promise<boolean | undefined> {
199+
public async authenticate({ cookie, token: tokenCrossOrigin }: { cookie?: string | null, token?: string }) {
198200
if (!this.verifySessions || !this.clerkSessionPublicKey) {
199-
throw new Error('Public key or session verification is not enabled.')
201+
console.error('Public key or session verification is not enabled.')
202+
return false
200203
}
201204

202205
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-
}
206+
const tokenSameOrigin = cookie ? parse(cookie)[COOKIE_NAME] : undefined
207+
if (!tokenSameOrigin && !tokenCrossOrigin) return false
210208

211209
try {
212210
const publicKey = await importSPKI(this.clerkSessionPublicKey, 'RS256')
213211
const token = tokenSameOrigin || tokenCrossOrigin
214-
const decoded = await jwtVerify(token!, publicKey)
212+
const decoded = await jwtVerify<{ sid: string; sub: string }>(token!, publicKey)
215213

216214
const currentTime = Math.floor(Date.now() / 1000)
217215
if (
@@ -229,23 +227,37 @@ export class ClerkPlugin extends StarbasePlugin {
229227
return false
230228
}
231229

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) {
230+
const sessionExists = await this.sessionExistsInDb(decoded.payload)
231+
if (!sessionExists) {
241232
console.error("Session not found")
242233
return false
243234
}
244235

245-
return true
236+
return decoded.payload
246237
} catch (error) {
247238
console.error('Authentication error:', error)
248-
throw error
239+
return false
240+
}
241+
}
242+
243+
/**
244+
* Checks if a user session exists in the database.
245+
* @param sessionId The session ID to check.
246+
* @param userId The user ID to check.
247+
* @param dataSource The data source to use for the check.
248+
* @returns {boolean} True if the session exists, false if not.
249+
*/
250+
public async sessionExistsInDb(payload: { sub: string, sid: string }): Promise<boolean> {
251+
try {
252+
const result: any = await this.dataSource?.rpc.executeQuery({
253+
sql: SQL_QUERIES.GET_SESSION,
254+
params: [payload.sid, payload.sub],
255+
})
256+
257+
return result?.length > 0
258+
} catch (error) {
259+
console.error('db error while fetching session:', error)
260+
return false
249261
}
250262
}
251263
}

0 commit comments

Comments
 (0)