@rebasepro/server-core 0.6.1 → 0.8.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 (116) hide show
  1. package/dist/api/graphql/graphql-schema-generator.d.ts +9 -0
  2. package/dist/api/rest/api-generator.d.ts +2 -14
  3. package/dist/api/rest/query-parser.d.ts +2 -5
  4. package/dist/api/types.d.ts +8 -2
  5. package/dist/auth/adapter-middleware.d.ts +7 -1
  6. package/dist/auth/admin-roles-route.d.ts +18 -0
  7. package/dist/auth/admin-users-route.d.ts +28 -0
  8. package/dist/auth/api-keys/api-key-types.d.ts +8 -0
  9. package/dist/auth/auth-hooks.d.ts +17 -0
  10. package/dist/auth/builtin-auth-adapter.d.ts +3 -1
  11. package/dist/auth/index.d.ts +1 -0
  12. package/dist/auth/interfaces.d.ts +32 -2
  13. package/dist/auth/magic-link-routes.d.ts +30 -0
  14. package/dist/auth/mfa-crypto.d.ts +23 -0
  15. package/dist/auth/mfa-routes.d.ts +2 -1
  16. package/dist/auth/middleware.d.ts +9 -2
  17. package/dist/auth/routes.d.ts +3 -1
  18. package/dist/auth/session-routes.d.ts +2 -0
  19. package/dist/backend-CIxN4FVm.js.map +1 -1
  20. package/dist/base64-js-C_frYBkI.js +1687 -0
  21. package/dist/base64-js-C_frYBkI.js.map +1 -0
  22. package/dist/{dist-CZKP-Xz4.js → dist-DMO-zF6D.js} +2 -2
  23. package/dist/{dist-CZKP-Xz4.js.map → dist-DMO-zF6D.js.map} +1 -1
  24. package/dist/email/index.d.ts +2 -2
  25. package/dist/email/templates.d.ts +8 -0
  26. package/dist/email/types.d.ts +18 -0
  27. package/dist/functions/define-function.d.ts +50 -0
  28. package/dist/functions/index.d.ts +2 -0
  29. package/dist/index.d.ts +16 -18
  30. package/dist/index.es.js +1947 -2356
  31. package/dist/index.es.js.map +1 -1
  32. package/dist/index.umd.js +40615 -10203
  33. package/dist/index.umd.js.map +1 -1
  34. package/dist/init.d.ts +68 -12
  35. package/dist/jws-BqRRaK11.js +616 -0
  36. package/dist/jws-BqRRaK11.js.map +1 -0
  37. package/dist/{jwt-DHcQRGC3.js → jwt-BETC8a1J.js} +3 -611
  38. package/dist/jwt-BETC8a1J.js.map +1 -0
  39. package/dist/services/routed-realtime-service.d.ts +43 -0
  40. package/dist/{src-COaj0G3P.js → src-CB2PIpBe.js} +2 -2
  41. package/dist/{src-COaj0G3P.js.map → src-CB2PIpBe.js.map} +1 -1
  42. package/dist/src-DjzOT1kG.js +29746 -0
  43. package/dist/src-DjzOT1kG.js.map +1 -0
  44. package/dist/storage/GCSStorageController.d.ts +43 -0
  45. package/dist/storage/index.d.ts +5 -2
  46. package/dist/storage/routes.d.ts +33 -1
  47. package/dist/storage/tus-handler.d.ts +3 -1
  48. package/dist/storage/types.d.ts +28 -3
  49. package/dist/utils/dev-port.d.ts +2 -0
  50. package/package.json +13 -5
  51. package/src/api/errors.ts +16 -2
  52. package/src/api/graphql/graphql-schema-generator.ts +50 -15
  53. package/src/api/openapi-generator.ts +2 -2
  54. package/src/api/rest/api-generator.ts +44 -123
  55. package/src/api/rest/query-parser.ts +6 -23
  56. package/src/api/server.ts +10 -2
  57. package/src/api/types.ts +8 -2
  58. package/src/auth/adapter-middleware.ts +10 -2
  59. package/src/auth/admin-roles-route.ts +36 -0
  60. package/src/auth/admin-users-route.ts +302 -0
  61. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  62. package/src/auth/api-keys/api-key-routes.ts +12 -2
  63. package/src/auth/api-keys/api-key-store.ts +83 -66
  64. package/src/auth/api-keys/api-key-types.ts +8 -0
  65. package/src/auth/apple-oauth.ts +2 -1
  66. package/src/auth/auth-hooks.ts +21 -0
  67. package/src/auth/bitbucket-oauth.ts +2 -1
  68. package/src/auth/builtin-auth-adapter.ts +29 -6
  69. package/src/auth/custom-auth-adapter.ts +2 -0
  70. package/src/auth/discord-oauth.ts +2 -1
  71. package/src/auth/facebook-oauth.ts +2 -1
  72. package/src/auth/github-oauth.ts +2 -1
  73. package/src/auth/gitlab-oauth.ts +2 -1
  74. package/src/auth/google-oauth.ts +8 -4
  75. package/src/auth/index.ts +2 -0
  76. package/src/auth/interfaces.ts +38 -2
  77. package/src/auth/linkedin-oauth.ts +2 -1
  78. package/src/auth/magic-link-routes.ts +167 -0
  79. package/src/auth/mfa-crypto.ts +91 -0
  80. package/src/auth/mfa-routes.ts +34 -10
  81. package/src/auth/microsoft-oauth.ts +2 -1
  82. package/src/auth/middleware.ts +14 -5
  83. package/src/auth/reset-password-admin.ts +17 -1
  84. package/src/auth/routes.ts +78 -10
  85. package/src/auth/session-routes.ts +15 -3
  86. package/src/auth/slack-oauth.ts +2 -1
  87. package/src/auth/spotify-oauth.ts +2 -1
  88. package/src/auth/twitter-oauth.ts +8 -1
  89. package/src/cron/cron-store.ts +25 -23
  90. package/src/email/index.ts +3 -2
  91. package/src/email/templates.ts +82 -0
  92. package/src/email/types.ts +16 -0
  93. package/src/functions/define-function.ts +59 -0
  94. package/src/functions/function-loader.ts +16 -0
  95. package/src/functions/index.ts +2 -0
  96. package/src/index.ts +70 -37
  97. package/src/init.ts +214 -25
  98. package/src/services/routed-realtime-service.ts +113 -0
  99. package/src/storage/GCSStorageController.ts +334 -0
  100. package/src/storage/index.ts +9 -3
  101. package/src/storage/routes.ts +191 -23
  102. package/src/storage/tus-handler.ts +16 -4
  103. package/src/storage/types.ts +25 -3
  104. package/src/utils/dev-port.ts +13 -7
  105. package/test/api-generator.test.ts +1 -1
  106. package/test/auth-config-types.test.ts +40 -0
  107. package/test/auth-routes.test.ts +54 -4
  108. package/test/custom-auth-adapter.test.ts +20 -1
  109. package/test/define-function.test.ts +45 -0
  110. package/test/env.test.ts +9 -19
  111. package/test/multi-datasource-routing.test.ts +113 -0
  112. package/test/routed-realtime-service.test.ts +86 -0
  113. package/test/storage-routes.test.ts +160 -0
  114. package/test/transform-auth-response.test.ts +305 -0
  115. package/dist/jwt-DHcQRGC3.js.map +0 -1
  116. package/test/backend-hooks-data.test.ts +0 -477
