@korajs/auth 1.0.0-beta.11 → 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.
- package/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- 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
|
+
}
|