canopycms-auth-clerk 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,315 @@
1
+ import { createClerkClient, verifyToken as clerkVerifyToken } from '@clerk/backend'
2
+ import type { AuthPlugin, AuthPluginFactory } from 'canopycms/auth'
3
+ import type { UserSearchResult, GroupMetadata, AuthenticationResult } from 'canopycms/auth'
4
+ import { extractHeaders, type HeadersLike } from 'canopycms/auth'
5
+
6
+ export interface ClerkAuthConfig {
7
+ /**
8
+ * Use organizations as groups
9
+ * @default true
10
+ */
11
+ useOrganizationsAsGroups?: boolean
12
+
13
+ /**
14
+ * Clerk Secret Key. If not provided, will use CLERK_SECRET_KEY env var.
15
+ */
16
+ secretKey?: string
17
+
18
+ /**
19
+ * PEM public key for networkless JWT verification.
20
+ * If not provided, will use CLERK_JWT_KEY env var.
21
+ */
22
+ jwtKey?: string
23
+
24
+ /**
25
+ * List of authorized parties (domains) for CSRF protection.
26
+ * If not provided, will parse from CLERK_AUTHORIZED_PARTIES env var.
27
+ */
28
+ authorizedParties?: string[]
29
+ }
30
+
31
+ // Clerk API Response Types
32
+ // These handle both camelCase and snake_case variants from different Clerk SDK versions
33
+
34
+ interface ClerkUserData {
35
+ id: string
36
+ username?: string
37
+ fullName?: string | null
38
+ full_name?: string | null
39
+ imageUrl?: string | null
40
+ image_url?: string | null
41
+ primaryEmailAddress?: { emailAddress: string } | null
42
+ email_addresses?: Array<{ email_address: string }> | null
43
+ }
44
+
45
+ interface ClerkOrganization {
46
+ id: string
47
+ name: string
48
+ membersCount?: number
49
+ members_count?: number
50
+ }
51
+
52
+ interface ClerkOrganizationMembership {
53
+ organization: ClerkOrganization
54
+ }
55
+
56
+ interface ClerkPaginatedResponse<T> {
57
+ data: T[]
58
+ totalCount?: number
59
+ }
60
+
61
+ type ClerkResponse<T> = T[] | ClerkPaginatedResponse<T>
62
+
63
+ /**
64
+ * Unwrap Clerk paginated response to array.
65
+ * Clerk SDK sometimes returns arrays directly, sometimes paginated objects.
66
+ */
67
+ function unwrapClerkResponse<T>(response: ClerkResponse<T>): T[] {
68
+ return Array.isArray(response) ? response : response.data
69
+ }
70
+
71
+ /**
72
+ * Map Clerk user data to Canopy user metadata.
73
+ * Handles both camelCase and snake_case property variants.
74
+ */
75
+ function mapClerkUserData(clerkUser: ClerkUserData): {
76
+ email?: string
77
+ name: string
78
+ avatarUrl?: string
79
+ } {
80
+ const avatarUrl = clerkUser.imageUrl ?? clerkUser.image_url
81
+ return {
82
+ email:
83
+ clerkUser.primaryEmailAddress?.emailAddress ?? clerkUser.email_addresses?.[0]?.email_address,
84
+ name: clerkUser.fullName ?? clerkUser.full_name ?? clerkUser.username ?? clerkUser.id,
85
+ avatarUrl: avatarUrl ?? undefined,
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Get member count from organization, handling property name variants.
91
+ */
92
+ function getOrgMemberCount(org: ClerkOrganization): number | undefined {
93
+ return org.membersCount ?? org.members_count
94
+ }
95
+
96
+ /**
97
+ * Extract token from headers.
98
+ * Looks for Bearer token in Authorization header or __session cookie.
99
+ */
100
+ export const extractToken = (headers: HeadersLike): string | null => {
101
+ // Try Authorization header first
102
+ const authHeader = headers.get('Authorization')
103
+ if (authHeader?.startsWith('Bearer ')) {
104
+ return authHeader.slice(7)
105
+ }
106
+
107
+ // Try __session cookie
108
+ const cookie = headers.get('Cookie')
109
+ if (cookie) {
110
+ const match = cookie.match(/__session=([^;]+)/)
111
+ if (match) {
112
+ return match[1]
113
+ }
114
+ }
115
+
116
+ return null
117
+ }
118
+
119
+ /**
120
+ * Clerk authentication plugin implementation for CanopyCMS.
121
+ * Uses @clerk/backend for framework-agnostic JWT verification.
122
+ */
123
+ export class ClerkAuthPlugin implements AuthPlugin {
124
+ private config: Required<Omit<ClerkAuthConfig, 'secretKey' | 'jwtKey' | 'authorizedParties'>> & {
125
+ secretKey: string
126
+ jwtKey?: string
127
+ authorizedParties?: string[]
128
+ }
129
+ private clerkClient: ReturnType<typeof createClerkClient>
130
+
131
+ constructor(config: ClerkAuthConfig = {}) {
132
+ const secretKey = config.secretKey ?? process.env.CLERK_SECRET_KEY
133
+ if (!secretKey) {
134
+ throw new Error(
135
+ 'ClerkAuthPlugin: CLERK_SECRET_KEY environment variable or secretKey config is required',
136
+ )
137
+ }
138
+
139
+ const jwtKey = config.jwtKey ?? process.env.CLERK_JWT_KEY
140
+ const authorizedParties =
141
+ config.authorizedParties ??
142
+ process.env.CLERK_AUTHORIZED_PARTIES?.split(',')
143
+ .map((s) => s.trim())
144
+ .filter(Boolean)
145
+
146
+ this.config = {
147
+ useOrganizationsAsGroups: config.useOrganizationsAsGroups ?? true,
148
+ secretKey,
149
+ jwtKey,
150
+ authorizedParties,
151
+ }
152
+
153
+ this.clerkClient = createClerkClient({ secretKey })
154
+ }
155
+
156
+ async authenticate(context: unknown): Promise<AuthenticationResult> {
157
+ try {
158
+ // Extract headers from context (supports CanopyRequest and Headers)
159
+ const headers = extractHeaders(context)
160
+ if (!headers) {
161
+ return {
162
+ success: false,
163
+ error: 'Invalid context: expected CanopyRequest or Headers object',
164
+ }
165
+ }
166
+
167
+ // Extract token from headers
168
+ const token = extractToken(headers)
169
+ if (!token) {
170
+ return { success: false, error: 'No authentication token found' }
171
+ }
172
+
173
+ // Verify the token
174
+ const verifyOptions: Parameters<typeof clerkVerifyToken>[1] = {
175
+ secretKey: this.config.secretKey,
176
+ }
177
+
178
+ if (this.config.jwtKey) {
179
+ verifyOptions.jwtKey = this.config.jwtKey
180
+ }
181
+
182
+ if (this.config.authorizedParties) {
183
+ verifyOptions.authorizedParties = this.config.authorizedParties
184
+ }
185
+
186
+ const payload = await clerkVerifyToken(token, verifyOptions)
187
+
188
+ if (!payload || !payload.sub) {
189
+ return { success: false, error: 'Invalid token payload' }
190
+ }
191
+
192
+ const userId = payload.sub
193
+
194
+ // Get user details from Clerk
195
+ const clerkUser = (await this.clerkClient.users.getUser(userId)) as ClerkUserData
196
+
197
+ // Get organizations as external groups
198
+ let externalGroups: string[] | undefined
199
+ if (this.config.useOrganizationsAsGroups) {
200
+ const orgs = (await this.clerkClient.users.getOrganizationMembershipList({
201
+ userId: clerkUser.id,
202
+ })) as ClerkResponse<ClerkOrganizationMembership>
203
+
204
+ const memberships = unwrapClerkResponse(orgs)
205
+ externalGroups = memberships.map((m) => m.organization.id)
206
+ }
207
+
208
+ const userData = mapClerkUserData(clerkUser)
209
+
210
+ // Return identity only - core will apply bootstrap admins
211
+ return {
212
+ success: true,
213
+ user: {
214
+ userId: clerkUser.id,
215
+ ...userData,
216
+ externalGroups,
217
+ },
218
+ }
219
+ } catch (error) {
220
+ return {
221
+ success: false,
222
+ error: error instanceof Error ? error.message : 'Authentication failed',
223
+ }
224
+ }
225
+ }
226
+
227
+ async searchUsers(query: string, limit = 10): Promise<UserSearchResult[]> {
228
+ try {
229
+ const response = (await this.clerkClient.users.getUserList({
230
+ query,
231
+ limit,
232
+ })) as ClerkResponse<ClerkUserData>
233
+
234
+ const users = unwrapClerkResponse(response)
235
+ return users.map((u) => {
236
+ const mapped = mapClerkUserData(u)
237
+ return {
238
+ id: u.id,
239
+ name: mapped.name,
240
+ email: mapped.email ?? '',
241
+ avatarUrl: mapped.avatarUrl,
242
+ }
243
+ })
244
+ } catch (error) {
245
+ console.error('ClerkAuthPlugin: searchUsers failed', error)
246
+ return []
247
+ }
248
+ }
249
+
250
+ async getUserMetadata(userId: string): Promise<UserSearchResult | null> {
251
+ try {
252
+ const user = (await this.clerkClient.users.getUser(userId)) as ClerkUserData
253
+ const mapped = mapClerkUserData(user)
254
+ return {
255
+ id: user.id,
256
+ name: mapped.name,
257
+ email: mapped.email ?? '',
258
+ avatarUrl: mapped.avatarUrl,
259
+ }
260
+ } catch (error) {
261
+ console.error('ClerkAuthPlugin: getUserMetadata failed', error)
262
+ return null
263
+ }
264
+ }
265
+
266
+ async getGroupMetadata(groupId: string): Promise<GroupMetadata | null> {
267
+ if (!this.config.useOrganizationsAsGroups) {
268
+ return null
269
+ }
270
+
271
+ try {
272
+ const org = (await this.clerkClient.organizations.getOrganization({
273
+ organizationId: groupId,
274
+ })) as ClerkOrganization
275
+
276
+ return {
277
+ id: org.id,
278
+ name: org.name,
279
+ memberCount: getOrgMemberCount(org),
280
+ }
281
+ } catch (error) {
282
+ console.error('ClerkAuthPlugin: getGroupMetadata failed', error)
283
+ return null
284
+ }
285
+ }
286
+
287
+ async listGroups(limit = 50): Promise<GroupMetadata[]> {
288
+ if (!this.config.useOrganizationsAsGroups) {
289
+ return []
290
+ }
291
+
292
+ try {
293
+ const response = (await this.clerkClient.organizations.getOrganizationList({
294
+ limit,
295
+ })) as ClerkResponse<ClerkOrganization>
296
+
297
+ const orgs = unwrapClerkResponse(response)
298
+ return orgs.map((o) => ({
299
+ id: o.id,
300
+ name: o.name,
301
+ memberCount: getOrgMemberCount(o),
302
+ }))
303
+ } catch (error) {
304
+ console.error('ClerkAuthPlugin: listGroups failed', error)
305
+ return []
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Factory function to create a Clerk auth plugin instance
312
+ */
313
+ export const createClerkAuthPlugin: AuthPluginFactory<ClerkAuthConfig> = (config) => {
314
+ return new ClerkAuthPlugin(config)
315
+ }
package/src/client.ts ADDED
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { useClerk } from '@clerk/nextjs'
4
+ import { UserButton } from '@clerk/nextjs'
5
+ import type { CanopyClientConfig } from 'canopycms/client'
6
+
7
+ /**
8
+ * Hook that provides Clerk-specific auth handlers and components for CanopyCMS editor.
9
+ * Use this in your edit page to integrate Clerk authentication with CanopyCMS.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * import { useClerkAuthConfig } from 'canopycms-auth-clerk/client'
14
+ * import config from '../../canopycms.config'
15
+ *
16
+ * export default function EditPage() {
17
+ * const clerkAuth = useClerkAuthConfig()
18
+ * const editorConfig = config.client(clerkAuth)
19
+ * return <CanopyEditorPage config={editorConfig} />
20
+ * }
21
+ * ```
22
+ */
23
+ export function useClerkAuthConfig(): Pick<CanopyClientConfig, 'editor'> {
24
+ const { signOut } = useClerk()
25
+
26
+ return {
27
+ editor: {
28
+ AccountComponent: UserButton,
29
+ onLogoutClick: async () => {
30
+ try {
31
+ await signOut()
32
+ } catch (error) {
33
+ console.error('Failed to sign out:', error)
34
+ }
35
+ },
36
+ },
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { ClerkAuthPlugin, createClerkAuthPlugin } from './clerk-plugin'
2
+ export type { ClerkAuthConfig } from './clerk-plugin'
3
+ export { createClerkJwtVerifier } from './jwt-verifier'
4
+ export type { ClerkJwtVerifierConfig } from './jwt-verifier'
5
+ export { refreshClerkCache } from './cache-writer'
6
+ export type { RefreshClerkCacheOptions, RefreshClerkCacheResult } from './cache-writer'
@@ -0,0 +1,62 @@
1
+ import { verifyToken as clerkVerifyToken } from '@clerk/backend'
2
+ import { extractHeaders } from 'canopycms/auth'
3
+ import type { TokenVerifier } from 'canopycms/auth'
4
+ import { extractToken } from './clerk-plugin'
5
+
6
+ export interface ClerkJwtVerifierConfig {
7
+ /**
8
+ * PEM public key for networkless JWT verification.
9
+ * Required for operation without internet access.
10
+ */
11
+ jwtKey: string
12
+ /**
13
+ * Clerk Secret Key as fallback (requires internet).
14
+ * If not provided, only jwtKey verification is attempted.
15
+ */
16
+ secretKey?: string
17
+ /**
18
+ * Authorized parties for CSRF protection.
19
+ */
20
+ authorizedParties?: string[]
21
+ }
22
+
23
+ /**
24
+ * Creates a token verifier function that uses Clerk's JWT verification.
25
+ *
26
+ * When jwtKey (PEM public key) is provided, verification is **networkless** —
27
+ * no Clerk API calls are made. This is used in Lambda environments
28
+ * with no internet access.
29
+ *
30
+ * Returns a TokenVerifier compatible with CachingAuthPlugin.
31
+ */
32
+ export function createClerkJwtVerifier(config: ClerkJwtVerifierConfig): TokenVerifier {
33
+ return async (context: unknown) => {
34
+ const headers = extractHeaders(context)
35
+ if (!headers) return null
36
+
37
+ const token = extractToken(headers)
38
+ if (!token) return null
39
+
40
+ try {
41
+ const verifyOptions: Parameters<typeof clerkVerifyToken>[1] = {}
42
+
43
+ if (config.jwtKey) {
44
+ verifyOptions.jwtKey = config.jwtKey
45
+ }
46
+ if (config.secretKey) {
47
+ verifyOptions.secretKey = config.secretKey
48
+ }
49
+ if (config.authorizedParties) {
50
+ verifyOptions.authorizedParties = config.authorizedParties
51
+ }
52
+
53
+ const payload = await clerkVerifyToken(token, verifyOptions)
54
+
55
+ if (!payload?.sub) return null
56
+
57
+ return { userId: payload.sub }
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+ }