@@ -45,6 +45,7 @@ import {
45
45
  import type { PasswordValidationResult } from "./password";
46
46
  import type { AuthRepository, UserData, CreateUserData } from "./interfaces";
47
47
  import type { EmailService, EmailConfig } from "../email";
48
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
48
49
 
49
50
  /**
50
51
  * Authentication method identifier for lifecycle hooks.
@@ -181,6 +182,26 @@ export interface AuthHooks {
181
182
  */
182
183
  customizeAccessToken?(claims: Record<string, unknown>, user: UserData): Promise<Record<string, unknown>>;
183
184
 
185
+ /**
186
+ * Transform the auth response before sending it to the client.
187
+ *
188
+ * Called after successful login, register, refresh, OAuth, anonymous,
189
+ * magic-link, and MFA flows. The hook receives the fully-formed
190
+ * response and returns a (potentially enriched) response.
191
+ *
192
+ * Use cases:
193
+ * - Inject tokens from external auth systems (custom provider tokens, etc.)
194
+ * - Add project-specific metadata to the response
195
+ * - Enrich the user object with data from external sources
196
+ *
197
+ * The hook runs in the request path — keep it fast.
198
+ * Heavy work should be offloaded to `onAuthenticated` (fire-and-forget).
199
+ */
200
+ transformAuthResponse?(
201
+ response: AuthResponsePayload,
202
+ context: TransformAuthResponseContext
203
+ ): Promise<AuthResponsePayload>;
204
+
184
205
  /**
185
206
  * Called after a successful password reset.
186
207
  *
@@ -72,7 +72,8 @@ export function createBitbucketProvider(config: { clientId: string; clientSecret
72
72
  providerId: p.uuid,
73
73
  email,
74
74
  displayName: p.display_name || p.username || null,
75
- photoUrl: p.links?.avatar?.href || null
75
+ photoUrl: p.links?.avatar?.href || null,
76
+ emailVerified: true
76
77
  };
77
78
  } catch (error) {
78
79
  logger.error("Bitbucket OAuth error", { error: error });
@@ -22,11 +22,13 @@ import type {
22
22
  BootstrappedAuth
23
23
  } from "@rebasepro/types";
24
24
 
25
- import type { Hono } from "hono";
25
+ import { Hono } from "hono";
26
26
  import { verifyAccessToken } from "./jwt";
27
27
  import type { AccessTokenPayload } from "./jwt";
28
28
  import { createAuthRoutes } from "./routes";
29
29
  import { createResetPasswordRoute } from "./reset-password-admin";
30
+ import { createAdminRolesRoute } from "./admin-roles-route";
31
+ import { createAdminUsersRoute } from "./admin-users-route";
30
32
  import { prepareAdminUserValues, finalizeAdminUserCreation } from "./admin-user-ops";
31
33
  import type { AuthRepository, OAuthProvider } from "./interfaces";
32
34
  import type { AuthHooks, ResolvedAuthHooks } from "./auth-hooks";
@@ -54,14 +56,15 @@ export interface BuiltinAuthAdapterConfig {
54
56
  /** Default role to assign to new users. */
55
57
  defaultRole?: string;
56
58
  /** OAuth providers to register. */
57
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
- oauthProviders?: OAuthProvider<any>[];
59
+ oauthProviders?: OAuthProvider<unknown>[];
59
60
  /** Static service key for server-to-server auth. */
60
61
  serviceKey?: string;
61
62
  /** Auth hooks for customizing password, credentials, lifecycle, etc. */
62
63
  authHooks?: AuthHooks;
63
64
  /** The parsed auth config from the collection (if `auth` is an object, not just `true`). */
64
65
  collectionAuthConfig?: import("@rebasepro/types").AuthCollectionConfig;
66
+ /** Enable magic link (passwordless email) login. Requires email service. */
67
+ enableMagicLink?: boolean;
65
68
  }
