@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13

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.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2852 -1675
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +779 -168
  17. package/dist/server.d.ts +779 -168
  18. package/dist/server.js +2831 -1665
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +328 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +322 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +334 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,465 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { KoraError } from '@korajs/core'
3
+ import { InMemoryTokenRevocationStore, type TokenRevocationStore } from '../../tokens/token-manager'
4
+
5
+ /**
6
+ * A user as visible to the application layer.
7
+ * Does not include sensitive fields like password hash or salt.
8
+ */
9
+ export interface AuthUser {
10
+ /** Unique user identifier (UUID v7 or crypto.randomUUID) */
11
+ id: string
12
+ /** User's email address */
13
+ email: string
14
+ /** User's display name */
15
+ name: string
16
+ /** Whether the user's email has been verified */
17
+ emailVerified: boolean
18
+ /** Timestamp when the user was created (milliseconds since epoch) */
19
+ createdAt: number
20
+ }
21
+
22
+ /**
23
+ * Internal user record that includes credentials.
24
+ * Extends AuthUser with password hash and salt for verification.
25
+ */
26
+ export interface StoredUser extends AuthUser {
27
+ /** Hex-encoded PBKDF2 derived key */
28
+ passwordHash: string
29
+ /** Hex-encoded random salt used during hashing */
30
+ salt: string
31
+ }
32
+
33
+ /**
34
+ * A device registered to a user.
35
+ */
36
+ export interface AuthDevice {
37
+ /** Unique device identifier */
38
+ id: string
39
+ /** ID of the user who owns this device */
40
+ userId: string
41
+ /** Base64url-encoded public key (or thumbprint) for the device */
42
+ publicKey: string
43
+ /** Human-readable device name */
44
+ name: string
45
+ /** Whether the device has been revoked */
46
+ revoked: boolean
47
+ /** Timestamp when the device was first registered (milliseconds since epoch) */
48
+ createdAt: number
49
+ /** Timestamp when the device was last seen (milliseconds since epoch) */
50
+ lastSeenAt: number
51
+ }
52
+
53
+ /**
54
+ * Thrown when a user account already exists with the given email.
55
+ */
56
+ export class DuplicateEmailError extends KoraError {
57
+ constructor() {
58
+ super('A user with this email already exists.', 'DUPLICATE_EMAIL')
59
+ this.name = 'DuplicateEmailError'
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Thrown when a device id is already registered to a different user. A token's
65
+ * `dev` claim must always name a device owned by its `sub` (AUTH-5).
66
+ */
67
+ export class DeviceOwnershipError extends KoraError {
68
+ constructor(deviceId: string) {
69
+ super(
70
+ 'This device id is registered to another account. Use a device id generated for this install.',
71
+ 'DEVICE_OWNERSHIP_CONFLICT',
72
+ { deviceId },
73
+ )
74
+ this.name = 'DeviceOwnershipError'
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Generic interface for user and device persistence.
80
+ *
81
+ * Implement this interface to provide database-backed user storage for
82
+ * the built-in auth provider. All methods are async to support both
83
+ * synchronous (in-memory, SQLite) and asynchronous (PostgreSQL) backends.
84
+ *
85
+ * Built-in implementations:
86
+ * - {@link InMemoryUserStore} — development and testing (no persistence)
87
+ * - `SqliteUserStore` — SQLite via better-sqlite3 (from `@korajs/auth/server`)
88
+ * - `PostgresUserStore` — PostgreSQL via postgres-js (from `@korajs/auth/server`)
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * import { BuiltInAuthRoutes, SqliteUserStore } from '@korajs/auth/server'
93
+ *
94
+ * const userStore = await createSqliteUserStore({ filename: './auth.db' })
95
+ * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
96
+ * ```
97
+ */
98
+ export interface UserStore {
99
+ /** Create a new user account. Throws DuplicateEmailError if email exists. */
100
+ createUser(params: {
101
+ email: string
102
+ passwordHash: string
103
+ salt: string
104
+ name: string
105
+ }): Promise<AuthUser>
106
+
107
+ /** Find a user by email address (case-insensitive). */
108
+ findByEmail(email: string): Promise<StoredUser | null>
109
+
110
+ /** Find a user by ID. */
111
+ findById(id: string): Promise<StoredUser | null>
112
+
113
+ /**
114
+ * Register a device for a user. Idempotent for the same owner (a revoked
115
+ * device is re-activated). Must throw {@link DeviceOwnershipError} when the id
116
+ * already belongs to a different user.
117
+ */
118
+ registerDevice(params: {
119
+ id: string
120
+ userId: string
121
+ publicKey: string
122
+ name: string
123
+ }): Promise<AuthDevice>
124
+
125
+ /** Find a device by its ID. */
126
+ findDevice(deviceId: string): Promise<AuthDevice | null>
127
+
128
+ /** List all devices registered for a user (includes revoked). */
129
+ listDevices(userId: string): Promise<AuthDevice[]>
130
+
131
+ /** Soft-revoke a device. No-op if device does not exist. */
132
+ revokeDevice(deviceId: string): Promise<void>
133
+
134
+ /** Set a user's email verification status. */
135
+ setEmailVerified(userId: string, verified: boolean): Promise<void>
136
+
137
+ /** Update a user's password hash and salt. */
138
+ updatePassword(userId: string, passwordHash: string, salt: string): Promise<void>
139
+
140
+ /** List all users. For admin/development use. */
141
+ listAll(): Promise<StoredUser[]>
142
+
143
+ /** Update a stored user record. */
144
+ update(user: StoredUser): Promise<void>
145
+
146
+ /** Delete a user and all associated devices. */
147
+ delete(userId: string): Promise<void>
148
+
149
+ /** Update the last-seen timestamp for a device. No-op if device does not exist. */
150
+ touchDevice(deviceId: string): Promise<void>
151
+
152
+ /**
153
+ * Optional token revocation store that lives with the user data (same
154
+ * database). `createKoraAuthServer` uses it by default, so revocations
155
+ * persist and are shared exactly as far as users are.
156
+ */
157
+ getTokenRevocationStore?(): TokenRevocationStore
158
+ }
159
+
160
+ /**
161
+ * In-memory user and device store for the built-in auth provider.
162
+ *
163
+ * This is a simple implementation suitable for development and testing.
164
+ * Production applications should use {@link SqliteUserStore} or
165
+ * {@link PostgresUserStore} for persistent storage.
166
+ *
167
+ * @example
168
+ * ```typescript
169
+ * const store = new InMemoryUserStore()
170
+ * const user = await store.createUser({
171
+ * email: 'alice@example.com',
172
+ * passwordHash: 'abc123...',
173
+ * salt: 'def456...',
174
+ * name: 'Alice',
175
+ * })
176
+ * ```
177
+ */
178
+ export class InMemoryUserStore implements UserStore {
179
+ /** Users indexed by ID */
180
+ private readonly usersById = new Map<string, StoredUser>()
181
+
182
+ /** Users indexed by email (lowercase) for fast lookup */
183
+ private readonly usersByEmail = new Map<string, StoredUser>()
184
+
185
+ /** Devices indexed by device ID */
186
+ private readonly devicesById = new Map<string, AuthDevice>()
187
+
188
+ /** Device IDs indexed by user ID for fast listing */
189
+ private readonly devicesByUserId = new Map<string, Set<string>>()
190
+
191
+ /** Revocations live with the users, so every server sharing this store shares them. */
192
+ private readonly revocationStore = new InMemoryTokenRevocationStore()
193
+
194
+ /** The token revocation store that shares this store's lifetime. */
195
+ getTokenRevocationStore(): TokenRevocationStore {
196
+ return this.revocationStore
197
+ }
198
+
199
+ /**
200
+ * Create a new user account.
201
+ *
202
+ * @param params - User creation parameters
203
+ * @param params.email - The user's email address (must be unique, case-insensitive)
204
+ * @param params.passwordHash - Hex-encoded PBKDF2 derived key
205
+ * @param params.salt - Hex-encoded salt used during hashing
206
+ * @param params.name - The user's display name
207
+ * @returns The created user (without sensitive credential fields)
208
+ * @throws {DuplicateEmailError} If a user with the same email already exists
209
+ */
210
+ async createUser(params: {
211
+ email: string
212
+ passwordHash: string
213
+ salt: string
214
+ name: string
215
+ }): Promise<AuthUser> {
216
+ const normalizedEmail = params.email.toLowerCase()
217
+
218
+ if (this.usersByEmail.has(normalizedEmail)) {
219
+ throw new DuplicateEmailError()
220
+ }
221
+
222
+ const now = Date.now()
223
+ const id = randomUUID()
224
+
225
+ const storedUser: StoredUser = {
226
+ id,
227
+ email: normalizedEmail,
228
+ name: params.name,
229
+ emailVerified: false,
230
+ createdAt: now,
231
+ passwordHash: params.passwordHash,
232
+ salt: params.salt,
233
+ }
234
+
235
+ this.usersById.set(id, storedUser)
236
+ this.usersByEmail.set(normalizedEmail, storedUser)
237
+
238
+ return toAuthUser(storedUser)
239
+ }
240
+
241
+ /**
242
+ * Find a user by email address.
243
+ *
244
+ * @param email - The email to search for (case-insensitive)
245
+ * @returns The stored user record including credentials, or null if not found
246
+ */
247
+ async findByEmail(email: string): Promise<StoredUser | null> {
248
+ return this.usersByEmail.get(email.toLowerCase()) ?? null
249
+ }
250
+
251
+ /**
252
+ * Find a user by ID.
253
+ *
254
+ * @param id - The user ID to search for
255
+ * @returns The stored user record including credentials, or null if not found
256
+ */
257
+ async findById(id: string): Promise<StoredUser | null> {
258
+ return this.usersById.get(id) ?? null
259
+ }
260
+
261
+ /**
262
+ * Register a device for a user.
263
+ *
264
+ * If a device with the same ID already exists for the same user and is not
265
+ * revoked, it is returned as-is (idempotent registration). If it was
266
+ * previously revoked, it is re-activated with updated details. A device id
267
+ * owned by another user is refused.
268
+ *
269
+ * @param params - Device registration parameters
270
+ * @param params.id - Unique device identifier
271
+ * @param params.userId - ID of the user who owns the device
272
+ * @param params.publicKey - Base64url-encoded device public key or thumbprint
273
+ * @param params.name - Human-readable device name
274
+ * @returns The registered device record
275
+ * @throws {DeviceOwnershipError} If the id is registered to another user
276
+ */
277
+ async registerDevice(params: {
278
+ id: string
279
+ userId: string
280
+ publicKey: string
281
+ name: string
282
+ }): Promise<AuthDevice> {
283
+ const existing = this.devicesById.get(params.id)
284
+ if (existing !== undefined && existing.userId !== params.userId) {
285
+ throw new DeviceOwnershipError(params.id)
286
+ }
287
+ if (existing !== undefined && !existing.revoked) {
288
+ return existing
289
+ }
290
+
291
+ const now = Date.now()
292
+ const device: AuthDevice = {
293
+ id: params.id,
294
+ userId: params.userId,
295
+ publicKey: params.publicKey,
296
+ name: params.name,
297
+ revoked: false,
298
+ createdAt: now,
299
+ lastSeenAt: now,
300
+ }
301
+
302
+ this.devicesById.set(params.id, device)
303
+
304
+ let userDevices = this.devicesByUserId.get(params.userId)
305
+ if (userDevices === undefined) {
306
+ userDevices = new Set()
307
+ this.devicesByUserId.set(params.userId, userDevices)
308
+ }
309
+ userDevices.add(params.id)
310
+
311
+ return device
312
+ }
313
+
314
+ /**
315
+ * Find a device by its ID.
316
+ *
317
+ * @param deviceId - The device ID to search for
318
+ * @returns The device record, or null if not found
319
+ */
320
+ async findDevice(deviceId: string): Promise<AuthDevice | null> {
321
+ return this.devicesById.get(deviceId) ?? null
322
+ }
323
+
324
+ /**
325
+ * List all devices registered for a user.
326
+ *
327
+ * @param userId - The user ID whose devices to list
328
+ * @returns Array of device records (includes revoked devices)
329
+ */
330
+ async listDevices(userId: string): Promise<AuthDevice[]> {
331
+ const deviceIds = this.devicesByUserId.get(userId)
332
+ if (deviceIds === undefined) {
333
+ return []
334
+ }
335
+
336
+ const devices: AuthDevice[] = []
337
+ for (const deviceId of deviceIds) {
338
+ const device = this.devicesById.get(deviceId)
339
+ if (device !== undefined) {
340
+ devices.push(device)
341
+ }
342
+ }
343
+
344
+ return devices
345
+ }
346
+
347
+ /**
348
+ * Revoke a device, preventing it from being used for authentication.
349
+ *
350
+ * This is a soft revoke — the device record remains but is marked as revoked.
351
+ * If the device does not exist, this is a no-op.
352
+ *
353
+ * @param deviceId - The ID of the device to revoke
354
+ */
355
+ async revokeDevice(deviceId: string): Promise<void> {
356
+ const device = this.devicesById.get(deviceId)
357
+ if (device !== undefined) {
358
+ device.revoked = true
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Set a user's email verification status.
364
+ *
365
+ * @param userId - The user whose email to verify
366
+ * @param verified - Whether the email is verified
367
+ */
368
+ async setEmailVerified(userId: string, verified: boolean): Promise<void> {
369
+ const user = this.usersById.get(userId)
370
+ if (!user) return
371
+
372
+ const updated: StoredUser = { ...user, emailVerified: verified }
373
+ this.usersById.set(userId, updated)
374
+ this.usersByEmail.set(user.email, updated)
375
+ }
376
+
377
+ /**
378
+ * Update a user's password hash and salt.
379
+ *
380
+ * @param userId - The user whose password to update
381
+ * @param passwordHash - New hex-encoded PBKDF2 derived key
382
+ * @param salt - New hex-encoded salt
383
+ */
384
+ async updatePassword(userId: string, passwordHash: string, salt: string): Promise<void> {
385
+ const user = this.usersById.get(userId)
386
+ if (!user) return
387
+
388
+ const updated: StoredUser = { ...user, passwordHash, salt }
389
+ this.usersById.set(userId, updated)
390
+ this.usersByEmail.set(user.email, updated)
391
+ }
392
+
393
+ /**
394
+ * List all users. For admin/development use.
395
+ */
396
+ async listAll(): Promise<StoredUser[]> {
397
+ return [...this.usersById.values()]
398
+ }
399
+
400
+ /**
401
+ * Update a stored user record.
402
+ */
403
+ async update(user: StoredUser): Promise<void> {
404
+ const existing = this.usersById.get(user.id)
405
+ if (!existing) return
406
+
407
+ // If email changed, update the email index
408
+ if (existing.email !== user.email) {
409
+ this.usersByEmail.delete(existing.email)
410
+ this.usersByEmail.set(user.email, user)
411
+ } else {
412
+ this.usersByEmail.set(user.email, user)
413
+ }
414
+ this.usersById.set(user.id, user)
415
+ }
416
+
417
+ /**
418
+ * Delete a user and all associated devices.
419
+ */
420
+ async delete(userId: string): Promise<void> {
421
+ const user = this.usersById.get(userId)
422
+ if (!user) return
423
+
424
+ this.usersById.delete(userId)
425
+ this.usersByEmail.delete(user.email)
426
+
427
+ // Clean up devices
428
+ const deviceIds = this.devicesByUserId.get(userId)
429
+ if (deviceIds) {
430
+ for (const deviceId of deviceIds) {
431
+ this.devicesById.delete(deviceId)
432
+ }
433
+ this.devicesByUserId.delete(userId)
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Update the last-seen timestamp for a device.
439
+ *
440
+ * Called when a device authenticates or syncs to track activity.
441
+ * If the device does not exist, this is a no-op.
442
+ *
443
+ * @param deviceId - The ID of the device to update
444
+ */
445
+ async touchDevice(deviceId: string): Promise<void> {
446
+ const device = this.devicesById.get(deviceId)
447
+ if (device !== undefined) {
448
+ device.lastSeenAt = Date.now()
449
+ }
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Strip sensitive fields from a StoredUser to produce an AuthUser.
455
+ * Ensures password hash and salt are never leaked to the application layer.
456
+ */
457
+ function toAuthUser(stored: StoredUser): AuthUser {
458
+ return {
459
+ id: stored.id,
460
+ email: stored.email,
461
+ name: stored.name,
462
+ emailVerified: stored.emailVerified,
463
+ createdAt: stored.createdAt,
464
+ }
465
+ }
@@ -0,0 +1,157 @@
1
+ import {
2
+ ExternalJwtProvider,
3
+ type ExternalJwtProviderConfig,
4
+ type ExternalUserInfo,
5
+ } from './external-jwt-provider'
6
+
7
+ // ============================================================================
8
+ // Clerk Adapter Configuration
9
+ // ============================================================================
10
+
11
+ /**
12
+ * Configuration for the Clerk authentication adapter.
13
+ *
14
+ * Clerk uses asymmetric signing (RS256) with JWKS key rotation, so this adapter
15
+ * requires a custom `validateToken` function that handles JWKS-based verification.
16
+ * The adapter provides sensible defaults for mapping Clerk's JWT claims to Kora's
17
+ * expected format.
18
+ *
19
+ * Clerk JWTs typically contain:
20
+ * - `sub`: User ID (e.g., "user_2abc123...")
21
+ * - `email`: Primary email address (if configured in session claims)
22
+ * - `first_name`, `last_name`: Name fields (if configured in session claims)
23
+ * - `azp`: Authorized party (your frontend origin)
24
+ * - `org_id`, `org_slug`, `org_role`: Organization claims (if using Clerk orgs)
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * import { createClerkAdapter } from '@korajs/auth/server'
29
+ *
30
+ * const clerkAuth = createClerkAdapter({
31
+ * validateToken: async (token) => {
32
+ * // Use Clerk's backend SDK or your own JWKS verification
33
+ * const result = await clerkClient.verifyToken(token)
34
+ * return result ? { sub: result.sub, ...result } : null
35
+ * },
36
+ * })
37
+ *
38
+ * // Use with Kora sync server
39
+ * const syncServer = new KoraSyncServer({
40
+ * store,
41
+ * auth: clerkAuth.toSyncAuthProvider(),
42
+ * })
43
+ * ```
44
+ */
45
+ export interface ClerkAdapterConfig {
46
+ /**
47
+ * Custom token validator for Clerk JWTs.
48
+ *
49
+ * Clerk uses RS256 signing with JWKS key rotation, which requires either
50
+ * Clerk's backend SDK or a JWKS-based verifier. This function receives
51
+ * the raw JWT and should return the decoded claims or null.
52
+ *
53
+ * @param token - The raw JWT string from the Clerk session
54
+ * @returns Decoded claims with at least a `sub` field, or null if invalid
55
+ */
56
+ validateToken: (token: string) => Promise<{ sub: string; [key: string]: unknown } | null>
57
+
58
+ /**
59
+ * Custom claim mapping override.
60
+ *
61
+ * By default, the Clerk adapter maps:
62
+ * - `sub` -> `userId`
63
+ * - `email` -> `email` (if present)
64
+ * - `first_name` + `last_name` -> `name` (concatenated, if present)
65
+ * - `org_id`, `org_slug`, `org_role` -> `metadata` (if present)
66
+ *
67
+ * Override this to customize how Clerk claims are mapped to Kora's format.
68
+ */
69
+ mapClaims?: ExternalJwtProviderConfig['mapClaims']
70
+ }
71
+
72
+ // ============================================================================
73
+ // Default Clerk claim mapping
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Default claim mapping for Clerk JWTs.
78
+ *
79
+ * Extracts user identity from Clerk's standard session claims and maps
80
+ * organization data into Kora metadata when available.
81
+ */
82
+ function defaultClerkClaimMapping(claims: Record<string, unknown>): ExternalUserInfo {
83
+ const sub = claims.sub
84
+ if (typeof sub !== 'string' || sub.length === 0) {
85
+ // Delegate to the base provider's error handling by returning invalid data
86
+ // The ExternalJwtProvider will catch the missing userId
87
+ return { userId: '' }
88
+ }
89
+
90
+ // Build display name from first_name and last_name if available
91
+ const firstName = typeof claims.first_name === 'string' ? claims.first_name : ''
92
+ const lastName = typeof claims.last_name === 'string' ? claims.last_name : ''
93
+ const fullName = [firstName, lastName].filter(Boolean).join(' ')
94
+
95
+ // Extract email (Clerk may include this in session claims)
96
+ const email = typeof claims.email === 'string' ? claims.email : undefined
97
+
98
+ // Extract organization metadata if present
99
+ const metadata: Record<string, unknown> = {}
100
+ if (typeof claims.org_id === 'string') {
101
+ metadata.orgId = claims.org_id
102
+ }
103
+ if (typeof claims.org_slug === 'string') {
104
+ metadata.orgSlug = claims.org_slug
105
+ }
106
+ if (typeof claims.org_role === 'string') {
107
+ metadata.orgRole = claims.org_role
108
+ }
109
+
110
+ return {
111
+ userId: sub,
112
+ email,
113
+ name: fullName.length > 0 ? fullName : undefined,
114
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
115
+ }
116
+ }
117
+
118
+ // ============================================================================
119
+ // Factory function
120
+ // ============================================================================
121
+
122
+ /**
123
+ * Creates an ExternalJwtProvider configured for Clerk authentication.
124
+ *
125
+ * Clerk uses RS256 signing with JWKS key rotation, so a custom `validateToken`
126
+ * function must be provided. This function should use Clerk's backend SDK or
127
+ * a JWKS-based JWT verifier to validate tokens.
128
+ *
129
+ * This adapter does NOT depend on `@clerk/backend` or any Clerk SDK. It only
130
+ * provides sensible defaults for mapping Clerk's JWT claims to Kora's format.
131
+ * The actual token verification is delegated to the provided `validateToken` function.
132
+ *
133
+ * @param config - Clerk adapter configuration
134
+ * @returns An ExternalJwtProvider instance configured for Clerk
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * import { createClerkAdapter } from '@korajs/auth/server'
139
+ *
140
+ * const clerkAuth = createClerkAdapter({
141
+ * validateToken: async (token) => {
142
+ * // Your JWKS verification logic here
143
+ * const payload = await verifyWithJwks(token, CLERK_JWKS_URL)
144
+ * return payload
145
+ * },
146
+ * })
147
+ *
148
+ * const result = await clerkAuth.validateAccessToken(sessionToken)
149
+ * ```
150
+ */
151
+ export function createClerkAdapter(config: ClerkAdapterConfig): ExternalJwtProvider {
152
+ return new ExternalJwtProvider({
153
+ providerName: 'clerk',
154
+ validateToken: config.validateToken,
155
+ mapClaims: config.mapClaims ?? defaultClerkClaimMapping,
156
+ })
157
+ }