@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.
- package/dist/common/src/collections/default-collections.d.ts +12 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/util/permissions.d.ts +1 -0
- package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
- package/dist/index-Cr1D21av.js.map +1 -0
- package/dist/index.es.js +2166 -208
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2155 -193
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/logs-routes.d.ts +37 -0
- package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
- package/dist/server-core/src/api/types.d.ts +6 -1
- package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
- package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
- package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
- package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
- package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
- package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
- package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
- package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
- package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
- package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
- package/dist/server-core/src/auth/index.d.ts +5 -3
- package/dist/server-core/src/auth/interfaces.d.ts +93 -3
- package/dist/server-core/src/auth/jwt.d.ts +3 -1
- package/dist/server-core/src/auth/mfa.d.ts +49 -0
- package/dist/server-core/src/auth/middleware.d.ts +7 -0
- package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
- package/dist/server-core/src/auth/routes.d.ts +3 -3
- package/dist/server-core/src/env.d.ts +6 -0
- package/dist/server-core/src/index.d.ts +1 -0
- package/dist/server-core/src/init.d.ts +3 -3
- package/dist/server-core/src/services/webhook-service.d.ts +29 -0
- package/dist/server-core/src/storage/image-transform.d.ts +48 -0
- package/dist/server-core/src/storage/index.d.ts +3 -0
- package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
- package/dist/types/src/controllers/auth.d.ts +2 -24
- package/dist/types/src/controllers/client.d.ts +0 -3
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data_driver.d.ts +18 -0
- package/dist/types/src/controllers/registry.d.ts +5 -4
- package/dist/types/src/rebase_context.d.ts +1 -1
- package/dist/types/src/types/auth_adapter.d.ts +2 -4
- package/dist/types/src/types/collections.d.ts +0 -4
- package/dist/types/src/types/component_ref.d.ts +1 -1
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +1 -0
- package/dist/types/src/types/export_import.d.ts +1 -1
- package/dist/types/src/types/formex.d.ts +2 -2
- package/dist/types/src/types/properties.d.ts +2 -2
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +6 -4
- package/dist/types/src/users/roles.d.ts +0 -8
- package/package.json +8 -6
- package/src/api/ast-schema-editor.ts +4 -4
- package/src/api/errors.ts +16 -7
- package/src/api/logs-routes.ts +129 -0
- package/src/api/rest/api-generator.ts +42 -2
- package/src/api/rest/query-parser.ts +37 -1
- package/src/api/types.ts +6 -1
- package/src/auth/adapter-middleware.ts +20 -4
- package/src/auth/admin-routes.ts +39 -14
- package/src/auth/api-keys/api-key-middleware.ts +126 -0
- package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
- package/src/auth/api-keys/api-key-routes.ts +183 -0
- package/src/auth/api-keys/api-key-store.ts +317 -0
- package/src/auth/api-keys/api-key-types.ts +94 -0
- package/src/auth/api-keys/index.ts +37 -0
- package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
- package/src/auth/builtin-auth-adapter.ts +31 -19
- package/src/auth/index.ts +7 -3
- package/src/auth/interfaces.ts +111 -3
- package/src/auth/jwt.ts +19 -5
- package/src/auth/mfa.ts +160 -0
- package/src/auth/middleware.ts +20 -1
- package/src/auth/rate-limiter.ts +92 -0
- package/src/auth/routes.ts +455 -24
- package/src/cron/cron-loader.ts +5 -10
- package/src/cron/cron-scheduler.ts +11 -12
- package/src/cron/cron-store.ts +8 -7
- package/src/env.ts +2 -0
- package/src/functions/function-loader.ts +6 -9
- package/src/index.ts +1 -2
- package/src/init.ts +37 -7
- package/src/serve-spa.ts +5 -4
- package/src/services/webhook-service.ts +155 -0
- package/src/storage/image-transform.ts +202 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/routes.ts +56 -3
- package/src/storage/tus-handler.ts +315 -0
- package/src/utils/dev-port.ts +14 -0
- package/src/utils/logging.ts +9 -7
- package/test/admin-routes.test.ts +74 -7
- package/test/api-generator.test.ts +0 -1
- package/test/api-key-permission-guard.test.ts +132 -0
- package/test/ast-schema-editor.test.ts +26 -0
- package/test/auth-routes.test.ts +1 -2
- package/test/backend-hooks-admin.test.ts +3 -4
- package/test/email-templates.test.ts +169 -0
- package/test/function-loader.test.ts +124 -0
- package/test/jwt.test.ts +4 -2
- package/test/mfa.test.ts +197 -0
- package/test/middleware.test.ts +10 -5
- package/test/webhook-service.test.ts +249 -0
- package/vite.config.ts +3 -2
- package/dist/index-BZoAtuqi.js.map +0 -1
- package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
- package/src/bootstrappers/index.ts +0 -1
- 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 {
|
|
36
|
-
import {
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
86
|
+
authHooks,
|
|
87
87
|
} = config;
|
|
88
88
|
|
|
89
|
-
const resolvedOps =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
288
|
-
createData = await
|
|
287
|
+
if (authHooks?.beforeUserCreate) {
|
|
288
|
+
createData = await authHooks.beforeUserCreate(createData);
|
|
289
289
|
}
|
|
290
290
|
const user = await repo.createUser(createData);
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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 {
|
|
11
|
-
export {
|
|
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";
|
package/src/auth/interfaces.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
79
|
-
|
|
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));
|
package/src/auth/mfa.ts
ADDED
|
@@ -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
|
+
}
|
package/src/auth/middleware.ts
CHANGED
|
@@ -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);
|
package/src/auth/rate-limiter.ts
CHANGED
|
@@ -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
|
+
}
|