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.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "canopycms-auth-clerk",
3
+ "version": "0.0.0",
4
+ "description": "Clerk authentication provider for CanopyCMS",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/safeinsights/canopycms.git",
9
+ "directory": "packages/canopycms-auth-clerk"
10
+ },
11
+ "private": false,
12
+ "type": "module",
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./client": "./src/client.ts",
18
+ "./cache-writer": "./src/cache-writer.ts"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "publishConfig": {
25
+ "exports": {
26
+ ".": {
27
+ "import": "./dist/index.js",
28
+ "types": "./dist/index.d.ts"
29
+ },
30
+ "./client": {
31
+ "import": "./dist/client.js",
32
+ "types": "./dist/client.d.ts"
33
+ },
34
+ "./cache-writer": {
35
+ "import": "./dist/cache-writer.js",
36
+ "types": "./dist/cache-writer.d.ts"
37
+ }
38
+ }
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.build.json",
42
+ "test": "vitest run --reporter=dot",
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "peerDependencies": {
49
+ "canopycms": "*",
50
+ "@clerk/backend": "^2.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.9.0",
54
+ "typescript": "^5.6.3",
55
+ "vitest": "^1.6.0"
56
+ }
57
+ }
@@ -0,0 +1,152 @@
1
+ import { createClerkClient } from '@clerk/backend'
2
+ import { writeAuthCacheSnapshot } from 'canopycms/auth/cache'
3
+
4
+ /**
5
+ * Response types that handle both camelCase and snake_case Clerk SDK variants.
6
+ */
7
+ interface ClerkUserData {
8
+ id: string
9
+ username?: string
10
+ fullName?: string | null
11
+ full_name?: string | null
12
+ imageUrl?: string | null
13
+ image_url?: string | null
14
+ primaryEmailAddress?: { emailAddress: string } | null
15
+ email_addresses?: Array<{ email_address: string }> | null
16
+ }
17
+
18
+ interface ClerkOrganization {
19
+ id: string
20
+ name: string
21
+ membersCount?: number
22
+ members_count?: number
23
+ }
24
+
25
+ interface ClerkOrganizationMembership {
26
+ organization: ClerkOrganization
27
+ }
28
+
29
+ interface ClerkPaginatedResponse<T> {
30
+ data: T[]
31
+ }
32
+
33
+ type ClerkResponse<T> = T[] | ClerkPaginatedResponse<T>
34
+
35
+ function unwrapClerkResponse<T>(response: ClerkResponse<T>): T[] {
36
+ return Array.isArray(response) ? response : response.data
37
+ }
38
+
39
+ export interface RefreshClerkCacheOptions {
40
+ /** Clerk Secret Key (CLERK_SECRET_KEY) */
41
+ secretKey: string
42
+ /** Directory to write cache files to (e.g., /mnt/efs/workspace/.cache) */
43
+ cachePath: string
44
+ /** Whether to treat Clerk organizations as groups (default: true) */
45
+ useOrganizationsAsGroups?: boolean
46
+ }
47
+
48
+ export interface RefreshClerkCacheResult {
49
+ userCount: number
50
+ groupCount: number
51
+ membershipCount: number
52
+ }
53
+
54
+ /**
55
+ * Fetches all user/org metadata from Clerk API and writes to JSON cache files.
56
+ *
57
+ * Used by the EC2 worker to populate the cache that FileBasedAuthCache reads.
58
+ * Writes atomically (write to temp file, then rename) to avoid partial reads.
59
+ *
60
+ * Output files:
61
+ * - {cachePath}/users.json — { users: UserSearchResult[] }
62
+ * - {cachePath}/orgs.json — { groups: GroupMetadata[] }
63
+ * - {cachePath}/memberships.json — { memberships: { [userId]: groupId[] } }
64
+ */
65
+ export async function refreshClerkCache(
66
+ options: RefreshClerkCacheOptions,
67
+ ): Promise<RefreshClerkCacheResult> {
68
+ const { secretKey, cachePath, useOrganizationsAsGroups = true } = options
69
+
70
+ const clerkClient = createClerkClient({ secretKey })
71
+
72
+ // Fetch all users (paginate to handle large organizations)
73
+ const clerkUsers: ClerkUserData[] = []
74
+ const pageSize = 500
75
+ let offset = 0
76
+ // eslint-disable-next-line no-constant-condition
77
+ while (true) {
78
+ const usersResponse = (await clerkClient.users.getUserList({
79
+ limit: pageSize,
80
+ offset,
81
+ })) as ClerkResponse<ClerkUserData>
82
+ const page = unwrapClerkResponse(usersResponse)
83
+ clerkUsers.push(...page)
84
+ if (page.length < pageSize) break
85
+ offset += pageSize
86
+ }
87
+
88
+ const users = clerkUsers.map((u) => ({
89
+ id: u.id,
90
+ name: u.fullName ?? u.full_name ?? u.username ?? u.id,
91
+ email: u.primaryEmailAddress?.emailAddress ?? u.email_addresses?.[0]?.email_address ?? '',
92
+ avatarUrl: u.imageUrl ?? u.image_url ?? undefined,
93
+ }))
94
+
95
+ let groups: Array<{ id: string; name: string; memberCount?: number }> = []
96
+ const memberships: Record<string, string[]> = {}
97
+
98
+ if (useOrganizationsAsGroups) {
99
+ // Fetch all organizations (paginate)
100
+ const clerkOrgs: ClerkOrganization[] = []
101
+ let orgOffset = 0
102
+ const orgPageSize = 100
103
+ // eslint-disable-next-line no-constant-condition
104
+ while (true) {
105
+ const orgsResponse = (await clerkClient.organizations.getOrganizationList({
106
+ limit: orgPageSize,
107
+ offset: orgOffset,
108
+ })) as ClerkResponse<ClerkOrganization>
109
+ const page = unwrapClerkResponse(orgsResponse)
110
+ clerkOrgs.push(...page)
111
+ if (page.length < orgPageSize) break
112
+ orgOffset += orgPageSize
113
+ }
114
+
115
+ groups = clerkOrgs.map((o) => ({
116
+ id: o.id,
117
+ name: o.name,
118
+ memberCount: o.membersCount ?? o.members_count,
119
+ }))
120
+
121
+ // Fetch memberships per user
122
+ for (const user of clerkUsers) {
123
+ try {
124
+ const membershipResponse = (await clerkClient.users.getOrganizationMembershipList({
125
+ userId: user.id,
126
+ })) as ClerkResponse<ClerkOrganizationMembership>
127
+ const userMemberships = unwrapClerkResponse(membershipResponse)
128
+ if (userMemberships.length > 0) {
129
+ memberships[user.id] = userMemberships.map((m) => m.organization.id)
130
+ }
131
+ } catch (err) {
132
+ console.warn(
133
+ `Failed to fetch memberships for user ${user.id}:`,
134
+ err instanceof Error ? err.message : err,
135
+ )
136
+ }
137
+ }
138
+ }
139
+
140
+ // Write cache files atomically via snapshot directory + symlink swap
141
+ await writeAuthCacheSnapshot(cachePath, {
142
+ 'users.json': { users },
143
+ 'orgs.json': { groups },
144
+ 'memberships.json': { memberships },
145
+ })
146
+
147
+ return {
148
+ userCount: users.length,
149
+ groupCount: groups.length,
150
+ membershipCount: Object.keys(memberships).length,
151
+ }
152
+ }
@@ -0,0 +1,385 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { mockConsole } from '../../canopycms/src/test-utils/console-spy.js'
3
+
4
+ // Create mock objects
5
+ const mockGetUser = vi.fn()
6
+ const mockGetUserList = vi.fn()
7
+ const mockGetOrganizationMembershipList = vi.fn()
8
+ const mockGetOrganization = vi.fn()
9
+ const mockGetOrganizationList = vi.fn()
10
+
11
+ const mockClerkClient = {
12
+ users: {
13
+ getUser: mockGetUser,
14
+ getUserList: mockGetUserList,
15
+ getOrganizationMembershipList: mockGetOrganizationMembershipList,
16
+ },
17
+ organizations: {
18
+ getOrganization: mockGetOrganization,
19
+ getOrganizationList: mockGetOrganizationList,
20
+ },
21
+ }
22
+
23
+ // Mock @clerk/backend - must be hoisted before imports
24
+ vi.mock('@clerk/backend', () => ({
25
+ createClerkClient: vi.fn(() => mockClerkClient),
26
+ verifyToken: vi.fn(),
27
+ }))
28
+
29
+ import { ClerkAuthPlugin } from './clerk-plugin'
30
+ import { verifyToken } from '@clerk/backend'
31
+ import type { CanopyRequest } from 'canopycms/http'
32
+
33
+ const mockVerifyToken = verifyToken as any
34
+
35
+ describe('ClerkAuthPlugin', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks()
38
+ // Set default env var
39
+ process.env.CLERK_SECRET_KEY = 'sk_test_1234'
40
+ })
41
+
42
+ describe('constructor', () => {
43
+ it('throws error if CLERK_SECRET_KEY not provided', () => {
44
+ delete process.env.CLERK_SECRET_KEY
45
+ expect(() => new ClerkAuthPlugin()).toThrow('CLERK_SECRET_KEY')
46
+ })
47
+
48
+ it('uses env var for secret key by default', () => {
49
+ const plugin = new ClerkAuthPlugin()
50
+ expect(plugin).toBeDefined()
51
+ })
52
+
53
+ it('uses default config values', () => {
54
+ const plugin = new ClerkAuthPlugin()
55
+ // Config is private, but we can test behavior
56
+ expect(plugin).toBeDefined()
57
+ })
58
+ })
59
+
60
+ describe('authenticate', () => {
61
+ it('returns failure if no token in request', async () => {
62
+ const plugin = new ClerkAuthPlugin()
63
+ const req = {
64
+ method: 'GET',
65
+ header: vi.fn().mockReturnValue(null),
66
+ } as unknown as CanopyRequest
67
+
68
+ const result = await plugin.authenticate(req)
69
+
70
+ expect(result.success).toBe(false)
71
+ expect(result.error).toBe('No authentication token found')
72
+ })
73
+
74
+ it('returns failure if token verification fails', async () => {
75
+ const plugin = new ClerkAuthPlugin()
76
+ const req = {
77
+ method: 'GET',
78
+ header: vi.fn().mockImplementation((name: string) => {
79
+ if (name === 'Authorization') return 'Bearer test_token'
80
+ return null
81
+ }),
82
+ } as unknown as CanopyRequest
83
+
84
+ mockVerifyToken.mockRejectedValue(new Error('Invalid token'))
85
+
86
+ const result = await plugin.authenticate(req)
87
+
88
+ expect(result.success).toBe(false)
89
+ expect(result.error).toBe('Invalid token')
90
+ })
91
+
92
+ it('verifies valid session and returns user identity', async () => {
93
+ const plugin = new ClerkAuthPlugin()
94
+ const req = {
95
+ method: 'GET',
96
+ header: vi.fn().mockImplementation((name: string) => {
97
+ if (name === 'Authorization') return 'Bearer valid_token'
98
+ return null
99
+ }),
100
+ } as unknown as CanopyRequest
101
+
102
+ mockVerifyToken.mockResolvedValue({
103
+ sub: 'user_123',
104
+ sid: 'sess_123',
105
+ })
106
+
107
+ mockGetUser.mockResolvedValue({
108
+ id: 'user_123',
109
+ fullName: 'John Doe',
110
+ primaryEmailAddress: { emailAddress: 'john@example.com' },
111
+ })
112
+
113
+ mockGetOrganizationMembershipList.mockResolvedValue({
114
+ data: [{ organization: { id: 'org_1' } }, { organization: { id: 'org_2' } }],
115
+ })
116
+
117
+ const result = await plugin.authenticate(req)
118
+
119
+ expect(result.success).toBe(true)
120
+ expect(result.user).toEqual({
121
+ userId: 'user_123',
122
+ name: 'John Doe',
123
+ email: 'john@example.com',
124
+ externalGroups: ['org_1', 'org_2'],
125
+ })
126
+ })
127
+
128
+ it('returns user without external groups if organizations disabled', async () => {
129
+ const plugin = new ClerkAuthPlugin({ useOrganizationsAsGroups: false })
130
+ const req = {
131
+ method: 'GET',
132
+ header: vi.fn().mockImplementation((name: string) => {
133
+ if (name === 'Authorization') return 'Bearer valid_token'
134
+ return null
135
+ }),
136
+ } as unknown as CanopyRequest
137
+
138
+ mockVerifyToken.mockResolvedValue({
139
+ sub: 'user_123',
140
+ sid: 'sess_123',
141
+ })
142
+
143
+ mockGetUser.mockResolvedValue({
144
+ id: 'user_123',
145
+ fullName: 'Jane Doe',
146
+ primaryEmailAddress: { emailAddress: 'jane@example.com' },
147
+ })
148
+
149
+ const result = await plugin.authenticate(req)
150
+
151
+ expect(result.success).toBe(true)
152
+ expect(result.user?.externalGroups).toBeUndefined()
153
+ expect(mockGetOrganizationMembershipList).not.toHaveBeenCalled()
154
+ })
155
+
156
+ it('handles errors gracefully', async () => {
157
+ const plugin = new ClerkAuthPlugin()
158
+ const req = {
159
+ method: 'GET',
160
+ header: vi.fn().mockImplementation((name: string) => {
161
+ if (name === 'Authorization') return 'Bearer valid_token'
162
+ return null
163
+ }),
164
+ } as unknown as CanopyRequest
165
+
166
+ mockVerifyToken.mockRejectedValue(new Error('Network error'))
167
+
168
+ const result = await plugin.authenticate(req)
169
+
170
+ expect(result.success).toBe(false)
171
+ expect(result.error).toBe('Network error')
172
+ })
173
+
174
+ it('extracts token from __session cookie', async () => {
175
+ const plugin = new ClerkAuthPlugin()
176
+ const req = {
177
+ method: 'GET',
178
+ header: vi.fn().mockImplementation((name: string) => {
179
+ if (name === 'Cookie') return '__session=cookie_token; other=value'
180
+ return null
181
+ }),
182
+ } as unknown as CanopyRequest
183
+
184
+ mockVerifyToken.mockResolvedValue({
185
+ sub: 'user_123',
186
+ })
187
+
188
+ mockGetUser.mockResolvedValue({
189
+ id: 'user_123',
190
+ fullName: 'Cookie User',
191
+ primaryEmailAddress: { emailAddress: 'cookie@example.com' },
192
+ })
193
+
194
+ mockGetOrganizationMembershipList.mockResolvedValue({ data: [] })
195
+
196
+ const result = await plugin.authenticate(req)
197
+
198
+ expect(result.success).toBe(true)
199
+ expect(mockVerifyToken).toHaveBeenCalledWith('cookie_token', expect.any(Object))
200
+ })
201
+ })
202
+
203
+ describe('searchUsers', () => {
204
+ it('searches users and returns results', async () => {
205
+ const plugin = new ClerkAuthPlugin()
206
+
207
+ mockGetUserList.mockResolvedValue({
208
+ data: [
209
+ {
210
+ id: 'user_1',
211
+ fullName: 'Alice Smith',
212
+ primaryEmailAddress: { emailAddress: 'alice@example.com' },
213
+ imageUrl: 'https://example.com/alice.jpg',
214
+ },
215
+ {
216
+ id: 'user_2',
217
+ username: 'bob',
218
+ primaryEmailAddress: { emailAddress: 'bob@example.com' },
219
+ imageUrl: 'https://example.com/bob.jpg',
220
+ },
221
+ ],
222
+ })
223
+
224
+ const results = await plugin.searchUsers('alice')
225
+
226
+ expect(results).toHaveLength(2)
227
+ expect(results[0]).toEqual({
228
+ id: 'user_1',
229
+ name: 'Alice Smith',
230
+ email: 'alice@example.com',
231
+ avatarUrl: 'https://example.com/alice.jpg',
232
+ })
233
+ expect(results[1]).toEqual({
234
+ id: 'user_2',
235
+ name: 'bob',
236
+ email: 'bob@example.com',
237
+ avatarUrl: 'https://example.com/bob.jpg',
238
+ })
239
+ expect(mockGetUserList).toHaveBeenCalledWith({
240
+ query: 'alice',
241
+ limit: 10,
242
+ })
243
+ })
244
+
245
+ it('returns empty array on error', async () => {
246
+ const consoleSpy = mockConsole()
247
+ const plugin = new ClerkAuthPlugin()
248
+
249
+ mockGetUserList.mockRejectedValue(new Error('API error'))
250
+
251
+ const results = await plugin.searchUsers('test')
252
+
253
+ expect(results).toEqual([])
254
+ consoleSpy.restore()
255
+ })
256
+ })
257
+
258
+ describe('getUserMetadata', () => {
259
+ it('gets user metadata by ID', async () => {
260
+ const plugin = new ClerkAuthPlugin()
261
+
262
+ mockGetUser.mockResolvedValue({
263
+ id: 'user_123',
264
+ fullName: 'Test User',
265
+ primaryEmailAddress: { emailAddress: 'test@example.com' },
266
+ imageUrl: 'https://example.com/test.jpg',
267
+ })
268
+
269
+ const result = await plugin.getUserMetadata('user_123')
270
+
271
+ expect(result).toEqual({
272
+ id: 'user_123',
273
+ name: 'Test User',
274
+ email: 'test@example.com',
275
+ avatarUrl: 'https://example.com/test.jpg',
276
+ })
277
+ })
278
+
279
+ it('returns null on error', async () => {
280
+ const consoleSpy = mockConsole()
281
+ const plugin = new ClerkAuthPlugin()
282
+
283
+ mockGetUser.mockRejectedValue(new Error('User not found'))
284
+
285
+ const result = await plugin.getUserMetadata('user_123')
286
+
287
+ expect(result).toBeNull()
288
+ consoleSpy.restore()
289
+ })
290
+ })
291
+
292
+ describe('getGroupMetadata', () => {
293
+ it('gets organization metadata when enabled', async () => {
294
+ const plugin = new ClerkAuthPlugin({ useOrganizationsAsGroups: true })
295
+
296
+ mockGetOrganization.mockResolvedValue({
297
+ id: 'org_123',
298
+ name: 'Test Org',
299
+ membersCount: 42,
300
+ })
301
+
302
+ const result = await plugin.getGroupMetadata('org_123')
303
+
304
+ expect(result).toEqual({
305
+ id: 'org_123',
306
+ name: 'Test Org',
307
+ memberCount: 42,
308
+ })
309
+ })
310
+
311
+ it('returns null when organizations disabled', async () => {
312
+ const plugin = new ClerkAuthPlugin({ useOrganizationsAsGroups: false })
313
+
314
+ const result = await plugin.getGroupMetadata('org_123')
315
+
316
+ expect(result).toBeNull()
317
+ expect(mockGetOrganization).not.toHaveBeenCalled()
318
+ })
319
+
320
+ it('returns null on error', async () => {
321
+ const consoleSpy = mockConsole()
322
+ const plugin = new ClerkAuthPlugin({ useOrganizationsAsGroups: true })
323
+
324
+ mockGetOrganization.mockRejectedValue(new Error('Org not found'))
325
+
326
+ const result = await plugin.getGroupMetadata('org_123')
327
+
328
+ expect(result).toBeNull()
329
+ consoleSpy.restore()
330
+ })
331
+ })
332
+
333
+ describe('listGroups', () => {
334
+ it('lists organizations when enabled', async () => {
335
+ const plugin = new ClerkAuthPlugin({ useOrganizationsAsGroups: true })
336
+
337
+ mockGetOrganizationList.mockResolvedValue({
338
+ data: [
339
+ { id: 'org_1', name: 'Org One', membersCount: 10 },
340
+ { id: 'org_2', name: 'Org Two', membersCount: 20 },
341
+ ],
342
+ })
343
+
344
+ const results = await plugin.listGroups(50)
345
+
346
+ expect(results).toHaveLength(2)
347
+ expect(results[0]).toEqual({
348
+ id: 'org_1',
349
+ name: 'Org One',
350
+ memberCount: 10,
351
+ })
352
+ expect(mockGetOrganizationList).toHaveBeenCalledWith({ limit: 50 })
353
+ })
354
+
355
+ it('returns empty array when organizations disabled', async () => {
356
+ const plugin = new ClerkAuthPlugin({ useOrganizationsAsGroups: false })
357
+
358
+ const results = await plugin.listGroups()
359
+
360
+ expect(results).toEqual([])
361
+ expect(mockGetOrganizationList).not.toHaveBeenCalled()
362
+ })
363
+
364
+ it('returns empty array on error', async () => {
365
+ const consoleSpy = mockConsole()
366
+ const plugin = new ClerkAuthPlugin({ useOrganizationsAsGroups: true })
367
+
368
+ mockGetOrganizationList.mockRejectedValue(new Error('API error'))
369
+
370
+ const results = await plugin.listGroups()
371
+
372
+ expect(results).toEqual([])
373
+ consoleSpy.restore()
374
+ })
375
+ })
376
+
377
+ describe('createClerkAuthPlugin factory', () => {
378
+ it('creates plugin instance', async () => {
379
+ const { createClerkAuthPlugin } = await import('./clerk-plugin')
380
+ const plugin = createClerkAuthPlugin({})
381
+
382
+ expect(plugin).toBeInstanceOf(ClerkAuthPlugin)
383
+ })
384
+ })
385
+ })