hazo_auth 5.3.1 → 6.1.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 (126) hide show
  1. package/README.md +167 -17
  2. package/SETUP_CHECKLIST.md +99 -7
  3. package/cli-src/cli/generate.ts +10 -1
  4. package/cli-src/cli/validate.ts +4 -0
  5. package/cli-src/lib/auth/auth_types.ts +21 -12
  6. package/cli-src/lib/auth/hazo_get_tenant_auth.server.ts +25 -24
  7. package/cli-src/lib/auth/index.ts +2 -2
  8. package/cli-src/lib/auth/with_auth.server.ts +15 -15
  9. package/cli-src/lib/cookies_config.server.ts +1 -0
  10. package/cli-src/lib/login_config.server.ts +14 -0
  11. package/cli-src/lib/otp_config.server.ts +91 -0
  12. package/cli-src/lib/services/email_service.ts +3 -1
  13. package/cli-src/lib/services/email_template_manifest.ts +17 -0
  14. package/cli-src/lib/services/email_templates/otp_signin_code.html +13 -0
  15. package/cli-src/lib/services/email_templates/otp_signin_code.txt +5 -0
  16. package/cli-src/lib/services/index.ts +8 -2
  17. package/cli-src/lib/services/otp_service.ts +295 -0
  18. package/cli-src/lib/services/session_token_service.ts +4 -1
  19. package/config/hazo_auth_config.example.ini +38 -0
  20. package/dist/cli/generate.d.ts.map +1 -1
  21. package/dist/cli/generate.js +10 -1
  22. package/dist/cli/validate.d.ts.map +1 -1
  23. package/dist/cli/validate.js +4 -0
  24. package/dist/client.d.ts +2 -0
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +1 -0
  27. package/dist/components/layouts/login/index.d.ts +7 -1
  28. package/dist/components/layouts/login/index.d.ts.map +1 -1
  29. package/dist/components/layouts/login/index.js +2 -2
  30. package/dist/components/layouts/otp/index.d.ts +10 -0
  31. package/dist/components/layouts/otp/index.d.ts.map +1 -0
  32. package/dist/components/layouts/otp/index.js +14 -0
  33. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
  34. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +8 -3
  35. package/dist/components/otp/OTPRequestForm.d.ts +11 -0
  36. package/dist/components/otp/OTPRequestForm.d.ts.map +1 -0
  37. package/dist/components/otp/OTPRequestForm.js +42 -0
  38. package/dist/components/otp/OTPVerifyForm.d.ts +16 -0
  39. package/dist/components/otp/OTPVerifyForm.d.ts.map +1 -0
  40. package/dist/components/otp/OTPVerifyForm.js +75 -0
  41. package/dist/components/otp/index.d.ts +5 -0
  42. package/dist/components/otp/index.d.ts.map +1 -0
  43. package/dist/components/otp/index.js +2 -0
  44. package/dist/components/ui/input-otp.d.ts +35 -0
  45. package/dist/components/ui/input-otp.d.ts.map +1 -0
  46. package/dist/components/ui/input-otp.js +44 -0
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/lib/auth/auth_types.d.ts +13 -12
  50. package/dist/lib/auth/auth_types.d.ts.map +1 -1
  51. package/dist/lib/auth/auth_types.js +8 -0
  52. package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts +8 -7
  53. package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts.map +1 -1
  54. package/dist/lib/auth/hazo_get_tenant_auth.server.js +23 -22
  55. package/dist/lib/auth/index.d.ts +2 -2
  56. package/dist/lib/auth/index.d.ts.map +1 -1
  57. package/dist/lib/auth/with_auth.server.d.ts +13 -13
  58. package/dist/lib/auth/with_auth.server.d.ts.map +1 -1
  59. package/dist/lib/auth/with_auth.server.js +2 -2
  60. package/dist/lib/cookies_config.server.d.ts +1 -0
  61. package/dist/lib/cookies_config.server.d.ts.map +1 -1
  62. package/dist/lib/cookies_config.server.js +1 -0
  63. package/dist/lib/login_config.server.d.ts +6 -0
  64. package/dist/lib/login_config.server.d.ts.map +1 -1
  65. package/dist/lib/login_config.server.js +7 -0
  66. package/dist/lib/otp_config.server.d.ts +49 -0
  67. package/dist/lib/otp_config.server.d.ts.map +1 -0
  68. package/dist/lib/otp_config.server.js +48 -0
  69. package/dist/lib/services/email_service.d.ts +1 -1
  70. package/dist/lib/services/email_service.d.ts.map +1 -1
  71. package/dist/lib/services/email_service.js +2 -0
  72. package/dist/lib/services/email_template_manifest.d.ts.map +1 -1
  73. package/dist/lib/services/email_template_manifest.js +17 -0
  74. package/dist/lib/services/email_templates/otp_signin_code.html +13 -0
  75. package/dist/lib/services/email_templates/otp_signin_code.txt +5 -0
  76. package/dist/lib/services/index.d.ts +2 -0
  77. package/dist/lib/services/index.d.ts.map +1 -1
  78. package/dist/lib/services/index.js +1 -0
  79. package/dist/lib/services/otp_service.d.ts +46 -0
  80. package/dist/lib/services/otp_service.d.ts.map +1 -0
  81. package/dist/lib/services/otp_service.js +238 -0
  82. package/dist/lib/services/session_token_service.d.ts +3 -1
  83. package/dist/lib/services/session_token_service.d.ts.map +1 -1
  84. package/dist/lib/services/session_token_service.js +4 -2
  85. package/dist/page_components/otp.d.ts +4 -0
  86. package/dist/page_components/otp.d.ts.map +1 -0
  87. package/dist/page_components/otp.js +5 -0
  88. package/dist/server/routes/index.d.ts +2 -0
  89. package/dist/server/routes/index.d.ts.map +1 -1
  90. package/dist/server/routes/index.js +3 -0
  91. package/dist/server/routes/me.d.ts.map +1 -1
  92. package/dist/server/routes/me.js +43 -1
  93. package/dist/server/routes/otp/request.d.ts +3 -0
  94. package/dist/server/routes/otp/request.d.ts.map +1 -0
  95. package/dist/server/routes/otp/request.js +33 -0
  96. package/dist/server/routes/otp/verify.d.ts +3 -0
  97. package/dist/server/routes/otp/verify.d.ts.map +1 -0
  98. package/dist/server/routes/otp/verify.js +58 -0
  99. package/dist/server-lib.d.ts +3 -0
  100. package/dist/server-lib.d.ts.map +1 -1
  101. package/dist/server-lib.js +2 -0
  102. package/dist/server_pages/forgot_password.d.ts +1 -1
  103. package/dist/server_pages/forgot_password.d.ts.map +1 -1
  104. package/dist/server_pages/forgot_password.js +2 -1
  105. package/dist/server_pages/login.d.ts +1 -1
  106. package/dist/server_pages/login.d.ts.map +1 -1
  107. package/dist/server_pages/login.js +3 -2
  108. package/dist/server_pages/login_client_wrapper.d.ts +1 -1
  109. package/dist/server_pages/login_client_wrapper.d.ts.map +1 -1
  110. package/dist/server_pages/login_client_wrapper.js +2 -2
  111. package/dist/server_pages/my_settings.d.ts +1 -1
  112. package/dist/server_pages/my_settings.d.ts.map +1 -1
  113. package/dist/server_pages/my_settings.js +2 -1
  114. package/dist/server_pages/otp.d.ts +42 -0
  115. package/dist/server_pages/otp.d.ts.map +1 -0
  116. package/dist/server_pages/otp.js +38 -0
  117. package/dist/server_pages/register.d.ts +1 -1
  118. package/dist/server_pages/register.d.ts.map +1 -1
  119. package/dist/server_pages/register.js +2 -1
  120. package/dist/server_pages/reset_password.d.ts +1 -1
  121. package/dist/server_pages/reset_password.d.ts.map +1 -1
  122. package/dist/server_pages/reset_password.js +2 -1
  123. package/dist/server_pages/verify_email.d.ts +1 -1
  124. package/dist/server_pages/verify_email.d.ts.map +1 -1
  125. package/dist/server_pages/verify_email.js +2 -1
  126. package/package.json +20 -3
