@rebasepro/server-core 0.2.3 → 0.2.4

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 (109) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +12 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/util/permissions.d.ts +1 -0
  4. package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
  5. package/dist/index-Cr1D21av.js.map +1 -0
  6. package/dist/index.es.js +2166 -208
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +2155 -193
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/server-core/src/api/logs-routes.d.ts +37 -0
  11. package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
  12. package/dist/server-core/src/api/types.d.ts +6 -1
  13. package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
  14. package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
  15. package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
  16. package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
  17. package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
  18. package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
  19. package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
  20. package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
  21. package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
  22. package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
  23. package/dist/server-core/src/auth/index.d.ts +5 -3
  24. package/dist/server-core/src/auth/interfaces.d.ts +93 -3
  25. package/dist/server-core/src/auth/jwt.d.ts +3 -1
  26. package/dist/server-core/src/auth/mfa.d.ts +49 -0
  27. package/dist/server-core/src/auth/middleware.d.ts +7 -0
  28. package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
  29. package/dist/server-core/src/auth/routes.d.ts +3 -3
  30. package/dist/server-core/src/env.d.ts +6 -0
  31. package/dist/server-core/src/index.d.ts +1 -0
  32. package/dist/server-core/src/init.d.ts +3 -3
  33. package/dist/server-core/src/services/webhook-service.d.ts +29 -0
  34. package/dist/server-core/src/storage/image-transform.d.ts +48 -0
  35. package/dist/server-core/src/storage/index.d.ts +3 -0
  36. package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
  37. package/dist/types/src/controllers/auth.d.ts +2 -24
  38. package/dist/types/src/controllers/client.d.ts +0 -3
  39. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  40. package/dist/types/src/controllers/data_driver.d.ts +18 -0
  41. package/dist/types/src/controllers/registry.d.ts +5 -4
  42. package/dist/types/src/rebase_context.d.ts +1 -1
  43. package/dist/types/src/types/auth_adapter.d.ts +2 -4
  44. package/dist/types/src/types/collections.d.ts +0 -4
  45. package/dist/types/src/types/component_ref.d.ts +1 -1
  46. package/dist/types/src/types/cron.d.ts +1 -1
  47. package/dist/types/src/types/entity_views.d.ts +1 -0
  48. package/dist/types/src/types/export_import.d.ts +1 -1
  49. package/dist/types/src/types/formex.d.ts +2 -2
  50. package/dist/types/src/types/properties.d.ts +2 -2
  51. package/dist/types/src/types/translations.d.ts +28 -12
  52. package/dist/types/src/types/user_management_delegate.d.ts +6 -4
  53. package/dist/types/src/users/roles.d.ts +0 -8
  54. package/package.json +8 -6
  55. package/src/api/ast-schema-editor.ts +4 -4
  56. package/src/api/errors.ts +16 -7
  57. package/src/api/logs-routes.ts +129 -0
  58. package/src/api/rest/api-generator.ts +42 -2
  59. package/src/api/rest/query-parser.ts +37 -1
  60. package/src/api/types.ts +6 -1
  61. package/src/auth/adapter-middleware.ts +20 -4
  62. package/src/auth/admin-routes.ts +39 -14
  63. package/src/auth/api-keys/api-key-middleware.ts +126 -0
  64. package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
  65. package/src/auth/api-keys/api-key-routes.ts +183 -0
  66. package/src/auth/api-keys/api-key-store.ts +317 -0
  67. package/src/auth/api-keys/api-key-types.ts +94 -0
  68. package/src/auth/api-keys/index.ts +37 -0
  69. package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
  70. package/src/auth/builtin-auth-adapter.ts +31 -19
  71. package/src/auth/index.ts +7 -3
  72. package/src/auth/interfaces.ts +111 -3
  73. package/src/auth/jwt.ts +19 -5
  74. package/src/auth/mfa.ts +160 -0
  75. package/src/auth/middleware.ts +20 -1
  76. package/src/auth/rate-limiter.ts +92 -0
  77. package/src/auth/routes.ts +455 -24
  78. package/src/cron/cron-loader.ts +5 -10
  79. package/src/cron/cron-scheduler.ts +11 -12
  80. package/src/cron/cron-store.ts +8 -7
  81. package/src/env.ts +2 -0
  82. package/src/functions/function-loader.ts +6 -9
  83. package/src/index.ts +1 -2
  84. package/src/init.ts +37 -7
  85. package/src/serve-spa.ts +5 -4
  86. package/src/services/webhook-service.ts +155 -0
  87. package/src/storage/image-transform.ts +202 -0
  88. package/src/storage/index.ts +3 -0
  89. package/src/storage/routes.ts +56 -3
  90. package/src/storage/tus-handler.ts +315 -0
  91. package/src/utils/dev-port.ts +14 -0
  92. package/src/utils/logging.ts +9 -7
  93. package/test/admin-routes.test.ts +74 -7
  94. package/test/api-generator.test.ts +0 -1
  95. package/test/api-key-permission-guard.test.ts +132 -0
  96. package/test/ast-schema-editor.test.ts +26 -0
  97. package/test/auth-routes.test.ts +1 -2
  98. package/test/backend-hooks-admin.test.ts +3 -4
  99. package/test/email-templates.test.ts +169 -0
  100. package/test/function-loader.test.ts +124 -0
  101. package/test/jwt.test.ts +4 -2
  102. package/test/mfa.test.ts +197 -0
  103. package/test/middleware.test.ts +10 -5
  104. package/test/webhook-service.test.ts +249 -0
  105. package/vite.config.ts +3 -2
  106. package/dist/index-BZoAtuqi.js.map +0 -1
  107. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  108. package/src/bootstrappers/index.ts +0 -1
  109. package/src/singleton.test.ts +0 -28
