@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,826 @@
1
+ import { KoraError } from '@korajs/core'
2
+
3
+ // ============================================================================
4
+ // TOTP Types
5
+ // ============================================================================
6
+
7
+ /**
8
+ * Configuration for TOTP MFA.
9
+ */
10
+ export interface TotpConfig {
11
+ /** Issuer name shown in authenticator apps (e.g., "MyApp") */
12
+ issuer: string
13
+ /** Number of digits in the TOTP code. Default: 6 */
14
+ digits?: number
15
+ /** Time step in seconds. Default: 30 */
16
+ period?: number
17
+ /** Hash algorithm. Default: 'SHA-1' (most compatible with authenticator apps) */
18
+ algorithm?: 'SHA-1' | 'SHA-256' | 'SHA-512'
19
+ /** Number of time windows to check before/after current. Default: 1 */
20
+ window?: number
21
+ /** Number of recovery codes to generate. Default: 8 */
22
+ recoveryCodes?: number
23
+ }
24
+
25
+ /**
26
+ * A TOTP secret with metadata for a user.
27
+ */
28
+ export interface TotpSecret {
29
+ /** User ID */
30
+ userId: string
31
+ /** Raw secret bytes (base32-encoded for display) */
32
+ secret: string
33
+ /** Whether MFA is verified (user has confirmed setup with a valid code) */
34
+ verified: boolean
35
+ /** Hashed recovery codes (unused ones only) */
36
+ recoveryCodes: string[]
37
+ /** When this secret was created */
38
+ createdAt: number
39
+ /** When MFA was verified (confirmed with first valid code) */
40
+ verifiedAt: number | null
41
+ /**
42
+ * The most recent TOTP time-step counter that was successfully consumed
43
+ * during authentication. Used to enforce single-use of codes (RFC 6238 §5.2):
44
+ * a code at or before this time-step is rejected as a replay. `undefined`
45
+ * until the first code is consumed.
46
+ */
47
+ lastUsedTimeStep?: number
48
+ /** Consecutive failed code checks (TOTP or recovery). Reset on success. */
49
+ failedAttempts?: number
50
+ /** While set and in the future, every code check fails without being evaluated. */
51
+ lockedUntil?: number
52
+ }
53
+
54
+ /**
55
+ * Setup result returned when enabling TOTP MFA.
56
+ */
57
+ export interface TotpSetupResult {
58
+ /** The raw secret in base32 encoding (for manual entry) */
59
+ secret: string
60
+ /** otpauth:// URI for QR code generation */
61
+ uri: string
62
+ /** Plaintext recovery codes (shown once, then discarded) */
63
+ recoveryCodes: string[]
64
+ }
65
+
66
+ /**
67
+ * Store for TOTP secrets.
68
+ */
69
+ export interface TotpStore {
70
+ /** Save or update a TOTP secret for a user */
71
+ save(secret: TotpSecret): Promise<void>
72
+ /** Get a TOTP secret by user ID */
73
+ getByUserId(userId: string): Promise<TotpSecret | null>
74
+ /** Delete TOTP secret for a user (disable MFA) */
75
+ delete(userId: string): Promise<void>
76
+ }
77
+
78
+ // ============================================================================
79
+ // Errors
80
+ // ============================================================================
81
+
82
+ export class TotpError extends KoraError {
83
+ constructor(message: string, code: string, context?: Record<string, unknown>) {
84
+ super(message, code, context)
85
+ this.name = 'TotpError'
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Thrown by `disable` / `regenerateRecoveryCodes` while code checks are locked
91
+ * after repeated failures (AUTH-10). `verify` returns false instead.
92
+ */
93
+ export class TotpLockedError extends TotpError {
94
+ constructor(public readonly lockedUntil: number) {
95
+ super('Too many invalid codes. Try again later.', 'TOTP_LOCKED', { lockedUntil })
96
+ this.name = 'TotpLockedError'
97
+ }
98
+ }
99
+
100
+ export class TotpInvalidCodeError extends TotpError {
101
+ constructor() {
102
+ super('Invalid TOTP code.', 'TOTP_INVALID_CODE')
103
+ }
104
+ }
105
+
106
+ export class TotpNotEnabledError extends TotpError {
107
+ constructor(userId: string) {
108
+ super('TOTP MFA is not enabled for this user.', 'TOTP_NOT_ENABLED', { userId })
109
+ }
110
+ }
111
+
112
+ export class TotpAlreadyEnabledError extends TotpError {
113
+ constructor(userId: string) {
114
+ super('TOTP MFA is already enabled for this user.', 'TOTP_ALREADY_ENABLED', { userId })
115
+ }
116
+ }
117
+
118
+ export class TotpNotVerifiedError extends TotpError {
119
+ constructor(userId: string) {
120
+ super(
121
+ 'TOTP MFA setup is pending verification. Verify with a valid code first.',
122
+ 'TOTP_NOT_VERIFIED',
123
+ { userId },
124
+ )
125
+ }
126
+ }
127
+
128
+ export class TotpRecoveryExhaustedError extends TotpError {
129
+ constructor() {
130
+ super('All recovery codes have been used. Please regenerate.', 'TOTP_RECOVERY_EXHAUSTED')
131
+ }
132
+ }
133
+
134
+ // ============================================================================
135
+ // InMemoryTotpStore
136
+ // ============================================================================
137
+
138
+ /**
139
+ * In-memory TOTP store for development and testing.
140
+ */
141
+ export class InMemoryTotpStore implements TotpStore {
142
+ private readonly secrets = new Map<string, TotpSecret>()
143
+
144
+ async save(secret: TotpSecret): Promise<void> {
145
+ this.secrets.set(secret.userId, secret)
146
+ }
147
+
148
+ async getByUserId(userId: string): Promise<TotpSecret | null> {
149
+ return this.secrets.get(userId) ?? null
150
+ }
151
+
152
+ async delete(userId: string): Promise<void> {
153
+ this.secrets.delete(userId)
154
+ }
155
+ }
156
+
157
+ // ============================================================================
158
+ // TotpManager
159
+ // ============================================================================
160
+
161
+ const DEFAULT_DIGITS = 6
162
+ const DEFAULT_PERIOD = 30
163
+ const DEFAULT_ALGORITHM = 'SHA-1'
164
+ const DEFAULT_WINDOW = 1
165
+ const DEFAULT_RECOVERY_CODES = 8
166
+ const RECOVERY_CODE_LENGTH = 10
167
+
168
+ /**
169
+ * Manages TOTP-based Multi-Factor Authentication.
170
+ *
171
+ * Implements RFC 6238 (TOTP) and RFC 4226 (HOTP) with Web Crypto API.
172
+ * Compatible with Google Authenticator, Authy, 1Password, and other
173
+ * TOTP-compatible authenticator apps.
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * const totp = new TotpManager({
178
+ * issuer: 'MyApp',
179
+ * store: new InMemoryTotpStore(),
180
+ * })
181
+ *
182
+ * // Step 1: Enable MFA (returns QR code URI and recovery codes)
183
+ * const setup = await totp.enable('user-123', 'alice@example.com')
184
+ * // Show setup.uri as QR code, show setup.recoveryCodes once
185
+ *
186
+ * // Step 2: Verify setup with a code from authenticator app
187
+ * await totp.verifySetup('user-123', '123456')
188
+ *
189
+ * // Step 3: On login, verify TOTP code
190
+ * const valid = await totp.verify('user-123', '654321')
191
+ * ```
192
+ */
193
+ /** Consecutive failures allowed before code checks start locking. */
194
+ const FREE_FAILURES = 5
195
+ const LOCK_BASE_MS = 30_000
196
+ const LOCK_MAX_MS = 15 * 60_000
197
+
198
+ export class TotpManager {
199
+ private readonly store: TotpStore
200
+ private readonly issuer: string
201
+ private readonly digits: number
202
+ private readonly period: number
203
+ private readonly algorithm: 'SHA-1' | 'SHA-256' | 'SHA-512'
204
+ private readonly window: number
205
+ private readonly recoveryCodeCount: number
206
+
207
+ constructor(config: TotpConfig & { store: TotpStore }) {
208
+ this.store = config.store
209
+ this.issuer = config.issuer
210
+ this.digits = config.digits ?? DEFAULT_DIGITS
211
+ this.period = config.period ?? DEFAULT_PERIOD
212
+ this.algorithm = config.algorithm ?? DEFAULT_ALGORITHM
213
+ this.window = config.window ?? DEFAULT_WINDOW
214
+ this.recoveryCodeCount = config.recoveryCodes ?? DEFAULT_RECOVERY_CODES
215
+ }
216
+
217
+ /**
218
+ * Enable TOTP MFA for a user.
219
+ * Returns the secret URI (for QR code) and recovery codes.
220
+ * The user must verify setup with a valid code before MFA is active.
221
+ */
222
+ async enable(userId: string, accountName: string): Promise<TotpSetupResult> {
223
+ const existing = await this.store.getByUserId(userId)
224
+ if (existing?.verified) {
225
+ throw new TotpAlreadyEnabledError(userId)
226
+ }
227
+
228
+ const secretBytes = generateSecret(20)
229
+ const secret = base32Encode(secretBytes)
230
+ const recoveryCodes = generateRecoveryCodes(this.recoveryCodeCount, RECOVERY_CODE_LENGTH)
231
+ const hashedCodes = await Promise.all(recoveryCodes.map((c) => hashRecoveryCode(c)))
232
+
233
+ const totpSecret: TotpSecret = {
234
+ userId,
235
+ secret,
236
+ verified: false,
237
+ recoveryCodes: hashedCodes,
238
+ createdAt: Date.now(),
239
+ verifiedAt: null,
240
+ }
241
+
242
+ await this.store.save(totpSecret)
243
+
244
+ const uri = buildOtpauthUri({
245
+ issuer: this.issuer,
246
+ accountName,
247
+ secret,
248
+ algorithm: this.algorithm,
249
+ digits: this.digits,
250
+ period: this.period,
251
+ })
252
+
253
+ return { secret, uri, recoveryCodes }
254
+ }
255
+
256
+ /**
257
+ * Verify the TOTP setup by confirming the user can generate a valid code.
258
+ * Must be called after `enable()` and before MFA is enforced.
259
+ */
260
+ async verifySetup(userId: string, code: string): Promise<boolean> {
261
+ const stored = await this.store.getByUserId(userId)
262
+ if (!stored) {
263
+ throw new TotpNotEnabledError(userId)
264
+ }
265
+
266
+ if (stored.verified) {
267
+ // Already verified, just validate the code (single-use enforced)
268
+ return this.consumeCode(stored, code)
269
+ }
270
+
271
+ const valid = await this.consumeCode(stored, code)
272
+ if (!valid) {
273
+ throw new TotpInvalidCodeError()
274
+ }
275
+
276
+ // Mark as verified
277
+ stored.verified = true
278
+ stored.verifiedAt = Date.now()
279
+ await this.store.save(stored)
280
+
281
+ return true
282
+ }
283
+
284
+ /**
285
+ * Verify a TOTP code during login.
286
+ * Returns true if the code is valid, false otherwise.
287
+ *
288
+ * Enforces single-use: a code is rejected if its time-step has already
289
+ * been consumed (or predates a previously consumed time-step). This
290
+ * prevents an attacker who observes one valid code from replaying it
291
+ * within the acceptance window (RFC 6238 §5.2).
292
+ */
293
+ async verify(userId: string, code: string): Promise<boolean> {
294
+ const stored = await this.store.getByUserId(userId)
295
+ if (!stored) {
296
+ throw new TotpNotEnabledError(userId)
297
+ }
298
+
299
+ if (!stored.verified) {
300
+ throw new TotpNotVerifiedError(userId)
301
+ }
302
+
303
+ if (this.isLocked(stored)) {
304
+ return false
305
+ }
306
+ return this.checkTotp(stored, code)
307
+ }
308
+
309
+ /**
310
+ * Verify a recovery code as an alternative to TOTP.
311
+ * Recovery codes are single-use.
312
+ */
313
+ async verifyRecoveryCode(userId: string, recoveryCode: string): Promise<boolean> {
314
+ const stored = await this.store.getByUserId(userId)
315
+ if (!stored) {
316
+ throw new TotpNotEnabledError(userId)
317
+ }
318
+
319
+ if (!stored.verified) {
320
+ throw new TotpNotVerifiedError(userId)
321
+ }
322
+
323
+ if (this.isLocked(stored)) {
324
+ return false
325
+ }
326
+
327
+ const hashed = await hashRecoveryCode(recoveryCode.trim())
328
+
329
+ const index = stored.recoveryCodes.indexOf(hashed)
330
+ if (index === -1) {
331
+ await this.recordFailure(stored)
332
+ return false
333
+ }
334
+
335
+ // Consume the recovery code (single-use)
336
+ stored.recoveryCodes.splice(index, 1)
337
+ stored.failedAttempts = 0
338
+ stored.lockedUntil = undefined
339
+ await this.store.save(stored)
340
+
341
+ return true
342
+ }
343
+
344
+ /**
345
+ * Regenerate recovery codes. Requires a valid TOTP code for authorization.
346
+ * Replaces all existing recovery codes.
347
+ */
348
+ async regenerateRecoveryCodes(userId: string, totpCode: string): Promise<string[]> {
349
+ const stored = await this.store.getByUserId(userId)
350
+ if (!stored) {
351
+ throw new TotpNotEnabledError(userId)
352
+ }
353
+
354
+ if (!stored.verified) {
355
+ throw new TotpNotVerifiedError(userId)
356
+ }
357
+
358
+ this.assertNotLocked(stored)
359
+ // consumeCode (not a bare validation): an observed or already-used code
360
+ // cannot authorize this a second time.
361
+ const valid = await this.checkTotp(stored, totpCode)
362
+ if (!valid) {
363
+ throw new TotpInvalidCodeError()
364
+ }
365
+
366
+ const recoveryCodes = generateRecoveryCodes(this.recoveryCodeCount, RECOVERY_CODE_LENGTH)
367
+ const hashedCodes = await Promise.all(recoveryCodes.map((c) => hashRecoveryCode(c)))
368
+
369
+ stored.recoveryCodes = hashedCodes
370
+ await this.store.save(stored)
371
+
372
+ return recoveryCodes
373
+ }
374
+
375
+ /**
376
+ * Disable TOTP MFA for a user.
377
+ * Requires a valid TOTP code or recovery code for authorization.
378
+ */
379
+ async disable(userId: string, code: string): Promise<void> {
380
+ const stored = await this.store.getByUserId(userId)
381
+ if (!stored) {
382
+ throw new TotpNotEnabledError(userId)
383
+ }
384
+
385
+ this.assertNotLocked(stored)
386
+
387
+ // Accept either a fresh (never used) TOTP code or a recovery code
388
+ let authorized = false
389
+
390
+ if (stored.verified) {
391
+ authorized = this.matchFreshTimeStep(stored, code) !== null
392
+ }
393
+
394
+ if (!authorized) {
395
+ const hashed = await hashRecoveryCode(code.trim())
396
+ authorized = stored.recoveryCodes.includes(hashed)
397
+ }
398
+
399
+ if (!authorized) {
400
+ await this.recordFailure(stored)
401
+ throw new TotpInvalidCodeError()
402
+ }
403
+
404
+ await this.store.delete(userId)
405
+ }
406
+
407
+ /**
408
+ * Check if a user has TOTP MFA enabled and verified.
409
+ */
410
+ async isEnabled(userId: string): Promise<boolean> {
411
+ const stored = await this.store.getByUserId(userId)
412
+ return stored?.verified ?? false
413
+ }
414
+
415
+ /**
416
+ * Get the number of remaining recovery codes for a user.
417
+ */
418
+ async remainingRecoveryCodes(userId: string): Promise<number> {
419
+ const stored = await this.store.getByUserId(userId)
420
+ if (!stored || !stored.verified) return 0
421
+ return stored.recoveryCodes.length
422
+ }
423
+
424
+ // --- Private ---
425
+
426
+ private isLocked(stored: TotpSecret): boolean {
427
+ return stored.lockedUntil !== undefined && Date.now() < stored.lockedUntil
428
+ }
429
+
430
+ private assertNotLocked(stored: TotpSecret): void {
431
+ if (stored.lockedUntil !== undefined && Date.now() < stored.lockedUntil) {
432
+ throw new TotpLockedError(stored.lockedUntil)
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Count a failed code check. After FREE_FAILURES consecutive failures, code
438
+ * checks lock for an exponentially growing period (30s, 60s, ... up to 15
439
+ * minutes), which bounds online guessing of a 6-digit code (AUTH-10) without
440
+ * a permanent lockout an attacker could trigger at will.
441
+ */
442
+ private async recordFailure(stored: TotpSecret): Promise<void> {
443
+ const failures = (stored.failedAttempts ?? 0) + 1
444
+ stored.failedAttempts = failures
445
+ if (failures >= FREE_FAILURES) {
446
+ const delay = Math.min(LOCK_BASE_MS * 2 ** (failures - FREE_FAILURES), LOCK_MAX_MS)
447
+ stored.lockedUntil = Date.now() + delay
448
+ }
449
+ await this.store.save(stored)
450
+ }
451
+
452
+ /** consumeCode plus failure accounting. */
453
+ private async checkTotp(stored: TotpSecret, code: string): Promise<boolean> {
454
+ const ok = await this.consumeCode(stored, code)
455
+ if (!ok) {
456
+ await this.recordFailure(stored)
457
+ return false
458
+ }
459
+ if (stored.failedAttempts || stored.lockedUntil !== undefined) {
460
+ stored.failedAttempts = 0
461
+ stored.lockedUntil = undefined
462
+ await this.store.save(stored)
463
+ }
464
+ return true
465
+ }
466
+
467
+ /** A matching time-step that has not been consumed yet, or null. */
468
+ private matchFreshTimeStep(stored: TotpSecret, code: string): number | null {
469
+ const matched = this.matchTimeStep(stored.secret, code)
470
+ if (matched === null) return null
471
+ if (stored.lastUsedTimeStep !== undefined && matched <= stored.lastUsedTimeStep) return null
472
+ return matched
473
+ }
474
+
475
+ /**
476
+ * Find the TOTP time-step counter (within the acceptance window) whose
477
+ * generated code matches `code`, or null if none matches. Comparison is
478
+ * timing-safe.
479
+ */
480
+ private matchTimeStep(base32Secret: string, code: string): number | null {
481
+ const secretBytes = base32Decode(base32Secret)
482
+ const now = Math.floor(Date.now() / 1000)
483
+
484
+ // Check current window and adjacent windows
485
+ for (let offset = -this.window; offset <= this.window; offset++) {
486
+ const timeCounter = Math.floor((now + offset * this.period) / this.period)
487
+ const expected = generateTotpCode(secretBytes, timeCounter, this.digits, this.algorithm)
488
+ if (timingSafeEqual(code, expected)) {
489
+ return timeCounter
490
+ }
491
+ }
492
+
493
+ return null
494
+ }
495
+
496
+ /**
497
+ * Validate a code AND consume its time-step so it (and any earlier code in
498
+ * the window) cannot be reused. Returns false for an invalid code or a
499
+ * replay of an already-consumed time-step. Persists the consumed time-step.
500
+ */
501
+ private async consumeCode(stored: TotpSecret, code: string): Promise<boolean> {
502
+ // Reject replays: a code whose time-step was already used (or predates
503
+ // the last consumed time-step) must not authenticate a second time.
504
+ const matched = this.matchFreshTimeStep(stored, code)
505
+ if (matched === null) {
506
+ return false
507
+ }
508
+
509
+ stored.lastUsedTimeStep = matched
510
+ await this.store.save(stored)
511
+ return true
512
+ }
513
+ }
514
+
515
+ // ============================================================================
516
+ // TOTP Core (RFC 6238 / RFC 4226)
517
+ // ============================================================================
518
+
519
+ /**
520
+ * Generate a TOTP code for a given time counter.
521
+ * Implements HOTP (RFC 4226) with a time-based counter (RFC 6238).
522
+ */
523
+ function generateTotpCode(
524
+ secret: Uint8Array,
525
+ counter: number,
526
+ digits: number,
527
+ algorithm: string,
528
+ ): string {
529
+ // Counter as 8-byte big-endian
530
+ const counterBytes = new Uint8Array(8)
531
+ let c = counter
532
+ for (let i = 7; i >= 0; i--) {
533
+ counterBytes[i] = c & 0xff
534
+ c = Math.floor(c / 256)
535
+ }
536
+
537
+ // HMAC-SHA1 (or SHA-256/SHA-512)
538
+ const hash = hmacSha(algorithm, secret, counterBytes)
539
+
540
+ // Dynamic truncation (RFC 4226 section 5.4)
541
+ const offset = (hash[hash.length - 1] as number) & 0x0f
542
+ const binary =
543
+ (((hash[offset] as number) & 0x7f) << 24) |
544
+ (((hash[offset + 1] as number) & 0xff) << 16) |
545
+ (((hash[offset + 2] as number) & 0xff) << 8) |
546
+ ((hash[offset + 3] as number) & 0xff)
547
+
548
+ const otp = binary % 10 ** digits
549
+ return otp.toString().padStart(digits, '0')
550
+ }
551
+
552
+ /**
553
+ * Synchronous HMAC using a simplified implementation.
554
+ * TOTP only needs HMAC-SHA1 which we implement directly
555
+ * to avoid async Web Crypto for the hot path (validation).
556
+ */
557
+ function hmacSha(algorithm: string, key: Uint8Array, message: Uint8Array): Uint8Array {
558
+ // Use the appropriate block size and hash
559
+ const blockSize = algorithm === 'SHA-512' ? 128 : 64
560
+
561
+ // Pad or hash the key
562
+ let keyPad = key
563
+ if (keyPad.length > blockSize) {
564
+ keyPad = sha1(keyPad)
565
+ }
566
+
567
+ // Create padded key
568
+ const ipad = new Uint8Array(blockSize)
569
+ const opad = new Uint8Array(blockSize)
570
+ for (let i = 0; i < blockSize; i++) {
571
+ const k = i < keyPad.length ? (keyPad[i] as number) : 0
572
+ ipad[i] = k ^ 0x36
573
+ opad[i] = k ^ 0x5c
574
+ }
575
+
576
+ // Inner hash: H(key XOR ipad || message)
577
+ const innerData = new Uint8Array(blockSize + message.length)
578
+ innerData.set(ipad)
579
+ innerData.set(message, blockSize)
580
+ const innerHash = sha1(innerData)
581
+
582
+ // Outer hash: H(key XOR opad || inner_hash)
583
+ const outerData = new Uint8Array(blockSize + innerHash.length)
584
+ outerData.set(opad)
585
+ outerData.set(innerHash, blockSize)
586
+ return sha1(outerData)
587
+ }
588
+
589
+ /**
590
+ * SHA-1 implementation for TOTP HMAC.
591
+ * SHA-1 is the standard for TOTP (RFC 6238) and is NOT used for security
592
+ * hashing here — it's used as a PRF inside HMAC which is still secure.
593
+ */
594
+ function sha1(data: Uint8Array): Uint8Array {
595
+ let h0 = 0x67452301
596
+ let h1 = 0xefcdab89
597
+ let h2 = 0x98badcfe
598
+ let h3 = 0x10325476
599
+ let h4 = 0xc3d2e1f0
600
+
601
+ const bitLength = data.length * 8
602
+
603
+ // Pre-processing: add padding
604
+ // message + 1 bit + zeros + 64-bit length
605
+ const paddedLength = Math.ceil((data.length + 9) / 64) * 64
606
+ const padded = new Uint8Array(paddedLength)
607
+ padded.set(data)
608
+ padded[data.length] = 0x80
609
+
610
+ // Append length as 64-bit big-endian
611
+ const view = new DataView(padded.buffer, padded.byteOffset)
612
+ // For messages < 2^32 bits, high 32 bits are 0
613
+ view.setUint32(paddedLength - 4, bitLength, false)
614
+
615
+ // Process each 512-bit (64-byte) block
616
+ const w = new Int32Array(80)
617
+
618
+ for (let offset = 0; offset < paddedLength; offset += 64) {
619
+ for (let i = 0; i < 16; i++) {
620
+ w[i] = view.getInt32(offset + i * 4, false)
621
+ }
622
+
623
+ for (let i = 16; i < 80; i++) {
624
+ w[i] = rotl32(
625
+ ((w[i - 3] as number) ^
626
+ (w[i - 8] as number) ^
627
+ (w[i - 14] as number) ^
628
+ (w[i - 16] as number)) |
629
+ 0,
630
+ 1,
631
+ )
632
+ }
633
+
634
+ let a = h0
635
+ let b = h1
636
+ let c = h2
637
+ let d = h3
638
+ let e = h4
639
+
640
+ for (let i = 0; i < 80; i++) {
641
+ let f: number
642
+ let k: number
643
+
644
+ if (i < 20) {
645
+ f = (b & c) | (~b & d)
646
+ k = 0x5a827999
647
+ } else if (i < 40) {
648
+ f = b ^ c ^ d
649
+ k = 0x6ed9eba1
650
+ } else if (i < 60) {
651
+ f = (b & c) | (b & d) | (c & d)
652
+ k = 0x8f1bbcdc
653
+ } else {
654
+ f = b ^ c ^ d
655
+ k = 0xca62c1d6
656
+ }
657
+
658
+ const temp = (rotl32(a, 5) + f + e + k + (w[i] as number)) | 0
659
+ e = d
660
+ d = c
661
+ c = rotl32(b, 30)
662
+ b = a
663
+ a = temp
664
+ }
665
+
666
+ h0 = (h0 + a) | 0
667
+ h1 = (h1 + b) | 0
668
+ h2 = (h2 + c) | 0
669
+ h3 = (h3 + d) | 0
670
+ h4 = (h4 + e) | 0
671
+ }
672
+
673
+ const result = new Uint8Array(20)
674
+ const rv = new DataView(result.buffer)
675
+ rv.setInt32(0, h0, false)
676
+ rv.setInt32(4, h1, false)
677
+ rv.setInt32(8, h2, false)
678
+ rv.setInt32(12, h3, false)
679
+ rv.setInt32(16, h4, false)
680
+
681
+ return result
682
+ }
683
+
684
+ function rotl32(value: number, shift: number): number {
685
+ return (value << shift) | (value >>> (32 - shift)) | 0
686
+ }
687
+
688
+ // ============================================================================
689
+ // Base32 Encoding/Decoding (RFC 4648)
690
+ // ============================================================================
691
+
692
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
693
+
694
+ /**
695
+ * Encode bytes to base32 string (RFC 4648, no padding).
696
+ */
697
+ export function base32Encode(data: Uint8Array): string {
698
+ let result = ''
699
+ let bits = 0
700
+ let buffer = 0
701
+
702
+ for (let i = 0; i < data.length; i++) {
703
+ buffer = (buffer << 8) | (data[i] as number)
704
+ bits += 8
705
+ while (bits >= 5) {
706
+ bits -= 5
707
+ result += BASE32_ALPHABET[(buffer >>> bits) & 0x1f]
708
+ }
709
+ }
710
+
711
+ if (bits > 0) {
712
+ result += BASE32_ALPHABET[(buffer << (5 - bits)) & 0x1f]
713
+ }
714
+
715
+ return result
716
+ }
717
+
718
+ /**
719
+ * Decode a base32 string to bytes (RFC 4648).
720
+ */
721
+ export function base32Decode(encoded: string): Uint8Array {
722
+ const cleaned = encoded.replace(/=+$/, '').toUpperCase()
723
+ const output: number[] = []
724
+ let bits = 0
725
+ let buffer = 0
726
+
727
+ for (let i = 0; i < cleaned.length; i++) {
728
+ const char = cleaned[i] as string
729
+ const value = BASE32_ALPHABET.indexOf(char)
730
+ if (value === -1) continue // skip invalid chars
731
+
732
+ buffer = (buffer << 5) | value
733
+ bits += 5
734
+
735
+ if (bits >= 8) {
736
+ bits -= 8
737
+ output.push((buffer >>> bits) & 0xff)
738
+ }
739
+ }
740
+
741
+ return new Uint8Array(output)
742
+ }
743
+
744
+ // ============================================================================
745
+ // Helpers
746
+ // ============================================================================
747
+
748
+ /**
749
+ * Generate a random secret of the given byte length.
750
+ */
751
+ function generateSecret(byteLength: number): Uint8Array {
752
+ const bytes = new Uint8Array(byteLength)
753
+ globalThis.crypto.getRandomValues(bytes)
754
+ return bytes
755
+ }
756
+
757
+ /**
758
+ * Generate random recovery codes.
759
+ */
760
+ function generateRecoveryCodes(count: number, length: number): string[] {
761
+ const codes: string[] = []
762
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
763
+
764
+ for (let i = 0; i < count; i++) {
765
+ const bytes = new Uint8Array(length)
766
+ globalThis.crypto.getRandomValues(bytes)
767
+ let code = ''
768
+ for (let j = 0; j < length; j++) {
769
+ code += chars[(bytes[j] as number) % chars.length]
770
+ }
771
+ // Format as xxxxx-xxxxx for readability
772
+ codes.push(`${code.slice(0, 5)}-${code.slice(5)}`)
773
+ }
774
+
775
+ return codes
776
+ }
777
+
778
+ /**
779
+ * Hash a recovery code for storage (SHA-256).
780
+ */
781
+ async function hashRecoveryCode(code: string): Promise<string> {
782
+ const encoded = new TextEncoder().encode(code.toLowerCase().replace(/[-\s]/g, ''))
783
+ const hash = await globalThis.crypto.subtle.digest('SHA-256', encoded)
784
+ const bytes = new Uint8Array(hash)
785
+ let hex = ''
786
+ for (let i = 0; i < bytes.length; i++) {
787
+ hex += bytes[i]?.toString(16).padStart(2, '0')
788
+ }
789
+ return hex
790
+ }
791
+
792
+ /**
793
+ * Build an otpauth:// URI for QR code generation.
794
+ */
795
+ function buildOtpauthUri(params: {
796
+ issuer: string
797
+ accountName: string
798
+ secret: string
799
+ algorithm: string
800
+ digits: number
801
+ period: number
802
+ }): string {
803
+ const label = `${encodeURIComponent(params.issuer)}:${encodeURIComponent(params.accountName)}`
804
+ const query = new URLSearchParams({
805
+ secret: params.secret,
806
+ issuer: params.issuer,
807
+ algorithm: params.algorithm.replace('-', ''),
808
+ digits: params.digits.toString(),
809
+ period: params.period.toString(),
810
+ })
811
+ return `otpauth://totp/${label}?${query.toString()}`
812
+ }
813
+
814
+ /**
815
+ * Timing-safe string comparison to prevent timing attacks.
816
+ */
817
+ function timingSafeEqual(a: string, b: string): boolean {
818
+ if (a.length !== b.length) return false
819
+
820
+ let result = 0
821
+ for (let i = 0; i < a.length; i++) {
822
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i)
823
+ }
824
+
825
+ return result === 0
826
+ }