11import { 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'
35import { StarbasePlugin } from '../../src/plugin'
6+ import { DataSource } from '../../src/types'
47import { 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'
610import UPSERT_USER from './sql/upsert-user.sql'
711import GET_USER_INFORMATION from './sql/get-user-information.sql'
812import 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'
1016type 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
3245const 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
3956export 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}
0 commit comments