@rebasepro/server-core 0.6.1 → 0.7.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.
Files changed (165) hide show
  1. package/package.json +14 -14
  2. package/src/api/errors.ts +16 -2
  3. package/src/api/graphql/graphql-schema-generator.ts +45 -10
  4. package/src/api/server.ts +10 -2
  5. package/src/api/types.ts +6 -0
  6. package/src/auth/adapter-middleware.ts +10 -2
  7. package/src/auth/admin-roles-route.ts +36 -0
  8. package/src/auth/admin-users-route.ts +302 -0
  9. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  10. package/src/auth/api-keys/api-key-routes.ts +12 -2
  11. package/src/auth/api-keys/api-key-store.ts +83 -66
  12. package/src/auth/api-keys/api-key-types.ts +8 -0
  13. package/src/auth/apple-oauth.ts +2 -1
  14. package/src/auth/auth-hooks.ts +21 -0
  15. package/src/auth/bitbucket-oauth.ts +2 -1
  16. package/src/auth/builtin-auth-adapter.ts +28 -4
  17. package/src/auth/custom-auth-adapter.ts +2 -0
  18. package/src/auth/discord-oauth.ts +2 -1
  19. package/src/auth/facebook-oauth.ts +2 -1
  20. package/src/auth/github-oauth.ts +2 -1
  21. package/src/auth/gitlab-oauth.ts +2 -1
  22. package/src/auth/google-oauth.ts +8 -4
  23. package/src/auth/index.ts +2 -0
  24. package/src/auth/interfaces.ts +27 -0
  25. package/src/auth/linkedin-oauth.ts +2 -1
  26. package/src/auth/magic-link-routes.ts +167 -0
  27. package/src/auth/mfa-crypto.ts +91 -0
  28. package/src/auth/mfa-routes.ts +34 -10
  29. package/src/auth/microsoft-oauth.ts +2 -1
  30. package/src/auth/middleware.ts +10 -1
  31. package/src/auth/reset-password-admin.ts +17 -1
  32. package/src/auth/routes.ts +77 -8
  33. package/src/auth/session-routes.ts +15 -3
  34. package/src/auth/slack-oauth.ts +2 -1
  35. package/src/auth/spotify-oauth.ts +2 -1
  36. package/src/auth/twitter-oauth.ts +8 -1
  37. package/src/cron/cron-store.ts +25 -23
  38. package/src/email/index.ts +3 -2
  39. package/src/email/templates.ts +82 -0
  40. package/src/email/types.ts +16 -0
  41. package/src/init.ts +137 -5
  42. package/src/services/routed-realtime-service.ts +113 -0
  43. package/src/utils/dev-port.ts +13 -7
  44. package/test/auth-routes.test.ts +54 -4
  45. package/test/custom-auth-adapter.test.ts +20 -1
  46. package/test/env.test.ts +9 -19
  47. package/test/multi-datasource-routing.test.ts +113 -0
  48. package/test/routed-realtime-service.test.ts +86 -0
  49. package/test/transform-auth-response.test.ts +305 -0
  50. package/build-errors.txt +0 -52
  51. package/dist/api/ast-schema-editor.d.ts +0 -21
  52. package/dist/api/collections_for_test/callbacks_test_collection.d.ts +0 -2
  53. package/dist/api/errors.d.ts +0 -53
  54. package/dist/api/graphql/graphql-schema-generator.d.ts +0 -35
  55. package/dist/api/graphql/index.d.ts +0 -1
  56. package/dist/api/index.d.ts +0 -9
  57. package/dist/api/logs-routes.d.ts +0 -37
  58. package/dist/api/openapi-generator.d.ts +0 -16
  59. package/dist/api/rest/api-generator.d.ts +0 -88
  60. package/dist/api/rest/index.d.ts +0 -1
  61. package/dist/api/rest/query-parser.d.ts +0 -11
  62. package/dist/api/schema-editor-routes.d.ts +0 -3
  63. package/dist/api/server.d.ts +0 -40
  64. package/dist/api/types.d.ts +0 -98
  65. package/dist/auth/adapter-middleware.d.ts +0 -37
  66. package/dist/auth/admin-user-ops.d.ts +0 -79
  67. package/dist/auth/api-keys/api-key-middleware.d.ts +0 -39
  68. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -32
  69. package/dist/auth/api-keys/api-key-routes.d.ts +0 -20
  70. package/dist/auth/api-keys/api-key-store.d.ts +0 -35
  71. package/dist/auth/api-keys/api-key-types.d.ts +0 -88
  72. package/dist/auth/api-keys/index.d.ts +0 -17
  73. package/dist/auth/apple-oauth.d.ts +0 -30
  74. package/dist/auth/auth-hooks.d.ts +0 -225
  75. package/dist/auth/bitbucket-oauth.d.ts +0 -11
  76. package/dist/auth/builtin-auth-adapter.d.ts +0 -49
  77. package/dist/auth/crypto-utils.d.ts +0 -16
  78. package/dist/auth/custom-auth-adapter.d.ts +0 -39
  79. package/dist/auth/discord-oauth.d.ts +0 -14
  80. package/dist/auth/facebook-oauth.d.ts +0 -14
  81. package/dist/auth/github-oauth.d.ts +0 -15
  82. package/dist/auth/gitlab-oauth.d.ts +0 -13
  83. package/dist/auth/google-oauth.d.ts +0 -47
  84. package/dist/auth/index.d.ts +0 -36
  85. package/dist/auth/interfaces.d.ts +0 -401
  86. package/dist/auth/jwt.d.ts +0 -55
  87. package/dist/auth/linkedin-oauth.d.ts +0 -18
  88. package/dist/auth/mfa-routes.d.ts +0 -6
  89. package/dist/auth/mfa.d.ts +0 -49
  90. package/dist/auth/microsoft-oauth.d.ts +0 -16
  91. package/dist/auth/middleware.d.ts +0 -106
  92. package/dist/auth/password.d.ts +0 -22
  93. package/dist/auth/rate-limiter.d.ts +0 -50
  94. package/dist/auth/reset-password-admin.d.ts +0 -29
  95. package/dist/auth/rls-scope.d.ts +0 -31
  96. package/dist/auth/routes.d.ts +0 -33
  97. package/dist/auth/session-routes.d.ts +0 -25
  98. package/dist/auth/slack-oauth.d.ts +0 -12
  99. package/dist/auth/spotify-oauth.d.ts +0 -12
  100. package/dist/auth/twitter-oauth.d.ts +0 -18
  101. package/dist/backend-CIxN4FVm.js +0 -15
  102. package/dist/backend-CIxN4FVm.js.map +0 -1
  103. package/dist/chunk-Dze3rakg.js +0 -42
  104. package/dist/collections/BackendCollectionRegistry.d.ts +0 -13
  105. package/dist/collections/loader.d.ts +0 -5
  106. package/dist/cron/cron-loader.d.ts +0 -17
  107. package/dist/cron/cron-routes.d.ts +0 -14
  108. package/dist/cron/cron-scheduler.d.ts +0 -106
  109. package/dist/cron/cron-store.d.ts +0 -32
  110. package/dist/cron/index.d.ts +0 -6
  111. package/dist/db/interfaces.d.ts +0 -18
  112. package/dist/dist-CZKP-Xz4.js +0 -832
  113. package/dist/dist-CZKP-Xz4.js.map +0 -1
  114. package/dist/email/index.d.ts +0 -6
  115. package/dist/email/smtp-email-service.d.ts +0 -25
  116. package/dist/email/templates.d.ts +0 -42
  117. package/dist/email/types.d.ts +0 -108
  118. package/dist/env.d.ts +0 -102
  119. package/dist/from-VbwD7xRf.js +0 -3849
  120. package/dist/from-VbwD7xRf.js.map +0 -1
  121. package/dist/functions/function-loader.d.ts +0 -17
  122. package/dist/functions/function-routes.d.ts +0 -10
  123. package/dist/functions/index.d.ts +0 -3
  124. package/dist/history/history-routes.d.ts +0 -23
  125. package/dist/history/index.d.ts +0 -1
  126. package/dist/index.d.ts +0 -33
  127. package/dist/index.es.js +0 -61731
  128. package/dist/index.es.js.map +0 -1
  129. package/dist/index.umd.js +0 -73221
  130. package/dist/index.umd.js.map +0 -1
  131. package/dist/init/docs.d.ts +0 -4
  132. package/dist/init/health.d.ts +0 -2
  133. package/dist/init/middlewares.d.ts +0 -10
  134. package/dist/init/shutdown.d.ts +0 -11
  135. package/dist/init/storage.d.ts +0 -5
  136. package/dist/init.d.ts +0 -258
  137. package/dist/jwt-DHcQRGC3.js +0 -4168
  138. package/dist/jwt-DHcQRGC3.js.map +0 -1
  139. package/dist/logger-BYU66ENZ.js +0 -94
  140. package/dist/logger-BYU66ENZ.js.map +0 -1
  141. package/dist/ms-BeBSuOXN.js +0 -125
  142. package/dist/ms-BeBSuOXN.js.map +0 -1
  143. package/dist/multipart-parser-CedBDOeC.js +0 -299
  144. package/dist/multipart-parser-CedBDOeC.js.map +0 -1
  145. package/dist/serve-spa.d.ts +0 -30
  146. package/dist/services/driver-registry.d.ts +0 -78
  147. package/dist/services/webhook-service.d.ts +0 -29
  148. package/dist/singleton.d.ts +0 -35
  149. package/dist/src-COaj0G3P.js +0 -1182
  150. package/dist/src-COaj0G3P.js.map +0 -1
  151. package/dist/storage/LocalStorageController.d.ts +0 -46
  152. package/dist/storage/S3StorageController.d.ts +0 -36
  153. package/dist/storage/image-transform.d.ts +0 -50
  154. package/dist/storage/index.d.ts +0 -28
  155. package/dist/storage/routes.d.ts +0 -38
  156. package/dist/storage/storage-registry.d.ts +0 -78
  157. package/dist/storage/tus-handler.d.ts +0 -51
  158. package/dist/storage/types.d.ts +0 -103
  159. package/dist/types/index.d.ts +0 -11
  160. package/dist/utils/dev-port.d.ts +0 -35
  161. package/dist/utils/logger.d.ts +0 -31
  162. package/dist/utils/logging.d.ts +0 -9
  163. package/dist/utils/request-id.d.ts +0 -4
  164. package/dist/utils/request-logger.d.ts +0 -19
  165. package/dist/utils/sql.d.ts +0 -27