@@ -32,8 +32,8 @@ import type { AccessTokenPayload } from "./jwt";
32
32
  import { createAuthRoutes } from "./routes";
33
33
  import { createAdminRoutes } from "./admin-routes";
34
34
  import type { AuthRepository, OAuthProvider } from "./interfaces";
35
- import type { AuthOverrides, ResolvedAuthOperations } from "./auth-overrides";
36
- import { resolveAuthOverrides } from "./auth-overrides";
35
+ import type { AuthHooks, ResolvedAuthOperations } from "./auth-hooks";
36
+ import { resolveAuthHooks } from "./auth-hooks";
37
37
  import type { EmailService, EmailConfig } from "../email";
38
38
  import type { HonoEnv } from "../api/types";
39
39
  import { safeCompare } from "./crypto-utils";
@@ -62,8 +62,8 @@ export interface BuiltinAuthAdapterConfig {
62
62
  serviceKey?: string;
63
63
  /** Backend hooks for intercepting admin data. */
64
64
  hooks?: BackendHooks;
65
- /** Auth overrides for customizing password, credentials, lifecycle, etc. */
66
- overrides?: AuthOverrides;
65
+ /** Auth hooks for customizing password, credentials, lifecycle, etc. */
66
+ authHooks?: AuthHooks;
67
67
  }
68
68
 
