@rebasepro/server-core 0.2.3 → 0.3.0
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
package/src/auth/routes.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { ApiError, errorHandler } from "../api/errors";
|
|
3
3
|
import { randomBytes, createHash } from "crypto";
|
|
4
|
-
import type { AuthRepository, OAuthProvider } from "./interfaces";
|
|
4
|
+
import type { AuthRepository, OAuthProvider, CreateUserData } from "./interfaces";
|
|
5
5
|
import { generateAccessToken, generateRefreshToken, hashRefreshToken, getRefreshTokenExpiry, getAccessTokenExpiry } from "./jwt";
|
|
6
|
-
import type {
|
|
7
|
-
import {
|
|
6
|
+
import type { AuthHooks } from "./auth-hooks";
|
|
7
|
+
import { resolveAuthHooks } from "./auth-hooks";
|
|
8
8
|
import { requireAuth } from "./middleware";
|
|
9
9
|
import { EmailService, EmailConfig } from "../email";
|
|
10
10
|
import { getPasswordResetTemplate, getEmailVerificationTemplate, getWelcomeEmailTemplate } from "../email/templates";
|
|
11
11
|
import { HonoEnv } from "../api/types";
|
|
12
12
|
import { defaultAuthLimiter, strictAuthLimiter } from "./rate-limiter";
|
|
13
13
|
import { z } from "zod";
|
|
14
|
+
import { generateTotpSecret, verifyTotp, base32Decode, generateRecoveryCodes, hashRecoveryCode } from "./mfa";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Shared configuration for auth and admin route factories.
|
|
@@ -29,10 +30,10 @@ export interface AuthModuleConfig {
|
|
|
29
30
|
/** When true, blocks all self-registration regardless of `allowRegistration`. */
|
|
30
31
|
disableSelfRegistration?: boolean;
|
|
31
32
|
/**
|
|
32
|
-
* Auth
|
|
33
|
+
* Auth hooks for customizing password hashing, credential
|
|
33
34
|
* verification, lifecycle hooks, etc.
|
|
34
35
|
*/
|
|
35
|
-
|
|
36
|
+
authHooks?: AuthHooks;
|
|
36
37
|
/**
|
|
37
38
|
* Callback that checks if bootstrap has already been completed.
|
|
38
39
|
* Used by GET /auth/config to report `needsSetup` status.
|
|
@@ -101,8 +102,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
101
102
|
router.onError(errorHandler);
|
|
102
103
|
|
|
103
104
|
const authRepo = config.authRepo;
|
|
104
|
-
const { emailService, emailConfig, allowRegistration = false,
|
|
105
|
-
const ops =
|
|
105
|
+
const { emailService, emailConfig, allowRegistration = false, authHooks } = config;
|
|
106
|
+
const ops = resolveAuthHooks(authHooks);
|
|
106
107
|
|
|
107
108
|
// ── Zod input schemas ──────────────────────────────────────────────
|
|
108
109
|
const registerSchema = z.object({
|
|
@@ -191,7 +192,18 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
191
192
|
async function createSessionAndTokens(userId: string, userAgent: string, ipAddress: string) {
|
|
192
193
|
const roles = await authRepo.getUserRoles(userId);
|
|
193
194
|
const roleIds = roles.map(r => r.id);
|
|
194
|
-
|
|
195
|
+
|
|
196
|
+
// Allow customization of access token claims via hook
|
|
197
|
+
let customClaims: Record<string, unknown> | undefined;
|
|
198
|
+
if (authHooks?.customizeAccessToken) {
|
|
199
|
+
const user = await authRepo.getUserById(userId);
|
|
200
|
+
if (user) {
|
|
201
|
+
const defaultClaims: Record<string, unknown> = { userId, roles: roleIds, aal: "aal1" };
|
|
202
|
+
customClaims = await authHooks.customizeAccessToken(defaultClaims, user);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const accessToken = generateAccessToken(userId, roleIds, "aal1", customClaims);
|
|
195
207
|
const refreshToken = generateRefreshToken();
|
|
196
208
|
|
|
197
209
|
await authRepo.createRefreshToken(
|
|
@@ -241,8 +253,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
241
253
|
passwordHash,
|
|
242
254
|
displayName: displayName || undefined
|
|
243
255
|
};
|
|
244
|
-
if (
|
|
245
|
-
createData = await
|
|
256
|
+
if (authHooks?.beforeUserCreate) {
|
|
257
|
+
createData = await authHooks.beforeUserCreate(createData);
|
|
246
258
|
}
|
|
247
259
|
const user = await authRepo.createUser(createData);
|
|
248
260
|
|
|
@@ -269,17 +281,19 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
269
281
|
sendWelcomeEmail({ email: user.email,
|
|
270
282
|
displayName: user.displayName });
|
|
271
283
|
|
|
272
|
-
// Fire afterUserCreate hook
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
})
|
|
284
|
+
// Fire afterUserCreate hook
|
|
285
|
+
if (authHooks?.afterUserCreate) {
|
|
286
|
+
try {
|
|
287
|
+
await authHooks.afterUserCreate(user);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
console.error("[AuthHooks] afterUserCreate error:", err instanceof Error ? err.message : err);
|
|
290
|
+
}
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
// Fire onAuthenticated hook (fire-and-forget)
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
console.error("[
|
|
294
|
+
if (authHooks?.onAuthenticated) {
|
|
295
|
+
authHooks.onAuthenticated(user, "register").catch(err => {
|
|
296
|
+
console.error("[AuthHooks] onAuthenticated error:", err instanceof Error ? err.message : err);
|
|
283
297
|
});
|
|
284
298
|
}
|
|
285
299
|
|
|
@@ -293,11 +307,16 @@ displayName: user.displayName });
|
|
|
293
307
|
router.post("/login", defaultAuthLimiter, async (c) => {
|
|
294
308
|
const { email, password } = parseBody(loginSchema, await c.req.json());
|
|
295
309
|
|
|
310
|
+
// Call beforeLogin hook if provided (throw to reject)
|
|
311
|
+
if (authHooks?.beforeLogin) {
|
|
312
|
+
await authHooks.beforeLogin(email, "login");
|
|
313
|
+
}
|
|
314
|
+
|
|
296
315
|
let user;
|
|
297
316
|
|
|
298
|
-
if (
|
|
317
|
+
if (authHooks?.verifyCredentials) {
|
|
299
318
|
// Full credential verification override
|
|
300
|
-
user = await
|
|
319
|
+
user = await authHooks.verifyCredentials(email, password, authRepo);
|
|
301
320
|
if (!user) {
|
|
302
321
|
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
303
322
|
}
|
|
@@ -325,9 +344,9 @@ displayName: user.displayName });
|
|
|
325
344
|
);
|
|
326
345
|
|
|
327
346
|
// Fire onAuthenticated hook (fire-and-forget)
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
console.error("[
|
|
347
|
+
if (authHooks?.onAuthenticated) {
|
|
348
|
+
authHooks.onAuthenticated(user, "login").catch(err => {
|
|
349
|
+
console.error("[AuthHooks] onAuthenticated error:", err instanceof Error ? err.message : err);
|
|
331
350
|
});
|
|
332
351
|
}
|
|
333
352
|
|
|
@@ -379,6 +398,15 @@ displayName: user.displayName });
|
|
|
379
398
|
|
|
380
399
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
381
400
|
|
|
401
|
+
// Fire afterUserCreate hook
|
|
402
|
+
if (authHooks?.afterUserCreate) {
|
|
403
|
+
try {
|
|
404
|
+
await authHooks.afterUserCreate(user);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error("[AuthHooks] afterUserCreate error:", err instanceof Error ? err.message : err);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
382
410
|
// Auto-bootstrap: first user in the system gets admin
|
|
383
411
|
const allUsers = await authRepo.listUsers();
|
|
384
412
|
const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
|
|
@@ -502,6 +530,13 @@ displayName: user.displayName }, appName);
|
|
|
502
530
|
// Invalidate all refresh tokens (security: log out all sessions)
|
|
503
531
|
await authRepo.deleteAllRefreshTokensForUser(storedToken.userId);
|
|
504
532
|
|
|
533
|
+
// Fire onPasswordReset hook (fire-and-forget)
|
|
534
|
+
if (authHooks?.onPasswordReset) {
|
|
535
|
+
authHooks.onPasswordReset(storedToken.userId).catch(err => {
|
|
536
|
+
console.error("[AuthHooks] onPasswordReset error:", err instanceof Error ? err.message : err);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
505
540
|
return c.json({ success: true,
|
|
506
541
|
message: "Password has been reset successfully" });
|
|
507
542
|
});
|
|
@@ -649,7 +684,17 @@ message: "Email verified successfully" });
|
|
|
649
684
|
const roles = await authRepo.getUserRoles(storedToken.userId);
|
|
650
685
|
const roleIds = roles.map(r => r.id);
|
|
651
686
|
|
|
652
|
-
|
|
687
|
+
// Allow customization of access token claims via hook
|
|
688
|
+
let customClaims: Record<string, unknown> | undefined;
|
|
689
|
+
if (authHooks?.customizeAccessToken) {
|
|
690
|
+
const user = await authRepo.getUserById(storedToken.userId);
|
|
691
|
+
if (user) {
|
|
692
|
+
const defaultClaims: Record<string, unknown> = { userId: storedToken.userId, roles: roleIds, aal: "aal1" };
|
|
693
|
+
customClaims = await authHooks.customizeAccessToken(defaultClaims, user);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const newAccessToken = generateAccessToken(storedToken.userId, roleIds, "aal1", customClaims);
|
|
653
698
|
const newRefreshToken = generateRefreshToken();
|
|
654
699
|
|
|
655
700
|
// Rotate refresh token (delete old, create new)
|
|
@@ -686,6 +731,19 @@ message: "Email verified successfully" });
|
|
|
686
731
|
await authRepo.deleteRefreshToken(tokenHash);
|
|
687
732
|
}
|
|
688
733
|
|
|
734
|
+
// Call afterLogout hook (fire-and-forget)
|
|
735
|
+
// Extract userId from the access token if present
|
|
736
|
+
const authHeader = c.req.header("authorization");
|
|
737
|
+
if (authHooks?.afterLogout && authHeader?.startsWith("Bearer ")) {
|
|
738
|
+
const { verifyAccessToken } = await import("./jwt");
|
|
739
|
+
const payload = verifyAccessToken(authHeader.substring(7));
|
|
740
|
+
if (payload) {
|
|
741
|
+
authHooks.afterLogout(payload.userId).catch(err => {
|
|
742
|
+
console.error("[AuthHooks] afterLogout error:", err instanceof Error ? err.message : err);
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
689
747
|
return c.json({ success: true });
|
|
690
748
|
});
|
|
691
749
|
|
|
@@ -846,5 +904,378 @@ message: "Session revoked successfully" });
|
|
|
846
904
|
});
|
|
847
905
|
});
|
|
848
906
|
|
|
907
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
908
|
+
// ANONYMOUS SIGN-IN
|
|
909
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* POST /auth/anonymous
|
|
913
|
+
* Create an anonymous user with temporary credentials
|
|
914
|
+
*/
|
|
915
|
+
router.post("/anonymous", strictAuthLimiter, async (c) => {
|
|
916
|
+
const anonId = randomBytes(16).toString("hex");
|
|
917
|
+
const anonEmail = `anon_${anonId.slice(0, 8)}@anonymous.local`;
|
|
918
|
+
|
|
919
|
+
let createData: CreateUserData = {
|
|
920
|
+
email: anonEmail,
|
|
921
|
+
emailVerified: false,
|
|
922
|
+
isAnonymous: true
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
if (authHooks?.beforeUserCreate) {
|
|
926
|
+
createData = await authHooks.beforeUserCreate(createData);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const user = await authRepo.createUser(createData);
|
|
930
|
+
|
|
931
|
+
// Assign default role (follow register route pattern, but never auto-admin)
|
|
932
|
+
if (config.defaultRole) {
|
|
933
|
+
await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
|
|
937
|
+
user.id,
|
|
938
|
+
c.req.header("user-agent") || "unknown",
|
|
939
|
+
c.req.header("x-forwarded-for") || "unknown"
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
// Fire afterUserCreate hook
|
|
943
|
+
if (authHooks?.afterUserCreate) {
|
|
944
|
+
authHooks.afterUserCreate(user).catch(err => {
|
|
945
|
+
console.error("[AuthHooks] afterUserCreate error:", err instanceof Error ? err.message : err);
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Fire onAuthenticated hook
|
|
950
|
+
if (authHooks?.onAuthenticated) {
|
|
951
|
+
authHooks.onAuthenticated(user, "anonymous").catch(err => {
|
|
952
|
+
console.error("[AuthHooks] onAuthenticated error:", err instanceof Error ? err.message : err);
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* POST /auth/anonymous/link
|
|
961
|
+
* Upgrade an anonymous user to a permanent account with email/password
|
|
962
|
+
*/
|
|
963
|
+
router.post("/anonymous/link", requireAuth, async (c) => {
|
|
964
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
965
|
+
if (!userCtx) {
|
|
966
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const user = await authRepo.getUserById(userCtx.userId);
|
|
970
|
+
if (!user?.isAnonymous) {
|
|
971
|
+
throw ApiError.badRequest("User is not anonymous", "NOT_ANONYMOUS");
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const linkSchema = z.object({
|
|
975
|
+
email: z.string().email("Invalid email address").max(255),
|
|
976
|
+
password: z.string().min(1, "Password is required").max(128)
|
|
977
|
+
});
|
|
978
|
+
const { email, password } = parseBody(linkSchema, await c.req.json());
|
|
979
|
+
|
|
980
|
+
// Validate password strength
|
|
981
|
+
const passwordValidation = ops.validatePasswordStrength(password);
|
|
982
|
+
if (!passwordValidation.valid) {
|
|
983
|
+
throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Check if email is already taken
|
|
987
|
+
const existingUser = await authRepo.getUserByEmail(email.toLowerCase());
|
|
988
|
+
if (existingUser) {
|
|
989
|
+
throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Hash password
|
|
993
|
+
const passwordHash = await ops.hashPassword(password);
|
|
994
|
+
|
|
995
|
+
// Update user: set email, password, remove anonymous flag
|
|
996
|
+
const updatedUser = await authRepo.updateUser(user.id, {
|
|
997
|
+
email: email.toLowerCase(),
|
|
998
|
+
passwordHash,
|
|
999
|
+
isAnonymous: false
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
if (!updatedUser) {
|
|
1003
|
+
throw ApiError.notFound("User not found");
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Generate new tokens with updated identity
|
|
1007
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
|
|
1008
|
+
user.id,
|
|
1009
|
+
c.req.header("user-agent") || "unknown",
|
|
1010
|
+
c.req.header("x-forwarded-for") || "unknown"
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
return c.json(buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken));
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1017
|
+
// MFA / TOTP
|
|
1018
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* POST /auth/mfa/enroll
|
|
1022
|
+
* Start MFA enrollment: generate TOTP secret and recovery codes
|
|
1023
|
+
*/
|
|
1024
|
+
router.post("/mfa/enroll", requireAuth, async (c) => {
|
|
1025
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
1026
|
+
if (!userCtx) {
|
|
1027
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
|
|
1031
|
+
const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : undefined;
|
|
1032
|
+
const issuer = typeof body.issuer === "string" ? body.issuer : (emailConfig?.appName || "Rebase");
|
|
1033
|
+
|
|
1034
|
+
// Get user for account name
|
|
1035
|
+
const user = await authRepo.getUserById(userCtx.userId);
|
|
1036
|
+
if (!user) {
|
|
1037
|
+
throw ApiError.notFound("User not found");
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Generate TOTP secret
|
|
1041
|
+
const { secret, uri } = generateTotpSecret(issuer, user.email);
|
|
1042
|
+
|
|
1043
|
+
// Store the factor (unverified until user confirms with a valid code)
|
|
1044
|
+
const factor = await authRepo.createMfaFactor(
|
|
1045
|
+
user.id,
|
|
1046
|
+
"totp",
|
|
1047
|
+
secret, // In production, encrypt this before storage
|
|
1048
|
+
friendlyName
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
// Generate recovery codes
|
|
1052
|
+
const codes = generateRecoveryCodes(10);
|
|
1053
|
+
const codeHashes = codes.map(hashRecoveryCode);
|
|
1054
|
+
await authRepo.createRecoveryCodes(user.id, codeHashes);
|
|
1055
|
+
|
|
1056
|
+
return c.json({
|
|
1057
|
+
factor: {
|
|
1058
|
+
id: factor.id,
|
|
1059
|
+
factorType: factor.factorType,
|
|
1060
|
+
friendlyName: factor.friendlyName
|
|
1061
|
+
},
|
|
1062
|
+
totp: {
|
|
1063
|
+
secret,
|
|
1064
|
+
uri,
|
|
1065
|
+
qrUri: uri // Client can use a QR library to render this
|
|
1066
|
+
},
|
|
1067
|
+
recoveryCodes: codes
|
|
1068
|
+
}, 201);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* POST /auth/mfa/verify
|
|
1073
|
+
* Verify TOTP code to complete MFA enrollment
|
|
1074
|
+
*/
|
|
1075
|
+
router.post("/mfa/verify", requireAuth, async (c) => {
|
|
1076
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
1077
|
+
if (!userCtx) {
|
|
1078
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const verifySchema = z.object({
|
|
1082
|
+
factorId: z.string().min(1, "Factor ID is required"),
|
|
1083
|
+
code: z.string().length(6, "Code must be 6 digits")
|
|
1084
|
+
});
|
|
1085
|
+
const { factorId, code } = parseBody(verifySchema, await c.req.json());
|
|
1086
|
+
|
|
1087
|
+
// Get the factor
|
|
1088
|
+
const factor = await authRepo.getMfaFactorById(factorId);
|
|
1089
|
+
if (!factor || factor.userId !== userCtx.userId) {
|
|
1090
|
+
throw ApiError.notFound("MFA factor not found");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (factor.verified) {
|
|
1094
|
+
throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Verify the TOTP code
|
|
1098
|
+
const secretBuffer = base32Decode(factor.secretEncrypted);
|
|
1099
|
+
const isValid = verifyTotp(secretBuffer, code);
|
|
1100
|
+
|
|
1101
|
+
if (!isValid) {
|
|
1102
|
+
throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Mark factor as verified
|
|
1106
|
+
await authRepo.verifyMfaFactor(factorId);
|
|
1107
|
+
|
|
1108
|
+
return c.json({ success: true, message: "MFA factor verified and enrolled" });
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* POST /auth/mfa/challenge
|
|
1113
|
+
* Create an MFA challenge during login (user has MFA enrolled)
|
|
1114
|
+
*/
|
|
1115
|
+
router.post("/mfa/challenge", requireAuth, async (c) => {
|
|
1116
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
1117
|
+
if (!userCtx) {
|
|
1118
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const challengeSchema = z.object({
|
|
1122
|
+
factorId: z.string().min(1, "Factor ID is required")
|
|
1123
|
+
});
|
|
1124
|
+
const { factorId } = parseBody(challengeSchema, await c.req.json());
|
|
1125
|
+
|
|
1126
|
+
// Verify the factor belongs to this user and is verified
|
|
1127
|
+
const factor = await authRepo.getMfaFactorById(factorId);
|
|
1128
|
+
if (!factor || factor.userId !== userCtx.userId) {
|
|
1129
|
+
throw ApiError.notFound("MFA factor not found");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (!factor.verified) {
|
|
1133
|
+
throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
1137
|
+
const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
|
|
1138
|
+
|
|
1139
|
+
return c.json({
|
|
1140
|
+
challengeId: challenge.id,
|
|
1141
|
+
factorId: challenge.factorId,
|
|
1142
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString()
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* POST /auth/mfa/challenge/verify
|
|
1148
|
+
* Verify a TOTP code for an active challenge, upgrade aal1 → aal2
|
|
1149
|
+
*/
|
|
1150
|
+
router.post("/mfa/challenge/verify", requireAuth, async (c) => {
|
|
1151
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
1152
|
+
if (!userCtx) {
|
|
1153
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const challengeVerifySchema = z.object({
|
|
1157
|
+
challengeId: z.string().min(1, "Challenge ID is required"),
|
|
1158
|
+
code: z.string().min(1, "Code is required")
|
|
1159
|
+
});
|
|
1160
|
+
const { challengeId, code } = parseBody(challengeVerifySchema, await c.req.json());
|
|
1161
|
+
|
|
1162
|
+
// Find the challenge
|
|
1163
|
+
const challenge = await authRepo.getMfaChallengeById(challengeId);
|
|
1164
|
+
if (!challenge) {
|
|
1165
|
+
throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Get the factor and verify ownership
|
|
1169
|
+
const factor = await authRepo.getMfaFactorById(challenge.factorId);
|
|
1170
|
+
if (!factor || factor.userId !== userCtx.userId) {
|
|
1171
|
+
throw ApiError.notFound("MFA factor not found");
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Check if this is a recovery code (10 chars with hyphen) or TOTP (6 digits)
|
|
1175
|
+
const isRecoveryCode = code.length > 6;
|
|
1176
|
+
let isValid = false;
|
|
1177
|
+
|
|
1178
|
+
if (isRecoveryCode) {
|
|
1179
|
+
// Try recovery code
|
|
1180
|
+
const codeHash = hashRecoveryCode(code);
|
|
1181
|
+
isValid = await authRepo.useRecoveryCode(userCtx.userId, codeHash);
|
|
1182
|
+
} else {
|
|
1183
|
+
// Verify TOTP
|
|
1184
|
+
const secretBuffer = base32Decode(factor.secretEncrypted);
|
|
1185
|
+
isValid = verifyTotp(secretBuffer, code);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (!isValid) {
|
|
1189
|
+
throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Mark challenge as verified
|
|
1193
|
+
await authRepo.verifyMfaChallenge(challengeId);
|
|
1194
|
+
|
|
1195
|
+
// Generate new access token with aal2
|
|
1196
|
+
const roles = await authRepo.getUserRoles(userCtx.userId);
|
|
1197
|
+
const roleIds = roles.map(r => r.id);
|
|
1198
|
+
const accessToken = generateAccessToken(userCtx.userId, roleIds, "aal2");
|
|
1199
|
+
const refreshToken = generateRefreshToken();
|
|
1200
|
+
|
|
1201
|
+
// Create new refresh token
|
|
1202
|
+
await authRepo.createRefreshToken(
|
|
1203
|
+
userCtx.userId,
|
|
1204
|
+
hashRefreshToken(refreshToken),
|
|
1205
|
+
getRefreshTokenExpiry(),
|
|
1206
|
+
c.req.header("user-agent") || "unknown",
|
|
1207
|
+
c.req.header("x-forwarded-for") || "unknown"
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
// Fire onMfaVerified hook
|
|
1211
|
+
if (authHooks?.onMfaVerified) {
|
|
1212
|
+
authHooks.onMfaVerified(userCtx.userId, factor.id).catch(err => {
|
|
1213
|
+
console.error("[AuthHooks] onMfaVerified error:", err instanceof Error ? err.message : err);
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return c.json({
|
|
1218
|
+
tokens: {
|
|
1219
|
+
accessToken,
|
|
1220
|
+
refreshToken,
|
|
1221
|
+
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* GET /auth/mfa/factors
|
|
1228
|
+
* List enrolled MFA factors for the current user
|
|
1229
|
+
*/
|
|
1230
|
+
router.get("/mfa/factors", requireAuth, async (c) => {
|
|
1231
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
1232
|
+
if (!userCtx) {
|
|
1233
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const factors = await authRepo.getMfaFactors(userCtx.userId);
|
|
1237
|
+
return c.json({
|
|
1238
|
+
factors: factors.map(f => ({
|
|
1239
|
+
id: f.id,
|
|
1240
|
+
factorType: f.factorType,
|
|
1241
|
+
friendlyName: f.friendlyName,
|
|
1242
|
+
verified: f.verified,
|
|
1243
|
+
createdAt: f.createdAt
|
|
1244
|
+
}))
|
|
1245
|
+
});
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* DELETE /auth/mfa/unenroll
|
|
1250
|
+
* Remove an MFA factor
|
|
1251
|
+
*/
|
|
1252
|
+
router.delete("/mfa/unenroll", requireAuth, async (c) => {
|
|
1253
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
1254
|
+
if (!userCtx) {
|
|
1255
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const unenrollSchema = z.object({
|
|
1259
|
+
factorId: z.string().min(1, "Factor ID is required")
|
|
1260
|
+
});
|
|
1261
|
+
const { factorId } = parseBody(unenrollSchema, await c.req.json());
|
|
1262
|
+
|
|
1263
|
+
// Verify ownership
|
|
1264
|
+
const factor = await authRepo.getMfaFactorById(factorId);
|
|
1265
|
+
if (!factor || factor.userId !== userCtx.userId) {
|
|
1266
|
+
throw ApiError.notFound("MFA factor not found");
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
await authRepo.deleteMfaFactor(factorId, userCtx.userId);
|
|
1270
|
+
|
|
1271
|
+
// If no more verified factors, clean up recovery codes
|
|
1272
|
+
const hasFactors = await authRepo.hasVerifiedMfaFactors(userCtx.userId);
|
|
1273
|
+
if (!hasFactors) {
|
|
1274
|
+
await authRepo.deleteAllRecoveryCodes(userCtx.userId);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
return c.json({ success: true, message: "MFA factor removed" });
|
|
1278
|
+
});
|
|
1279
|
+
|
|
849
1280
|
return router;
|
|
850
1281
|
}
|
package/src/cron/cron-loader.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { pathToFileURL } from "url";
|
|
4
4
|
import type { CronJobDefinition } from "@rebasepro/types";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
5
6
|
|
|
6
7
|
export interface LoadedCronJob {
|
|
7
8
|
/** Job ID derived from filename (e.g. "cleanup-sessions"). */
|
|
@@ -48,17 +49,13 @@ export async function loadCronJobsFromDirectory(
|
|
|
48
49
|
const exported: unknown = mod.default;
|
|
49
50
|
|
|
50
51
|
if (!exported || typeof exported !== "object") {
|
|
51
|
-
|
|
52
|
-
`[cron] ${file}: no valid default export. Skipping.`
|
|
53
|
-
);
|
|
52
|
+
logger.warn(`[cron] ${file}: no valid default export. Skipping.`);
|
|
54
53
|
continue;
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
const def = exported as Record<string, unknown>;
|
|
58
57
|
if (typeof def.schedule !== "string" || typeof def.handler !== "function") {
|
|
59
|
-
|
|
60
|
-
`[cron] ${file}: default export missing required 'schedule' or 'handler'. Skipping.`
|
|
61
|
-
);
|
|
58
|
+
logger.warn(`[cron] ${file}: default export missing required 'schedule' or 'handler'. Skipping.`);
|
|
62
59
|
continue;
|
|
63
60
|
}
|
|
64
61
|
|
|
@@ -74,13 +71,11 @@ export async function loadCronJobsFromDirectory(
|
|
|
74
71
|
|
|
75
72
|
jobs.push({ id,
|
|
76
73
|
definition });
|
|
77
|
-
|
|
74
|
+
logger.info(`⏰ Loaded cron job: ${id} (${definition.schedule})`);
|
|
78
75
|
} catch (err: unknown) {
|
|
79
76
|
const message =
|
|
80
77
|
err instanceof Error ? err.message : String(err);
|
|
81
|
-
|
|
82
|
-
`[cron] Failed to load ${file}: ${message}`
|
|
83
|
-
);
|
|
78
|
+
logger.error(`[cron] Failed to load ${file}: ${message}`);
|
|
84
79
|
}
|
|
85
80
|
}
|
|
86
81
|
}
|