66
69
 
67
70
  /**
@@ -81,7 +84,8 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
81
84
  oauthProviders = [],
82
85
  serviceKey,
83
86
  authHooks,
84
- collectionAuthConfig
87
+ collectionAuthConfig,
88
+ enableMagicLink = false
85
89
  } = config;
86
90
 
87
91
  const resolvedOps = resolveAuthHooks(authHooks);
@@ -188,12 +192,26 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
188
192
  allowRegistration,
189
193
  defaultRole,
190
194
  oauthProviders,
191
- authHooks
195
+ authHooks,
196
+ enableMagicLink
192
197
  });
193
198
  },
194
199
 
195
200
  createAdminRoutes(): Hono<HonoEnv> | undefined {
196
- return createResetPasswordRoute({
201
+ const router = new Hono<HonoEnv>();
202
+ const resetPasswordRoute = createResetPasswordRoute({
203
+ authRepo: authRepository,
204
+ emailService,
205
+ emailConfig,
206
+ serviceKey,
207
+ authHooks,
208
+ collectionAuthConfig
209
+ });
210
+ const rolesRoute = createAdminRolesRoute({
211
+ authRepo: authRepository,
212
+ serviceKey
213
+ });
214
+ const adminUsersRoute = createAdminUsersRoute({
197
215
  authRepo: authRepository,
198
216
  emailService,
199
217
  emailConfig,
@@ -201,6 +219,10 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
201
219
  authHooks,
202
220
  collectionAuthConfig
203
221
  });
222
+ router.route("/", resetPasswordRoute);
223
+ router.route("/", rolesRoute);
224
+ router.route("/", adminUsersRoute);
225
+ return router;
204
226
  },
205
227
 
206
228
  async prepareUserCreation(values, collectionAuth) {
@@ -245,6 +267,7 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
245
267
  sessionManagement: true,
246
268
  profileUpdate: true,
247
269
  emailVerification: !!emailService?.isConfigured(),
270
+ magicLink: enableMagicLink && !!emailService?.isConfigured(),
248
271
  enabledProviders,
249
272
  needsSetup
250
273
  };
@@ -51,6 +51,7 @@ export function createCustomAuthAdapter(options: CustomAuthAdapterOptions): Auth
51
51
  sessionManagement: false,
52
52
  profileUpdate: false,
53
53
  emailVerification: false,
54
+ magicLink: false,
54
55
  enabledProviders: [],
55
56
  ...options.capabilities
56
57
  };
@@ -76,6 +77,7 @@ export function createCustomAuthAdapter(options: CustomAuthAdapterOptions): Auth
76
77
 
77
78
  userManagement: options.userManagement,
78
79
 
80
+ transformAuthResponse: options.transformAuthResponse,
79
81
 
80
82
  getCapabilities() {
81
83
  return defaultCapabilities;
@@ -73,7 +73,8 @@ export function createDiscordProvider(config: { clientId: string; clientSecret:
73
73
  providerId: profileData.id,
74
74
  email: profileData.email,
75
75
  displayName: profileData.global_name || profileData.username || null,
76
- photoUrl
76
+ photoUrl,
77
+ emailVerified: profileData.verified === true
77
78
  };
78
79
  } catch (error) {
79
80
  logger.error("Discord OAuth error", { error: error });
@@ -62,7 +62,8 @@ export function createFacebookProvider(config: { clientId: string; clientSecret:
62
62
  providerId: profileData.id,
63
63
  email: profileData.email,
64
64
  displayName: profileData.name || null,
65
- photoUrl: profileData.picture?.data?.url || null
65
+ photoUrl: profileData.picture?.data?.url || null,
66
+ emailVerified: true
66
67
  };
67
68
  } catch (error) {
68
69
  logger.error("Facebook OAuth error", { error: error });
@@ -100,7 +100,8 @@ export function createGitHubProvider(config: { clientId: string; clientSecret: s
100
100
  providerId: String(profileData.id),
101
101
  email,
102
102
  displayName: profileData.name || profileData.login || null,
103
- photoUrl: profileData.avatar_url || null
103
+ photoUrl: profileData.avatar_url || null,
104
+ emailVerified: true
104
105
  };
105
106
  } catch (error) {
106
107
  logger.error("GitHub OAuth error", { error: error });
@@ -60,7 +60,8 @@ export function createGitLabProvider(config: {
60
60
  providerId: String(p.id),
61
61
  email: p.email,
62
62
  displayName: p.name || p.username || null,
63
- photoUrl: p.avatar_url || null
63
+ photoUrl: p.avatar_url || null,
64
+ emailVerified: true
64
65
  };
65
66
  } catch (error) {
66
67
  logger.error("GitLab OAuth error", { error: error });
@@ -89,7 +89,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
89
89
  providerId: content.sub,
90
90
  email: content.email || "",
91
91
  displayName: content.name || null,
92
- photoUrl: content.picture || null
92
+ photoUrl: content.picture || null,
93
+ emailVerified: true
93
94
  };
94
95
  }
95
96
 
@@ -115,7 +116,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
115
116
  providerId: info.sub,
116
117
  email: info.email,
117
118
  displayName: info.name || null,
118
- photoUrl: info.picture || null
119
+ photoUrl: info.picture || null,
120
+ emailVerified: true
119
121
  };
120
122
  }
121
123
 
@@ -176,7 +178,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
176
178
  providerId: content.sub,
177
179
  email: content.email || "",
178
180
  displayName: content.name || null,
179
- photoUrl: content.picture || null
181
+ photoUrl: content.picture || null,
182
+ emailVerified: true
180
183
  };
181
184
  }
182
185
 
@@ -202,7 +205,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
202
205
  providerId: info.sub,
203
206
  email: info.email,
204
207
  displayName: info.name || null,
205
- photoUrl: info.picture || null
208
+ photoUrl: info.picture || null,
209
+ emailVerified: true
206
210
  };
207
211
  }
208
212
 
package/src/auth/index.ts CHANGED
@@ -35,6 +35,8 @@ export type { AuthMiddlewareOptions, AuthResult } from "./middleware";
35
35
  export { createAuthRoutes } from "./routes";
36
36
  export type { AuthModuleConfig } from "./routes";
37
37
 
38
+ export { mountMagicLinkRoutes } from "./magic-link-routes";
39
+
38
40
  export { createResetPasswordRoute } from "./reset-password-admin";
39
41
  export type { ResetPasswordRouteConfig } from "./reset-password-admin";
40
42
 
@@ -60,6 +60,8 @@ export interface OAuthProviderProfile {
60
60
  email: string;
61
61
  displayName?: string | null;
62
62
  photoUrl?: string | null;
63
+ /** Whether the OAuth provider has verified the user's email address */
64
+ emailVerified?: boolean;
63
65
  }