69
69
  /**
@@ -83,10 +83,10 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
83
83
  oauthProviders = [],
84
84
  serviceKey,
85
85
  hooks,
86
- overrides,
86
+ authHooks,
87
87
  } = config;
88
88
 
89
- const resolvedOps = resolveAuthOverrides(overrides);
89
+ const resolvedOps = resolveAuthHooks(authHooks);
90
90
 
91
91
  const adapter: AuthAdapter = {
92
92
  id: "rebase-builtin",
@@ -192,7 +192,7 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
192
192
  };
193
193
  },
194
194
 
195
- userManagement: createUserManagementFromRepo(authRepository, resolvedOps, overrides),
195
+ userManagement: createUserManagementFromRepo(authRepository, resolvedOps, authHooks),
196
196
 
197
197
  roleManagement: createRoleManagementFromRepo(authRepository),
198
198
 
@@ -204,7 +204,7 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
204
204
  allowRegistration,
205
205
  defaultRole,
206
206
  oauthProviders,
207
- overrides,
207
+ authHooks,
208
208
  });
209
209
  },
210
210
 
@@ -215,7 +215,7 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
215
215
  emailConfig,
216
216
  serviceKey,
217
217
  hooks,
218
- overrides,
218
+ authHooks,
219
219
  });
220
220
  },
221
221
 
@@ -251,7 +251,7 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
251
251
 
252
252
  // ─── Internal Helpers ────────────────────────────────────────────────────────
253
253
 
254
- function createUserManagementFromRepo(repo: AuthRepository, resolvedOps: ResolvedAuthOperations, overrides?: AuthOverrides): UserManagementAdapter {
254
+ function createUserManagementFromRepo(repo: AuthRepository, resolvedOps: ResolvedAuthOperations, authHooks?: AuthHooks): UserManagementAdapter {
255
255
  return {
256
256
  async listUsers(options?: AuthUserListOptions): Promise<AuthUserListResult> {
257
257
  const result = await repo.listUsersPaginated({
@@ -284,14 +284,16 @@ function createUserManagementFromRepo(repo: AuthRepository, resolvedOps: Resolve
284
284
  photoUrl: data.photoUrl,
285
285
  metadata: data.metadata,
286
286
  };
287
- if (overrides?.beforeUserCreate) {
288
- createData = await overrides.beforeUserCreate(createData);
287
+ if (authHooks?.beforeUserCreate) {
288
+ createData = await authHooks.beforeUserCreate(createData);
289
289
  }
290
290
  const user = await repo.createUser(createData);
291
- if (overrides?.afterUserCreate) {
292
- overrides.afterUserCreate(user).catch(err => {
293
- console.error("[AuthOverrides] afterUserCreate error:", err instanceof Error ? err.message : err);
294
- });
291
+ if (authHooks?.afterUserCreate) {
292
+ try {
293
+ await authHooks.afterUserCreate(user);
294
+ } catch (err) {
295
+ console.error("[AuthHooks] afterUserCreate error:", err instanceof Error ? err.message : err);
296
+ }
295
297
  }
296
298
  return toAuthUserData(user);
297
299
  },
@@ -310,7 +312,19 @@ function createUserManagementFromRepo(repo: AuthRepository, resolvedOps: Resolve
310
312
  },
311
313
 
312
314
  async deleteUser(id: string): Promise<void> {
315
+ // Call beforeUserDelete hook (throw to prevent deletion)
316
+ if (authHooks?.beforeUserDelete) {
317
+ await authHooks.beforeUserDelete(id);
318
+ }
319
+
313
320
  await repo.deleteUser(id);
321
+
322
+ // Fire afterUserDelete hook (fire-and-forget)
323
+ if (authHooks?.afterUserDelete) {
324
+ authHooks.afterUserDelete(id).catch(err => {
325
+ console.error("[AuthHooks] afterUserDelete error:", err instanceof Error ? err.message : err);
326
+ });
327
+ }
314
328
  },
315
329
 
316
330
  async getUserRoles(userId: string): Promise<AuthRoleData[]> {
@@ -343,7 +357,6 @@ function createRoleManagementFromRepo(repo: AuthRepository): RoleManagementAdapt
343
357
  isAdmin: data.isAdmin,
344
358
  defaultPermissions: data.defaultPermissions,
345
359
  collectionPermissions: data.collectionPermissions,
346
- config: data.config,
347
360
  });
348
361
  return toAuthRoleData(role);
349
362
  },
@@ -372,13 +385,12 @@ function toAuthUserData(user: { id: string; email: string; displayName?: string
372
385
  };
373
386
  }
374
387
 
375
- function toAuthRoleData(role: { id: string; name: string; isAdmin: boolean; defaultPermissions?: unknown; collectionPermissions?: unknown; config?: unknown }): AuthRoleData {
388
+ function toAuthRoleData(role: { id: string; name: string; isAdmin: boolean; defaultPermissions?: unknown; collectionPermissions?: unknown }): AuthRoleData {
376
389
  return {
377
390
  id: role.id,
378
391
  name: role.name,
379
392
  isAdmin: role.isAdmin,
380
393
  defaultPermissions: role.defaultPermissions as AuthRoleData["defaultPermissions"],
381
394
  collectionPermissions: role.collectionPermissions as AuthRoleData["collectionPermissions"],
382
- config: role.config as AuthRoleData["config"],
383
395
  };
384
396
  }
package/src/auth/index.ts CHANGED
@@ -7,8 +7,8 @@ export type { JwtConfig, AccessTokenPayload } from "./jwt";
7
7
  export { hashPassword, verifyPassword, validatePasswordStrength } from "./password";
8
8
  export type { PasswordValidationResult } from "./password";
9
9
 
10
- export type { AuthOverrides, AuthMethod, ResolvedAuthOperations } from "./auth-overrides";
11
- export { resolveAuthOverrides } from "./auth-overrides";
10
+ export type { AuthHooks, AuthMethod, ResolvedAuthOperations } from "./auth-hooks";
11
+ export { resolveAuthHooks } from "./auth-hooks";
12
12
 
13
13
  // OAuth Providers
14
14
  export { createGoogleProvider } from "./google-oauth";
@@ -35,7 +35,11 @@ export type { AuthModuleConfig } from "./routes";
35
35
  export { createAdminRoutes } from "./admin-routes";
36
36
 
37
37
 
38
- export { createRateLimiter, defaultAuthLimiter, strictAuthLimiter } from "./rate-limiter";
38
+ export { createRateLimiter, defaultAuthLimiter, strictAuthLimiter, createApiKeyRateLimiter, apiKeyKeyGenerator } from "./rate-limiter";
39
+
40
+ // API Keys
41
+ export { createApiKeyStore, createApiKeyRoutes, isApiKeyToken, validateApiKey, httpMethodToOperation, isOperationAllowed } from "./api-keys";
42
+ export type { ApiKey, ApiKeyMasked, ApiKeyPermission, ApiKeyWithSecret, CreateApiKeyRequest, UpdateApiKeyRequest, ApiKeyStore, ApiKeyOperation } from "./api-keys";
39
43
 
40
44
  // Auth Adapters
41
45
  export { createBuiltinAuthAdapter } from "./builtin-auth-adapter";
@@ -20,6 +20,7 @@ export interface UserData {
20
20
  emailVerified: boolean;
21
21
  emailVerificationToken?: string | null;
22
22
  emailVerificationSentAt?: Date | null;
23
+ isAnonymous?: boolean;
23
24
  metadata?: Record<string, unknown>;
24
25
  createdAt: Date;
25
26
  updatedAt: Date;
@@ -34,6 +35,7 @@ export interface CreateUserData {
34
35
  displayName?: string;
35
36
  photoUrl?: string;
36
37
  emailVerified?: boolean;
38
+ isAnonymous?: boolean;
37
39
  metadata?: Record<string, unknown>;
38
40
  }
39
41
 
@@ -93,7 +95,6 @@ export interface RoleData {
93
95
  edit?: boolean;
94
96
  delete?: boolean;
95
97
  }> | null;
96
- config: Record<string, unknown> | null;
97
98
  }
98
99
 
99
100
  /**
@@ -105,7 +106,6 @@ export interface CreateRoleData {
105
106
  isAdmin?: boolean;
106
107
  defaultPermissions?: RoleData["defaultPermissions"];
107
108
  collectionPermissions?: RoleData["collectionPermissions"];
108
- config?: RoleData["config"];
109
109
  }
110
110
 
111
111
  /**
@@ -359,7 +359,115 @@ export interface TokenRepository {
359
359
  deleteExpiredTokens(): Promise<void>;
360
360
  }
361
361
 
362
+ // =============================================================================
363
+ // MFA INTERFACES
364
+ // =============================================================================
365
+
366
+ /**
367
+ * MFA factor data structure
368
+ */
369
+ export interface MfaFactor {
370
+ id: string;
371
+ userId: string;
372
+ factorType: "totp";
373
+ friendlyName?: string;
374
+ verified: boolean;
375
+ createdAt: Date;
376
+ updatedAt: Date;
377
+ }
378
+
379
+ /**
380
+ * MFA challenge information
381
+ */
382
+ export interface MfaChallengeInfo {
383
+ id: string;
384
+ factorId: string;
385
+ createdAt: Date;
386
+ verifiedAt?: Date;
387
+ ipAddress?: string;
388
+ }
389
+
390
+ /**
391
+ * Recovery code data structure
392
+ */
393
+ export interface RecoveryCode {
394
+ id: string;
395
+ userId: string;
396
+ usedAt?: Date;
397
+ }
398
+
399
+ /**
400
+ * Abstract MFA repository interface.
401
+ * Handles all MFA-related database operations.
402
+ */
403
+ export interface MfaRepository {
404
+ /**
405
+ * Create a new MFA factor for a user
406
+ */
407
+ createMfaFactor(userId: string, factorType: "totp", secretEncrypted: string, friendlyName?: string): Promise<MfaFactor>;
408
+
409
+ /**
410
+ * Get all MFA factors for a user
411
+ */
412
+ getMfaFactors(userId: string): Promise<MfaFactor[]>;
413
+
414
+ /**
415
+ * Get a specific MFA factor by ID
416
+ */
417
+ getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null>;
418
+
419
+ /**
420
+ * Mark an MFA factor as verified
421
+ */
422
+ verifyMfaFactor(factorId: string): Promise<void>;
423
+
424
+ /**
425
+ * Delete an MFA factor
426
+ */
427
+ deleteMfaFactor(factorId: string, userId: string): Promise<void>;
428
+
429
+ /**
430
+ * Create an MFA challenge
431
+ */
432
+ createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo>;
433
+
434
+ /**
435
+ * Get an MFA challenge by ID
436
+ */
437
+ getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null>;
438
+
439
+ /**
440
+ * Mark an MFA challenge as verified
441
+ */
442
+ verifyMfaChallenge(challengeId: string): Promise<void>;
443
+
444
+ /**
445
+ * Create recovery codes for a user
446
+ */
447
+ createRecoveryCodes(userId: string, codeHashes: string[]): Promise<void>;
448
+
449
+ /**
450
+ * Use a recovery code (mark as used)
451
+ */
452
+ useRecoveryCode(userId: string, codeHash: string): Promise<boolean>;
453
+
454
+ /**
455
+ * Get unused recovery code count for a user
456
+ */
457
+ getUnusedRecoveryCodeCount(userId: string): Promise<number>;
458
+
459
+ /**
460
+ * Delete all recovery codes for a user
461
+ */
462
+ deleteAllRecoveryCodes(userId: string): Promise<void>;
463
+
464
+ /**
465
+ * Check if a user has any verified MFA factors
466
+ */
467
+ hasVerifiedMfaFactors(userId: string): Promise<boolean>;
468
+ }
469
+
362
470
  /**
363
471
  * Combined auth repository interface for convenience
364
472
  */