@@ -10,7 +10,7 @@ import {
10
10
  PermissionError,
11
11
  type TenantAuthOptions,
12
12
  type TenantAuthResult,
13
- type TenantOrganization,
13
+ type SelectedScope,
14
14
  type HazoAuthUser,
15
15
  type ScopeDetails,
16
16
  type ScopeAccessInfo,
@@ -27,19 +27,19 @@ export type AuthenticatedTenantAuth = {
27
27
  permissions: string[];
28
28
  permission_ok: boolean;
29
29
  missing_permissions?: string[];
30
- organization: TenantOrganization | null;
31
- organization_id: string | null;
30
+ selected_scope: SelectedScope | null;
31
+ selected_scope_id: string | null;
32
32
  user_scopes: ScopeDetails[];
33
33
  scope_ok?: boolean;
34
34
  scope_access_via?: ScopeAccessInfo;
35
35
  };
36
36
 
37
37
  /**
38
- * Authenticated branch with guaranteed non-null organization
38
+ * Authenticated branch with guaranteed non-null selected_scope
39
39
  */
40
- export type AuthenticatedTenantAuthWithOrg = AuthenticatedTenantAuth & {
41
- organization: TenantOrganization;
42
- organization_id: string;
40
+ export type AuthenticatedTenantAuthWithSelectedScope = AuthenticatedTenantAuth & {
41
+ selected_scope: SelectedScope;
42
+ selected_scope_id: string;
43
43
  };
44
44
 
45
45
  /**
@@ -48,8 +48,8 @@ export type AuthenticatedTenantAuthWithOrg = AuthenticatedTenantAuth & {
48
48
  */
49
49
  export type WithAuthOptions = TenantAuthOptions & {
50
50
  /**
51
- * If true, requires organization context (403 if missing)
52
- * Narrows auth type to AuthenticatedTenantAuthWithOrg
51
+ * If true, requires tenant/scope context (403 if missing)
52
+ * Narrows auth type to AuthenticatedTenantAuthWithSelectedScope
53
53
  */
54
54
  require_tenant?: boolean;
55
55
  };
@@ -75,7 +75,7 @@ type AuthenticatedHandler<TParams> = (
75
75
  */
76
76
  type AuthenticatedTenantHandler<TParams> = (
77
77
  request: NextRequest,
78
- auth: AuthenticatedTenantAuthWithOrg,
78
+ auth: AuthenticatedTenantAuthWithSelectedScope,
79
79
  params: TParams,
80
80
  ) => Promise<NextResponse> | NextResponse;
81
81
 
@@ -138,7 +138,7 @@ async function resolve_params<TParams>(
138
138
  *
139
139
  * - Calls `hazo_get_tenant_auth` and returns 401 if not authenticated
140
140
  * - Returns 403 if `required_permissions` are specified and not satisfied
141
- * - Returns 403 if `require_tenant: true` and no organization context
141
+ * - Returns 403 if `require_tenant: true` and no tenant/scope context
142
142
  * - Resolves `await context.params` (Next.js 15 pattern)
143
143
  * - Catches HazoAuthError, PermissionError, and unexpected errors
144
144
  *
@@ -161,8 +161,8 @@ async function resolve_params<TParams>(
161
161
  * // With tenant requirement
162
162
  * export const GET = withAuth<{ id: string }>(
163
163
  * async (request, auth, { id }) => {
164
- * // auth.organization is guaranteed non-null
165
- * const data = await getData(auth.organization.id, id);
164
+ * // auth.selected_scope is guaranteed non-null
165
+ * const data = await getData(auth.selected_scope.id, id);
166
166
  * return NextResponse.json(data);
167
167
  * },
168
168
  * { require_tenant: true }
@@ -227,10 +227,10 @@ export function withAuth<TParams = Record<string, never>>(
227
227
  }
228
228
 
229
229
  // Check tenant requirement
230
- if (options.require_tenant && !auth.organization) {
230
+ if (options.require_tenant && !auth.selected_scope) {
231
231
  return NextResponse.json(
232
232
  {
233
- error: "Organization context required",
233
+ error: "Tenant scope context required",
234
234
  code: "TENANT_REQUIRED",
235
235
  },
236
236
  { status: 403 },
@@ -27,6 +27,7 @@ export const BASE_COOKIE_NAMES = {
27
27
  USER_ID: "hazo_auth_user_id",
28
28
  USER_EMAIL: "hazo_auth_user_email",
29
29
  SESSION: "hazo_auth_session",
30
+ SESSION_KIND: "hazo_auth_session_kind", // v6.1: marks OTP-issued sessions so /me can apply sliding expiry
30
31
  DEV_LOCK: "hazo_auth_dev_lock",
31
32
  SCOPE_ID: "hazo_auth_scope_id", // v5.2: Tenant context cookie for multi-tenancy
32
33
  ANON_ID: "hazo_auth_anon_id", // v5.2: Stable opaque per-visitor ID for anonymous flows (e.g. hazo_feedback)
@@ -31,6 +31,12 @@ export type LoginConfig = {
31
31
  imageBackgroundColor: string;
32
32
  /** OAuth configuration */
33
33
  oauth: OAuthConfig;
34
+ /** Whether the OTP sign-in link is shown below the login form */
35
+ otpSigninEnabled: boolean;
36
+ /** Label for the OTP sign-in link */
37
+ otpSigninLabel: string;
38
+ /** href for the OTP sign-in link */
39
+ otpSigninHref: string;
34
40
  };
35
41
 
36
42
  // section: helpers
@@ -94,6 +100,11 @@ export function get_login_config(): LoginConfig {
94
100
  // Get OAuth configuration
95
101
  const oauth = get_oauth_config();
96
102
 
103
+ // OTP sign-in link
104
+ const otpSigninEnabled = get_config_value(section, "otp_signin_enabled", "false") === "true";
105
+ const otpSigninLabel = get_config_value(section, "otp_signin_label", "Sign in with email code");
106
+ const otpSigninHref = get_config_value(section, "otp_signin_href", "/hazo_auth/otp");
107
+
97
108
  return {
98
109
  redirectRoute,
99
110
  successMessage,
@@ -111,6 +122,9 @@ export function get_login_config(): LoginConfig {
111
122
  imageAlt,
112
123
  imageBackgroundColor,
113
124
  oauth,
125
+ otpSigninEnabled,
126
+ otpSigninLabel,
127
+ otpSigninHref,
114
128
  };
115
129
  }
116
130
 
@@ -0,0 +1,91 @@
1
+ // file_description: server-only helper to read OTP sign-in configuration from hazo_auth_config.ini
2
+ // section: server-only-guard
3
+ import "server-only";
4
+
5
+ // section: imports
6
+ import { get_config_value, get_config_boolean, get_config_number } from "./config/config_loader.server.js";
7
+
8
+ // section: defaults
9
+ export const OTP_CONFIG_DEFAULTS = {
10
+ auto_register: false,
11
+ code_ttl_seconds: 600,
12
+ session_ttl_seconds: 604800,
13
+ slide_when_within_seconds: 86400,
14
+ email_rate_limit_max: 3,
15
+ email_rate_limit_window_seconds: 900,
16
+ ip_rate_limit_max: 20,
17
+ ip_rate_limit_window_seconds: 3600,
18
+ max_verify_attempts: 5,
19
+ auto_assign_scope_id: "00000000-0000-0000-0000-000000000001",
20
+ auto_assign_role_name: "member",
21
+ } as const;
22
+
23
+ // section: types
24
+ export type OtpConfig = {
25
+ /** Whether to automatically register a new user when an unrecognised email requests an OTP */
26
+ auto_register: boolean;
27
+ /** How long (seconds) a generated OTP code is valid */
28
+ code_ttl_seconds: number;
29
+ /** How long (seconds) the session created after successful OTP verification lasts */
30
+ session_ttl_seconds: number;
31
+ /** Slide the session expiry when the remaining TTL falls below this many seconds */
32
+ slide_when_within_seconds: number;
33
+ /** Maximum OTP requests allowed per email address within the rate-limit window */
34
+ email_rate_limit_max: number;
35
+ /** Rate-limit window (seconds) for per-email OTP requests */
36
+ email_rate_limit_window_seconds: number;
37
+ /** Maximum OTP requests allowed per IP address within the rate-limit window */
38
+ ip_rate_limit_max: number;
39
+ /** Rate-limit window (seconds) for per-IP OTP requests */
40
+ ip_rate_limit_window_seconds: number;
41
+ /** Maximum failed verify attempts before the OTP code is invalidated */
42
+ max_verify_attempts: number;
43
+ /** Scope ID to auto-assign a newly registered OTP user to */
44
+ auto_assign_scope_id: string;
45
+ /** Role name to assign within the auto-assign scope */
46
+ auto_assign_role_name: string;
47
+ };
48
+
49
+ // section: helpers
50
+ /**
51
+ * Reads OTP configuration from hazo_auth_config.ini [hazo_auth__otp] section.
52
+ * Falls back to defaults if the config file or section is missing.
53
+ */
54
+ export function get_otp_config(): OtpConfig {
55
+ const section = "hazo_auth__otp";
56
+ const d = OTP_CONFIG_DEFAULTS;
57
+
58
+ return {
59
+ auto_register: get_config_boolean(section, "otp_auto_register", d.auto_register),
60
+ code_ttl_seconds: get_config_number(section, "otp_code_ttl_seconds", d.code_ttl_seconds),
61
+ session_ttl_seconds: get_config_number(section, "otp_session_ttl_seconds", d.session_ttl_seconds),
62
+ slide_when_within_seconds: get_config_number(
63
+ section,
64
+ "otp_slide_when_within_seconds",
65
+ d.slide_when_within_seconds
66
+ ),
67
+ email_rate_limit_max: get_config_number(section, "otp_email_rate_limit_max", d.email_rate_limit_max),
68
+ email_rate_limit_window_seconds: get_config_number(
69
+ section,
70
+ "otp_email_rate_limit_window_seconds",
71
+ d.email_rate_limit_window_seconds
72
+ ),
73
+ ip_rate_limit_max: get_config_number(section, "otp_ip_rate_limit_max", d.ip_rate_limit_max),
74
+ ip_rate_limit_window_seconds: get_config_number(
75
+ section,
76
+ "otp_ip_rate_limit_window_seconds",
77
+ d.ip_rate_limit_window_seconds
78
+ ),
79
+ max_verify_attempts: get_config_number(section, "otp_max_verify_attempts", d.max_verify_attempts),
80
+ auto_assign_scope_id: get_config_value(section, "otp_auto_assign_scope_id", d.auto_assign_scope_id),
81
+ auto_assign_role_name: get_config_value(section, "otp_auto_assign_role_name", d.auto_assign_role_name),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Convenience accessor — returns just the session TTL seconds from OTP config.
87
+ * Suitable for passing to token-creation utilities.
88
+ */
89
+ export function hazo_auth_otp_session_ttl_seconds(): number {
90
+ return get_otp_config().session_ttl_seconds;
91
+ }
@@ -14,7 +14,7 @@ export type EmailOptions = {
14
14
  text_body?: string;
15
15
  };
16
16
 
17
- export type EmailTemplateType = "forgot_password" | "email_verification" | "password_changed";
17
+ export type EmailTemplateType = "forgot_password" | "email_verification" | "password_changed" | "otp_signin_code";
18
18
 
19
19
  export type EmailTemplateData = {
20
20
  token?: string;
@@ -243,6 +243,8 @@ function get_email_subject(template_type: EmailTemplateType): string {
243
243
  return "Reset Your Password";
244
244
  case "password_changed":
245
245
  return "Password Changed Successfully";
246
+ case "otp_signin_code":
247
+ return "Your sign-in code";
246
248
  default:
247
249
  return "Email from hazo_auth";
248
250
  }
@@ -101,4 +101,21 @@ export const hazo_auth_template_manifest: SystemTemplateManifest[] = [
101
101
  },
102
102
  ],
103
103
  },
104
+ {
105
+ template_name: "otp_signin_code",
106
+ template_label: "OTP sign-in code",
107
+ category: SYSTEM_CATEGORY,
108
+ html: read_template("otp_signin_code", "html"),
109
+ text: read_template("otp_signin_code", "txt"),
110
+ variables: [
111
+ {
112
+ variable_name: "otp_code",
113
+ variable_description: "6-digit OTP code for email sign-in (v6.1.0+)",
114
+ },
115
+ {
116
+ variable_name: "expires_in_minutes",
117
+ variable_description: "Number of minutes until the OTP code expires",
118
+ },
119
+ ],
120
+ },
104
121
  ];
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Your sign-in code</title>
6
+ </head>
7
+ <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
8
+ <p>Your sign-in code is:</p>
9
+ <p style="font-size: 28px; font-weight: bold; letter-spacing: 0.2em; font-family: monospace; margin: 16px 0;">{{otp_code}}</p>
10
+ <p>This code expires in {{expires_in_minutes}} minutes.</p>
11
+ <p style="color: #666; font-size: 12px;">If you didn't request this code, you can safely ignore this email.</p>
12
+ </body>
13
+ </html>
@@ -0,0 +1,5 @@
1
+ Your sign-in code is: {{otp_code}}
2
+
3
+ This code expires in {{expires_in_minutes}} minutes.
4
+
5
+ If you didn't request this code, you can safely ignore this email.
@@ -20,5 +20,11 @@ export * from "./scope_service.js";
20
20
  export * from "./user_scope_service.js";
21
21
  export * from "./oauth_service.js";
22
22
  export * from "./branding_service.js";
23
-
24
-
23
+ export {
24
+ request_email_otp,
25
+ verify_email_otp,
26
+ generate_otp_code,
27
+ hash_otp_code,
28
+ verify_otp_code,
29
+ } from "./otp_service.js";
30
+ export type { RequestEmailOTPResult, VerifyEmailOTPResult } from "./otp_service";
@@ -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 };
@@ -159,6 +159,11 @@ enable_admin_ui = true
159
159
  # Success message (shown when no redirect route is provided)
160
160
  # success_message = Successfully logged in
161
161
 
162
+ ; OTP sign-in link in the login layout.
163
+ otp_signin_enabled = false
164
+ otp_signin_label = Sign in with email code
165
+ otp_signin_href = /hazo_auth/otp
166
+
162
167
  [hazo_auth__forgot_password_layout]
163
168
  # Image configuration
164
169
  # image_src = /globe.svg
@@ -729,3 +734,36 @@ company_name = My Company
729
734
  # [hazo_auth__login_layout]
730
735
  # image_src = /your-custom-image.jpg
731
736
 
737
+ [hazo_auth__otp]
738
+ ; Email-OTP sign-in configuration (v6.1.0+).
739
+ ;
740
+ ; Whether /otp/verify may create new hazo_users rows for unknown emails.
741
+ ; false (default): /otp/request silently no-ops for unknown emails.
742
+ ; true: /otp/verify creates a user row on first successful code match.
743
+ otp_auto_register = false
744
+
745
+ ; OTP code lifetime in seconds (default 600 = 10 minutes).
746
+ otp_code_ttl_seconds = 600
747
+
748
+ ; OTP session lifetime in seconds (default 604800 = 7 days).
749
+ otp_session_ttl_seconds = 604800
750
+
751
+ ; Sliding-session threshold: if a /me call lands and the JWT exp is within
752
+ ; this many seconds, re-issue the session for another otp_session_ttl_seconds.
753
+ otp_slide_when_within_seconds = 86400
754
+
755
+ ; Per-email rate limit for /otp/request.
756
+ otp_email_rate_limit_max = 3
757
+ otp_email_rate_limit_window_seconds = 900
758
+
759
+ ; Per-IP rate limit for /otp/request.
760
+ otp_ip_rate_limit_max = 20
761
+ otp_ip_rate_limit_window_seconds = 3600
762
+
763
+ ; Wrong-guess limit per code; row is poisoned after this many failures.
764
+ otp_max_verify_attempts = 5
765
+
766
+ ; Scope binding for newly auto-registered users (only used when otp_auto_register=true).
767
+ otp_auto_assign_scope_id = 00000000-0000-0000-0000-000000000001
768
+ otp_auto_assign_role_name = member
769
+
@@ -1 +1 @@
1
- {"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../../src/cli/generate.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,eAAe,GAAG;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf,CAAC;AAiMF,wBAAgB,eAAe,CAAC,OAAO,GAAE,eAAoB,GAAG,IAAI,CA8DnE"}
1
+ {"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../../src/cli/generate.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,eAAe,GAAG;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf,CAAC;AA0MF,wBAAgB,eAAe,CAAC,OAAO,GAAE,eAAoB,GAAG,IAAI,CA8DnE"}