1+ import { parse } from 'cookie'
2+ import { jwtVerify , importSPKI } from 'jose'
13import { Webhook } from 'svix'
2- import { StarbaseApp , StarbaseContext } from '../../src/handler'
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'
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
1017type 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
3246const 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
3957export 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}
0 commit comments