hazo_auth 6.0.0 → 7.0.1

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 (256) hide show
  1. package/README.md +233 -8
  2. package/SETUP_CHECKLIST.md +240 -0
  3. package/cli-src/cli/validate.ts +4 -0
  4. package/cli-src/lib/auth/nextauth_config.ts +101 -1
  5. package/cli-src/lib/cookies_config.server.ts +1 -0
  6. package/cli-src/lib/email_verification_config.server.ts +0 -34
  7. package/cli-src/lib/forgot_password_config.server.ts +0 -34
  8. package/cli-src/lib/login_config.server.ts +14 -31
  9. package/cli-src/lib/my_settings_config.server.ts +0 -3
  10. package/cli-src/lib/oauth_config.server.ts +58 -0
  11. package/cli-src/lib/otp_config.server.ts +91 -0
  12. package/cli-src/lib/register_config.server.ts +11 -31
  13. package/cli-src/lib/reset_password_config.server.ts +0 -31
  14. package/cli-src/lib/services/email_service.ts +3 -1
  15. package/cli-src/lib/services/email_template_manifest.ts +17 -0
  16. package/cli-src/lib/services/email_templates/otp_signin_code.html +13 -0
  17. package/cli-src/lib/services/email_templates/otp_signin_code.txt +5 -0
  18. package/cli-src/lib/services/index.ts +8 -2
  19. package/cli-src/lib/services/oauth_service.ts +197 -0
  20. package/cli-src/lib/services/otp_service.ts +295 -0
  21. package/cli-src/lib/services/session_token_service.ts +4 -1
  22. package/config/hazo_auth_config.example.ini +76 -41
  23. package/dist/cli/validate.d.ts.map +1 -1
  24. package/dist/cli/validate.js +4 -0
  25. package/dist/client.d.ts +2 -0
  26. package/dist/client.d.ts.map +1 -1
  27. package/dist/client.js +1 -0
  28. package/dist/components/layouts/create_firm/index.d.ts +4 -8
  29. package/dist/components/layouts/create_firm/index.d.ts.map +1 -1
  30. package/dist/components/layouts/create_firm/index.js +3 -3
  31. package/dist/components/layouts/email_verification/index.d.ts +4 -5
  32. package/dist/components/layouts/email_verification/index.d.ts.map +1 -1
  33. package/dist/components/layouts/email_verification/index.js +4 -4
  34. package/dist/components/layouts/forgot_password/index.d.ts +4 -5
  35. package/dist/components/layouts/forgot_password/index.d.ts.map +1 -1
  36. package/dist/components/layouts/forgot_password/index.js +2 -2
  37. package/dist/components/layouts/login/index.d.ts +19 -9
  38. package/dist/components/layouts/login/index.d.ts.map +1 -1
  39. package/dist/components/layouts/login/index.js +12 -6
  40. package/dist/components/layouts/otp/index.d.ts +17 -0
  41. package/dist/components/layouts/otp/index.d.ts.map +1 -0
  42. package/dist/components/layouts/otp/index.js +16 -0
  43. package/dist/components/layouts/register/index.d.ts +11 -7
  44. package/dist/components/layouts/register/index.d.ts.map +1 -1
  45. package/dist/components/layouts/register/index.js +8 -4
  46. package/dist/components/layouts/reset_password/index.d.ts +4 -5
  47. package/dist/components/layouts/reset_password/index.d.ts.map +1 -1
  48. package/dist/components/layouts/reset_password/index.js +5 -5
  49. package/dist/components/layouts/shared/components/already_logged_in_guard.d.ts +3 -5
  50. package/dist/components/layouts/shared/components/already_logged_in_guard.d.ts.map +1 -1
  51. package/dist/components/layouts/shared/components/already_logged_in_guard.js +2 -2
  52. package/dist/components/layouts/shared/components/facebook_sign_in_button.d.ts +25 -0
  53. package/dist/components/layouts/shared/components/facebook_sign_in_button.d.ts.map +1 -0
  54. package/dist/components/layouts/shared/components/facebook_sign_in_button.js +49 -0
  55. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
  56. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +8 -3
  57. package/dist/components/layouts/shared/components/two_column_auth_layout.d.ts +3 -6
  58. package/dist/components/layouts/shared/components/two_column_auth_layout.d.ts.map +1 -1
  59. package/dist/components/layouts/shared/components/two_column_auth_layout.js +8 -5
  60. package/dist/components/otp/OTPRequestForm.d.ts +11 -0
  61. package/dist/components/otp/OTPRequestForm.d.ts.map +1 -0
  62. package/dist/components/otp/OTPRequestForm.js +42 -0
  63. package/dist/components/otp/OTPVerifyForm.d.ts +16 -0
  64. package/dist/components/otp/OTPVerifyForm.d.ts.map +1 -0
  65. package/dist/components/otp/OTPVerifyForm.js +75 -0
  66. package/dist/components/otp/index.d.ts +5 -0
  67. package/dist/components/otp/index.d.ts.map +1 -0
  68. package/dist/components/otp/index.js +2 -0
  69. package/dist/components/ui/input-otp.d.ts +35 -0
  70. package/dist/components/ui/input-otp.d.ts.map +1 -0
  71. package/dist/components/ui/input-otp.js +44 -0
  72. package/dist/consent/consent_state.d.ts +18 -0
  73. package/dist/consent/consent_state.d.ts.map +1 -0
  74. package/dist/consent/consent_state.js +29 -0
  75. package/dist/consent/cookie_consent_banner.d.ts +11 -0
  76. package/dist/consent/cookie_consent_banner.d.ts.map +1 -0
  77. package/dist/consent/cookie_consent_banner.js +40 -0
  78. package/dist/consent/gtm_mapping.d.ts +13 -0
  79. package/dist/consent/gtm_mapping.d.ts.map +1 -0
  80. package/dist/consent/gtm_mapping.js +30 -0
  81. package/dist/consent/index.d.ts +7 -0
  82. package/dist/consent/index.d.ts.map +1 -0
  83. package/dist/consent/index.js +7 -0
  84. package/dist/consent/manage_modal.d.ts +2 -0
  85. package/dist/consent/manage_modal.d.ts.map +1 -0
  86. package/dist/consent/manage_modal.js +33 -0
  87. package/dist/consent/read_consent.d.ts +15 -0
  88. package/dist/consent/read_consent.d.ts.map +1 -0
  89. package/dist/consent/read_consent.js +23 -0
  90. package/dist/consent/use_consent.d.ts +7 -0
  91. package/dist/consent/use_consent.d.ts.map +1 -0
  92. package/dist/consent/use_consent.js +55 -0
  93. package/dist/lib/auth/nextauth_config.d.ts +10 -0
  94. package/dist/lib/auth/nextauth_config.d.ts.map +1 -1
  95. package/dist/lib/auth/nextauth_config.js +80 -2
  96. package/dist/lib/cookies_config.server.d.ts +1 -0
  97. package/dist/lib/cookies_config.server.d.ts.map +1 -1
  98. package/dist/lib/cookies_config.server.js +1 -0
  99. package/dist/lib/email_verification_config.server.d.ts +0 -3
  100. package/dist/lib/email_verification_config.server.d.ts.map +1 -1
  101. package/dist/lib/email_verification_config.server.js +0 -15
  102. package/dist/lib/forgot_password_config.server.d.ts +0 -3
  103. package/dist/lib/forgot_password_config.server.d.ts.map +1 -1
  104. package/dist/lib/forgot_password_config.server.js +0 -15
  105. package/dist/lib/login_config.server.d.ts +6 -3
  106. package/dist/lib/login_config.server.d.ts.map +1 -1
  107. package/dist/lib/login_config.server.js +7 -13
  108. package/dist/lib/my_settings_config.server.d.ts +0 -1
  109. package/dist/lib/my_settings_config.server.d.ts.map +1 -1
  110. package/dist/lib/my_settings_config.server.js +0 -2
  111. package/dist/lib/oauth_config.server.d.ts +17 -0
  112. package/dist/lib/oauth_config.server.d.ts.map +1 -1
  113. package/dist/lib/oauth_config.server.js +25 -0
  114. package/dist/lib/otp_config.server.d.ts +49 -0
  115. package/dist/lib/otp_config.server.d.ts.map +1 -0
  116. package/dist/lib/otp_config.server.js +48 -0
  117. package/dist/lib/register_config.server.d.ts +2 -3
  118. package/dist/lib/register_config.server.d.ts.map +1 -1
  119. package/dist/lib/register_config.server.js +4 -13
  120. package/dist/lib/reset_password_config.server.d.ts +0 -3
  121. package/dist/lib/reset_password_config.server.d.ts.map +1 -1
  122. package/dist/lib/reset_password_config.server.js +0 -13
  123. package/dist/lib/services/email_service.d.ts +1 -1
  124. package/dist/lib/services/email_service.d.ts.map +1 -1
  125. package/dist/lib/services/email_service.js +2 -0
  126. package/dist/lib/services/email_template_manifest.d.ts.map +1 -1
  127. package/dist/lib/services/email_template_manifest.js +17 -0
  128. package/dist/lib/services/email_templates/otp_signin_code.html +13 -0
  129. package/dist/lib/services/email_templates/otp_signin_code.txt +5 -0
  130. package/dist/lib/services/index.d.ts +2 -0
  131. package/dist/lib/services/index.d.ts.map +1 -1
  132. package/dist/lib/services/index.js +1 -0
  133. package/dist/lib/services/oauth_service.d.ts +24 -0
  134. package/dist/lib/services/oauth_service.d.ts.map +1 -1
  135. package/dist/lib/services/oauth_service.js +155 -0
  136. package/dist/lib/services/otp_service.d.ts +46 -0
  137. package/dist/lib/services/otp_service.d.ts.map +1 -0
  138. package/dist/lib/services/otp_service.js +238 -0
  139. package/dist/lib/services/session_token_service.d.ts +3 -1
  140. package/dist/lib/services/session_token_service.d.ts.map +1 -1
  141. package/dist/lib/services/session_token_service.js +4 -2
  142. package/dist/page_components/create_firm.d.ts +13 -1
  143. package/dist/page_components/create_firm.d.ts.map +1 -1
  144. package/dist/page_components/create_firm.js +10 -6
  145. package/dist/page_components/forgot_password.d.ts +1 -4
  146. package/dist/page_components/forgot_password.d.ts.map +1 -1
  147. package/dist/page_components/forgot_password.js +2 -6
  148. package/dist/page_components/login.d.ts +1 -4
  149. package/dist/page_components/login.d.ts.map +1 -1
  150. package/dist/page_components/login.js +2 -6
  151. package/dist/page_components/otp.d.ts +4 -0
  152. package/dist/page_components/otp.d.ts.map +1 -0
  153. package/dist/page_components/otp.js +5 -0
  154. package/dist/page_components/register.d.ts +1 -4
  155. package/dist/page_components/register.d.ts.map +1 -1
  156. package/dist/page_components/register.js +2 -6
  157. package/dist/page_components/reset_password.d.ts +1 -4
  158. package/dist/page_components/reset_password.d.ts.map +1 -1
  159. package/dist/page_components/reset_password.js +2 -6
  160. package/dist/page_components/verify_email.d.ts +1 -4
  161. package/dist/page_components/verify_email.d.ts.map +1 -1
  162. package/dist/page_components/verify_email.js +2 -6
  163. package/dist/server/routes/index.d.ts +3 -0
  164. package/dist/server/routes/index.d.ts.map +1 -1
  165. package/dist/server/routes/index.js +4 -0
  166. package/dist/server/routes/me.d.ts.map +1 -1
  167. package/dist/server/routes/me.js +43 -1
  168. package/dist/server/routes/oauth_facebook_callback.d.ts +8 -0
  169. package/dist/server/routes/oauth_facebook_callback.d.ts.map +1 -0
  170. package/dist/server/routes/oauth_facebook_callback.js +157 -0
  171. package/dist/server/routes/oauth_google_callback.js +1 -1
  172. package/dist/server/routes/otp/request.d.ts +3 -0
  173. package/dist/server/routes/otp/request.d.ts.map +1 -0
  174. package/dist/server/routes/otp/request.js +33 -0
  175. package/dist/server/routes/otp/verify.d.ts +3 -0
  176. package/dist/server/routes/otp/verify.d.ts.map +1 -0
  177. package/dist/server/routes/otp/verify.js +58 -0
  178. package/dist/server-lib.d.ts +3 -0
  179. package/dist/server-lib.d.ts.map +1 -1
  180. package/dist/server-lib.js +2 -0
  181. package/dist/server_pages/forgot_password.d.ts +13 -17
  182. package/dist/server_pages/forgot_password.d.ts.map +1 -1
  183. package/dist/server_pages/forgot_password.js +12 -8
  184. package/dist/server_pages/forgot_password_client_wrapper.d.ts +7 -6
  185. package/dist/server_pages/forgot_password_client_wrapper.d.ts.map +1 -1
  186. package/dist/server_pages/forgot_password_client_wrapper.js +2 -2
  187. package/dist/server_pages/login.d.ts +22 -21
  188. package/dist/server_pages/login.d.ts.map +1 -1
  189. package/dist/server_pages/login.js +15 -19
  190. package/dist/server_pages/login_client_wrapper.d.ts +10 -6
  191. package/dist/server_pages/login_client_wrapper.d.ts.map +1 -1
  192. package/dist/server_pages/login_client_wrapper.js +2 -2
  193. package/dist/server_pages/my_settings.d.ts +2 -0
  194. package/dist/server_pages/my_settings.d.ts.map +1 -1
  195. package/dist/server_pages/my_settings.js +8 -2
  196. package/dist/server_pages/otp.d.ts +56 -0
  197. package/dist/server_pages/otp.d.ts.map +1 -0
  198. package/dist/server_pages/otp.js +45 -0
  199. package/dist/server_pages/register.d.ts +19 -16
  200. package/dist/server_pages/register.d.ts.map +1 -1
  201. package/dist/server_pages/register.js +15 -12
  202. package/dist/server_pages/register_client_wrapper.d.ts +10 -6
  203. package/dist/server_pages/register_client_wrapper.d.ts.map +1 -1
  204. package/dist/server_pages/register_client_wrapper.js +2 -2
  205. package/dist/server_pages/reset_password.d.ts +11 -16
  206. package/dist/server_pages/reset_password.d.ts.map +1 -1
  207. package/dist/server_pages/reset_password.js +11 -9
  208. package/dist/server_pages/reset_password_client_wrapper.d.ts +7 -6
  209. package/dist/server_pages/reset_password_client_wrapper.d.ts.map +1 -1
  210. package/dist/server_pages/reset_password_client_wrapper.js +2 -2
  211. package/dist/server_pages/verify_email.d.ts +11 -17
  212. package/dist/server_pages/verify_email.d.ts.map +1 -1
  213. package/dist/server_pages/verify_email.js +11 -8
  214. package/dist/server_pages/verify_email_client_wrapper.d.ts +7 -6
  215. package/dist/server_pages/verify_email_client_wrapper.d.ts.map +1 -1
  216. package/dist/server_pages/verify_email_client_wrapper.js +2 -2
  217. package/dist/strings/default_strings.d.ts +47 -0
  218. package/dist/strings/default_strings.d.ts.map +1 -0
  219. package/dist/strings/default_strings.js +18 -0
  220. package/dist/strings/index.d.ts +4 -0
  221. package/dist/strings/index.d.ts.map +1 -0
  222. package/dist/strings/index.js +3 -0
  223. package/dist/strings/strings_context.d.ts +12 -0
  224. package/dist/strings/strings_context.d.ts.map +1 -0
  225. package/dist/strings/strings_context.js +23 -0
  226. package/dist/strings/strings_provider.d.ts +26 -0
  227. package/dist/strings/strings_provider.d.ts.map +1 -0
  228. package/dist/strings/strings_provider.js +45 -0
  229. package/dist/theme/create_theme.d.ts +7 -0
  230. package/dist/theme/create_theme.d.ts.map +1 -0
  231. package/dist/theme/create_theme.js +97 -0
  232. package/dist/theme/hex_to_hsl.d.ts +16 -0
  233. package/dist/theme/hex_to_hsl.d.ts.map +1 -0
  234. package/dist/theme/hex_to_hsl.js +110 -0
  235. package/dist/theme/index.d.ts +4 -0
  236. package/dist/theme/index.d.ts.map +1 -0
  237. package/dist/theme/index.js +3 -0
  238. package/dist/theme/luminance.d.ts +11 -0
  239. package/dist/theme/luminance.d.ts.map +1 -0
  240. package/dist/theme/luminance.js +45 -0
  241. package/dist/theme/theme_provider.d.ts +14 -0
  242. package/dist/theme/theme_provider.d.ts.map +1 -0
  243. package/dist/theme/theme_provider.js +23 -0
  244. package/dist/theme/theme_types.d.ts +36 -0
  245. package/dist/theme/theme_types.d.ts.map +1 -0
  246. package/dist/theme/theme_types.js +1 -0
  247. package/dist/themes/index.d.ts +3 -0
  248. package/dist/themes/index.d.ts.map +1 -0
  249. package/dist/themes/index.js +2 -0
  250. package/dist/themes/preset_indigo_sunset.d.ts +3 -0
  251. package/dist/themes/preset_indigo_sunset.d.ts.map +1 -0
  252. package/dist/themes/preset_indigo_sunset.js +20 -0
  253. package/dist/themes/preset_neutral.d.ts +3 -0
  254. package/dist/themes/preset_neutral.d.ts.map +1 -0
  255. package/dist/themes/preset_neutral.js +14 -0
  256. package/package.json +36 -2
