Skip to content

Commit f32fb4f

Browse files
authored
Merge branch 'outerbase:main' into feat/granular-feature-flags
2 parents a4e64bd + 354d80a commit f32fb4f

15 files changed

+524
-77
lines changed

dist/plugins.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { StripeSubscriptionPlugin } from '../plugins/stripe'
55
export { ChangeDataCapturePlugin } from '../plugins/cdc'
66
export { QueryLogPlugin } from '../plugins/query-log'
77
export { ResendPlugin } from '../plugins/resend'
8+
export { ClerkPlugin } from '../plugins/clerk'

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,15 @@
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",
5758
"mongodb": "^6.11.0",
5859
"mysql2": "^3.11.4",
5960
"node-sql-parser": "^4.18.0",
6061
"pg": "^8.13.1",
62+
"svix": "^1.59.2",
6163
"tailwind-merge": "^2.6.0",
6264
"vite": "^5.4.11"
6365
},

plugins/clerk/README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Clerk Plugin
2+
3+
The Clerk Plugin for Starbase provides a quick and simple way for applications to add Clerk user information to their database.
4+
5+
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).
6+
7+
## Usage
8+
9+
Add the ClerkPlugin plugin to your Starbase configuration:
10+
11+
```typescript
12+
import { ClerkPlugin } from './plugins/clerk'
13+
const clerkPlugin = new ClerkPlugin({
14+
dataSource,
15+
clerkInstanceId: 'ins_**********',
16+
clerkSigningSecret: 'whsec_**********',
17+
clerkSessionPublicKey: '-----BEGIN PUBLIC KEY***'
18+
})
19+
const plugins = [
20+
clerkPlugin,
21+
// ... other plugins
22+
] satisfies StarbasePlugin[]
23+
```
24+
25+
If you want to use the Clerk plugin to verify sessions, change the function `authenticate` in `src/index.ts` to the following:
26+
27+
```diff
28+
... 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
37+
} else {
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
46+
}
47+
... existing code ...
48+
```
49+
50+
## Configuration Options
51+
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 |
60+
61+
## How To Use
62+
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+
68+
### Webhook Setup
69+
70+
For our Starbase instance to receive webhook events when user information changes, we need to add our plugin endpoint to Clerk.
71+
72+
1. Visit the Webhooks page for your Clerk instance: https://dashboard.clerk.com/last-active?path=webhooks
73+
2. Add a new endpoint with the following settings:
74+
- URL: `https://<your-starbase-instance-url>/clerk/webhook`
75+
- Events:
76+
- `User`,
77+
- `Session` if you also want to verify sessions ("session.pending" does not appear to be sent by Clerk, so you can keep it deselected)
78+
3. Save by clicking "Create" and copy the signing secret into the Clerk plugin
79+
4. If you want to verify sessions, you will need to add a public key to your Clerk instance:
80+
- Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys
81+
- Click the copy icon next to `JWKS Public Key`
82+
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: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { parse } from 'cookie'
2+
import { jwtVerify, importSPKI, JWTPayload } from 'jose'
3+
import { Webhook } from 'svix'
4+
import { StarbaseApp } from '../../src/handler'
5+
import { StarbasePlugin } from '../../src/plugin'
6+
import { DataSource } from '../../src/types'
7+
import { createResponse } from '../../src/utils'
8+
import CREATE_USER_TABLE from './sql/create-user-table.sql'
9+
import CREATE_SESSION_TABLE from './sql/create-session-table.sql'
10+
import UPSERT_USER from './sql/upsert-user.sql'
11+
import GET_USER_INFORMATION from './sql/get-user-information.sql'
12+
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'
16+
17+
type ClerkEvent = {
18+
instance_id: string
19+
} & (
20+
| {
21+
type: 'user.created' | 'user.updated'
22+
data: {
23+
id: string
24+
first_name: string
25+
last_name: string
26+
email_addresses: Array<{
27+
id: string
28+
email_address: string
29+
}>
30+
primary_email_address_id: string
31+
}
32+
}
33+
| {
34+
type: 'user.deleted'
35+
data: { id: string }
36+
}
37+
| {
38+
type: 'session.created' | 'session.ended' | 'session.removed' | 'session.revoked'
39+
data: {
40+
id: string
41+
user_id: string
42+
}
43+
}
44+
)
45+
46+
const SQL_QUERIES = {
47+
CREATE_USER_TABLE,
48+
CREATE_SESSION_TABLE,
49+
UPSERT_USER,
50+
GET_USER_INFORMATION, // Currently not used, but can be turned into an endpoint
51+
DELETE_USER,
52+
UPSERT_SESSION,
53+
DELETE_SESSION,
54+
GET_SESSION,
55+
}
56+
57+
export class ClerkPlugin extends StarbasePlugin {
58+
private dataSource?: DataSource
59+
pathPrefix: string = '/clerk'
60+
clerkInstanceId?: string
61+
clerkSigningSecret: string
62+
clerkSessionPublicKey?: string
63+
permittedOrigins: string[]
64+
verifySessions: boolean
65+
constructor(opts?: {
66+
clerkInstanceId?: string
67+
clerkSigningSecret: string
68+
clerkSessionPublicKey?: string
69+
verifySessions?: boolean
70+
permittedOrigins?: string[]
71+
dataSource: DataSource
72+
}) {
73+
super('starbasedb:clerk', {
74+
// The `requiresAuth` is set to false to allow for the webhooks sent by Clerk to be accessible
75+
requiresAuth: false,
76+
})
77+
78+
if (!opts?.clerkSigningSecret) {
79+
throw new Error('A signing secret is required for this plugin.')
80+
}
81+
82+
this.clerkInstanceId = opts.clerkInstanceId
83+
this.clerkSigningSecret = opts.clerkSigningSecret
84+
this.clerkSessionPublicKey = opts.clerkSessionPublicKey
85+
this.verifySessions = opts.verifySessions ?? true
86+
this.permittedOrigins = opts.permittedOrigins ?? []
87+
this.dataSource = opts.dataSource
88+
}
89+
90+
override async register(app: StarbaseApp) {
91+
app.use(async (_, next) => {
92+
// Create user table if it doesn't exist
93+
await this.dataSource?.rpc.executeQuery({
94+
sql: SQL_QUERIES.CREATE_USER_TABLE,
95+
params: [],
96+
})
97+
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+
106+
await next()
107+
})
108+
109+
// Webhook to handle Clerk events
110+
app.post(`${this.pathPrefix}/webhook`, async (c) => {
111+
const wh = new Webhook(this.clerkSigningSecret)
112+
const svix_id = c.req.header('svix-id')
113+
const svix_signature = c.req.header('svix-signature')
114+
const svix_timestamp = c.req.header('svix-timestamp')
115+
116+
if (!svix_id || !svix_signature || !svix_timestamp) {
117+
return createResponse(
118+
undefined,
119+
'Missing required headers: svix-id, svix-signature, svix-timestamp',
120+
400
121+
)
122+
}
123+
124+
const body = await c.req.text()
125+
126+
try {
127+
const event = wh.verify(body, {
128+
'svix-id': svix_id,
129+
'svix-timestamp': svix_timestamp,
130+
'svix-signature': svix_signature,
131+
}) as ClerkEvent
132+
133+
if (this.clerkInstanceId && 'instance_id' in event && event.instance_id !== this.clerkInstanceId) {
134+
return createResponse(
135+
undefined,
136+
'Invalid instance ID',
137+
401
138+
)
139+
}
140+
141+
if (event.type === 'user.deleted') {
142+
const { id } = event.data
143+
144+
await this.dataSource?.rpc.executeQuery({
145+
sql: SQL_QUERIES.DELETE_USER,
146+
params: [id],
147+
})
148+
149+
// todo if user is deleted, delete all sessions for that user
150+
} else if (
151+
event.type === 'user.updated' ||
152+
event.type === 'user.created'
153+
) {
154+
const { id, first_name, last_name, email_addresses, primary_email_address_id } = event.data
155+
156+
const email = email_addresses.find(
157+
(email: any) => email.id === primary_email_address_id
158+
)?.email_address
159+
160+
await this.dataSource?.rpc.executeQuery({
161+
sql: SQL_QUERIES.UPSERT_USER,
162+
params: [id, email, first_name, last_name],
163+
})
164+
} else if (event.type === 'session.created') {
165+
const { id, user_id } = event.data
166+
167+
await this.dataSource?.rpc.executeQuery({
168+
sql: SQL_QUERIES.UPSERT_SESSION,
169+
params: [id, user_id],
170+
})
171+
} else if (event.type === 'session.ended' || event.type === 'session.removed' || event.type === 'session.revoked') {
172+
const { id, user_id } = event.data
173+
174+
await this.dataSource?.rpc.executeQuery({
175+
sql: SQL_QUERIES.DELETE_SESSION,
176+
params: [id, user_id],
177+
})
178+
}
179+
180+
return createResponse({ success: true }, undefined, 200)
181+
} catch (error: any) {
182+
console.error('Webhook processing error:', error)
183+
return createResponse(
184+
undefined,
185+
`Webhook processing failed: ${error.message}`,
186+
400
187+
)
188+
}
189+
})
190+
}
191+
192+
/**
193+
* Authenticates a request using the Clerk session public key.
194+
* heavily references https://clerk.com/docs/backend-requests/handling/manual-jwt
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.
198+
*/
199+
public async authenticate({ cookie, token: tokenCrossOrigin }: { cookie?: string | null, token?: string }) {
200+
if (!this.verifySessions || !this.clerkSessionPublicKey) {
201+
console.error('Public key or session verification is not enabled.')
202+
return false
203+
}
204+
205+
const COOKIE_NAME = "__session"
206+
const tokenSameOrigin = cookie ? parse(cookie)[COOKIE_NAME] : undefined
207+
if (!tokenSameOrigin && !tokenCrossOrigin) return false
208+
209+
try {
210+
const publicKey = await importSPKI(this.clerkSessionPublicKey, 'RS256')
211+
const token = tokenSameOrigin || tokenCrossOrigin
212+
const decoded = await jwtVerify<{ sid: string; sub: string }>(token!, publicKey)
213+
214+
const currentTime = Math.floor(Date.now() / 1000)
215+
if (
216+
(decoded.payload.exp && decoded.payload.exp < currentTime)
217+
|| (decoded.payload.nbf && decoded.payload.nbf > currentTime)
218+
) {
219+
console.error('Token is expired or not yet valid')
220+
return false
221+
}
222+
223+
if (this.permittedOrigins.length > 0 && decoded.payload.azp
224+
&& !this.permittedOrigins.includes(decoded.payload.azp as string)
225+
) {
226+
console.error("Invalid 'azp' claim")
227+
return false
228+
}
229+
230+
const sessionExists = await this.sessionExistsInDb(decoded.payload)
231+
if (!sessionExists) {
232+
console.error("Session not found")
233+
return false
234+
}
235+
236+
return decoded.payload
237+
} catch (error) {
238+
console.error('Authentication error:', 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
261+
}
262+
}
263+
}

0 commit comments

Comments
 (0)