@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,1313 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto'
2
+ import { computePublicKeyThumbprint, verifyChallenge } from '../../device/device-identity'
3
+ import type { TokenManager } from '../../tokens/token-manager'
4
+ import type { AuthTokens, TokenPayload } from '../../types'
5
+ import { hashPassword, verifyPassword } from './password-hash'
6
+ import {
7
+ type SyncAuthProvider,
8
+ type SyncScopeOptions,
9
+ type VerifiedSyncClaims,
10
+ resolveSyncGrant,
11
+ } from './sync-scopes'
12
+ import {
13
+ type AuthDevice,
14
+ type AuthUser,
15
+ DeviceOwnershipError,
16
+ type StoredUser,
17
+ type UserStore,
18
+ } from './user-store'
19
+
20
+ // ============================================================================
21
+ // Challenge Store
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Interface for server-side challenge storage.
26
+ *
27
+ * Challenges must be stored server-side with expiry and single-use semantics
28
+ * to prevent replay attacks on device verification.
29
+ */
30
+ export interface ChallengeStore {
31
+ /**
32
+ * Store a challenge for later verification.
33
+ * @param challenge - The challenge string
34
+ * @param deviceId - The device this challenge is intended for
35
+ * @param expiresAt - Timestamp (ms since epoch) when this challenge expires
36
+ */
37
+ store(challenge: string, deviceId: string, expiresAt: number): Promise<void>
38
+
39
+ /**
40
+ * Consume a challenge (single-use). Returns the associated device ID if the
41
+ * challenge is valid and not expired, or null if it doesn't exist, has expired,
42
+ * or was already consumed.
43
+ */
44
+ consume(challenge: string): Promise<{ deviceId: string } | null>
45
+ }
46
+
47
+ /**
48
+ * In-memory challenge store with expiry and single-use semantics.
49
+ * Suitable for development and testing. Use Redis or a database in production.
50
+ */
51
+ export class InMemoryChallengeStore implements ChallengeStore {
52
+ private readonly challenges = new Map<string, { deviceId: string; expiresAt: number }>()
53
+
54
+ async store(challenge: string, deviceId: string, expiresAt: number): Promise<void> {
55
+ this.challenges.set(challenge, { deviceId, expiresAt })
56
+ }
57
+
58
+ async consume(challenge: string): Promise<{ deviceId: string } | null> {
59
+ const entry = this.challenges.get(challenge)
60
+ if (entry === undefined) {
61
+ return null
62
+ }
63
+
64
+ // Always delete (single-use)
65
+ this.challenges.delete(challenge)
66
+
67
+ // Check expiry
68
+ if (Date.now() > entry.expiresAt) {
69
+ return null
70
+ }
71
+
72
+ return { deviceId: entry.deviceId }
73
+ }
74
+
75
+ /**
76
+ * Remove expired challenges to prevent unbounded memory growth.
77
+ */
78
+ cleanup(): void {
79
+ const now = Date.now()
80
+ for (const [challenge, entry] of this.challenges) {
81
+ if (now > entry.expiresAt) {
82
+ this.challenges.delete(challenge)
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ // ============================================================================
89
+ // Rate Limiter
90
+ // ============================================================================
91
+
92
+ /**
93
+ * Interface for rate limiting auth endpoints.
94
+ *
95
+ * Rate limiting is critical for preventing brute-force password guessing
96
+ * and credential stuffing attacks.
97
+ */
98
+ export interface RateLimiter {
99
+ /**
100
+ * Check if an action is allowed for the given key.
101
+ * @param key - Rate limit key (e.g., IP address, email, or composite key)
102
+ * @returns true if the action is allowed, false if rate limited
103
+ */
104
+ isAllowed(key: string): Promise<boolean>
105
+
106
+ /**
107
+ * Record that an action was performed for the given key.
108
+ * Call this after each authentication attempt.
109
+ */
110
+ record(key: string): Promise<void>
111
+
112
+ /**
113
+ * Reset the rate limit for a key (e.g., after a successful login).
114
+ */
115
+ reset(key: string): Promise<void>
116
+ }
117
+
118
+ /**
119
+ * In-memory sliding window rate limiter.
120
+ * Suitable for development and single-server deployments.
121
+ * Use Redis-based rate limiting for multi-server production deployments.
122
+ */
123
+ export class InMemoryRateLimiter implements RateLimiter {
124
+ private readonly attempts = new Map<string, number[]>()
125
+ private readonly maxAttempts: number
126
+ private readonly windowMs: number
127
+
128
+ /**
129
+ * @param maxAttempts - Maximum number of attempts within the time window (default: 10)
130
+ * @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
131
+ */
132
+ constructor(maxAttempts = 10, windowMs = 60_000) {
133
+ this.maxAttempts = maxAttempts
134
+ this.windowMs = windowMs
135
+ }
136
+
137
+ async isAllowed(key: string): Promise<boolean> {
138
+ const now = Date.now()
139
+ const attempts = this.attempts.get(key) ?? []
140
+ const recentAttempts = attempts.filter((t) => now - t < this.windowMs)
141
+ return recentAttempts.length < this.maxAttempts
142
+ }
143
+
144
+ async record(key: string): Promise<void> {
145
+ const now = Date.now()
146
+ const attempts = this.attempts.get(key) ?? []
147
+ // Keep only recent attempts to bound memory
148
+ const recentAttempts = attempts.filter((t) => now - t < this.windowMs)
149
+ recentAttempts.push(now)
150
+ this.attempts.set(key, recentAttempts)
151
+ }
152
+
153
+ async reset(key: string): Promise<void> {
154
+ this.attempts.delete(key)
155
+ }
156
+ }
157
+
158
+ // ============================================================================
159
+ // Auth Routes Configuration
160
+ // ============================================================================
161
+
162
+ /**
163
+ * Configuration for building the built-in auth routes.
164
+ */
165
+ export interface AuthRoutesConfig {
166
+ /** The user/device store backing the auth routes */
167
+ userStore: UserStore
168
+ /** The token manager for issuing and validating JWTs */
169
+ tokenManager: TokenManager
170
+ /**
171
+ * Optional challenge store for device verification.
172
+ * Required for secure device proof-of-possession verification.
173
+ * If not provided, an in-memory store is created automatically.
174
+ */
175
+ challengeStore?: ChallengeStore
176
+ /**
177
+ * Optional rate limiter for authentication endpoints.
178
+ * If not provided, an in-memory rate limiter is created with defaults
179
+ * (10 attempts per minute).
180
+ */
181
+ rateLimiter?: RateLimiter
182
+ /**
183
+ * Called after credentials are revoked (sign-out, device revocation, user-wide
184
+ * revocation). `createKoraAuthServer().bindSyncServer()` uses it to end live
185
+ * sync sessions (AUTH-11).
186
+ */
187
+ onRevoke?: (event: AuthRevocationEvent) => void | Promise<void>
188
+ /**
189
+ * Second-factor verifier (a `TotpManager` fits). When configured, users with
190
+ * MFA enabled get `{ mfaRequired, mfaToken }` from sign-in instead of tokens,
191
+ * and only `POST /auth/mfa/verify` issues their session (AUTH-10).
192
+ */
193
+ mfa?: MfaVerifier
194
+ }
195
+
196
+ /** Second-factor checks used at sign-in. `TotpManager` implements this. */
197
+ export interface MfaVerifier {
198
+ isEnabled(userId: string): Promise<boolean>
199
+ verify(userId: string, code: string): Promise<boolean>
200
+ verifyRecoveryCode?(userId: string, recoveryCode: string): Promise<boolean>
201
+ }
202
+
203
+ /** Sign-in result for a user who still has to pass the second factor. */
204
+ export interface MfaChallenge {
205
+ mfaRequired: true
206
+ /** Short-lived token accepted only by `POST /auth/mfa/verify`. */
207
+ mfaToken: string
208
+ }
209
+
210
+ /** Successful primary authentication: a session, or an MFA challenge. */
211
+ export type SignInResult = { user: AuthUser; tokens: AuthTokens } | MfaChallenge
212
+
213
+ /**
214
+ * Describes credentials that were just revoked, so live sessions holding them
215
+ * (for example open sync connections) can be terminated.
216
+ */
217
+ export type AuthRevocationEvent =
218
+ | { kind: 'device'; userId: string; deviceId: string }
219
+ | { kind: 'user'; userId: string }
220
+ | { kind: 'session'; userId: string; deviceId: string; family: string | null }
221
+
222
+ /** Result of {@link BuiltInAuthRoutes.authenticateAccess}. */
223
+ export interface AuthenticatedAccess {
224
+ /** Verified access-token payload. */
225
+ payload: TokenPayload
226
+ /** The token's user, as stored. */
227
+ user: StoredUser
228
+ /** The token's device record (null when the token was minted without one). */
229
+ device: AuthDevice | null
230
+ }
231
+
232
+ /**
233
+ * Response envelope returned by all auth route handlers.
234
+ *
235
+ * Successful responses include a `data` field; failures include an `error` string.
236
+ * The `status` field maps directly to an HTTP status code.
237
+ */
238
+ export interface AuthRouteResponse<T> {
239
+ /** HTTP status code */
240
+ status: number
241
+ /**
242
+ * Either the success payload or an error message. `code` is a stable,
243
+ * machine-readable Kora error code; clients use it to tell a definitive
244
+ * rejection from a proxy or captive-portal response.
245
+ */
246
+ body: { data: T } | { error: string; code?: string }
247
+ /** Optional response headers (for example `Retry-After`). */
248
+ headers?: Record<string, string>
249
+ }
250
+
251
+ /** 401 returned for any unusable access token. */
252
+ function invalidAccessToken(): AuthRouteResponse<never> {
253
+ return {
254
+ status: 401,
255
+ body: { error: 'Invalid or expired access token.', code: 'ACCESS_TOKEN_INVALID' },
256
+ }
257
+ }
258
+
259
+ /** Server-assigned default device id: random per sign-in, never derived from the user. */
260
+ function generateDeviceId(): string {
261
+ return `dev-${randomUUID()}`
262
+ }
263
+
264
+ function deviceConflict(): AuthRouteResponse<never> {
265
+ return {
266
+ status: 409,
267
+ body: {
268
+ error:
269
+ 'This device id is registered to another account. Use a device id generated for this install.',
270
+ code: 'DEVICE_OWNERSHIP_CONFLICT',
271
+ },
272
+ }
273
+ }
274
+
275
+ /** Minimum password length enforced at sign-up. */
276
+ const MIN_PASSWORD_LENGTH = 8
277
+
278
+ /** Maximum password length to prevent hash-DoS attacks via extremely long passwords. */
279
+ const MAX_PASSWORD_LENGTH = 128
280
+
281
+ /** Maximum length for user/device name fields. */
282
+ const MAX_NAME_LENGTH = 200
283
+
284
+ /** Challenge validity window in milliseconds (60 seconds). */
285
+ const CHALLENGE_TTL_MS = 60_000
286
+
287
+ /**
288
+ * Simple email format validation.
289
+ * Checks for the presence of exactly one @ with non-empty local and domain parts,
290
+ * and at least one dot in the domain. This is intentionally lenient — real email
291
+ * validation happens by sending a confirmation email, not by regex.
292
+ */
293
+ function isValidEmail(email: string): boolean {
294
+ // Bodies come from JSON.parse'd network input, not a type-checked call
295
+ // site: a missing or malformed `email` field means this runs with
296
+ // `undefined` at runtime despite the `string` type, and `.length` would
297
+ // throw, crashing the request (and, since httpRoutes handlers aren't
298
+ // wrapped in a try/catch further up, potentially the whole process).
299
+ if (typeof email !== 'string' || email.length === 0 || email.length > 254) {
300
+ return false
301
+ }
302
+ const atIndex = email.indexOf('@')
303
+ if (atIndex < 1) {
304
+ return false
305
+ }
306
+ const domain = email.slice(atIndex + 1)
307
+ if (domain.length === 0 || !domain.includes('.')) {
308
+ return false
309
+ }
310
+ // No double-@ or spaces
311
+ if (email.indexOf('@', atIndex + 1) !== -1) {
312
+ return false
313
+ }
314
+ if (email.includes(' ')) {
315
+ return false
316
+ }
317
+ return true
318
+ }
319
+
320
+ /**
321
+ * Sanitize and limit a name string.
322
+ * Trims whitespace, enforces max length, and strips control characters.
323
+ */
324
+ function sanitizeName(name: string): string {
325
+ // Same reasoning as isValidEmail: `name` can come straight from a request
326
+ // body field (handleDeviceRegister's `body.name`), which is untyped at
327
+ // runtime, so guard before calling .replace on something that isn't
328
+ // actually a string.
329
+ if (typeof name !== 'string') {
330
+ return ''
331
+ }
332
+ // Strip ASCII control characters (0x00-0x1F, 0x7F)
333
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally stripping ASCII control characters for sanitization
334
+ const cleaned = name.replace(/[\x00-\x1f\x7f]/g, '')
335
+ const trimmed = cleaned.trim()
336
+ if (trimmed.length > MAX_NAME_LENGTH) {
337
+ return trimmed.slice(0, MAX_NAME_LENGTH)
338
+ }
339
+ return trimmed
340
+ }
341
+
342
+ /**
343
+ * HTTP route handlers for the built-in Kora auth provider.
344
+ *
345
+ * These are framework-agnostic functions that accept parsed request bodies and return
346
+ * structured responses. The server package is responsible for wiring them into its
347
+ * HTTP server (e.g., mapping `POST /auth/signup` to `handleSignUp`).
348
+ *
349
+ * All handlers follow the same pattern:
350
+ * - Validate input
351
+ * - Perform the operation
352
+ * - Return `{ status, body: { data } }` on success
353
+ * - Return `{ status, body: { error } }` on failure
354
+ *
355
+ * Security features:
356
+ * - Rate limiting on sign-in/sign-up to prevent brute-force attacks
357
+ * - Server-side challenge store for device verification (single-use, time-limited)
358
+ * - Token revocation on sign-out and device revocation
359
+ * - Input sanitization on all name fields
360
+ * - Maximum password length to prevent hash-DoS
361
+ *
362
+ * @example
363
+ * ```typescript
364
+ * const routes = new BuiltInAuthRoutes({
365
+ * userStore: new InMemoryUserStore(),
366
+ * tokenManager: new TokenManager({ secret: TokenManager.generateSecret() }),
367
+ * })
368
+ *
369
+ * // Wire into an HTTP server:
370
+ * app.post('/auth/signup', async (req, res) => {
371
+ * const result = await routes.handleSignUp(req.body)
372
+ * res.status(result.status).json(result.body)
373
+ * })
374
+ * ```
375
+ */
376
+ export class BuiltInAuthRoutes {
377
+ private readonly userStore: UserStore
378
+ private readonly tokenManager: TokenManager
379
+ private readonly challengeStore: ChallengeStore
380
+ private readonly rateLimiter: RateLimiter
381
+ private readonly revokeListeners = new Set<(event: AuthRevocationEvent) => void | Promise<void>>()
382
+ /** Lazily computed hash used to equalize sign-in timing for unknown emails. */
383
+ private dummyCredential: Promise<{ hash: string; salt: string }> | null = null
384
+ private readonly mfa: MfaVerifier | undefined
385
+
386
+ constructor(config: AuthRoutesConfig) {
387
+ this.userStore = config.userStore
388
+ this.tokenManager = config.tokenManager
389
+ this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore()
390
+ this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter()
391
+ if (config.onRevoke) this.revokeListeners.add(config.onRevoke)
392
+ this.mfa = config.mfa
393
+ }
394
+
395
+ /**
396
+ * Finish a successful primary authentication (password, OAuth): issue a
397
+ * session, or an MFA challenge when the user is enrolled in MFA. No
398
+ * full-privilege token is ever issued to an MFA user without a fresh second
399
+ * factor.
400
+ *
401
+ * @param user - The authenticated user
402
+ * @param deviceId - The (already registered) device
403
+ * @param amr - Methods satisfied so far (for example `['pwd']`)
404
+ */
405
+ async completePrimaryAuthentication(
406
+ user: AuthUser,
407
+ deviceId: string,
408
+ amr: string[],
409
+ ): Promise<AuthRouteResponse<SignInResult>> {
410
+ if (this.mfa && (await this.mfa.isEnabled(user.id))) {
411
+ return {
412
+ status: 200,
413
+ body: {
414
+ data: {
415
+ mfaRequired: true,
416
+ mfaToken: this.tokenManager.issueMfaPendingToken(user.id, deviceId, amr),
417
+ },
418
+ },
419
+ }
420
+ }
421
+ const tokens = this.tokenManager.issueTokens(user.id, deviceId, undefined, { amr })
422
+ return { status: 200, body: { data: { user, tokens } } }
423
+ }
424
+
425
+ /**
426
+ * Handle the second factor (POST /auth/mfa/verify).
427
+ *
428
+ * Exchanges an `mfa_pending` token plus a TOTP code (or recovery code) for a
429
+ * session whose tokens carry `amr` including `otp` (or `rcv`). The pending
430
+ * token is redeemed once; a wrong code can be retried until it expires.
431
+ */
432
+ async handleMfaVerify(body: {
433
+ mfaToken?: unknown
434
+ code?: unknown
435
+ recoveryCode?: unknown
436
+ }): Promise<AuthRouteResponse<{ user: AuthUser; tokens: AuthTokens }>> {
437
+ const invalidToken: AuthRouteResponse<never> = {
438
+ status: 401,
439
+ body: { error: 'Invalid or expired MFA session. Sign in again.', code: 'MFA_TOKEN_INVALID' },
440
+ }
441
+ if (!this.mfa || typeof body?.mfaToken !== 'string') return invalidToken
442
+ const pending = await this.tokenManager.verifyMfaPendingToken(body.mfaToken)
443
+ if (!pending) return invalidToken
444
+
445
+ const storedUser = await this.userStore.findById(pending.sub)
446
+ const device = await this.userStore.findDevice(pending.dev)
447
+ if (!storedUser || (device && (device.revoked || device.userId !== pending.sub))) {
448
+ return invalidToken
449
+ }
450
+
451
+ let method: 'otp' | 'rcv' | null = null
452
+ if (typeof body.code === 'string' && (await this.mfa.verify(pending.sub, body.code))) {
453
+ method = 'otp'
454
+ } else if (
455
+ typeof body.recoveryCode === 'string' &&
456
+ this.mfa.verifyRecoveryCode &&
457
+ (await this.mfa.verifyRecoveryCode(pending.sub, body.recoveryCode))
458
+ ) {
459
+ method = 'rcv'
460
+ }
461
+ if (method === null) {
462
+ return { status: 401, body: { error: 'Invalid MFA code.', code: 'MFA_CODE_INVALID' } }
463
+ }
464
+ if (!(await this.tokenManager.redeemMfaPendingToken(pending))) return invalidToken
465
+
466
+ const tokens = this.tokenManager.issueTokens(pending.sub, pending.dev, undefined, {
467
+ amr: [...new Set([...pending.amr, method])],
468
+ })
469
+ const user: AuthUser = {
470
+ id: storedUser.id,
471
+ email: storedUser.email,
472
+ name: storedUser.name,
473
+ emailVerified: storedUser.emailVerified,
474
+ createdAt: storedUser.createdAt,
475
+ }
476
+ return { status: 200, body: { data: { user, tokens } } }
477
+ }
478
+
479
+ /**
480
+ * Subscribe to credential revocations (sign-out, device revocation,
481
+ * user-wide revocation).
482
+ *
483
+ * @param listener - Called after each revocation is persisted
484
+ * @returns An unsubscribe function
485
+ */
486
+ onRevoke(listener: (event: AuthRevocationEvent) => void | Promise<void>): () => void {
487
+ this.revokeListeners.add(listener)
488
+ return () => {
489
+ this.revokeListeners.delete(listener)
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Authenticate an access token for any request-authorization path.
495
+ *
496
+ * The single check used by every HTTP route and by the sync provider: it
497
+ * verifies signature and expiry, the token's own revocation, its family, the
498
+ * device cut-off, the per-user cut-off, that the user still exists, and that
499
+ * the device record is neither revoked nor owned by someone else.
500
+ *
501
+ * @param token - Raw access token (without "Bearer ")
502
+ * @returns The verified access, or null when the token must be rejected
503
+ */
504
+ async authenticateAccess(token: string): Promise<AuthenticatedAccess | null> {
505
+ if (typeof token !== 'string' || token.length === 0) return null
506
+ const payload = await this.tokenManager.validateTokenWithRevocation(token)
507
+ if (payload === null || payload.type !== 'access') return null
508
+ const user = await this.userStore.findById(payload.sub)
509
+ if (user === null) return null
510
+ const device = await this.userStore.findDevice(payload.dev)
511
+ if (device && (device.revoked || device.userId !== payload.sub)) return null
512
+ return { payload, user, device }
513
+ }
514
+
515
+ /**
516
+ * Revoke every credential a user holds (password reset or change, admin
517
+ * session revocation, account deletion) and notify revocation listeners.
518
+ *
519
+ * @param userId - The user whose sessions end now
520
+ */
521
+ async revokeAllForUser(userId: string): Promise<void> {
522
+ await this.tokenManager.revokeAllForUser(userId)
523
+ await this.emitRevoke({ kind: 'user', userId })
524
+ }
525
+
526
+ private async emitRevoke(event: AuthRevocationEvent): Promise<void> {
527
+ for (const listener of this.revokeListeners) {
528
+ try {
529
+ await listener(event)
530
+ } catch (error) {
531
+ // A failing listener (for example a sync server that is shutting down)
532
+ // must not undo or block the revocation itself, but it must be visible.
533
+ console.error('[kora] auth revocation listener failed', error)
534
+ }
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Register the device for a successful primary authentication, refusing ids
540
+ * owned by another user.
541
+ */
542
+ private async registerSignInDevice(params: {
543
+ userId: string
544
+ deviceId: string | undefined
545
+ publicKey: string | undefined
546
+ named: string
547
+ }): Promise<string | AuthRouteResponse<never>> {
548
+ const deviceId =
549
+ typeof params.deviceId === 'string' && params.deviceId.length > 0
550
+ ? params.deviceId
551
+ : generateDeviceId()
552
+ try {
553
+ await this.userStore.registerDevice({
554
+ id: deviceId,
555
+ userId: params.userId,
556
+ publicKey: params.publicKey ?? '',
557
+ name: params.named,
558
+ })
559
+ } catch (error) {
560
+ if (error instanceof DeviceOwnershipError) return deviceConflict()
561
+ throw error
562
+ }
563
+ return deviceId
564
+ }
565
+
566
+ /**
567
+ * Handle user sign-up (POST /auth/signup).
568
+ *
569
+ * Validates email format and password length, hashes the password,
570
+ * creates the user, optionally registers a device, and issues tokens.
571
+ *
572
+ * @param body - Sign-up request body
573
+ * @param body.email - The user's email address
574
+ * @param body.password - The plaintext password (8-128 characters)
575
+ * @param body.name - Optional display name (defaults to email local part)
576
+ * @param body.deviceId - Optional device ID to register
577
+ * @param body.devicePublicKey - Optional device public key (base64url)
578
+ * @param clientIp - Optional client IP for rate limiting
579
+ * @returns Auth response with the created user and tokens, or an error
580
+ */
581
+ async handleSignUp(
582
+ body: {
583
+ email: string
584
+ password: string
585
+ name?: string
586
+ deviceId?: string
587
+ devicePublicKey?: string
588
+ },
589
+ clientIp?: string,
590
+ ): Promise<AuthRouteResponse<{ user: AuthUser; tokens: AuthTokens }>> {
591
+ // Rate limiting
592
+ const rateLimitKey = clientIp ?? 'global'
593
+ if (!(await this.rateLimiter.isAllowed(rateLimitKey))) {
594
+ return {
595
+ status: 429,
596
+ body: { error: 'Too many requests. Please try again later.' },
597
+ }
598
+ }
599
+ await this.rateLimiter.record(rateLimitKey)
600
+
601
+ // Validate email format
602
+ if (!isValidEmail(body.email)) {
603
+ return {
604
+ status: 400,
605
+ body: {
606
+ error:
607
+ 'Invalid email address. Please provide a valid email in the format user@domain.com.',
608
+ },
609
+ }
610
+ }
611
+
612
+ // Validate password length (min and max). Same runtime-untyped-body
613
+ // reasoning as the email check above: a missing password must not crash
614
+ // this length check.
615
+ if (typeof body.password !== 'string' || body.password.length < MIN_PASSWORD_LENGTH) {
616
+ return {
617
+ status: 400,
618
+ body: {
619
+ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`,
620
+ },
621
+ }
622
+ }
623
+
624
+ if (body.password.length > MAX_PASSWORD_LENGTH) {
625
+ return {
626
+ status: 400,
627
+ body: {
628
+ error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`,
629
+ },
630
+ }
631
+ }
632
+
633
+ // Hash the password
634
+ const { hash, salt } = await hashPassword(body.password)
635
+
636
+ // Sanitize the display name
637
+ const rawName = body.name ?? body.email.split('@')[0] ?? body.email
638
+ const name = sanitizeName(rawName)
639
+
640
+ // Create the user — may throw DuplicateEmailError
641
+ let user: AuthUser
642
+ try {
643
+ user = await this.userStore.createUser({
644
+ email: body.email,
645
+ passwordHash: hash,
646
+ salt,
647
+ name,
648
+ })
649
+ } catch (err: unknown) {
650
+ if (err instanceof Error && err.name === 'DuplicateEmailError') {
651
+ return {
652
+ status: 409,
653
+ body: { error: 'An account with this email already exists.' },
654
+ }
655
+ }
656
+ throw err
657
+ }
658
+
659
+ // A client-chosen id must be owned by this user; without one the server
660
+ // assigns a random id (never `device-${userId}`, which every browser of the
661
+ // user would share).
662
+ const deviceId = await this.registerSignInDevice({
663
+ userId: user.id,
664
+ deviceId: body.deviceId,
665
+ publicKey: body.devicePublicKey,
666
+ named: body.deviceId ? 'Primary Device' : 'Browser',
667
+ })
668
+ if (typeof deviceId !== 'string') return deviceId
669
+
670
+ // Issue tokens
671
+ const tokens = this.tokenManager.issueTokens(user.id, deviceId, undefined, { amr: ['pwd'] })
672
+
673
+ return {
674
+ status: 201,
675
+ body: { data: { user, tokens } },
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Handle user sign-in (POST /auth/signin).
681
+ *
682
+ * Looks up the user by email, verifies the password, optionally registers
683
+ * a new device, and issues tokens.
684
+ *
685
+ * @param body - Sign-in request body
686
+ * @param body.email - The user's email address
687
+ * @param body.password - The plaintext password
688
+ * @param body.deviceId - Optional device ID to register
689
+ * @param body.devicePublicKey - Optional device public key (base64url)
690
+ * @param clientIp - Optional client IP for rate limiting
691
+ * @returns Auth response with the user and tokens, or an error
692
+ */
693
+ async handleSignIn(
694
+ body: {
695
+ email: string
696
+ password: string
697
+ deviceId?: string
698
+ devicePublicKey?: string
699
+ },
700
+ clientIp?: string,
701
+ ): Promise<AuthRouteResponse<SignInResult>> {
702
+ // Request bodies are untyped at runtime (JSON.parse'd network input, not
703
+ // a checked call site), so a missing/malformed `email` or `password`
704
+ // field reaches here as `undefined` despite the `string` type. Reject it
705
+ // before it's used, rather than crashing on `.toLowerCase()` below.
706
+ if (typeof body.email !== 'string' || typeof body.password !== 'string') {
707
+ return {
708
+ status: 400,
709
+ body: { error: 'Email and password are required.' },
710
+ }
711
+ }
712
+
713
+ // Two independent limits (AUTH-9): one per account, so rotating source IPs
714
+ // cannot buy unlimited guesses against one user, and one per client IP, so
715
+ // one client cannot spray many accounts. The IP must come from a trusted
716
+ // source (the socket, or a configured proxy hop), never a raw header.
717
+ const accountKey = `signin:account:${body.email.trim().toLowerCase()}`
718
+ const ipKey = clientIp ? `signin:ip:${clientIp}` : null
719
+ if (
720
+ !(await this.rateLimiter.isAllowed(accountKey)) ||
721
+ (ipKey !== null && !(await this.rateLimiter.isAllowed(ipKey)))
722
+ ) {
723
+ return {
724
+ status: 429,
725
+ body: {
726
+ error: 'Too many sign-in attempts. Please try again later.',
727
+ code: 'RATE_LIMITED',
728
+ },
729
+ headers: { 'Retry-After': '60' },
730
+ }
731
+ }
732
+ await this.rateLimiter.record(accountKey)
733
+ if (ipKey !== null) await this.rateLimiter.record(ipKey)
734
+
735
+ const storedUser = await this.userStore.findByEmail(body.email)
736
+ // Always run the password KDF, against a fixed dummy credential when the
737
+ // account does not exist, so response time does not reveal registration.
738
+ const credential = storedUser
739
+ ? { hash: storedUser.passwordHash, salt: storedUser.salt }
740
+ : await this.getDummyCredential()
741
+ const passwordValid = await verifyPassword(body.password, credential.hash, credential.salt)
742
+ if (storedUser === null || !passwordValid) {
743
+ return {
744
+ status: 401,
745
+ body: { error: 'Invalid email or password.', code: 'INVALID_CREDENTIALS' },
746
+ }
747
+ }
748
+
749
+ // Successful login: clear this account's failure budget only.
750
+ await this.rateLimiter.reset(accountKey)
751
+
752
+ const deviceId = await this.registerSignInDevice({
753
+ userId: storedUser.id,
754
+ deviceId: body.deviceId,
755
+ publicKey: body.devicePublicKey,
756
+ named: body.deviceId ? 'Device' : 'Browser',
757
+ })
758
+ if (typeof deviceId !== 'string') return deviceId
759
+
760
+ const user: AuthUser = {
761
+ id: storedUser.id,
762
+ email: storedUser.email,
763
+ name: storedUser.name,
764
+ emailVerified: storedUser.emailVerified,
765
+ createdAt: storedUser.createdAt,
766
+ }
767
+
768
+ return this.completePrimaryAuthentication(user, deviceId, ['pwd'])
769
+ }
770
+
771
+ /**
772
+ * Handle token refresh (POST /auth/refresh).
773
+ *
774
+ * Validates the provided refresh token and issues a new token pair
775
+ * (refresh token rotation with reuse detection). The old refresh token
776
+ * is marked as consumed in the revocation store.
777
+ *
778
+ * @param body - Refresh request body
779
+ * @param body.refreshToken - The current refresh token
780
+ * @returns Auth response with new tokens, or an error
781
+ */
782
+ async handleRefresh(body: {
783
+ refreshToken: string
784
+ }): Promise<AuthRouteResponse<AuthTokens>> {
785
+ const rejected: AuthRouteResponse<never> = {
786
+ status: 401,
787
+ body: { error: 'Invalid or expired refresh token.', code: 'REFRESH_TOKEN_INVALID' },
788
+ }
789
+ const refreshToken = body?.refreshToken
790
+ const presented =
791
+ typeof refreshToken === 'string' ? this.tokenManager.validateToken(refreshToken) : null
792
+ if (presented === null || presented.type !== 'refresh') {
793
+ return rejected
794
+ }
795
+
796
+ // The device and the user must still be in good standing (AUTH-2): a revoked
797
+ // device keeps no refresh rights, whatever the revocation store says.
798
+ const user = await this.userStore.findById(presented.sub)
799
+ const device = await this.userStore.findDevice(presented.dev)
800
+ if (user === null || (device && (device.revoked || device.userId !== presented.sub))) {
801
+ return rejected
802
+ }
803
+
804
+ const result = await this.tokenManager.rotateRefreshToken(refreshToken as string)
805
+ if (!result.ok) {
806
+ if (result.reason === 'in_progress') {
807
+ // A duplicate of a rotation still running on this instance: transient.
808
+ return {
809
+ status: 409,
810
+ body: { error: 'A refresh with this token is in progress.', code: 'REFRESH_IN_PROGRESS' },
811
+ headers: { 'Retry-After': '1' },
812
+ }
813
+ }
814
+ return rejected
815
+ }
816
+
817
+ return {
818
+ status: 200,
819
+ body: { data: result.tokens },
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Handle sign-out (POST /auth/signout).
825
+ *
826
+ * Validates the access token and revokes the current refresh token
827
+ * (if a revocation store is configured). This ensures that stolen
828
+ * refresh tokens cannot be used after the user signs out.
829
+ *
830
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
831
+ * @param body - Sign-out request body
832
+ * @param body.refreshToken - The current refresh token to revoke
833
+ * @returns Auth response with success flag, or an error
834
+ */
835
+ async handleSignOut(
836
+ accessToken: string,
837
+ body: { refreshToken?: string },
838
+ ): Promise<AuthRouteResponse<{ success: boolean }>> {
839
+ const access = await this.authenticateAccess(accessToken)
840
+ if (access === null) {
841
+ return invalidAccessToken()
842
+ }
843
+ const { payload } = access
844
+
845
+ // Revoke the access token itself
846
+ await this.tokenManager.revokeToken(payload.jti, payload.exp)
847
+
848
+ // Revoke the refresh token if provided, and its whole family, so a successor
849
+ // minted by a just-completed rotation (or its grace replay) dies with it.
850
+ let refreshExp = payload.exp
851
+ if (body?.refreshToken) {
852
+ const refreshPayload = this.tokenManager.validateToken(body.refreshToken)
853
+ if (
854
+ refreshPayload !== null &&
855
+ refreshPayload.type === 'refresh' &&
856
+ refreshPayload.sub === payload.sub
857
+ ) {
858
+ await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp)
859
+ refreshExp = Math.max(refreshExp, refreshPayload.exp)
860
+ if (refreshPayload.fam) {
861
+ await this.tokenManager.revokeFamily(refreshPayload.fam, refreshPayload.exp)
862
+ }
863
+ }
864
+ }
865
+ if (payload.fam) {
866
+ await this.tokenManager.revokeFamily(payload.fam, refreshExp)
867
+ }
868
+ await this.emitRevoke({
869
+ kind: 'session',
870
+ userId: payload.sub,
871
+ deviceId: payload.dev,
872
+ family: payload.fam ?? null,
873
+ })
874
+
875
+ return {
876
+ status: 200,
877
+ body: { data: { success: true } },
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Handle get-current-user (GET /auth/me).
883
+ *
884
+ * Validates the access token and returns the authenticated user's profile.
885
+ *
886
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
887
+ * @returns Auth response with the user profile, or an error
888
+ */
889
+ async handleGetMe(accessToken: string): Promise<AuthRouteResponse<AuthUser>> {
890
+ const access = await this.authenticateAccess(accessToken)
891
+ if (access === null) {
892
+ return invalidAccessToken()
893
+ }
894
+ const storedUser = access.user
895
+
896
+ const user: AuthUser = {
897
+ id: storedUser.id,
898
+ email: storedUser.email,
899
+ name: storedUser.name,
900
+ emailVerified: storedUser.emailVerified,
901
+ createdAt: storedUser.createdAt,
902
+ }
903
+
904
+ return {
905
+ status: 200,
906
+ body: { data: user },
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Handle list-devices (GET /auth/devices).
912
+ *
913
+ * Validates the access token and returns all devices registered for the user.
914
+ *
915
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
916
+ * @returns Auth response with the device list, or an error
917
+ */
918
+ async handleListDevices(accessToken: string): Promise<AuthRouteResponse<AuthDevice[]>> {
919
+ const access = await this.authenticateAccess(accessToken)
920
+ if (access === null) {
921
+ return invalidAccessToken()
922
+ }
923
+
924
+ const devices = await this.userStore.listDevices(access.payload.sub)
925
+
926
+ return {
927
+ status: 200,
928
+ body: { data: devices },
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Handle device revocation (DELETE /auth/device/:id).
934
+ *
935
+ * Validates the access token, revokes the specified device, and invalidates
936
+ * all tokens issued to that device. Only the device's owner can revoke it.
937
+ *
938
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
939
+ * @param deviceId - The ID of the device to revoke
940
+ * @returns Auth response with success flag, or an error
941
+ */
942
+ async handleRevokeDevice(
943
+ accessToken: string,
944
+ deviceId: string,
945
+ ): Promise<AuthRouteResponse<{ success: boolean }>> {
946
+ const access = await this.authenticateAccess(accessToken)
947
+ if (access === null) {
948
+ return invalidAccessToken()
949
+ }
950
+ const { payload } = access
951
+
952
+ // Verify the device belongs to the authenticated user
953
+ const device = await this.userStore.findDevice(deviceId)
954
+ if (device === null) {
955
+ return {
956
+ status: 404,
957
+ body: { error: 'Device not found.' },
958
+ }
959
+ }
960
+
961
+ if (device.userId !== payload.sub) {
962
+ return {
963
+ status: 403,
964
+ body: { error: 'You can only revoke your own devices.' },
965
+ }
966
+ }
967
+
968
+ await this.userStore.revokeDevice(deviceId)
969
+
970
+ // Invalidate every token issued to this device so far. A later sign-in on
971
+ // the same device issues fresh tokens that are accepted (NEW-AUTH-1).
972
+ await this.tokenManager.revokeDeviceTokens(deviceId)
973
+ await this.emitRevoke({ kind: 'device', userId: payload.sub, deviceId })
974
+
975
+ return {
976
+ status: 200,
977
+ body: { data: { success: true } },
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Handle device registration (POST /auth/device/register).
983
+ *
984
+ * Requires a valid access token. Registers a new device for the authenticated
985
+ * user and issues a device credential token bound to the device's public key.
986
+ *
987
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
988
+ * @param body - Device registration request body
989
+ * @param body.deviceId - Unique identifier for the device
990
+ * @param body.publicKey - The device's public key as a JWK JSON string
991
+ * @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
992
+ * @returns Auth response with the registered device and device credential, or an error
993
+ */
994
+ async handleDeviceRegister(
995
+ accessToken: string,
996
+ body: {
997
+ deviceId: string
998
+ publicKey: string
999
+ name: string
1000
+ },
1001
+ ): Promise<AuthRouteResponse<{ device: AuthDevice; deviceCredential: string }>> {
1002
+ const access = await this.authenticateAccess(accessToken)
1003
+ if (access === null) {
1004
+ return invalidAccessToken()
1005
+ }
1006
+ const { payload } = access
1007
+
1008
+ // Sanitize the device name
1009
+ const deviceName = sanitizeName(body.name)
1010
+ if (deviceName.length === 0) {
1011
+ return {
1012
+ status: 400,
1013
+ body: { error: 'Device name must not be empty.' },
1014
+ }
1015
+ }
1016
+
1017
+ // Parse the JWK JSON string into a JsonWebKey object
1018
+ let publicKeyJwk: JsonWebKey
1019
+ try {
1020
+ publicKeyJwk = JSON.parse(body.publicKey) as JsonWebKey
1021
+ } catch {
1022
+ return {
1023
+ status: 400,
1024
+ body: { error: 'Invalid public key format. Expected a JSON-encoded JWK string.' },
1025
+ }
1026
+ }
1027
+
1028
+ // Compute the SHA-256 thumbprint of the public key for binding to the credential
1029
+ let thumbprint: string
1030
+ try {
1031
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk)
1032
+ } catch {
1033
+ return {
1034
+ status: 400,
1035
+ body: {
1036
+ error: 'Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK.',
1037
+ },
1038
+ }
1039
+ }
1040
+
1041
+ // Register the device in the user store (refused if another user owns the id)
1042
+ let device: AuthDevice
1043
+ try {
1044
+ device = await this.userStore.registerDevice({
1045
+ id: body.deviceId,
1046
+ userId: payload.sub,
1047
+ publicKey: body.publicKey,
1048
+ name: deviceName,
1049
+ })
1050
+ } catch (error) {
1051
+ if (error instanceof DeviceOwnershipError) return deviceConflict()
1052
+ throw error
1053
+ }
1054
+
1055
+ // Issue a device credential token bound to the public key thumbprint
1056
+ const deviceCredential = this.tokenManager.issueDeviceCredential(
1057
+ payload.sub,
1058
+ body.deviceId,
1059
+ thumbprint,
1060
+ )
1061
+
1062
+ return {
1063
+ status: 201,
1064
+ body: { data: { device, deviceCredential } },
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * Generate a challenge for device proof-of-possession verification.
1070
+ *
1071
+ * Creates a cryptographically random challenge, stores it server-side with
1072
+ * a 60-second TTL and the target device ID, and returns the challenge string.
1073
+ * The client signs this challenge with its private key and submits it via
1074
+ * {@link handleDeviceVerify}.
1075
+ *
1076
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
1077
+ * @param deviceId - The device this challenge is intended for
1078
+ * @returns Auth response with the challenge string, or an error
1079
+ */
1080
+ async handleDeviceChallenge(
1081
+ accessToken: string,
1082
+ deviceId: string,
1083
+ ): Promise<AuthRouteResponse<{ challenge: string }>> {
1084
+ const access = await this.authenticateAccess(accessToken)
1085
+ if (access === null) {
1086
+ return invalidAccessToken()
1087
+ }
1088
+ const { payload } = access
1089
+
1090
+ // Verify the device exists and belongs to this user
1091
+ const device = await this.userStore.findDevice(deviceId)
1092
+ if (device === null || device.userId !== payload.sub) {
1093
+ return {
1094
+ status: 404,
1095
+ body: { error: 'Device not found.' },
1096
+ }
1097
+ }
1098
+
1099
+ if (device.revoked) {
1100
+ return {
1101
+ status: 403,
1102
+ body: { error: 'Device has been revoked.' },
1103
+ }
1104
+ }
1105
+
1106
+ const challenge = randomBytes(32).toString('hex')
1107
+ const expiresAt = Date.now() + CHALLENGE_TTL_MS
1108
+
1109
+ await this.challengeStore.store(challenge, deviceId, expiresAt)
1110
+
1111
+ return {
1112
+ status: 200,
1113
+ body: { data: { challenge } },
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * Handle device proof-of-possession verification (POST /auth/device/verify).
1119
+ *
1120
+ * Verifies that the device holds the private key corresponding to its registered
1121
+ * public key by checking a signed challenge. The challenge must have been previously
1122
+ * issued via {@link handleDeviceChallenge} and is single-use.
1123
+ *
1124
+ * On success, issues fresh tokens for the device.
1125
+ *
1126
+ * @param body - Device verification request body
1127
+ * @param body.deviceId - The ID of the device to verify
1128
+ * @param body.challenge - The challenge string (from handleDeviceChallenge)
1129
+ * @param body.signature - The base64url-encoded ECDSA signature of the challenge
1130
+ * @returns Auth response with fresh tokens on success, or an error
1131
+ */
1132
+ async handleDeviceVerify(body: {
1133
+ deviceId: string
1134
+ challenge: string
1135
+ signature: string
1136
+ }): Promise<AuthRouteResponse<{ tokens: AuthTokens }>> {
1137
+ // Consume the challenge (single-use, time-limited)
1138
+ const challengeEntry = await this.challengeStore.consume(body.challenge)
1139
+ if (challengeEntry === null) {
1140
+ return {
1141
+ status: 401,
1142
+ body: { error: 'Invalid or expired challenge. Request a new challenge and try again.' },
1143
+ }
1144
+ }
1145
+
1146
+ // Verify the challenge was issued for this device
1147
+ if (challengeEntry.deviceId !== body.deviceId) {
1148
+ return {
1149
+ status: 401,
1150
+ body: { error: 'Challenge was not issued for this device.' },
1151
+ }
1152
+ }
1153
+
1154
+ // Look up the device in the store
1155
+ const device = await this.userStore.findDevice(body.deviceId)
1156
+ if (device === null) {
1157
+ return {
1158
+ status: 404,
1159
+ body: { error: 'Device not found.' },
1160
+ }
1161
+ }
1162
+
1163
+ // Revoked devices cannot verify
1164
+ if (device.revoked) {
1165
+ return {
1166
+ status: 403,
1167
+ body: { error: 'Device has been revoked and cannot authenticate.' },
1168
+ }
1169
+ }
1170
+
1171
+ // Parse the stored public key JWK
1172
+ let publicKeyJwk: JsonWebKey
1173
+ try {
1174
+ publicKeyJwk = JSON.parse(device.publicKey) as JsonWebKey
1175
+ } catch {
1176
+ return {
1177
+ status: 500,
1178
+ body: { error: 'Device has an invalid stored public key.' },
1179
+ }
1180
+ }
1181
+
1182
+ // Verify the signature against the challenge using the device's public key
1183
+ let isValid: boolean
1184
+ try {
1185
+ isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature)
1186
+ } catch {
1187
+ return {
1188
+ status: 400,
1189
+ body: {
1190
+ error:
1191
+ 'Signature verification failed. The signature or public key format may be invalid.',
1192
+ },
1193
+ }
1194
+ }
1195
+
1196
+ if (!isValid) {
1197
+ return {
1198
+ status: 401,
1199
+ body: { error: 'Invalid signature. Proof-of-possession verification failed.' },
1200
+ }
1201
+ }
1202
+
1203
+ // Compute thumbprint for the device credential
1204
+ let thumbprint: string
1205
+ try {
1206
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk)
1207
+ } catch {
1208
+ return {
1209
+ status: 500,
1210
+ body: { error: 'Failed to compute public key thumbprint.' },
1211
+ }
1212
+ }
1213
+
1214
+ // Issue fresh tokens for this device
1215
+ const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint)
1216
+
1217
+ return {
1218
+ status: 200,
1219
+ body: { data: { tokens } },
1220
+ }
1221
+ }
1222
+
1223
+ /**
1224
+ * Generates a random challenge string for proof-of-possession verification.
1225
+ *
1226
+ * **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
1227
+ * the challenge server-side with expiry and single-use semantics.
1228
+ *
1229
+ * @returns A 64-character hex string (32 random bytes)
1230
+ */
1231
+ static generateChallenge(): string {
1232
+ return randomBytes(32).toString('hex')
1233
+ }
1234
+
1235
+ private getDummyCredential(): Promise<{ hash: string; salt: string }> {
1236
+ if (!this.dummyCredential) {
1237
+ this.dummyCredential = hashPassword(randomUUID())
1238
+ }
1239
+ return this.dummyCredential
1240
+ }
1241
+
1242
+ /**
1243
+ * Creates a sync server auth provider compatible with `@korajs/server`.
1244
+ *
1245
+ * The returned object implements the `AuthProvider` interface from
1246
+ * `@korajs/server`, validating access tokens and returning an auth
1247
+ * context containing the user ID, device metadata and a SERVER-DERIVED
1248
+ * scope grant. The client handshake can only narrow that grant (AUTH-1).
1249
+ *
1250
+ * By default the grant binds every schema-scoped collection from
1251
+ * `{ userId: <verified sub> }`. Collections scoped by any other key (for
1252
+ * example `orgId`) are denied until `scopeValues` or `resolveScopes`
1253
+ * supplies it; they are never widened to "every tenant".
1254
+ *
1255
+ * Also checks device revocation status during authentication, ensuring
1256
+ * that revoked devices are rejected even if their tokens haven't expired.
1257
+ *
1258
+ * @param options - Optional server-side scope derivation
1259
+ * @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
1260
+ *
1261
+ * @example
1262
+ * ```typescript
1263
+ * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
1264
+ * const syncServer = new KoraSyncServer({
1265
+ * store,
1266
+ * auth: routes.toSyncAuthProvider({
1267
+ * scopeValues: async ({ userId }) => ({ orgId: await orgOf(userId) }),
1268
+ * }),
1269
+ * })
1270
+ * ```
1271
+ */
1272
+ toSyncAuthProvider(options: SyncScopeOptions = {}): SyncAuthProvider {
1273
+ return {
1274
+ authenticate: async (token: string) => {
1275
+ const authenticated = await this.authenticateAccess(token)
1276
+ if (!authenticated) {
1277
+ return null
1278
+ }
1279
+ const { user, payload } = authenticated
1280
+
1281
+ // Touch the device to update last-seen timestamp
1282
+ await this.userStore.touchDevice(payload.dev)
1283
+
1284
+ const claims: VerifiedSyncClaims = {
1285
+ userId: payload.sub,
1286
+ deviceId: payload.dev,
1287
+ email: user.email,
1288
+ name: user.name,
1289
+ }
1290
+ const scopes = await resolveSyncGrant(claims, options)
1291
+
1292
+ return {
1293
+ userId: payload.sub,
1294
+ scopes,
1295
+ expiresAt: payload.exp * 1000,
1296
+ metadata: {
1297
+ deviceId: payload.dev,
1298
+ email: user.email,
1299
+ name: user.name,
1300
+ },
1301
+ }
1302
+ },
1303
+ onRevoke: (listener) =>
1304
+ this.onRevoke((event) =>
1305
+ listener(
1306
+ event.kind === 'user'
1307
+ ? { userId: event.userId }
1308
+ : { userId: event.userId, deviceId: event.deviceId },
1309
+ ),
1310
+ ),
1311
+ }
1312
+ }
1313
+ }