@@ -0,0 +1,167 @@
1
+ import { Hono } from "hono";
2
+ import type { AuthModuleConfig } from "./routes";
3
+ import type { ResolvedAuthHooks } from "./auth-hooks";
4
+ import type { HonoEnv } from "../api/types";
5
+ import { ApiError } from "../api/errors";
6
+ import { generateSecureToken, hashToken } from "./admin-user-ops";
7
+ import { getMagicLinkTemplate } from "../email/templates";
8
+ import { strictAuthLimiter } from "./rate-limiter";
9
+ import { z } from "zod";
10
+ import { logger } from "../utils/logger";
11
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
12
+
13
+ /**
14
+ * Magic link token expiry (15 minutes from now)
15
+ */
16
+ function getMagicLinkExpiry(): Date {
17
+ return new Date(Date.now() + 15 * 60 * 1000);
18
+ }
19
+
20
+ /**
21
+ * Mount magic link routes onto the auth router.
22
+ *
23
+ * Follows the same delegation pattern as `mountMfaRoutes()` and `mountSessionRoutes()`.
24
+ */
25
+ export function mountMagicLinkRoutes(deps: {
26
+ router: Hono<HonoEnv>;
27
+ config: AuthModuleConfig;
28
+ ops: ResolvedAuthHooks;
29
+ parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T;
30
+ buildAuthResponse: (
31
+ user: { id: string; email: string; displayName?: string | null; photoUrl?: string | null; metadata?: Record<string, unknown> | null },
32
+ roleIds: string[],
33
+ accessToken: string,
34
+ refreshToken: string
35
+ ) => unknown;
36
+ createSessionAndTokens: (userId: string, userAgent: string, ipAddress: string) => Promise<{ roleIds: string[]; accessToken: string; refreshToken: string }>;
37
+ applyTransformHook: (
38
+ response: AuthResponsePayload,
39
+ method: TransformAuthResponseContext["method"],
40
+ request: Request,
41
+ userId: string
42
+ ) => Promise<AuthResponsePayload>;
43
+ }) {
44
+ const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = deps;
45
+ const { authRepo, emailService, emailConfig } = config;
46
+
47
+ const magicLinkSchema = z.object({
48
+ email: z.string().email("Invalid email address").max(255)
49
+ });
50
+
51
+ const verifyMagicLinkSchema = z.object({
52
+ token: z.string().min(1, "Token is required")
53
+ });
54
+
55
+ function isEmailConfigured(): boolean {
56
+ return !!(emailService && emailService.isConfigured());
57
+ }
58
+
59
+ /**
60
+ * POST /auth/magic-link
61
+ * Request a magic link email
62
+ */
63
+ router.post("/magic-link", strictAuthLimiter, async (c) => {
64
+ const { email } = parseBody(magicLinkSchema, await c.req.json());
65
+
66
+ // Require email service
67
+ if (!isEmailConfigured()) {
68
+ throw ApiError.serviceUnavailable("Email service not configured. Magic link login is not available.", "EMAIL_NOT_CONFIGURED");
69
+ }
70
+
71
+ // Always return success (security: don't reveal if email exists)
72
+ const user = await authRepo.getUserByEmail(email);
73
+
74
+ if (user) {
75
+ // Call beforeLogin hook if provided (throw to reject)
76
+ if (ops.beforeLogin) {
77
+ await ops.beforeLogin(email, "magic-link");
78
+ }
79
+
80
+ // Generate magic link token
81
+ const token = generateSecureToken();
82
+ const tokenHash = hashToken(token);
83
+ const expiresAt = getMagicLinkExpiry();
84
+
85
+ await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
86
+
87
+ // Build magic link URL
88
+ const baseUrl = emailConfig?.magicLinkUrl || emailConfig?.resetPasswordUrl || "";
89
+ const magicLinkUrl = `${baseUrl}/auth/magic-link?token=${token}`;
90
+
91
+ // Get email template
92
+ const appName = emailConfig?.appName || "Rebase";
93
+ const templateFn = emailConfig?.templates?.magicLink;
94
+ const emailContent = templateFn
95
+ ? templateFn(magicLinkUrl, { email: user.email, displayName: user.displayName })
96
+ : getMagicLinkTemplate(magicLinkUrl, { email: user.email, displayName: user.displayName }, appName);
97
+
98
+ // Send email
99
+ try {
100
+ await emailService!.send({
101
+ to: user.email,
102
+ subject: emailContent.subject,
103
+ html: emailContent.html,
104
+ text: emailContent.text
105
+ });
106
+ } catch (emailError: unknown) {
107
+ logger.error("Failed to send magic link email", { error: emailError instanceof Error ? emailError.message : emailError });
108
+ // Don't reveal email sending failure to client
109
+ }
110
+ }
111
+
112
+ // Always return success
113
+ return c.json({
114
+ success: true,
115
+ message: "If an account with that email exists, a magic link has been sent."
116
+ });
117
+ });
118
+
119
+ /**
120
+ * POST /auth/magic-link/verify
121
+ * Verify magic link token and create session
122
+ */
123
+ router.post("/magic-link/verify", strictAuthLimiter, async (c) => {
124
+ const { token } = parseBody(verifyMagicLinkSchema, await c.req.json());
125
+
126
+ // Find valid token
127
+ const tokenHash = hashToken(token);
128
+ const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
129
+
130
+ if (!storedToken) {
131
+ throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
132
+ }
133
+
134
+ // Mark token as used (one-time use)
135
+ await authRepo.markMagicLinkTokenUsed(tokenHash);
136
+
137
+ // Get user
138
+ const user = await authRepo.getUserById(storedToken.userId);
139
+ if (!user) {
140
+ throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
141
+ }
142
+
143
+ // Clicking a magic link proves email ownership — auto-verify
144
+ if (!user.emailVerified) {
145
+ await authRepo.setEmailVerified(user.id, true);
146
+ user.emailVerified = true;
147
+ }
148
+
149
+ // Create session
150
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
151
+ user.id,
152
+ c.req.header("user-agent") || "unknown",
153
+ c.req.header("x-forwarded-for") || "unknown"
154
+ );
155
+
156
+ // Fire onAuthenticated hook (fire-and-forget)
157
+ if (ops.onAuthenticated) {
158
+ ops.onAuthenticated(user, "magic-link").catch(err => {
159
+ logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
160
+ });
161
+ }
162
+
163
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken) as AuthResponsePayload;
164
+ const finalResponse = await applyTransformHook(authResponse, "magic-link", c.req.raw, user.id);
165
+ return c.json(finalResponse);
166
+ });
167
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Envelope encryption for TOTP secrets using AES-256-GCM.
3
+ *
4
+ * - Derives a 32-byte key via SHA-256 from `MFA_ENCRYPTION_KEY` or `JWT_SECRET`.
5
+ * - Generates a random 12-byte IV per encryption call.
6
+ * - Returns ciphertexts in the format `iv_hex:authTag_hex:ciphertext_hex`.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
12
+ import { logger } from "../utils/logger";
13
+
14
+ const ALGORITHM = "aes-256-gcm";
15
+ const IV_BYTES = 12;
16
+ const AUTH_TAG_LENGTH = 16;
17
+
18
+ /**
19
+ * Resolve the raw key string from environment variables.
20
+ *
21
+ * Prefers `MFA_ENCRYPTION_KEY`; falls back to `JWT_SECRET` with a warning.
22
+ * Throws if neither is set.
23
+ */
24
+ function resolveKeyString(): string {
25
+ const explicit = process.env.MFA_ENCRYPTION_KEY;
26
+ if (explicit) {
27
+ return explicit;
28
+ }
29
+
30
+ const fallback = process.env.JWT_SECRET;
31
+ if (fallback) {
32
+ logger.warn(
33
+ "[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. " +
34
+ "Set a dedicated MFA_ENCRYPTION_KEY for production."
35
+ );
36
+ return fallback;
37
+ }
38
+
39
+ throw new Error(
40
+ "Cannot encrypt TOTP secret: neither MFA_ENCRYPTION_KEY nor JWT_SECRET is configured."
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Derive a deterministic 32-byte AES-256 key from the raw secret string.
46
+ */
47
+ function deriveKey(raw: string): Buffer {
48
+ return createHash("sha256").update(raw).digest();
49
+ }
50
+
51
+ /**
52
+ * Encrypt a plaintext TOTP secret.
53
+ *
54
+ * @param plaintext - The Base32 TOTP secret to encrypt.
55
+ * @returns A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
56
+ */
57
+ export function encryptTotpSecret(plaintext: string): string {
58
+ const key = deriveKey(resolveKeyString());
59
+ const iv = randomBytes(IV_BYTES);
60
+
61
+ const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
62
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
63
+ const authTag = cipher.getAuthTag();
64
+
65
+ return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
66
+ }
67
+
68
+ /**
69
+ * Decrypt a previously encrypted TOTP secret.
70
+ *
71
+ * @param ciphertext - A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
72
+ * @returns The original Base32 TOTP secret.
73
+ */
74
+ export function decryptTotpSecret(ciphertext: string): string {
75
+ const parts = ciphertext.split(":");
76
+ if (parts.length !== 3) {
77
+ throw new Error("Invalid encrypted TOTP secret format — expected iv:authTag:ciphertext");
78
+ }
79
+
80
+ const [ivHex, authTagHex, encryptedHex] = parts;
81
+ const key = deriveKey(resolveKeyString());
82
+ const iv = Buffer.from(ivHex, "hex");
83
+ const authTag = Buffer.from(authTagHex, "hex");
84
+ const encrypted = Buffer.from(encryptedHex, "hex");
85
+
86
+ const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
87
+ decipher.setAuthTag(authTag);
88
+
89
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
90
+ return decrypted.toString("utf8");
91
+ }
@@ -11,21 +11,30 @@ import {
11
11
  generateRecoveryCodes,
12
12
  hashRecoveryCode
13
13
  } from "./mfa";
14
+ import { encryptTotpSecret, decryptTotpSecret } from "./mfa-crypto";
14
15
  import {
15
16
  generateAccessToken,
16
17
  generateRefreshToken,
17
18
  hashRefreshToken,
18
19
  getRefreshTokenExpiry,
19
- getAccessTokenExpiry
20
+ getAccessTokenExpiry,
21
+ type AccessTokenPayload
20
22
  } from "./jwt";
21
23
  import type { AuthModuleConfig } from "./routes";
22
24
  import { resolveAuthHooks } from "./auth-hooks";
25
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
23
26
 
24
27
  export function mountMfaRoutes(
25
28
  router: Hono<HonoEnv>,
26
29
  config: AuthModuleConfig,
27
30
  ops: ReturnType<typeof resolveAuthHooks>,
28
- parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T
31
+ parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T,
32
+ applyTransformHook?: (
33
+ response: AuthResponsePayload,
34
+ method: TransformAuthResponseContext["method"],
35
+ request: Request,
36
+ userId: string
37
+ ) => Promise<AuthResponsePayload>
29
38
  ): void {
30
39
  const authRepo = config.authRepo;
31
40
  const emailConfig = config.emailConfig;
@@ -53,11 +62,12 @@ export function mountMfaRoutes(
53
62
  // Generate TOTP secret
54
63
  const { secret, uri } = generateTotpSecret(issuer, user.email);
55
64
 
56
- // Store the factor (unverified until user confirms with a valid code)
65
+ // Encrypt the TOTP secret before storage (AES-256-GCM envelope encryption)
66
+ const encryptedSecret = encryptTotpSecret(secret);
57
67
  const factor = await authRepo.createMfaFactor(
58
68
  user.id,
59
69
  "totp",
60
- secret, // In production, encrypt this before storage
70
+ encryptedSecret,
61
71
  friendlyName
62
72
  );
63
73
 
@@ -110,8 +120,9 @@ export function mountMfaRoutes(
110
120
  throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
111
121
  }
112
122
 
113
- // Verify the TOTP code
114
- const secretBuffer = base32Decode(factor.secretEncrypted);
123
+ // Decrypt and verify the TOTP code
124
+ const decryptedSecret = decryptTotpSecret(factor.secretEncrypted);
125
+ const secretBuffer = base32Decode(decryptedSecret);
115
126
  const isValid = verifyTotp(secretBuffer, code);
116
127
 
117
128
  if (!isValid) {
@@ -191,7 +202,8 @@ export function mountMfaRoutes(
191
202
  }
192
203
 
193
204
  // Try TOTP verification first (standard 6-digit codes)
194
- const secretBuffer = base32Decode(factor.secretEncrypted);
205
+ const decryptedSecret = decryptTotpSecret(factor.secretEncrypted);
206
+ const secretBuffer = base32Decode(decryptedSecret);
195
207
  let isValid = verifyTotp(secretBuffer, code);
196
208
 
197
209
  // Fall back to recovery code verification if TOTP didn't match
@@ -231,13 +243,17 @@ export function mountMfaRoutes(
231
243
  });
232
244
  }
233
245
 
234
- return c.json({
246
+ let mfaResponse: AuthResponsePayload = {
235
247
  tokens: {
236
248
  accessToken,
237
249
  refreshToken,
238
250
  accessTokenExpiresAt: getAccessTokenExpiry()
239
251
  }
240
- });
252
+ };
253
+ if (applyTransformHook) {
254
+ mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.userId);
255
+ }
256
+ return c.json(mfaResponse);
241
257
  });