365
- export interface AuthRepository extends UserRepository, RoleRepository, TokenRepository { }
473
+ export interface AuthRepository extends UserRepository, RoleRepository, TokenRepository, MfaRepository { }
package/src/auth/jwt.ts CHANGED
@@ -11,6 +11,8 @@ export interface AccessTokenPayload {
11
11
  userId: string;
12
12
  roles: string[];
13
13
  uid?: string;
14
+ /** Authentication Assurance Level: aal1 = password/oauth, aal2 = MFA verified */
15
+ aal?: "aal1" | "aal2";
14
16
  }
15
17
 
16
18
  let jwtConfig: JwtConfig = {
@@ -70,13 +72,22 @@ export function configureJwt(config: JwtConfig): void {
70
72
  /**
71
73
  * Generate an access token (short-lived, 1 hour by default)
72
74
  */
73
- export function generateAccessToken(userId: string, roles: string[]): string {
75
+ export function generateAccessToken(
76
+ userId: string,
77
+ roles: string[],
78
+ aal: "aal1" | "aal2" = "aal1",
79
+ customClaims?: Record<string, unknown>
80
+ ): string {
74
81
  if (!jwtConfig.secret) {
75
82
  throw new Error("JWT secret not configured. Call configureJwt() first.");
76
83
  }
77
84
 
78
- const payload: AccessTokenPayload = { userId,
79
- roles };
85
+ const payload: Record<string, unknown> = {
86
+ userId,
87
+ roles,
88
+ aal,
89
+ ...customClaims
90
+ };
80
91
 
81
92
  return jwt.sign(payload, jwtConfig.secret, {
82
93
  expiresIn: jwtConfig.accessExpiresIn as jwt.SignOptions["expiresIn"],
@@ -124,16 +135,19 @@ export function verifyAccessToken(token: string): AccessTokenPayload | null {
124
135
  }
125
136
 
126
137
  try {
127
- const decoded = jwt.verify(token, jwtConfig.secret, { algorithms: ["HS256"] }) as { userId?: string; uid?: string; sub?: string; roles?: string[] };
138
+ const decoded = jwt.verify(token, jwtConfig.secret, { algorithms: ["HS256"] }) as { userId?: string; uid?: string; sub?: string; roles?: string[]; aal?: string };
128
139
  const id = decoded.userId || decoded.uid || decoded.sub;
129
140
  if (!id) {
130
141
  console.error("[JWT] Verification failed: missing id in payload", decoded);
131
142
  return null;
132
143
  }
133
144
 
145
+ const aal = (decoded.aal === "aal1" || decoded.aal === "aal2") ? decoded.aal : "aal1";
146
+
134
147
  return {
135
148
  userId: id,
136
- roles: decoded.roles || []
149
+ roles: decoded.roles || [],
150
+ aal
137
151
  };
138
152
  } catch (error) {
139
153
  console.error("[JWT] Verification failed:", error, "Token start:", token.substring(0, 15));
@@ -0,0 +1,160 @@
1
+ /**
2
+ * TOTP (Time-based One-Time Password) implementation.
3
+ *
4
+ * Pure Node.js crypto implementation — no external dependencies required.
5
+ * Implements RFC 6238 (TOTP) and RFC 4226 (HOTP).
6
+ */
7
+
8
+ import { createHmac, randomBytes, createHash } from "crypto";
9
+
10
+ // ─── Base32 Encoding/Decoding ────────────────────────────────────────────────
11
+
12
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
13
+
14
+ /**
15
+ * Encode a Buffer to Base32 string (RFC 4648)
16
+ */
17
+ export function base32Encode(buffer: Buffer): string {
18
+ let bits = 0;
19
+ let value = 0;
20
+ let output = "";
21
+
22
+ for (let i = 0; i < buffer.length; i++) {
23
+ value = (value << 8) | buffer[i];
24
+ bits += 8;
25
+
26
+ while (bits >= 5) {
27
+ output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
28
+ bits -= 5;
29
+ }
30
+ }
31
+
32
+ if (bits > 0) {
33
+ output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
34
+ }
35
+
36
+ return output;
37
+ }
38
+
39
+ /**
40
+ * Decode a Base32 string to Buffer
41
+ */
42
+ export function base32Decode(encoded: string): Buffer {
43
+ const cleanInput = encoded.replace(/=+$/, "").toUpperCase();
44
+ const bytes: number[] = [];
45
+ let bits = 0;
46
+ let value = 0;
47
+
48
+ for (let i = 0; i < cleanInput.length; i++) {
49
+ const index = BASE32_ALPHABET.indexOf(cleanInput[i]);
50
+ if (index === -1) continue;
51
+
52
+ value = (value << 5) | index;
53
+ bits += 5;
54
+
55
+ if (bits >= 8) {
56
+ bytes.push((value >>> (bits - 8)) & 255);
57
+ bits -= 8;
58
+ }
59
+ }
60
+
61
+ return Buffer.from(bytes);
62
+ }
63
+
64
+ // ─── HOTP/TOTP ───────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Generate an HOTP value per RFC 4226
68
+ */
69
+ function generateHotp(secret: Buffer, counter: bigint): string {
70
+ const hmac = createHmac("sha1", secret);
71
+ const counterBuffer = Buffer.alloc(8);
72
+ counterBuffer.writeBigInt64BE(counter);
73
+ hmac.update(counterBuffer);
74
+ const hash = hmac.digest();
75
+
76
+ const offset = hash[hash.length - 1] & 0x0f;
77
+ const code =
78
+ (((hash[offset] & 0x7f) << 24) |
79
+ (hash[offset + 1] << 16) |
80
+ (hash[offset + 2] << 8) |
81
+ hash[offset + 3]) %
82
+ 1000000;
83
+
84
+ return code.toString().padStart(6, "0");
85
+ }
86
+
87
+ /**
88
+ * Generate a TOTP value for the current time step
89
+ */
90
+ export function generateTotp(secret: Buffer, timeStep = 30): string {
91
+ const counter = BigInt(Math.floor(Date.now() / 1000 / timeStep));
92
+ return generateHotp(secret, counter);
93
+ }
94
+
95
+ /**
96
+ * Verify a TOTP token with a configurable time window
97
+ *
98
+ * @param secret - The shared secret as a Buffer
99
+ * @param token - The 6-digit TOTP code to verify
100
+ * @param window - Number of time steps to check on each side (default: 1)
101
+ * @returns true if the token is valid within the window
102
+ */
103
+ export function verifyTotp(secret: Buffer, token: string, window = 1): boolean {
104
+ const timeStep = 30;
105
+ const counter = BigInt(Math.floor(Date.now() / 1000 / timeStep));
106
+ for (let i = -window; i <= window; i++) {
107
+ if (generateHotp(secret, counter + BigInt(i)) === token) {
108
+ return true;
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+
114
+ // ─── Secret Generation ───────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Generate a new TOTP secret and return the setup information
118
+ *
119
+ * @param issuer - The issuer name (app name) for the QR code
120
+ * @param accountName - The account identifier (usually email)
121
+ * @returns Object with base32 secret and otpauth URI
122
+ */
123
+ export function generateTotpSecret(issuer: string, accountName: string): {
124
+ secret: string;
125
+ uri: string;
126
+ } {
127
+ // Generate a 20-byte random secret (160 bits, recommended by RFC 4226)
128
+ const secretBuffer = randomBytes(20);
129
+ const secret = base32Encode(secretBuffer);
130
+
131
+ // Build otpauth:// URI for QR code generation
132
+ const encodedIssuer = encodeURIComponent(issuer);
133
+ const encodedAccount = encodeURIComponent(accountName);
134
+ const uri = `otpauth://totp/${encodedIssuer}:${encodedAccount}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
135
+
136
+ return { secret, uri };
137
+ }
138
+
139
+ // ─── Recovery Codes ──────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Generate a set of one-time recovery codes
143
+ *
144
+ * @param count - Number of recovery codes to generate (default: 10)
145
+ * @returns Array of formatted recovery code strings (e.g. "A1B2C-D3E4F")
146
+ */
147
+ export function generateRecoveryCodes(count = 10): string[] {
148
+ return Array.from({ length: count }, () => {
149
+ const raw = randomBytes(5).toString("hex").toUpperCase();
150
+ const parts = raw.match(/.{1,5}/g);
151
+ return parts ? parts.join("-") : raw;
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Hash a recovery code for storage
157
+ */
158
+ export function hashRecoveryCode(code: string): string {
159
+ return createHash("sha256").update(code.replace(/-/g, "").toUpperCase()).digest("hex");
160
+ }
@@ -4,6 +4,8 @@ import { verifyAccessToken, AccessTokenPayload } from "./jwt";
4
4
  import { HonoEnv } from "../api/types";
5
5
  import { scopeDataDriver } from "./rls-scope";
6
6
  import { safeCompare } from "./crypto-utils";
7
+ import { isApiKeyToken, validateApiKey } from "./api-keys/api-key-middleware";
8
+ import type { ApiKeyStore } from "./api-keys/api-key-store";
7
9
 
8
10
  /**
9
11
  * Result from a custom auth validator.
@@ -44,6 +46,12 @@ export interface AuthMiddlewareOptions {
44
46
  * timing attacks. The key must be at least 32 characters.
45
47
  */
46
48
  serviceKey?: string;
49
+ /**
50
+ * API key store for authenticating `rk_` prefixed tokens.
51
+ * When set, tokens starting with `rk_` are validated against the
52
+ * database instead of being treated as JWTs.
53
+ */
54
+ apiKeyStore?: ApiKeyStore;
47
55
  }
48
56
 
49
57
  /**
@@ -223,7 +231,7 @@ export function extractUserFromToken(token: string): AccessTokenPayload | null {
223
231
  */
224
232
 
225
233
  export function createAuthMiddleware(options: AuthMiddlewareOptions): MiddlewareHandler<HonoEnv> {
226
- const { driver, requireAuth: enforceAuth = true, validator, serviceKey } = options;
234
+ const { driver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
227
235
 
228
236
  return async (c, next) => {
229
237
  if (validator) {
@@ -291,6 +299,17 @@ code: "UNAUTHORIZED" } }, 401);
291
299
  return c.json({ error: { message: "Internal authentication error",
292
300
  code: "INTERNAL_ERROR" } }, 500);
293
301
  }
302
+ } else if (apiKeyStore && isApiKeyToken(token)) {
303
+ // ── API Key verification ──────────────────────────────
304
+ // Tokens starting with `rk_` are validated against the
305
+ // api_keys table instead of JWT verification.
306
+ const result = await validateApiKey(c, token, {
307
+ store: apiKeyStore,
308
+ driver,
309
+ });
310
+ if (result !== true) {
311
+ return result;
312
+ }
294
313
  } else {
295
314
  // ── JWT verification ───────────────────────────────────
296
315
  const payload = extractUserFromToken(token);
@@ -131,3 +131,95 @@ export const strictAuthLimiter = createRateLimiter({
131
131
  limit: 50,
132
132
  message: "Too many requests to this sensitive endpoint, please try again later."
133
133
  });
134
+
135
+ /**
136
+ * Key generator for API-key-based rate limiting.
137
+ *
138
+ * Uses the API key ID (from `c.get("apiKey")`) as the rate limit key.
139
+ * Falls back to IP-based keying when the request is not authenticated
140
+ * via an API key.
141
+ */
142
+ export function apiKeyKeyGenerator(c: Parameters<MiddlewareHandler<HonoEnv>>[0]): string {
143
+ const apiKey = c.get("apiKey") as { id: string } | undefined;
144
+ if (apiKey) {
145
+ return `api-key:${apiKey.id}`;
146
+ }
147
+ return defaultKeyGenerator(c);
148
+ }
149
+
150
+ /**
151
+ * Create a rate limiter specifically for API key requests.
152
+ *
153
+ * When a request is authenticated via an API key that has a `rate_limit`
154
+ * configured, this limiter enforces per-key limits using the key's ID
155
+ * as the rate limit bucket.
156
+ *
157
+ * @param defaultLimit - Fallback limit when the key has no `rate_limit` set.
158
+ * @param windowMs - Time window in milliseconds (default: 15 minutes).
159
+ */
160
+ export function createApiKeyRateLimiter(
161
+ defaultLimit = 1000,
162
+ windowMs = 15 * 60 * 1000,
163
+ ): MiddlewareHandler<HonoEnv> {
164
+ // We maintain a single shared store keyed by API key ID.
165
+ // The actual limit is resolved per-request from the key's metadata.
166
+ const store = new Map<string, { timestamps: number[] }>();
167
+
168
+ const cleanupInterval = setInterval(() => {
169
+ const now = Date.now();
170
+ for (const [key, entry] of store.entries()) {
171
+ entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);
172
+ if (entry.timestamps.length === 0) {
173
+ store.delete(key);
174
+ }
175
+ }
176
+ }, windowMs);
177
+
178
+ if (cleanupInterval.unref) {
179
+ cleanupInterval.unref();
180
+ }
181
+
182
+ return async (c, next) => {
183
+ const apiKey = c.get("apiKey") as { id: string; rate_limit: number | null } | undefined;
184
+ if (!apiKey) {
185
+ // Not an API key request — skip this limiter
186
+ return next();
187
+ }
188
+
189
+ const limit = apiKey.rate_limit ?? defaultLimit;
190
+ const key = `api-key:${apiKey.id}`;
191
+ const now = Date.now();
192
+
193
+ let entry = store.get(key);
194
+ if (!entry) {
195
+ entry = { timestamps: [] };
196
+ store.set(key, entry);
197
+ }
198
+
199
+ entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);
200
+
201
+ if (entry.timestamps.length >= limit) {
202
+ const retryAfterMs = entry.timestamps[0] + windowMs - now;
203
+ const retryAfterSec = Math.ceil(retryAfterMs / 1000);
204
+
205
+ c.header("Retry-After", String(retryAfterSec));
206
+ c.header("X-RateLimit-Limit", String(limit));
207
+ c.header("X-RateLimit-Remaining", "0");
208
+ c.header("X-RateLimit-Reset", String(Math.ceil((now + retryAfterMs) / 1000)));
209
+
210
+ return c.json({
211
+ error: {
212
+ message: "API key rate limit exceeded, please try again later.",
213
+ code: "RATE_LIMITED"
214
+ }
215
+ }, 429);
216
+ }
217
+
218
+ entry.timestamps.push(now);
219
+
220
+ c.header("X-RateLimit-Limit", String(limit));
221
+ c.header("X-RateLimit-Remaining", String(limit - entry.timestamps.length));
222
+
223
+ return next();
224
+ };
225
+ }