-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #91 from jeroenptrs/feat/clerk-plugin
feat(plugins): add Clerk plugin to manage users with webhooks
- Loading branch information
Showing
15 changed files
with
524 additions
and
77 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
# Clerk Plugin | ||
|
||
The Clerk Plugin for Starbase provides a quick and simple way for applications to add Clerk user information to their database. | ||
|
||
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). | ||
|
||
## Usage | ||
|
||
Add the ClerkPlugin plugin to your Starbase configuration: | ||
|
||
```typescript | ||
import { ClerkPlugin } from './plugins/clerk' | ||
const clerkPlugin = new ClerkPlugin({ | ||
dataSource, | ||
clerkInstanceId: 'ins_**********', | ||
clerkSigningSecret: 'whsec_**********', | ||
clerkSessionPublicKey: '-----BEGIN PUBLIC KEY***' | ||
}) | ||
const plugins = [ | ||
clerkPlugin, | ||
// ... other plugins | ||
] satisfies StarbasePlugin[] | ||
``` | ||
|
||
If you want to use the Clerk plugin to verify sessions, change the function `authenticate` in `src/index.ts` to the following: | ||
|
||
```diff | ||
... existing code ... | ||
- if (!payload.sub) { | ||
+ if (!payload.sub || !await clerkPlugin.sessionExistsInDb(payload)) { | ||
throw new Error( | ||
'Invalid JWT payload, subject not found.' | ||
) | ||
} | ||
|
||
context = payload | ||
} else { | ||
+ const authenticated = await clerkPlugin.authenticate({ | ||
+ cookie: request.headers.get("Cookie"), | ||
+ token, | ||
+ }) | ||
// If no JWT secret or JWKS endpoint is provided, then the request has no authorization. | ||
- throw new Error('Unauthorized request') | ||
+ if (!authenticated) throw new Error('Unauthorized request') | ||
+ context = authenticated | ||
} | ||
... existing code ... | ||
``` | ||
|
||
## Configuration Options | ||
|
||
| Option | Type | Default | Description | | ||
| ----------------------- | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | ||
| `dataSource` | DataSource | `null` | dataSource is needed to create tables and execute queries. | | ||
| `clerkSigningSecret` | string | `null` | Access your signing secret from (https://dashboard.clerk.com/last-active?path=webhooks) | | ||
| `clerkInstanceId` | string | `null` | (optional) Access your instance ID from (https://dashboard.clerk.com/last-active?path=settings) | | ||
| `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 | | ||
| `verifySessions` | boolean | `true` | (optional) Verify sessions, this creates a user_session table to store session data | | ||
| `permittedOrigins` | string[] | `[]` | (optional) A list of allowed origins | | ||
|
||
## How To Use | ||
|
||
### Available Methods | ||
|
||
- `authenticate` - Authenticates a request using the Clerk session public key, returns the payload if authenticated, false in any other case. | ||
- `sessionExistsInDb` - Checks if a user session exists in the database, returns true if it does, false in any other case. | ||
|
||
### Webhook Setup | ||
|
||
For our Starbase instance to receive webhook events when user information changes, we need to add our plugin endpoint to Clerk. | ||
|
||
1. Visit the Webhooks page for your Clerk instance: https://dashboard.clerk.com/last-active?path=webhooks | ||
2. Add a new endpoint with the following settings: | ||
- URL: `https://<your-starbase-instance-url>/clerk/webhook` | ||
- Events: | ||
- `User`, | ||
- `Session` if you also want to verify sessions ("session.pending" does not appear to be sent by Clerk, so you can keep it deselected) | ||
3. Save by clicking "Create" and copy the signing secret into the Clerk plugin | ||
4. If you want to verify sessions, you will need to add a public key to your Clerk instance: | ||
- Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys | ||
- Click the copy icon next to `JWKS Public Key` | ||
5. Copy the public key into the Clerk plugin | ||
6. Alternatively, you can use a JWKS endpoint instead of a public key. | ||
- Visit the API Keys page for your Clerk instance: https://dashboard.clerk.com/last-active?path=api-keys | ||
- Click the copy icon next to `JWKS URL` | ||
- Paste the URL under `AUTH_JWKS_ENDPOINT` in your `wrangler.toml` | ||
- Tweak the `authenticate` function in `src/index.ts` to check whether the session exists in the database, as shown in the [Usage](#usage) section. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,263 @@ | ||
import { parse } from 'cookie' | ||
import { jwtVerify, importSPKI, JWTPayload } from 'jose' | ||
import { Webhook } from 'svix' | ||
import { StarbaseApp } from '../../src/handler' | ||
import { StarbasePlugin } from '../../src/plugin' | ||
import { DataSource } from '../../src/types' | ||
import { createResponse } from '../../src/utils' | ||
import CREATE_USER_TABLE from './sql/create-user-table.sql' | ||
import CREATE_SESSION_TABLE from './sql/create-session-table.sql' | ||
import UPSERT_USER from './sql/upsert-user.sql' | ||
import GET_USER_INFORMATION from './sql/get-user-information.sql' | ||
import DELETE_USER from './sql/delete-user.sql' | ||
import UPSERT_SESSION from './sql/upsert-session.sql' | ||
import DELETE_SESSION from './sql/delete-session.sql' | ||
import GET_SESSION from './sql/get-session.sql' | ||
|
||
type ClerkEvent = { | ||
instance_id: string | ||
} & ( | ||
| { | ||
type: 'user.created' | 'user.updated' | ||
data: { | ||
id: string | ||
first_name: string | ||
last_name: string | ||
email_addresses: Array<{ | ||
id: string | ||
email_address: string | ||
}> | ||
primary_email_address_id: string | ||
} | ||
} | ||
| { | ||
type: 'user.deleted' | ||
data: { id: string } | ||
} | ||
| { | ||
type: 'session.created' | 'session.ended' | 'session.removed' | 'session.revoked' | ||
data: { | ||
id: string | ||
user_id: string | ||
} | ||
} | ||
) | ||
|
||
const SQL_QUERIES = { | ||
CREATE_USER_TABLE, | ||
CREATE_SESSION_TABLE, | ||
UPSERT_USER, | ||
GET_USER_INFORMATION, // Currently not used, but can be turned into an endpoint | ||
DELETE_USER, | ||
UPSERT_SESSION, | ||
DELETE_SESSION, | ||
GET_SESSION, | ||
} | ||
|
||
export class ClerkPlugin extends StarbasePlugin { | ||
private dataSource?: DataSource | ||
pathPrefix: string = '/clerk' | ||
clerkInstanceId?: string | ||
clerkSigningSecret: string | ||
clerkSessionPublicKey?: string | ||
permittedOrigins: string[] | ||
verifySessions: boolean | ||
constructor(opts?: { | ||
clerkInstanceId?: string | ||
clerkSigningSecret: string | ||
clerkSessionPublicKey?: string | ||
verifySessions?: boolean | ||
permittedOrigins?: string[] | ||
dataSource: DataSource | ||
}) { | ||
super('starbasedb:clerk', { | ||
// The `requiresAuth` is set to false to allow for the webhooks sent by Clerk to be accessible | ||
requiresAuth: false, | ||
}) | ||
|
||
if (!opts?.clerkSigningSecret) { | ||
throw new Error('A signing secret is required for this plugin.') | ||
} | ||
|
||
this.clerkInstanceId = opts.clerkInstanceId | ||
this.clerkSigningSecret = opts.clerkSigningSecret | ||
this.clerkSessionPublicKey = opts.clerkSessionPublicKey | ||
this.verifySessions = opts.verifySessions ?? true | ||
this.permittedOrigins = opts.permittedOrigins ?? [] | ||
this.dataSource = opts.dataSource | ||
} | ||
|
||
override async register(app: StarbaseApp) { | ||
app.use(async (_, next) => { | ||
// Create user table if it doesn't exist | ||
await this.dataSource?.rpc.executeQuery({ | ||
sql: SQL_QUERIES.CREATE_USER_TABLE, | ||
params: [], | ||
}) | ||
|
||
if (this.verifySessions) { | ||
// Create session table if it doesn't exist | ||
await this.dataSource?.rpc.executeQuery({ | ||
sql: SQL_QUERIES.CREATE_SESSION_TABLE, | ||
params: [], | ||
}) | ||
} | ||
|
||
await next() | ||
}) | ||
|
||
// Webhook to handle Clerk events | ||
app.post(`${this.pathPrefix}/webhook`, async (c) => { | ||
const wh = new Webhook(this.clerkSigningSecret) | ||
const svix_id = c.req.header('svix-id') | ||
const svix_signature = c.req.header('svix-signature') | ||
const svix_timestamp = c.req.header('svix-timestamp') | ||
|
||
if (!svix_id || !svix_signature || !svix_timestamp) { | ||
return createResponse( | ||
undefined, | ||
'Missing required headers: svix-id, svix-signature, svix-timestamp', | ||
400 | ||
) | ||
} | ||
|
||
const body = await c.req.text() | ||
|
||
try { | ||
const event = wh.verify(body, { | ||
'svix-id': svix_id, | ||
'svix-timestamp': svix_timestamp, | ||
'svix-signature': svix_signature, | ||
}) as ClerkEvent | ||
|
||
if (this.clerkInstanceId && 'instance_id' in event && event.instance_id !== this.clerkInstanceId) { | ||
return createResponse( | ||
undefined, | ||
'Invalid instance ID', | ||
401 | ||
) | ||
} | ||
|
||
if (event.type === 'user.deleted') { | ||
const { id } = event.data | ||
|
||
await this.dataSource?.rpc.executeQuery({ | ||
sql: SQL_QUERIES.DELETE_USER, | ||
params: [id], | ||
}) | ||
|
||
// todo if user is deleted, delete all sessions for that user | ||
} else if ( | ||
event.type === 'user.updated' || | ||
event.type === 'user.created' | ||
) { | ||
const { id, first_name, last_name, email_addresses, primary_email_address_id } = event.data | ||
|
||
const email = email_addresses.find( | ||
(email: any) => email.id === primary_email_address_id | ||
)?.email_address | ||
|
||
await this.dataSource?.rpc.executeQuery({ | ||
sql: SQL_QUERIES.UPSERT_USER, | ||
params: [id, email, first_name, last_name], | ||
}) | ||
} else if (event.type === 'session.created') { | ||
const { id, user_id } = event.data | ||
|
||
await this.dataSource?.rpc.executeQuery({ | ||
sql: SQL_QUERIES.UPSERT_SESSION, | ||
params: [id, user_id], | ||
}) | ||
} else if (event.type === 'session.ended' || event.type === 'session.removed' || event.type === 'session.revoked') { | ||
const { id, user_id } = event.data | ||
|
||
await this.dataSource?.rpc.executeQuery({ | ||
sql: SQL_QUERIES.DELETE_SESSION, | ||
params: [id, user_id], | ||
}) | ||
} | ||
|
||
return createResponse({ success: true }, undefined, 200) | ||
} catch (error: any) { | ||
console.error('Webhook processing error:', error) | ||
return createResponse( | ||
undefined, | ||
`Webhook processing failed: ${error.message}`, | ||
400 | ||
) | ||
} | ||
}) | ||
} | ||
|
||
/** | ||
* Authenticates a request using the Clerk session public key. | ||
* heavily references https://clerk.com/docs/backend-requests/handling/manual-jwt | ||
* @param cookie The cookie to authenticate. | ||
* @param token The token to authenticate. | ||
* @returns {JWTPayload | false} The decoded payload if authenticated, false if not. | ||
*/ | ||
public async authenticate({ cookie, token: tokenCrossOrigin }: { cookie?: string | null, token?: string }) { | ||
if (!this.verifySessions || !this.clerkSessionPublicKey) { | ||
console.error('Public key or session verification is not enabled.') | ||
return false | ||
} | ||
|
||
const COOKIE_NAME = "__session" | ||
const tokenSameOrigin = cookie ? parse(cookie)[COOKIE_NAME] : undefined | ||
if (!tokenSameOrigin && !tokenCrossOrigin) return false | ||
|
||
try { | ||
const publicKey = await importSPKI(this.clerkSessionPublicKey, 'RS256') | ||
const token = tokenSameOrigin || tokenCrossOrigin | ||
const decoded = await jwtVerify<{ sid: string; sub: string }>(token!, publicKey) | ||
|
||
const currentTime = Math.floor(Date.now() / 1000) | ||
if ( | ||
(decoded.payload.exp && decoded.payload.exp < currentTime) | ||
|| (decoded.payload.nbf && decoded.payload.nbf > currentTime) | ||
) { | ||
console.error('Token is expired or not yet valid') | ||
return false | ||
} | ||
|
||
if (this.permittedOrigins.length > 0 && decoded.payload.azp | ||
&& !this.permittedOrigins.includes(decoded.payload.azp as string) | ||
) { | ||
console.error("Invalid 'azp' claim") | ||
return false | ||
} | ||
|
||
const sessionExists = await this.sessionExistsInDb(decoded.payload) | ||
if (!sessionExists) { | ||
console.error("Session not found") | ||
return false | ||
} | ||
|
||
return decoded.payload | ||
} catch (error) { | ||
console.error('Authentication error:', error) | ||
return false | ||
} | ||
} | ||
|
||
/** | ||
* Checks if a user session exists in the database. | ||
* @param sessionId The session ID to check. | ||
* @param userId The user ID to check. | ||
* @param dataSource The data source to use for the check. | ||
* @returns {boolean} True if the session exists, false if not. | ||
*/ | ||
public async sessionExistsInDb(payload: { sub: string, sid: string }): Promise<boolean> { | ||
try { | ||
const result: any = await this.dataSource?.rpc.executeQuery({ | ||
sql: SQL_QUERIES.GET_SESSION, | ||
params: [payload.sid, payload.sub], | ||
}) | ||
|
||
return result?.length > 0 | ||
} catch (error) { | ||
console.error('db error while fetching session:', error) | ||
return false | ||
} | ||
} | ||
} |
Oops, something went wrong.