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

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 +2880 -1667
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +810 -169
  17. package/dist/server.d.ts +810 -169
  18. package/dist/server.js +2848 -1646
  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 +365 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +335 -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 +285 -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 +338 -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,335 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import {
5
+ type SqliteRevocationDatabase,
6
+ SqliteTokenRevocationStore,
7
+ } from '../../tokens/sqlite-token-revocation-store'
8
+ import type { TokenRevocationStore } from '../../tokens/token-manager'
9
+ import type { AuthDevice, AuthUser, StoredUser, UserStore } from './user-store'
10
+ import { DeviceOwnershipError, DuplicateEmailError } from './user-store'
11
+
12
+ /**
13
+ * Row shape returned by better-sqlite3 for the auth_users table.
14
+ */
15
+ interface UserRow {
16
+ id: string
17
+ email: string
18
+ name: string
19
+ email_verified: number
20
+ created_at: number
21
+ password_hash: string
22
+ salt: string
23
+ }
24
+
25
+ /**
26
+ * Row shape returned by better-sqlite3 for the auth_devices table.
27
+ */
28
+ interface DeviceRow {
29
+ id: string
30
+ user_id: string
31
+ public_key: string
32
+ name: string
33
+ revoked: number
34
+ created_at: number
35
+ last_seen_at: number
36
+ }
37
+
38
+ /**
39
+ * Minimal better-sqlite3 subset to avoid a hard dependency on the package.
40
+ * The real Database instance satisfies this at runtime.
41
+ */
42
+ interface SqliteDatabase {
43
+ pragma(source: string): unknown
44
+ exec(source: string): void
45
+ prepare(source: string): {
46
+ run(...params: unknown[]): unknown
47
+ get(...params: unknown[]): unknown
48
+ all(...params: unknown[]): unknown[]
49
+ }
50
+ transaction<T>(fn: () => T): () => T
51
+ }
52
+
53
+ /**
54
+ * SQLite-backed user and device store using better-sqlite3.
55
+ *
56
+ * Provides persistent user storage suitable for single-server deployments,
57
+ * Electron apps, and development environments. Uses WAL mode for concurrent
58
+ * read/write performance.
59
+ *
60
+ * This implementation uses dynamic imports for better-sqlite3 so projects
61
+ * that do not use SQLite server-side do not need to install it.
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * import { createSqliteUserStore } from '@korajs/auth/server'
66
+ *
67
+ * const userStore = await createSqliteUserStore({ filename: './auth.db' })
68
+ * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
69
+ * ```
70
+ */
71
+ export class SqliteUserStore implements UserStore {
72
+ private readonly db: SqliteDatabase
73
+ private revocationStore: SqliteTokenRevocationStore | null = null
74
+
75
+ constructor(db: SqliteDatabase) {
76
+ this.db = db
77
+ this.db.pragma('journal_mode = WAL')
78
+ this.ensureTables()
79
+ }
80
+
81
+ /** Token revocations stored in the same SQLite database as the users. */
82
+ getTokenRevocationStore(): TokenRevocationStore {
83
+ if (!this.revocationStore) {
84
+ this.revocationStore = new SqliteTokenRevocationStore(
85
+ this.db as unknown as SqliteRevocationDatabase,
86
+ )
87
+ }
88
+ return this.revocationStore
89
+ }
90
+
91
+ private ensureTables(): void {
92
+ this.db.exec(`
93
+ CREATE TABLE IF NOT EXISTS auth_users (
94
+ id TEXT PRIMARY KEY,
95
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
96
+ name TEXT NOT NULL,
97
+ email_verified INTEGER NOT NULL DEFAULT 0,
98
+ created_at INTEGER NOT NULL,
99
+ password_hash TEXT NOT NULL,
100
+ salt TEXT NOT NULL
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS auth_devices (
104
+ id TEXT PRIMARY KEY,
105
+ user_id TEXT NOT NULL,
106
+ public_key TEXT NOT NULL,
107
+ name TEXT NOT NULL,
108
+ revoked INTEGER NOT NULL DEFAULT 0,
109
+ created_at INTEGER NOT NULL,
110
+ last_seen_at INTEGER NOT NULL,
111
+ FOREIGN KEY (user_id) REFERENCES auth_users(id) ON DELETE CASCADE
112
+ );
113
+
114
+ CREATE INDEX IF NOT EXISTS idx_auth_devices_user_id ON auth_devices(user_id);
115
+ `)
116
+ }
117
+
118
+ async createUser(params: {
119
+ email: string
120
+ passwordHash: string
121
+ salt: string
122
+ name: string
123
+ }): Promise<AuthUser> {
124
+ const normalizedEmail = params.email.toLowerCase()
125
+ const now = Date.now()
126
+ const id = randomUUID()
127
+
128
+ try {
129
+ this.db
130
+ .prepare(`
131
+ INSERT INTO auth_users (id, email, name, email_verified, created_at, password_hash, salt)
132
+ VALUES (?, ?, ?, 0, ?, ?, ?)
133
+ `)
134
+ .run(id, normalizedEmail, params.name, now, params.passwordHash, params.salt)
135
+ } catch (err: unknown) {
136
+ if (err instanceof Error && err.message.includes('UNIQUE constraint failed')) {
137
+ throw new DuplicateEmailError()
138
+ }
139
+ throw err
140
+ }
141
+
142
+ return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now }
143
+ }
144
+
145
+ async findByEmail(email: string): Promise<StoredUser | null> {
146
+ const row = this.db
147
+ .prepare('SELECT * FROM auth_users WHERE email = ?')
148
+ .get(email.toLowerCase()) as UserRow | undefined
149
+
150
+ return row ? rowToStoredUser(row) : null
151
+ }
152
+
153
+ async findById(id: string): Promise<StoredUser | null> {
154
+ const row = this.db.prepare('SELECT * FROM auth_users WHERE id = ?').get(id) as
155
+ | UserRow
156
+ | undefined
157
+
158
+ return row ? rowToStoredUser(row) : null
159
+ }
160
+
161
+ async registerDevice(params: {
162
+ id: string
163
+ userId: string
164
+ publicKey: string
165
+ name: string
166
+ }): Promise<AuthDevice> {
167
+ const now = Date.now()
168
+ // INSERT-or-ignore first, then read: the owner check sees whichever
169
+ // registration won, even when two users race for the same id.
170
+ this.db
171
+ .prepare(`
172
+ INSERT INTO auth_devices (id, user_id, public_key, name, revoked, created_at, last_seen_at)
173
+ VALUES (?, ?, ?, ?, 0, ?, ?)
174
+ ON CONFLICT(id) DO NOTHING
175
+ `)
176
+ .run(params.id, params.userId, params.publicKey, params.name, now, now)
177
+ const existing = this.db.prepare('SELECT * FROM auth_devices WHERE id = ?').get(params.id) as
178
+ | DeviceRow
179
+ | undefined
180
+
181
+ if (existing && existing.user_id !== params.userId) {
182
+ throw new DeviceOwnershipError(params.id)
183
+ }
184
+ if (existing && !existing.revoked) {
185
+ return rowToDevice(existing)
186
+ }
187
+
188
+ if (existing) {
189
+ // Re-activate the owner's previously revoked device
190
+ this.db
191
+ .prepare(`
192
+ UPDATE auth_devices SET revoked = 0, public_key = ?, name = ?, last_seen_at = ?
193
+ WHERE id = ? AND user_id = ?
194
+ `)
195
+ .run(params.publicKey, params.name, now, params.id, params.userId)
196
+ }
197
+
198
+ return {
199
+ id: params.id,
200
+ userId: params.userId,
201
+ publicKey: params.publicKey,
202
+ name: params.name,
203
+ revoked: false,
204
+ createdAt: existing ? existing.created_at : now,
205
+ lastSeenAt: now,
206
+ }
207
+ }
208
+
209
+ async findDevice(deviceId: string): Promise<AuthDevice | null> {
210
+ const row = this.db.prepare('SELECT * FROM auth_devices WHERE id = ?').get(deviceId) as
211
+ | DeviceRow
212
+ | undefined
213
+
214
+ return row ? rowToDevice(row) : null
215
+ }
216
+
217
+ async listDevices(userId: string): Promise<AuthDevice[]> {
218
+ const rows = this.db
219
+ .prepare('SELECT * FROM auth_devices WHERE user_id = ?')
220
+ .all(userId) as DeviceRow[]
221
+
222
+ return rows.map(rowToDevice)
223
+ }
224
+
225
+ async revokeDevice(deviceId: string): Promise<void> {
226
+ this.db.prepare('UPDATE auth_devices SET revoked = 1 WHERE id = ?').run(deviceId)
227
+ }
228
+
229
+ async setEmailVerified(userId: string, verified: boolean): Promise<void> {
230
+ this.db
231
+ .prepare('UPDATE auth_users SET email_verified = ? WHERE id = ?')
232
+ .run(verified ? 1 : 0, userId)
233
+ }
234
+
235
+ async updatePassword(userId: string, passwordHash: string, salt: string): Promise<void> {
236
+ this.db
237
+ .prepare('UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?')
238
+ .run(passwordHash, salt, userId)
239
+ }
240
+
241
+ async listAll(): Promise<StoredUser[]> {
242
+ const rows = this.db.prepare('SELECT * FROM auth_users').all() as UserRow[]
243
+ return rows.map(rowToStoredUser)
244
+ }
245
+
246
+ async update(user: StoredUser): Promise<void> {
247
+ this.db
248
+ .prepare(`
249
+ UPDATE auth_users
250
+ SET email = ?, name = ?, email_verified = ?, password_hash = ?, salt = ?
251
+ WHERE id = ?
252
+ `)
253
+ .run(user.email, user.name, user.emailVerified ? 1 : 0, user.passwordHash, user.salt, user.id)
254
+ }
255
+
256
+ async delete(userId: string): Promise<void> {
257
+ const deleteInTransaction = this.db.transaction(() => {
258
+ this.db.prepare('DELETE FROM auth_devices WHERE user_id = ?').run(userId)
259
+ this.db.prepare('DELETE FROM auth_users WHERE id = ?').run(userId)
260
+ })
261
+ deleteInTransaction()
262
+ }
263
+
264
+ async touchDevice(deviceId: string): Promise<void> {
265
+ this.db
266
+ .prepare('UPDATE auth_devices SET last_seen_at = ? WHERE id = ?')
267
+ .run(Date.now(), deviceId)
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Creates a SqliteUserStore from a file path.
273
+ *
274
+ * Uses runtime dynamic imports so projects that do not use SQLite server-side
275
+ * do not need to install `better-sqlite3`.
276
+ *
277
+ * @param options.filename - Path to the SQLite database file, or `:memory:` for in-memory
278
+ */
279
+ export async function createSqliteUserStore(options: {
280
+ filename: string
281
+ }): Promise<SqliteUserStore> {
282
+ const Database = await loadBetterSqlite3()
283
+ ensureDatabaseDirectory(options.filename)
284
+ const db = new Database(options.filename)
285
+ return new SqliteUserStore(db as unknown as SqliteDatabase)
286
+ }
287
+
288
+ async function loadBetterSqlite3(): Promise<new (filename: string) => unknown> {
289
+ try {
290
+ // Use createRequire for CJS compatibility with better-sqlite3
291
+ const { createRequire } = await import('node:module')
292
+ const require = createRequire(import.meta.url)
293
+ return require('better-sqlite3') as new (
294
+ filename: string,
295
+ ) => unknown
296
+ } catch {
297
+ throw new Error(
298
+ 'SQLite backend requires the "better-sqlite3" package. Install it in your project dependencies.',
299
+ )
300
+ }
301
+ }
302
+
303
+ function rowToStoredUser(row: UserRow): StoredUser {
304
+ return {
305
+ id: row.id,
306
+ email: row.email,
307
+ name: row.name,
308
+ emailVerified: Boolean(row.email_verified),
309
+ createdAt: row.created_at,
310
+ passwordHash: row.password_hash,
311
+ salt: row.salt,
312
+ }
313
+ }
314
+
315
+ function rowToDevice(row: DeviceRow): AuthDevice {
316
+ return {
317
+ id: row.id,
318
+ userId: row.user_id,
319
+ publicKey: row.public_key,
320
+ name: row.name,
321
+ revoked: Boolean(row.revoked),
322
+ createdAt: row.created_at,
323
+ lastSeenAt: row.last_seen_at,
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Create the directory a SQLite database file lives in, so a default such as
329
+ * `./.kora/kora-server.db` works on a fresh checkout. In-memory databases and `file:`
330
+ * URIs are left alone.
331
+ */
332
+ function ensureDatabaseDirectory(filename: string): void {
333
+ if (filename === '' || filename === ':memory:' || filename.startsWith('file:')) return
334
+ mkdirSync(dirname(filename), { recursive: true })
335
+ }
@@ -0,0 +1,85 @@
1
+ import { type ScopeMap, claimScopes } from '@korajs/core'
2
+
3
+ type MaybePromise<T> = T | Promise<T>
4
+
5
+ /**
6
+ * Verified identity of a sync session, derived on the server from a validated
7
+ * access token. Never contains client-supplied values.
8
+ */
9
+ export interface VerifiedSyncClaims {
10
+ /** Verified user id (`sub`). */
11
+ userId: string
12
+ /** Verified device id (`dev`). */
13
+ deviceId: string
14
+ /** The user's email from the user store. */
15
+ email: string
16
+ /** The user's display name from the user store. */
17
+ name: string
18
+ }
19
+
20
+ /**
21
+ * Server-side scope derivation for the sync auth provider (AUTH-1).
22
+ *
23
+ * The result is the session's complete grant: the client handshake can only
24
+ * narrow it, and a schema-scoped collection whose binding is unresolved is
25
+ * denied (`SCOPE_REQUIRED`), never widened.
26
+ */
27
+ export interface SyncScopeOptions {
28
+ /**
29
+ * Extra verified scope values merged over the default `{ userId }`, for
30
+ * example `{ orgId: await orgOf(userId) }`. Every schema-scoped collection is
31
+ * bound from these values. Return `undefined`/`null` for a key to deny the
32
+ * collections that need it.
33
+ */
34
+ scopeValues?: (claims: VerifiedSyncClaims) => MaybePromise<Record<string, unknown>>
35
+ /**
36
+ * Full explicit grant. When provided it replaces the default derivation:
37
+ * collections it omits are not visible. Use `claimScopes(values, explicit)`
38
+ * from `@korajs/core` to combine claim binding with explicit predicates.
39
+ */
40
+ resolveScopes?: (claims: VerifiedSyncClaims) => MaybePromise<ScopeMap>
41
+ }
42
+
43
+ /** Auth context returned to `@korajs/server` by the sync auth provider. */
44
+ export interface SyncAuthContext {
45
+ userId: string
46
+ scopes?: Record<string, Record<string, unknown>>
47
+ metadata?: Record<string, unknown>
48
+ /** Access-token expiry (ms since epoch); a session must not outlive it (AUTH-11). */
49
+ expiresAt?: number
50
+ }
51
+
52
+ /** Structural `AuthProvider` returned by `toSyncAuthProvider()`. */
53
+ export interface SyncAuthProvider {
54
+ authenticate(token: string): Promise<SyncAuthContext | null>
55
+ /**
56
+ * Revocation feed (device revoke, sign-out, password reset or change, admin
57
+ * revoke). `KoraSyncServer` subscribes automatically and terminates the
58
+ * matching live sessions (AUTH-11).
59
+ *
60
+ * @returns An unsubscribe function
61
+ */
62
+ onRevoke?(
63
+ listener: (event: { userId: string; deviceId?: string }) => void | Promise<void>,
64
+ ): () => void
65
+ }
66
+
67
+ /**
68
+ * Compute the server grant for a verified session.
69
+ *
70
+ * @param claims - Verified identity
71
+ * @param options - Scope derivation options
72
+ * @returns The scope grant (possibly carrying `$claims` for schema binding)
73
+ */
74
+ export async function resolveSyncGrant(
75
+ claims: VerifiedSyncClaims,
76
+ options: SyncScopeOptions,
77
+ ): Promise<ScopeMap> {
78
+ if (options.resolveScopes) {
79
+ return options.resolveScopes(claims)
80
+ }
81
+ const extra = options.scopeValues ? await options.scopeValues(claims) : {}
82
+ // The verified subject always wins over anything a resolver returns for
83
+ // `userId`, so a buggy resolver cannot rebind a session to another user.
84
+ return claimScopes({ ...extra, userId: claims.userId })
85
+ }