242
258
 
243
259
  /**
@@ -267,11 +283,19 @@ export function mountMfaRoutes(
267
283
  * Remove an MFA factor
268
284
  */
269
285
  router.delete("/mfa/unenroll", requireAuth, async (c) => {
270
- const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
286
+ const userCtx = c.get("user") as AccessTokenPayload | undefined;
271
287
  if (!userCtx) {
272
288
  throw ApiError.unauthorized("Not authenticated");
273
289
  }
274
290
 
291
+ // Require aal2 (MFA-verified session) to unenroll MFA factors
292
+ if (userCtx.aal !== "aal2") {
293
+ throw ApiError.forbidden(
294
+ "MFA verification required to unenroll. Please re-authenticate with your second factor.",
295
+ "AAL2_REQUIRED"
296
+ );
297
+ }
298
+
275
299
  const unenrollSchema = z.object({
276
300
  factorId: z.string().min(1, "Factor ID is required")
277
301
  });
@@ -78,7 +78,8 @@ export function createMicrosoftProvider(config: {
78
78
  providerId: profileData.id,
79
79
  email,
80
80
  displayName: profileData.displayName || null,
81
- photoUrl: null
81
+ photoUrl: null,
82
+ emailVerified: true
82
83
  };
83
84
  } catch (error) {
84
85
  logger.error("Microsoft OAuth error", { error: error });
@@ -22,6 +22,13 @@ export type AuthResult = boolean | null | undefined | { userId?: string; uid?: s
22
22
  export interface AuthMiddlewareOptions {
23
23
  /** DataDriver to scope via withAuth() for RLS */
24
24
  driver: DataDriver;
25
+ /**
26
+ * Optional per-request driver resolver for multi-data-source backends.
27
+ * Given the request context, returns the unscoped delegate to use (e.g.
28
+ * Postgres vs Mongo, picked by the request's collection data source).
29
+ * When omitted, `driver` is used for every request.
30
+ */
31
+ resolveDriver?: (c: Context<HonoEnv>) => DataDriver;
25
32
  /**
26
33
  * If true, return 401 when no valid token is present.
27
34
  *
@@ -232,9 +239,11 @@ export function extractUserFromToken(token: string): AccessTokenPayload | null {
232
239
  */
233
240
 
234
241
  export function createAuthMiddleware(options: AuthMiddlewareOptions): MiddlewareHandler<HonoEnv> {
235
- const { driver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
242
+ const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
236
243
 
237
244
  return async (c, next) => {
245
+ // Pick the per-request delegate (multi-data-source) before scoping.
246
+ const driver = resolveDriver ? resolveDriver(c) : baseDriver;
238
247
  if (validator) {
239
248
  // Custom validator path (e.g., Firebase Auth, API keys)
240
249
  try {
@@ -54,8 +54,24 @@ export function createResetPasswordRoute(config: ResetPasswordRouteConfig): Hono
54
54
  let invitationSent = false;
55
55
  let temporaryPassword: string | undefined;
56
56
 
57
+ // Parse optional body — if a password is provided, set it directly
58
+ const body = await c.req.json().catch(() => ({}));
59
+
60
+ if (body.password) {
61
+ const password = body.password as string;
62
+ const validation = ops.validatePasswordStrength(password);
63
+ if (!validation.valid) {
64
+ throw ApiError.badRequest(
65
+ `Password too weak: ${validation.errors.join(", ")}`
66
+ );
67
+ }
68
+ const passwordHash = await ops.hashPassword(password);
69
+ await authRepo.updatePassword(existing.id, passwordHash);
70
+ temporaryPassword = undefined;
71
+ invitationSent = false;
72
+ }
57
73
  // 1. Collection-level hook (closest to the data)
58
- if (collectionAuthConfig?.onResetPassword) {
74
+ else if (collectionAuthConfig?.onResetPassword) {
59
75
  const isEmailConfigured = !!(emailService && emailService.isConfigured());
60
76
  const hookResult = await collectionAuthConfig.onResetPassword(existing.id, {
61
77
  hashPassword: (password: string) => ops.hashPassword(password),
@@ -15,6 +15,8 @@ import { z } from "zod";
15
15
  import { logger } from "../utils/logger";
16
16
  import { mountMfaRoutes } from "./mfa-routes";
17
17
  import { mountSessionRoutes } from "./session-routes";
18
+ import { mountMagicLinkRoutes } from "./magic-link-routes";
19
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
18
20
 
19
21
  /**
20
22
  * Shared configuration for auth and admin route factories.
@@ -43,6 +45,8 @@ export interface AuthModuleConfig {
43
45
  * When not provided, falls back to checking if any users exist.
44
46
  */
45
47
  isBootstrapCompleted?: () => Promise<boolean>;
48
+ /** Enable magic link (passwordless email) login. Requires email service. */
49
+ enableMagicLink?: boolean;
46
50
  }
47
51
 
48
52
  /**
@@ -53,7 +57,7 @@ function buildAuthResponse(
53
57
  roleIds: string[],
54
58
  accessToken: string,
55
59
  refreshToken: string
56
- ) {
60
+ ): AuthResponsePayload {
57
61
  return {
58
62
  user: {
59
63
  uid: user.id,
@@ -95,6 +99,29 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
95
99
  const { emailService, emailConfig, allowRegistration = false } = config;
96
100
  const ops = resolveAuthHooks(config.authHooks);
97
101
 
102
+ /**
103
+ * Apply the `transformAuthResponse` hook if provided.
104
+ *
105
+ * Errors are caught and logged — the untransformed response is returned
106
+ * as a graceful fallback so auth never breaks due to a hook failure.
107
+ */
108
+ async function applyTransformHook(
109
+ response: AuthResponsePayload,
110
+ method: TransformAuthResponseContext["method"],
111
+ request: Request,
112
+ userId: string
113
+ ): Promise<AuthResponsePayload> {
114
+ if (!ops.transformAuthResponse) return response;
115
+ try {
116
+ return await ops.transformAuthResponse(response, { userId, method, request });
117
+ } catch (err) {
118
+ logger.error("[AuthHooks] transformAuthResponse error", {
119
+ error: err instanceof Error ? err.message : err
120
+ });
121
+ return response;
122
+ }
123
+ }
124
+
98
125
  // ── Zod input schemas ──────────────────────────────────────────────
99
126
  const registerSchema = z.object({
100
127
  email: z.string().email("Invalid email address").max(255),
@@ -291,7 +318,9 @@ displayName: user.displayName });
291
318
  });
292
319
  }
293
320
 
294
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
321
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
322
+ const finalResponse = await applyTransformHook(authResponse, "register", c.req.raw, user.id);
323
+ return c.json(finalResponse, 201);
295
324
  });
296
325
 
297
326
  /**
@@ -327,6 +356,11 @@ displayName: user.displayName });
327
356
 
328
357
  const isValidPassword = await ops.verifyPassword(password, user.passwordHash);
329
358
  if (!isValidPassword) {
359
+ logger.warn("[Security Audit] Auth login failure", {
360
+ eventType: "auth.login.failure",
361
+ email,
362
+ userId: user.id
363
+ });
330
364
  throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
331
365
  }
332
366
  }
@@ -344,7 +378,15 @@ displayName: user.displayName });
344
378
  });
345
379
  }
346
380
 
347
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
381
+ logger.info("[Security Audit] Auth login success", {
382
+ eventType: "auth.login.success",
383
+ userId: user.id,
384
+ email
385
+ });
386
+
387
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
388
+ const finalResponse = await applyTransformHook(authResponse, "login", c.req.raw, user.id);
389
+ return c.json(finalResponse);
348
390
  });
349
391
 
350
392
  /**
@@ -374,6 +416,13 @@ displayName: user.displayName });
374
416
  user = await authRepo.getUserByEmail(externalUser.email);
375
417
 
376
418
  if (user) {
419
+ // Only auto-link if the OAuth provider confirmed the email is verified
420
+ if (!externalUser.emailVerified) {
421
+ throw ApiError.forbidden(
422
+ "Cannot auto-link account: email not verified by the OAuth provider. Please log in with your password first and link the provider from your profile.",
423
+ "EMAIL_NOT_VERIFIED"
424
+ );
425
+ }
377
426
  // Link Provider to existing account
378
427
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
379
428
 
@@ -430,7 +479,9 @@ displayName: user.displayName });
430
479
  c.req.header("x-forwarded-for") || "unknown"
431
480
  );
432
481
 
433
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
482
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
483
+ const finalResponse = await applyTransformHook(authResponse, "oauth", c.req.raw, user.id);
484
+ return c.json(finalResponse);
434
485
  });
435
486
  }
436
487
  }
@@ -706,13 +757,15 @@ aal: "aal1" };
706
757
  ipAddress
707
758
  );
708
759
 
709
- return c.json({
760
+ const refreshResponse: AuthResponsePayload = {
710
761
  tokens: {
711
762
  accessToken: newAccessToken,
712
763
  refreshToken: newRefreshToken,
713
764
  accessTokenExpiresAt: getAccessTokenExpiry()
714
765
  }
715
- });
766
+ };
767
+ const finalResponse = await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.userId);
768
+ return c.json(finalResponse);
716
769
  });
717
770
 
718
771
  mountSessionRoutes({
@@ -721,13 +774,29 @@ aal: "aal1" };
721
774
  ops,
722
775
  parseBody,
723
776
  buildAuthResponse,
724
- createSessionAndTokens
777
+ createSessionAndTokens,
778
+ applyTransformHook
725
779
  });
726
780
 
727
781
  // ═══════════════════════════════════════════════════════════════════════
728
782
  // MFA / TOTP
729
783
  // ═══════════════════════════════════════════════════════════════════════
730
- mountMfaRoutes(router, config, ops, parseBody);
784
+ mountMfaRoutes(router, config, ops, parseBody, applyTransformHook);
785
+
786
+ // ═══════════════════════════════════════════════════════════════════════
787
+ // Magic Link (passwordless email login)
788
+ // ═══════════════════════════════════════════════════════════════════════
789
+ if (config.enableMagicLink) {
790
+ mountMagicLinkRoutes({
791
+ router,
792
+ config,
793
+ ops,
794
+ parseBody,
795
+ buildAuthResponse,
796
+ createSessionAndTokens,
797
+ applyTransformHook
798
+ });
799
+ }
731
800
 
732
801
  return router;
733
802
  }
@@ -10,6 +10,7 @@ import { hashRefreshToken } from "./jwt";
10
10
  import type { AuthModuleConfig } from "./routes";
11
11
  import { resolveAuthHooks } from "./auth-hooks";
12
12
  import type { CreateUserData } from "./interfaces";
13
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
13
14
 
14
15
  interface SessionRoutesConfig {
15
16
  router: Hono<HonoEnv>;
@@ -27,10 +28,16 @@ interface SessionRoutesConfig {
27
28
  accessToken: string;
28
29
  refreshToken: string;
29
30
  }>;
31
+ applyTransformHook: (
32
+ response: AuthResponsePayload,
33
+ method: TransformAuthResponseContext["method"],
34
+ request: Request,
35
+ userId: string
36
+ ) => Promise<AuthResponsePayload>;
30
37
  }
31
38
 
32
39
  export function mountSessionRoutes(opts: SessionRoutesConfig): void {
33
- const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens } = opts;
40
+ const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
34
41
  const authRepo = config.authRepo;
35
42
 
36
43
  const logoutSchema = z.object({
@@ -230,6 +237,7 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
230
237
  needsSetup,
231
238
  registrationEnabled: registrationAllowed,
232
239
  emailServiceEnabled: isEmailConfigured(),
240
+ magicLinkEnabled: !!config.enableMagicLink && isEmailConfigured(),
233
241
  enabledProviders
234
242
  });
235
243
  });
@@ -283,7 +291,9 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
283
291
  });
284
292
  }
285
293
 
286
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
294
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken) as AuthResponsePayload;
295
+ const finalResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
296
+ return c.json(finalResponse, 201);
287
297
  });
288
298
 
289
299
  /**
@@ -336,6 +346,8 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
336
346
  c.req.header("x-forwarded-for") || "unknown"
337
347
  );
338
348
 
339
- return c.json(buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken));
349
+ const authResponse = buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken) as AuthResponsePayload;
350
+ const finalResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
351
+ return c.json(finalResponse);
340
352
  });
341
353
  }
@@ -61,7 +61,8 @@ export function createSlackProvider(config: { clientId: string; clientSecret: st
61
61
  providerId: p.sub,
62
62
  email: p.email,
63
63
  displayName: p.name || null,
64
- photoUrl: p.picture || null
64
+ photoUrl: p.picture || null,
65
+ emailVerified: p.email_verified === true
65
66
  };
66
67
  } catch (error) {
67
68
  logger.error("Slack OAuth error", { error: error });
@@ -57,7 +57,8 @@ export function createSpotifyProvider(config: { clientId: string; clientSecret:
57
57
  providerId: p.id,
58
58
  email: p.email,
59
59
  displayName: p.display_name || null,
60
- photoUrl: p.images?.[0]?.url || null
60
+ photoUrl: p.images?.[0]?.url || null,
61
+ emailVerified: true
61
62
  };
62
63
  } catch (error) {
63
64
  logger.error("Spotify OAuth error", { error: error });