64
66
 
65
67
  /**
@@ -72,8 +74,17 @@ export interface OAuthProvider<T = unknown> {
72
74
  /** Zod schema validating the expected request payload (e.g. { code: string }) */
73
75
  schema: z.ZodSchema<T>;
74
76
 
75
- /** Verify external tokens/codes and return a standardized user profile */
76
- verify: (payload: T) => Promise<OAuthProviderProfile | null>;
77
+ /**
78
+ * Verify external tokens/codes and return a standardized user profile.
79
+ *
80
+ * NOTE: Declared as method syntax (not arrow property) intentionally.
81
+ * This makes `OAuthProvider` bivariant in `T`, which is correct because
82
+ * each provider is self-contained — `schema` validates the request body
83
+ * and `verify` consumes the same `T`. Bivariance lets heterogeneous
84
+ * providers (`OAuthProvider<SpecificPayload>`) coexist in a single
85
+ * `OAuthProvider<unknown>[]` array without resorting to `any`.
86
+ */
87
+ verify(payload: T): Promise<OAuthProviderProfile | null>;
77
88
  }
78
89
 
79
90
  /**
@@ -129,6 +140,14 @@ export interface PasswordResetTokenInfo {
129
140
  expiresAt: Date;
130
141
  }
131
142
 
143
+ /**
144
+ * Magic link token info
145
+ */
146
+ export interface MagicLinkTokenInfo {
147
+ userId: string;
148
+ expiresAt: Date;
149
+ }
150
+
132
151
  // =============================================================================
