@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.
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,416 @@
1
+ import { KoraError } from '@korajs/core'
2
+ import { hashPassword } from './password-hash'
3
+ import type { UserStore } from './user-store'
4
+
5
+ // ============================================================================
6
+ // Types
7
+ // ============================================================================
8
+
9
+ /**
10
+ * A pending password reset request.
11
+ */
12
+ export interface PasswordResetToken {
13
+ /** Cryptographically random single-use token */
14
+ token: string
15
+ /** User ID the token was generated for */
16
+ userId: string
17
+ /** Email the reset was requested for */
18
+ email: string
19
+ /** When the token was created (ms since epoch) */
20
+ createdAt: number
21
+ /** When the token expires (ms since epoch) */
22
+ expiresAt: number
23
+ /** Whether the token has been consumed */
24
+ consumed: boolean
25
+ }
26
+
27
+ /**
28
+ * Persistence interface for password reset tokens.
29
+ */
30
+ export interface PasswordResetStore {
31
+ /** Store a reset token. */
32
+ store(token: PasswordResetToken): Promise<void>
33
+
34
+ /** Look up a token. Returns null if not found. */
35
+ get(token: string): Promise<PasswordResetToken | null>
36
+
37
+ /**
38
+ * Mark a token as consumed. Should be atomic and return `true` only for the
39
+ * call that consumed a not-yet-consumed token; `false` makes the reset fail
40
+ * as already used. Returning nothing is accepted for older stores.
41
+ */
42
+ consume(token: string): Promise<boolean | undefined>
43
+
44
+ /** Count active (non-consumed, non-expired) tokens for an email. */
45
+ countActiveForEmail(email: string): Promise<number>
46
+
47
+ /** Remove expired tokens. */
48
+ cleanExpired(): Promise<number>
49
+ }
50
+
51
+ /**
52
+ * Configuration for the password reset flow.
53
+ */
54
+ export interface PasswordResetConfig {
55
+ /** User store for looking up users and updating passwords */
56
+ userStore: UserStore
57
+ /** Store for reset tokens. Defaults to InMemoryPasswordResetStore. */
58
+ resetStore?: PasswordResetStore
59
+ /** Token TTL in milliseconds. Defaults to 1 hour. */
60
+ tokenTtlMs?: number
61
+ /** Max reset requests per email in the TTL window. Defaults to 3. */
62
+ maxRequestsPerEmail?: number
63
+ /**
64
+ * Callback invoked when a reset is requested. The developer must implement
65
+ * email sending: the token must only ever reach the account's mailbox.
66
+ */
67
+ onResetRequested?: (email: string, token: string, expiresAt: number) => void | Promise<void>
68
+ /**
69
+ * Development only: also return the reset token in the `requestReset`
70
+ * response when no `onResetRequested` callback is configured. Ignored when
71
+ * `NODE_ENV=production`; the token is never disclosed to the requester there.
72
+ * @default false
73
+ */
74
+ exposeTokenForDevelopment?: boolean
75
+ /**
76
+ * Called after a password is reset or changed, once every earlier credential
77
+ * of the user has been revoked. Use it to end live sessions (for example
78
+ * `authServer.revokeAllForUser` when the user store has no revocation store).
79
+ */
80
+ onPasswordChanged?: (userId: string) => void | Promise<void>
81
+ }
82
+
83
+ // ============================================================================
84
+ // Errors
85
+ // ============================================================================
86
+
87
+ export class PasswordResetError extends KoraError {
88
+ constructor(message: string, code: string, context?: Record<string, unknown>) {
89
+ super(message, code, context)
90
+ this.name = 'PasswordResetError'
91
+ }
92
+ }
93
+
94
+ export class ResetTokenExpiredError extends PasswordResetError {
95
+ constructor() {
96
+ super('Password reset token has expired.', 'RESET_TOKEN_EXPIRED')
97
+ }
98
+ }
99
+
100
+ export class ResetTokenNotFoundError extends PasswordResetError {
101
+ constructor() {
102
+ super('Password reset token not found or already used.', 'RESET_TOKEN_NOT_FOUND')
103
+ }
104
+ }
105
+
106
+ export class ResetRateLimitedError extends PasswordResetError {
107
+ constructor() {
108
+ super('Too many password reset requests. Please try again later.', 'RESET_RATE_LIMITED')
109
+ }
110
+ }
111
+
112
+ // ============================================================================
113
+ // InMemoryPasswordResetStore
114
+ // ============================================================================
115
+
116
+ export class InMemoryPasswordResetStore implements PasswordResetStore {
117
+ private tokens = new Map<string, PasswordResetToken>()
118
+
119
+ async store(token: PasswordResetToken): Promise<void> {
120
+ this.tokens.set(token.token, token)
121
+ }
122
+
123
+ async get(token: string): Promise<PasswordResetToken | null> {
124
+ return this.tokens.get(token) ?? null
125
+ }
126
+
127
+ async consume(token: string): Promise<boolean> {
128
+ const entry = this.tokens.get(token)
129
+ if (!entry || entry.consumed) return false
130
+ this.tokens.set(token, { ...entry, consumed: true })
131
+ return true
132
+ }
133
+
134
+ async countActiveForEmail(email: string): Promise<number> {
135
+ const now = Date.now()
136
+ let count = 0
137
+ for (const token of this.tokens.values()) {
138
+ if (token.email === email && !token.consumed && now < token.expiresAt) {
139
+ count++
140
+ }
141
+ }
142
+ return count
143
+ }
144
+
145
+ async cleanExpired(): Promise<number> {
146
+ const now = Date.now()
147
+ let count = 0
148
+ for (const [key, token] of this.tokens) {
149
+ if (now > token.expiresAt) {
150
+ this.tokens.delete(key)
151
+ count++
152
+ }
153
+ }
154
+ return count
155
+ }
156
+ }
157
+
158
+ // ============================================================================
159
+ // PasswordResetManager
160
+ // ============================================================================
161
+
162
+ /** Default TTL: 1 hour */
163
+ const DEFAULT_TOKEN_TTL_MS = 60 * 60 * 1000
164
+
165
+ /** Default max requests per email per TTL window */
166
+ const DEFAULT_MAX_REQUESTS = 3
167
+
168
+ /** Minimum password length (same as auth-routes) */
169
+ const MIN_PASSWORD_LENGTH = 8
170
+
171
+ /** Maximum password length (same as auth-routes) */
172
+ const MAX_PASSWORD_LENGTH = 128
173
+
174
+ /**
175
+ * Manages the password reset flow.
176
+ *
177
+ * @example
178
+ * ```typescript
179
+ * const resetManager = new PasswordResetManager({
180
+ * userStore,
181
+ * onResetRequested: async (email, token, expiresAt) => {
182
+ * await sendEmail(email, `Reset link: https://app.com/reset?token=${token}`)
183
+ * },
184
+ * })
185
+ *
186
+ * // Request reset (always returns 200 to prevent email enumeration)
187
+ * const response = await resetManager.requestReset('user@example.com')
188
+ *
189
+ * // Consume token and set new password
190
+ * const response = await resetManager.resetPassword(token, 'newPassword123')
191
+ * ```
192
+ */
193
+ export class PasswordResetManager {
194
+ private readonly userStore: UserStore
195
+ private readonly resetStore: PasswordResetStore
196
+ private readonly tokenTtlMs: number
197
+ private readonly maxRequestsPerEmail: number
198
+ private readonly onResetRequested?: (
199
+ email: string,
200
+ token: string,
201
+ expiresAt: number,
202
+ ) => void | Promise<void>
203
+ private readonly exposeTokenForDevelopment: boolean
204
+ private readonly onPasswordChanged?: (userId: string) => void | Promise<void>
205
+ private warnedNoDelivery = false
206
+
207
+ constructor(config: PasswordResetConfig) {
208
+ this.userStore = config.userStore
209
+ this.resetStore = config.resetStore ?? new InMemoryPasswordResetStore()
210
+ this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS
211
+ this.maxRequestsPerEmail = config.maxRequestsPerEmail ?? DEFAULT_MAX_REQUESTS
212
+ this.onResetRequested = config.onResetRequested
213
+ this.exposeTokenForDevelopment = config.exposeTokenForDevelopment === true && !isProduction()
214
+ this.onPasswordChanged = config.onPasswordChanged
215
+ }
216
+
217
+ /**
218
+ * Request a password reset for an email.
219
+ * Always returns the same success response to prevent email enumeration.
220
+ *
221
+ * The token is delivered only through `onResetRequested`. It is returned in
222
+ * the response only with `exposeTokenForDevelopment: true` outside production.
223
+ */
224
+ async requestReset(email: string): Promise<{
225
+ status: number
226
+ body: { data: { message: string; token?: string } } | { error: string }
227
+ }> {
228
+ const normalizedEmail = email.toLowerCase().trim()
229
+
230
+ // Always return 200 to prevent email enumeration
231
+ const successResponse = {
232
+ status: 200,
233
+ body: {
234
+ data: {
235
+ message: 'If an account with that email exists, a password reset link has been sent.',
236
+ },
237
+ } as { data: { message: string; token?: string } },
238
+ }
239
+
240
+ // Look up user
241
+ const user = await this.userStore.findByEmail(normalizedEmail)
242
+ if (!user) {
243
+ return successResponse
244
+ }
245
+
246
+ // Rate limit
247
+ const activeCount = await this.resetStore.countActiveForEmail(normalizedEmail)
248
+ if (activeCount >= this.maxRequestsPerEmail) {
249
+ return successResponse // Still 200 to prevent enumeration
250
+ }
251
+
252
+ // Generate token
253
+ const token = generateSecureToken()
254
+ const now = Date.now()
255
+ const resetToken: PasswordResetToken = {
256
+ token,
257
+ userId: user.id,
258
+ email: normalizedEmail,
259
+ createdAt: now,
260
+ expiresAt: now + this.tokenTtlMs,
261
+ consumed: false,
262
+ }
263
+
264
+ await this.resetStore.store(resetToken)
265
+
266
+ // Invoke callback
267
+ if (this.onResetRequested) {
268
+ try {
269
+ await this.onResetRequested(normalizedEmail, token, resetToken.expiresAt)
270
+ } catch {
271
+ // Don't fail the request if callback errors
272
+ }
273
+ }
274
+
275
+ if (!this.onResetRequested) {
276
+ if (this.exposeTokenForDevelopment) {
277
+ // Same message as the normal path so the response shape does not reveal
278
+ // whether the account exists; only the extra token field differs.
279
+ successResponse.body = { data: { ...successResponse.body.data, token } }
280
+ } else if (!this.warnedNoDelivery) {
281
+ this.warnedNoDelivery = true
282
+ console.error(
283
+ '[kora] PasswordResetManager has no onResetRequested callback, so reset tokens are not delivered to anyone. ' +
284
+ 'Configure onResetRequested to email the token (or exposeTokenForDevelopment: true for local development).',
285
+ )
286
+ }
287
+ }
288
+
289
+ return successResponse
290
+ }
291
+
292
+ /**
293
+ * Consume a reset token and set a new password.
294
+ */
295
+ async resetPassword(
296
+ token: string,
297
+ newPassword: string,
298
+ ): Promise<{ status: number; body: { data: { message: string } } | { error: string } }> {
299
+ // Validate password
300
+ if (typeof newPassword !== 'string' || newPassword.length < MIN_PASSWORD_LENGTH) {
301
+ return {
302
+ status: 400,
303
+ body: { error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` },
304
+ }
305
+ }
306
+ if (newPassword.length > MAX_PASSWORD_LENGTH) {
307
+ return {
308
+ status: 400,
309
+ body: { error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters.` },
310
+ }
311
+ }
312
+
313
+ const resetToken = await this.resetStore.get(token)
314
+ if (!resetToken || resetToken.consumed) {
315
+ return { status: 404, body: { error: 'Password reset token not found or already used.' } }
316
+ }
317
+
318
+ if (Date.now() > resetToken.expiresAt) {
319
+ await this.resetStore.consume(token)
320
+ return { status: 410, body: { error: 'Password reset token has expired.' } }
321
+ }
322
+
323
+ // Consume token atomically: only one concurrent reset may use it.
324
+ const consumed = await this.resetStore.consume(token)
325
+ if (consumed === false) {
326
+ return { status: 404, body: { error: 'Password reset token not found or already used.' } }
327
+ }
328
+
329
+ // Update password
330
+ const hashed = await hashPassword(newPassword)
331
+ await this.userStore.updatePassword(resetToken.userId, hashed.hash, hashed.salt)
332
+ await this.revokeEarlierCredentials(resetToken.userId)
333
+
334
+ return { status: 200, body: { data: { message: 'Password has been reset successfully.' } } }
335
+ }
336
+
337
+ /**
338
+ * Change password for an authenticated user (requires current password verification).
339
+ */
340
+ async changePassword(
341
+ userId: string,
342
+ currentPassword: string,
343
+ newPassword: string,
344
+ ): Promise<{ status: number; body: { data: { message: string } } | { error: string } }> {
345
+ // Validate new password
346
+ if (typeof newPassword !== 'string' || newPassword.length < MIN_PASSWORD_LENGTH) {
347
+ return {
348
+ status: 400,
349
+ body: { error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters.` },
350
+ }
351
+ }
352
+ if (newPassword.length > MAX_PASSWORD_LENGTH) {
353
+ return {
354
+ status: 400,
355
+ body: { error: `New password must be at most ${MAX_PASSWORD_LENGTH} characters.` },
356
+ }
357
+ }
358
+
359
+ const user = await this.userStore.findById(userId)
360
+ if (!user) {
361
+ return { status: 404, body: { error: 'User not found.' } }
362
+ }
363
+
364
+ // Verify current password
365
+ const { verifyPassword } = await import('./password-hash')
366
+ const isValid = await verifyPassword(currentPassword, user.passwordHash, user.salt)
367
+ if (!isValid) {
368
+ return { status: 401, body: { error: 'Current password is incorrect.' } }
369
+ }
370
+
371
+ // Update password
372
+ const hashed = await hashPassword(newPassword)
373
+ await this.userStore.updatePassword(userId, hashed.hash, hashed.salt)
374
+ await this.revokeEarlierCredentials(userId)
375
+
376
+ return { status: 200, body: { data: { message: 'Password changed successfully.' } } }
377
+ }
378
+
379
+ /**
380
+ * A password change kills every earlier credential (AUTH-7b): every access
381
+ * and refresh token issued before now is rejected on every path.
382
+ */
383
+ private async revokeEarlierCredentials(userId: string): Promise<void> {
384
+ const revocations = this.userStore.getTokenRevocationStore?.()
385
+ if (revocations) {
386
+ await revocations.revokeAllForUser(userId, Date.now())
387
+ }
388
+ if (this.onPasswordChanged) {
389
+ await this.onPasswordChanged(userId)
390
+ }
391
+ if (!revocations && !this.onPasswordChanged) {
392
+ console.warn(
393
+ '[kora] Password changed but earlier sessions could not be revoked: the user store has no ' +
394
+ 'getTokenRevocationStore() and no onPasswordChanged hook is configured.',
395
+ )
396
+ }
397
+ }
398
+ }
399
+
400
+ function isProduction(): boolean {
401
+ return typeof process !== 'undefined' && process.env.NODE_ENV === 'production'
402
+ }
403
+
404
+ // ============================================================================
405
+ // Helpers
406
+ // ============================================================================
407
+
408
+ function generateSecureToken(): string {
409
+ const bytes = new Uint8Array(32)
410
+ globalThis.crypto.getRandomValues(bytes)
411
+ let binary = ''
412
+ for (let i = 0; i < bytes.length; i++) {
413
+ binary += String.fromCharCode(bytes[i] as number)
414
+ }
415
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
416
+ }