@@ -36,6 +36,18 @@ export type OAuthLoginResult = {
36
36
  error?: string;
37
37
  };
38
38
 
39
+ export type FacebookOAuthData = {
40
+ /** Facebook's unique user ID */
41
+ facebook_id: string;
42
+ /** User's email address from Facebook (may be null if user denied email permission) */
43
+ email: string | null;
44
+ /** User's full name from Facebook profile */
45
+ name?: string;
46
+ /** User's profile picture URL from Facebook */
47
+ profile_picture_url?: string;
48
+ // NOTE: no email_verified — we never trust Facebook's email_verified claim
49
+ };
50
+
39
51
  export type LinkGoogleResult = {
40
52
  success: boolean;
41
53
  error?: string;
@@ -241,6 +253,191 @@ export async function handle_google_oauth_login(
241
253
  }
242
254
  }
243
255
 
256
+ /**
257
+ * Handles Facebook OAuth login/registration flow
258
+ * 1. Check if user exists with facebook_id -> login
259
+ * 2. Check if user exists with email -> link Facebook account (respects auto_link_unverified)
260
+ * 3. Create new user with Facebook data (email_verified always false — never trust Facebook)
261
+ *
262
+ * @param adapter - The hazo_connect adapter instance
263
+ * @param data - Facebook OAuth user data
264
+ * @param opts - Options (auto_link_unverified: whether to link unverified accounts)
265
+ * @returns OAuth login result with user_id and status flags
266
+ */
267
+ export async function handle_facebook_oauth_login(
268
+ adapter: HazoConnectAdapter,
269
+ data: FacebookOAuthData,
270
+ opts?: { auto_link_unverified?: boolean }
271
+ ): Promise<OAuthLoginResult> {
272
+ const logger = create_app_logger();
273
+
274
+ try {
275
+ const { facebook_id, email, name, profile_picture_url } = data;
276
+
277
+ const users_service = createCrudService(adapter, "hazo_users");
278
+ const now = new Date().toISOString();
279
+
280
+ // Step 1: Check if user exists with this facebook_id
281
+ const users_by_facebook_id = await users_service.findBy({ facebook_id });
282
+
283
+ if (Array.isArray(users_by_facebook_id) && users_by_facebook_id.length > 0) {
284
+ const user = users_by_facebook_id[0];
285
+
286
+ await users_service.updateById(user.id, {
287
+ last_logon: now,
288
+ changed_at: now,
289
+ });
290
+
291
+ logger.info("oauth_service_facebook_login_existing_facebook_user", {
292
+ filename: "oauth_service.ts",
293
+ line_number: get_line_number(),
294
+ user_id: user.id,
295
+ email: user.email_address,
296
+ });
297
+
298
+ return {
299
+ success: true,
300
+ user_id: user.id as string,
301
+ is_new_user: false,
302
+ was_linked: false,
303
+ email: user.email_address as string,
304
+ name: user.name as string | undefined,
305
+ };
306
+ }
307
+
308
+ // Step 2: Check if user exists with this email
309
+ if (email) {
310
+ const users_by_email = await users_service.findBy({ email_address: email });
311
+
312
+ if (Array.isArray(users_by_email) && users_by_email.length > 0) {
313
+ const user = users_by_email[0];
314
+ const user_email_verified = user.email_verified as boolean;
315
+
316
+ if (!user_email_verified) {
317
+ if (!opts?.auto_link_unverified) {
318
+ return {
319
+ success: false,
320
+ error: "link_blocked_unverified",
321
+ };
322
+ }
323
+ // auto_link_unverified=true: link but do NOT change email_verified status
324
+ }
325
+
326
+ // Link Facebook account to existing user
327
+ const current_auth_providers = (user.auth_providers as string) || "email";
328
+ const new_auth_providers = current_auth_providers.includes("facebook")
329
+ ? current_auth_providers
330
+ : `${current_auth_providers},facebook`;
331
+
332
+ const update_data: Record<string, unknown> = {
333
+ facebook_id,
334
+ auth_providers: new_auth_providers,
335
+ last_logon: now,
336
+ changed_at: now,
337
+ };
338
+
339
+ // Update name if not set and Facebook provides one
340
+ if (!user.name && name) {
341
+ update_data.name = name;
342
+ }
343
+
344
+ // Update profile picture if not set and Facebook provides one
345
+ if (!user.profile_picture_url && profile_picture_url) {
346
+ update_data.profile_picture_url = profile_picture_url;
347
+ update_data.profile_source = "custom";
348
+ }
349
+
350
+ await users_service.updateById(user.id, update_data);
351
+
352
+ logger.info("oauth_service_facebook_linked_to_existing", {
353
+ filename: "oauth_service.ts",
354
+ line_number: get_line_number(),
355
+ user_id: user.id,
356
+ email,
357
+ was_unverified: !user_email_verified,
358
+ });
359
+
360
+ return {
361
+ success: true,
362
+ user_id: user.id as string,
363
+ is_new_user: false,
364
+ was_linked: true,
365
+ email: user.email_address as string,
366
+ name: (update_data.name as string) || (user.name as string | undefined),
367
+ };
368
+ }
369
+ }
370
+
371
+ // Step 3: Create new user with Facebook data
372
+ const user_id = randomUUID();
373
+
374
+ const insert_data: Record<string, unknown> = {
375
+ id: user_id,
376
+ email_address: email,
377
+ password_hash: "", // Empty string for Facebook-only users
378
+ email_verified: false, // Never trust Facebook's email_verified claim
379
+ status: "ACTIVE",
380
+ login_attempts: 0,
381
+ facebook_id,
382
+ auth_providers: "facebook",
383
+ created_at: now,
384
+ changed_at: now,
385
+ last_logon: now,
386
+ };
387
+
388
+ if (name) {
389
+ insert_data.name = name;
390
+ }
391
+
392
+ if (profile_picture_url) {
393
+ insert_data.profile_picture_url = profile_picture_url;
394
+ insert_data.profile_source = "custom";
395
+ }
396
+
397
+ const inserted_users = await users_service.insert(insert_data);
398
+
399
+ if (!Array.isArray(inserted_users) || inserted_users.length === 0) {
400
+ return {
401
+ success: false,
402
+ error: "Failed to create user account",
403
+ };
404
+ }
405
+
406
+ logger.info("oauth_service_facebook_new_user_created", {
407
+ filename: "oauth_service.ts",
408
+ line_number: get_line_number(),
409
+ user_id,
410
+ email,
411
+ });
412
+
413
+ return {
414
+ success: true,
415
+ user_id,
416
+ is_new_user: true,
417
+ was_linked: false,
418
+ email: email ?? undefined,
419
+ name,
420
+ };
421
+ } catch (error) {
422
+ const user_friendly_error = sanitize_error_for_user(error, {
423
+ logToConsole: true,
424
+ logToLogger: true,
425
+ logger,
426
+ context: {
427
+ filename: "oauth_service.ts",
428
+ line_number: get_line_number(),
429
+ email: data.email,
430
+ operation: "handle_facebook_oauth_login",
431
+ },
432
+ });
433
+
434
+ return {
435
+ success: false,
436
+ error: user_friendly_error,
437
+ };
438
+ }
439
+ }
440
+
244
441
  /**
245
442
  * Links a Google account to an existing user
246
443
  * @param adapter - The hazo_connect adapter instance
@@ -0,0 +1,295 @@
1
+ import "server-only";
2
+ import crypto from "node:crypto";
3
+ import argon2 from "argon2";
4
+ import { createCrudService } from "hazo_connect/server";
5
+ import { get_otp_config, hazo_auth_otp_session_ttl_seconds } from "../otp_config.server.js";
6
+ import { get_hazo_connect_instance } from "../hazo_connect_instance.server.js";
7
+ import { send_template_email } from "./email_service.js";
8
+ import { create_app_logger } from "../app_logger.js";
9
+ import { create_session_token } from "./session_token_service.js";
10
+
11
+ /**
12
+ * Generates a cryptographically random 6-digit numeric OTP code (000000–999999).
13
+ * Uses crypto.randomInt for uniform distribution.
14
+ */
15
+ export function generate_otp_code(): string {
16
+ const n = crypto.randomInt(0, 1_000_000);
17
+ return String(n).padStart(6, "0");
18
+ }
19
+
20
+ export async function hash_otp_code(code: string): Promise<string> {
21
+ return argon2.hash(code);
22
+ }
23
+
24
+ export async function verify_otp_code(otp_hash: string, code: string): Promise<boolean> {
25
+ try {
26
+ return await argon2.verify(otp_hash, code);
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ // section: types
33
+
34
+ export type RequestEmailOTPResult =
35
+ | { ok: true }
36
+ | { ok: false; error: "rate_limited"; retry_after_seconds: number };
37
+
38
+ // section: request_email_otp
39
+
40
+ /**
41
+ * Initiates an OTP sign-in flow for the given email address.
42
+ *
43
+ * Behaviour:
44
+ * 1. Per-email rate limit — rejects if too many requests in the sliding window.
45
+ * 2. Per-IP rate limit — rejects if too many requests from this IP.
46
+ * 3. Unknown email + auto_register=false — silent no-op (constant-time padding).
47
+ * 4. Unknown email + auto_register=true — inserts OTP row with user_id=null, dispatches email.
48
+ * 5. Known email — marks prior unconsumed rows consumed, inserts fresh OTP row, dispatches email.
49
+ *
50
+ * Never reveals whether an email address is registered (always returns ok:true on success).
51
+ */
52
+ export async function request_email_otp(args: {
53
+ email: string;
54
+ ip: string;
55
+ }): Promise<RequestEmailOTPResult> {
56
+ const logger = create_app_logger();
57
+ const cfg = get_otp_config();
58
+ const email = args.email.trim().toLowerCase();
59
+ const ip = args.ip;
60
+
61
+ const adapter = get_hazo_connect_instance();
62
+ const otp_table = createCrudService(adapter, "hazo_email_otps");
63
+ const users_table = createCrudService(adapter, "hazo_users");
64
+
65
+ // 1. Per-email rate limit
66
+ const email_window_ms = cfg.email_rate_limit_window_seconds * 1000;
67
+ const email_threshold = new Date(Date.now() - email_window_ms).toISOString();
68
+ const recent_for_email = await otp_table.list((qb) =>
69
+ qb
70
+ .select(["created_at"])
71
+ .where("email", "eq", email)
72
+ .where("created_at", "gte", email_threshold)
73
+ );
74
+ if (recent_for_email.length >= cfg.email_rate_limit_max) {
75
+ const oldest = recent_for_email
76
+ .map((r) => Date.parse(String(r.created_at)))
77
+ .sort((a, b) => a - b)[0];
78
+ const retry_after_seconds = Math.max(
79
+ 1,
80
+ Math.ceil((oldest + email_window_ms - Date.now()) / 1000),
81
+ );
82
+ logger.warn("otp_request_email_rate_limited", { email, ip, retry_after_seconds });
83
+ return { ok: false, error: "rate_limited", retry_after_seconds };
84
+ }
85
+
86
+ // 2. Per-IP rate limit
87
+ const ip_window_ms = cfg.ip_rate_limit_window_seconds * 1000;
88
+ const ip_threshold = new Date(Date.now() - ip_window_ms).toISOString();
89
+ const recent_for_ip = await otp_table.list((qb) =>
90
+ qb
91
+ .select(["created_at"])
92
+ .where("requester_ip", "eq", ip)
93
+ .where("created_at", "gte", ip_threshold)
94
+ );
95
+ if (recent_for_ip.length >= cfg.ip_rate_limit_max) {
96
+ const oldest = recent_for_ip
97
+ .map((r) => Date.parse(String(r.created_at)))
98
+ .sort((a, b) => a - b)[0];
99
+ const retry_after_seconds = Math.max(
100
+ 1,
101
+ Math.ceil((oldest + ip_window_ms - Date.now()) / 1000),
102
+ );
103
+ logger.warn("otp_request_ip_rate_limited", { email, ip, retry_after_seconds });
104
+ return { ok: false, error: "rate_limited", retry_after_seconds };
105
+ }
106
+
107
+ // 3. Lookup user
108
+ const existing_users = await users_table.findBy({ email_address: email });
109
+ const existing_user = existing_users.length > 0 ? existing_users[0] : null;
110
+
111
+ // 4. Unknown email + auto_register=false → silent no-op with constant-time padding
112
+ if (!existing_user && !cfg.auto_register) {
113
+ await argon2.hash("000000"); // constant-time padding — never stored
114
+ return { ok: true };
115
+ }
116
+
117
+ // 5. Mark any unconsumed rows for this email as superseded
118
+ try {
119
+ const unconsumed = await otp_table.list((qb) =>
120
+ qb
121
+ .select(["id"])
122
+ .where("email", "eq", email)
123
+ .where("consumed_at", "is", null)
124
+ );
125
+ for (const row of unconsumed) {
126
+ await otp_table.updateById(String(row.id), { consumed_at: new Date().toISOString() });
127
+ }
128
+ } catch {
129
+ // IS NULL filter may not be supported in all adapter versions — not critical
130
+ }
131
+
132
+ // 6. Generate code + hash + insert row
133
+ const code = generate_otp_code();
134
+ const otp_hash = await hash_otp_code(code);
135
+ const expires_at = new Date(Date.now() + cfg.code_ttl_seconds * 1000).toISOString();
136
+ const row_id = crypto.randomUUID();
137
+
138
+ await otp_table.insert({
139
+ id: row_id,
140
+ user_id: existing_user ? String(existing_user.id) : null,
141
+ email,
142
+ otp_hash,
143
+ expires_at,
144
+ attempt_count: 0,
145
+ requester_ip: ip,
146
+ });
147
+
148
+ // 7. Dispatch email — fire-and-forget; errors are logged but do not surface to caller
149
+ try {
150
+ await send_template_email("otp_signin_code", email, {
151
+ otp_code: code,
152
+ expires_in_minutes: String(Math.round(cfg.code_ttl_seconds / 60)),
153
+ });
154
+ } catch (err) {
155
+ logger.error("otp_request_email_dispatch_failed", {
156
+ email,
157
+ ip,
158
+ error: err instanceof Error ? err.message : String(err),
159
+ });
160
+ // Return ok:true to preserve no-enumeration property — caller cannot distinguish
161
+ // a missing user from a delivery failure.
162
+ }
163
+
164
+ return { ok: true };
165
+ }
166
+
167
+ // section: verify_email_otp
168
+
169
+ export type VerifyEmailOTPResult =
170
+ | { ok: true; user_id: string; email: string; session_token: string }
171
+ | { ok: false; error: "invalid_or_expired" };
172
+
173
+ export async function verify_email_otp(args: {
174
+ email: string;
175
+ code: string;
176
+ ip: string;
177
+ }): Promise<VerifyEmailOTPResult> {
178
+ const logger = create_app_logger();
179
+ const cfg = get_otp_config();
180
+ const email = args.email.trim().toLowerCase();
181
+ const code = args.code.trim();
182
+
183
+ const adapter = get_hazo_connect_instance();
184
+ const otp_table = createCrudService(adapter, "hazo_email_otps");
185
+ const users_table = createCrudService(adapter, "hazo_users");
186
+ const user_scopes_table = createCrudService(adapter, "hazo_user_scopes");
187
+ const roles_table = createCrudService(adapter, "hazo_roles");
188
+
189
+ // 1. Find most-recent unconsumed row for this email
190
+ const now_iso = new Date().toISOString();
191
+ const candidates = await otp_table.list((qb) =>
192
+ qb
193
+ .select(["id", "user_id", "otp_hash", "expires_at", "attempt_count"])
194
+ .where("email", "eq", email)
195
+ .where("consumed_at", "is", null)
196
+ .order("created_at", "desc")
197
+ .limit(1)
198
+ );
199
+ const row = candidates.length > 0 ? candidates[0] : null;
200
+
201
+ if (!row) {
202
+ return { ok: false, error: "invalid_or_expired" };
203
+ }
204
+
205
+ // 2. Check expiry
206
+ const expires_at_ms = Date.parse(String(row.expires_at));
207
+ if (Number.isNaN(expires_at_ms) || expires_at_ms < Date.now()) {
208
+ return { ok: false, error: "invalid_or_expired" };
209
+ }
210
+
211
+ // 3. argon2 verify
212
+ const is_valid = await verify_otp_code(String(row.otp_hash), code);
213
+ if (!is_valid) {
214
+ const new_attempt_count = Number(row.attempt_count) + 1;
215
+ const updates: Record<string, unknown> = { attempt_count: new_attempt_count };
216
+ if (new_attempt_count >= cfg.max_verify_attempts) {
217
+ updates.consumed_at = now_iso; // poison
218
+ }
219
+ await otp_table.updateById(String(row.id), updates);
220
+ logger.info("otp_verify_invalid_code", {
221
+ email,
222
+ attempt_count: new_attempt_count,
223
+ poisoned: new_attempt_count >= cfg.max_verify_attempts,
224
+ });
225
+ return { ok: false, error: "invalid_or_expired" };
226
+ }
227
+
228
+ // 4. Mark consumed
229
+ await otp_table.updateById(String(row.id), { consumed_at: now_iso });
230
+
231
+ // 5. Resolve / create user
232
+ let user_id: string | null = row.user_id ? String(row.user_id) : null;
233
+
234
+ if (user_id) {
235
+ // Ensure email_verified=true
236
+ const user = await users_table.findById(user_id);
237
+ if (user && !user.email_verified) {
238
+ await users_table.updateById(user_id, { email_verified: true });
239
+ }
240
+ } else if (cfg.auto_register) {
241
+ // Create user + bind scope/role
242
+ const new_user_id = crypto.randomUUID();
243
+ await users_table.insert({
244
+ id: new_user_id,
245
+ email_address: email,
246
+ email_verified: true,
247
+ password_hash: null,
248
+ name: null,
249
+ });
250
+
251
+ // Resolve role_id from name — try scope-specific first, then any
252
+ const scoped_roles = await roles_table.findBy({
253
+ name: cfg.auto_assign_role_name,
254
+ scope_id: cfg.auto_assign_scope_id,
255
+ });
256
+ let role_id: string | null = null;
257
+ if (scoped_roles.length > 0) {
258
+ role_id = String(scoped_roles[0].id);
259
+ } else {
260
+ const any_roles = await roles_table.findBy({ name: cfg.auto_assign_role_name });
261
+ if (any_roles.length > 0) {
262
+ role_id = String(any_roles[0].id);
263
+ }
264
+ }
265
+
266
+ if (!role_id) {
267
+ logger.error("otp_verify_auto_register_role_not_found", {
268
+ email,
269
+ scope_id: cfg.auto_assign_scope_id,
270
+ role_name: cfg.auto_assign_role_name,
271
+ });
272
+ return { ok: false, error: "invalid_or_expired" };
273
+ }
274
+
275
+ await user_scopes_table.insert({
276
+ user_id: new_user_id,
277
+ scope_id: cfg.auto_assign_scope_id,
278
+ root_scope_id: cfg.auto_assign_scope_id,
279
+ role_id,
280
+ });
281
+
282
+ user_id = new_user_id;
283
+ } else {
284
+ // Row exists but no user and auto_register=false — stale edge case
285
+ logger.warn("otp_verify_no_user_resolvable", { email });
286
+ return { ok: false, error: "invalid_or_expired" };
287
+ }
288
+
289
+ // 6. Issue session JWT with OTP TTL
290
+ const ttl_seconds = hazo_auth_otp_session_ttl_seconds();
291
+ const session_token = await create_session_token(user_id, email, undefined, ttl_seconds);
292
+
293
+ logger.info("otp_verify_ok", { email, user_id, ttl_seconds });
294
+ return { ok: true, user_id, email, session_token };
295
+ }
@@ -63,19 +63,22 @@ function get_session_token_expiry_seconds(): number {
63
63
  * Token includes user_id, email, issued at time, and expiration
64
64
  * @param user_id - User ID
65
65
  * @param email - User email address
66
+ * @param managed_by_user_id - Optional: ID of the managing user (for impersonation)
67
+ * @param ttl_seconds - Optional: token lifetime in seconds (default: 30 days). Use 604800 for 7-day OTP sessions.
66
68
  * @returns JWT token string
67
69
  */
68
70
  export async function create_session_token(
69
71
  user_id: string,
70
72
  email: string,
71
73
  managed_by_user_id?: string,
74
+ ttl_seconds?: number,
72
75
  ): Promise<string> {
73
76
  const logger = create_app_logger();
74
77
 
75
78
  try {
76
79
  const secret = get_jwt_secret();
77
80
  const now = Math.floor(Date.now() / 1000); // Current time in seconds
78
- const expiry_seconds = get_session_token_expiry_seconds();
81
+ const expiry_seconds = ttl_seconds ?? get_session_token_expiry_seconds();
79
82
  const exp = now + expiry_seconds;
80
83
 
81
84
  const payload: Record<string, unknown> = { user_id, email };
@@ -74,11 +74,9 @@ enable_admin_ui = true
74
74
  # image_alt = Illustration of a globe representing secure authentication workflows
75
75
  # image_background_color = #e2e8f0
76
76
 
77
- # Labels
78
- # heading = Create your hazo account
79
- # sub_heading = Secure your access with editable fields powered by shadcn components.
80
- # submit_button = Register
81
- # cancel_button = Cancel
77
+ ; Page text is no longer configured via INI.
78
+ ; Use HazoAuthStringsProvider or per-page props instead.
79
+ ; See MIGRATION.md for details.
82
80
 
83
81
  # Field labels (defaults shown, uncomment to override)
84
82
  # name_label = Full name
@@ -129,11 +127,9 @@ enable_admin_ui = true
129
127
  # image_alt = Illustration of a globe representing secure authentication workflows
130
128
  # image_background_color = #e2e8f0
131
129
 
132
- # Labels
133
- # heading = Sign in to your account
134
- # sub_heading = Enter your credentials to access your secure workspace.
135
- # submit_button = Login
136
- # cancel_button = Cancel
130
+ ; Page text is no longer configured via INI.
131
+ ; Use HazoAuthStringsProvider or per-page props instead.
132
+ ; See MIGRATION.md for details.
137
133
 
138
134
  # Field labels (defaults shown, uncomment to override)
139
135
  # email_label = Email address
@@ -159,17 +155,20 @@ enable_admin_ui = true
159
155
  # Success message (shown when no redirect route is provided)
160
156
  # success_message = Successfully logged in
161
157
 
158
+ ; OTP sign-in link in the login layout.
159
+ otp_signin_enabled = false
160
+ otp_signin_label = Sign in with email code
161
+ otp_signin_href = /hazo_auth/otp
162
+
162
163
  [hazo_auth__forgot_password_layout]
163
164
  # Image configuration
164
165
  # image_src = /globe.svg
165
166
  # image_alt = Illustration of a globe representing secure authentication workflows
166
167
  # image_background_color = #e2e8f0
167
168
 
168
- # Labels
169
- # heading = Forgot your password?
170
- # sub_heading = Enter your email address and we'll send you a link to reset your password.
171
- # submit_button = Send reset link
172
- # cancel_button = Cancel
169
+ ; Page text is no longer configured via INI.
170
+ ; Use HazoAuthStringsProvider or per-page props instead.
171
+ ; See MIGRATION.md for details.
173
172
 
174
173
  [hazo_auth__reset_password_layout]
175
174
  # Image configuration
@@ -177,11 +176,9 @@ enable_admin_ui = true
177
176
  # image_alt = Illustration of a globe representing secure authentication workflows
178
177
  # image_background_color = #e2e8f0
179
178
 
180
- # Labels
181
- # heading = Reset your password
182
- # sub_heading = Enter your new password below.
183
- # submit_button = Reset password
184
- # cancel_button = Cancel
179
+ ; Page text is no longer configured via INI.
180
+ ; Use HazoAuthStringsProvider or per-page props instead.
181
+ ; See MIGRATION.md for details.
185
182
 
186
183
  # Field labels (defaults shown, uncomment to override)
187
184
  # password_label = New password
@@ -213,22 +210,9 @@ enable_admin_ui = true
213
210
  # image_alt = Illustration of a globe representing secure authentication workflows
214
211
  # image_background_color = #e2e8f0
215
212
 
216
- # Labels (verifying state)
217
- # heading = Email verification
218
- # sub_heading = Verifying your email address...
219
- # submit_button = Resend verification email
220
- # cancel_button = Cancel
221
-
222
- # Success labels
223
- # success_heading = Email verified successfully
224
- # success_message = Your email address has been verified. You can now log in to your account.
225
- # success_redirect_message = Redirecting to login page in
226
- # success_go_to_login_button = Go to login
227
-
228
- # Error labels
229
- # error_heading = Verification failed
230
- # error_message = The verification link is invalid or has expired.
231
- # error_resend_form_heading = Resend verification email
213
+ ; Page text is no longer configured via INI.
214
+ ; Use HazoAuthStringsProvider or per-page props instead.
215
+ ; See MIGRATION.md for details.
232
216
 
233
217
  # Field labels (defaults shown, uncomment to override)
234
218
  # email_label = Email address
@@ -270,11 +254,9 @@ enable_admin_ui = true
270
254
  # - [hazo_auth__password_requirements] for password validation rules
271
255
  # - [hazo_auth__profile_picture] for profile picture settings and prioritization
272
256
 
273
- # Heading
274
- # heading = Account Settings
275
-
276
- # Sub heading
277
- # sub_heading = Manage your profile, password, and email preferences.
257
+ ; Page heading text is no longer configured via INI.
258
+ ; Use HazoAuthStringsProvider or the title prop on MySettingsPage instead.
259
+ ; See MIGRATION.md for details.
278
260
 
279
261
  # Profile Photo section
280
262
  # profile_photo_label = Profile Photo
@@ -597,6 +579,26 @@ application_permission_list_defaults = admin_user_management,admin_role_manageme
597
579
  # Redirect when skip_invitation_check=true and user has no scope
598
580
  # no_scope_redirect = /
599
581
 
582
+ [hazo_auth__create_firm]
583
+ ; heading, sub_heading, and submit_button_label are no longer configured via INI in v7.0.0.
584
+ ; Use HazoAuthStringsProvider or the title/subtitle/ctaText props on CreateFirmPage instead.
585
+ ; See MIGRATION.md for details.
586
+
587
+ ; Firm name field label
588
+ # firm_name_label = Firm Name
589
+
590
+ ; Organisation structure field label
591
+ # org_structure_label = Organisation Structure
592
+
593
+ ; Default organisation structure value
594
+ # org_structure_default = Headquarters
595
+
596
+ ; Success message shown after firm creation
597
+ # success_message = Your firm has been created successfully!
598
+
599
+ ; Route to redirect to after firm creation
600
+ # redirect_route = /
601
+
600
602
  [hazo_auth__multi_tenancy]
601
603
  # Multi-tenancy configuration for organization hierarchy
602
604
  # Enables hierarchical organization structures for company-wide access control
@@ -729,3 +731,36 @@ company_name = My Company
729
731
  # [hazo_auth__login_layout]
730
732
  # image_src = /your-custom-image.jpg
731
733
 
734
+ [hazo_auth__otp]
735
+ ; Email-OTP sign-in configuration (v6.1.0+).
736
+ ;
737
+ ; Whether /otp/verify may create new hazo_users rows for unknown emails.
738
+ ; false (default): /otp/request silently no-ops for unknown emails.
739
+ ; true: /otp/verify creates a user row on first successful code match.
740
+ otp_auto_register = false
741
+
742
+ ; OTP code lifetime in seconds (default 600 = 10 minutes).
743
+ otp_code_ttl_seconds = 600
744
+
745
+ ; OTP session lifetime in seconds (default 604800 = 7 days).
746
+ otp_session_ttl_seconds = 604800
747
+
748
+ ; Sliding-session threshold: if a /me call lands and the JWT exp is within
749
+ ; this many seconds, re-issue the session for another otp_session_ttl_seconds.
750
+ otp_slide_when_within_seconds = 86400
751
+
752
+ ; Per-email rate limit for /otp/request.
753
+ otp_email_rate_limit_max = 3
754
+ otp_email_rate_limit_window_seconds = 900
755
+
756
+ ; Per-IP rate limit for /otp/request.
757
+ otp_ip_rate_limit_max = 20
758
+ otp_ip_rate_limit_window_seconds = 3600
759
+
760
+ ; Wrong-guess limit per code; row is poisoned after this many failures.
761
+ otp_max_verify_attempts = 5
762
+
763
+ ; Scope binding for newly auto-registered users (only used when otp_auto_register=true).
764
+ otp_auto_assign_scope_id = 00000000-0000-0000-0000-000000000001
765
+ otp_auto_assign_role_name = member
766
+