133
152
  // AUTH REPOSITORY INTERFACES
134
153
  // =============================================================================
@@ -357,6 +376,23 @@ export interface TokenRepository {
357
376
  * Clean up expired tokens
358
377
  */
359
378
  deleteExpiredTokens(): Promise<void>;
379
+
380
+ // Magic link tokens
381
+
382
+ /**
383
+ * Create a magic link token
384
+ */
385
+ createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void>;
386
+
387
+ /**
388
+ * Find a valid (not expired, not used) magic link token by hash
389
+ */
390
+ findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null>;
391
+
392
+ /**
393
+ * Mark a magic link token as used
394
+ */
395
+ markMagicLinkTokenUsed(tokenHash: string): Promise<void>;
360
396
  }
361
397
 
362
398
  // =============================================================================
@@ -71,7 +71,8 @@ export function createLinkedinProvider(config: { clientId: string, clientSecret:
71
71
  providerId: profileData.sub,
72
72
  email: profileData.email,
73
73
  displayName: profileData.name || null,
74
- photoUrl: profileData.picture || null
74
+ photoUrl: profileData.picture || null,
75
+ emailVerified: profileData.email_verified === true
75
76
  };
76
77
  } catch (error) {
77
78
  logger.error("LinkedIn OAuth error", { error: error });
@@ -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
+ }