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.
- package/README.md +167 -17
- package/SETUP_CHECKLIST.md +99 -7
- package/cli-src/cli/generate.ts +10 -1
- package/cli-src/cli/validate.ts +4 -0
- package/cli-src/lib/auth/auth_types.ts +21 -12
- package/cli-src/lib/auth/hazo_get_tenant_auth.server.ts +25 -24
- package/cli-src/lib/auth/index.ts +2 -2
- package/cli-src/lib/auth/with_auth.server.ts +15 -15
- package/cli-src/lib/cookies_config.server.ts +1 -0
- package/cli-src/lib/login_config.server.ts +14 -0
- package/cli-src/lib/otp_config.server.ts +91 -0
- package/cli-src/lib/services/email_service.ts +3 -1
- package/cli-src/lib/services/email_template_manifest.ts +17 -0
- package/cli-src/lib/services/email_templates/otp_signin_code.html +13 -0
- package/cli-src/lib/services/email_templates/otp_signin_code.txt +5 -0
- package/cli-src/lib/services/index.ts +8 -2
- package/cli-src/lib/services/otp_service.ts +295 -0
- package/cli-src/lib/services/session_token_service.ts +4 -1
- package/config/hazo_auth_config.example.ini +38 -0
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +10 -1
- package/dist/cli/validate.d.ts.map +1 -1
- package/dist/cli/validate.js +4 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -0
- package/dist/components/layouts/login/index.d.ts +7 -1
- package/dist/components/layouts/login/index.d.ts.map +1 -1
- package/dist/components/layouts/login/index.js +2 -2
- package/dist/components/layouts/otp/index.d.ts +10 -0
- package/dist/components/layouts/otp/index.d.ts.map +1 -0
- package/dist/components/layouts/otp/index.js +14 -0
- package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
- package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +8 -3
- package/dist/components/otp/OTPRequestForm.d.ts +11 -0
- package/dist/components/otp/OTPRequestForm.d.ts.map +1 -0
- package/dist/components/otp/OTPRequestForm.js +42 -0
- package/dist/components/otp/OTPVerifyForm.d.ts +16 -0
- package/dist/components/otp/OTPVerifyForm.d.ts.map +1 -0
- package/dist/components/otp/OTPVerifyForm.js +75 -0
- package/dist/components/otp/index.d.ts +5 -0
- package/dist/components/otp/index.d.ts.map +1 -0
- package/dist/components/otp/index.js +2 -0
- package/dist/components/ui/input-otp.d.ts +35 -0
- package/dist/components/ui/input-otp.d.ts.map +1 -0
- package/dist/components/ui/input-otp.js +44 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/auth/auth_types.d.ts +13 -12
- package/dist/lib/auth/auth_types.d.ts.map +1 -1
- package/dist/lib/auth/auth_types.js +8 -0
- package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts +8 -7
- package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts.map +1 -1
- package/dist/lib/auth/hazo_get_tenant_auth.server.js +23 -22
- package/dist/lib/auth/index.d.ts +2 -2
- package/dist/lib/auth/index.d.ts.map +1 -1
- package/dist/lib/auth/with_auth.server.d.ts +13 -13
- package/dist/lib/auth/with_auth.server.d.ts.map +1 -1
- package/dist/lib/auth/with_auth.server.js +2 -2
- package/dist/lib/cookies_config.server.d.ts +1 -0
- package/dist/lib/cookies_config.server.d.ts.map +1 -1
- package/dist/lib/cookies_config.server.js +1 -0
- package/dist/lib/login_config.server.d.ts +6 -0
- package/dist/lib/login_config.server.d.ts.map +1 -1
- package/dist/lib/login_config.server.js +7 -0
- package/dist/lib/otp_config.server.d.ts +49 -0
- package/dist/lib/otp_config.server.d.ts.map +1 -0
- package/dist/lib/otp_config.server.js +48 -0
- package/dist/lib/services/email_service.d.ts +1 -1
- package/dist/lib/services/email_service.d.ts.map +1 -1
- package/dist/lib/services/email_service.js +2 -0
- package/dist/lib/services/email_template_manifest.d.ts.map +1 -1
- package/dist/lib/services/email_template_manifest.js +17 -0
- package/dist/lib/services/email_templates/otp_signin_code.html +13 -0
- package/dist/lib/services/email_templates/otp_signin_code.txt +5 -0
- package/dist/lib/services/index.d.ts +2 -0
- package/dist/lib/services/index.d.ts.map +1 -1
- package/dist/lib/services/index.js +1 -0
- package/dist/lib/services/otp_service.d.ts +46 -0
- package/dist/lib/services/otp_service.d.ts.map +1 -0
- package/dist/lib/services/otp_service.js +238 -0
- package/dist/lib/services/session_token_service.d.ts +3 -1
- package/dist/lib/services/session_token_service.d.ts.map +1 -1
- package/dist/lib/services/session_token_service.js +4 -2
- package/dist/page_components/otp.d.ts +4 -0
- package/dist/page_components/otp.d.ts.map +1 -0
- package/dist/page_components/otp.js +5 -0
- package/dist/server/routes/index.d.ts +2 -0
- package/dist/server/routes/index.d.ts.map +1 -1
- package/dist/server/routes/index.js +3 -0
- package/dist/server/routes/me.d.ts.map +1 -1
- package/dist/server/routes/me.js +43 -1
- package/dist/server/routes/otp/request.d.ts +3 -0
- package/dist/server/routes/otp/request.d.ts.map +1 -0
- package/dist/server/routes/otp/request.js +33 -0
- package/dist/server/routes/otp/verify.d.ts +3 -0
- package/dist/server/routes/otp/verify.d.ts.map +1 -0
- package/dist/server/routes/otp/verify.js +58 -0
- package/dist/server-lib.d.ts +3 -0
- package/dist/server-lib.d.ts.map +1 -1
- package/dist/server-lib.js +2 -0
- package/dist/server_pages/forgot_password.d.ts +1 -1
- package/dist/server_pages/forgot_password.d.ts.map +1 -1
- package/dist/server_pages/forgot_password.js +2 -1
- package/dist/server_pages/login.d.ts +1 -1
- package/dist/server_pages/login.d.ts.map +1 -1
- package/dist/server_pages/login.js +3 -2
- package/dist/server_pages/login_client_wrapper.d.ts +1 -1
- package/dist/server_pages/login_client_wrapper.d.ts.map +1 -1
- package/dist/server_pages/login_client_wrapper.js +2 -2
- package/dist/server_pages/my_settings.d.ts +1 -1
- package/dist/server_pages/my_settings.d.ts.map +1 -1
- package/dist/server_pages/my_settings.js +2 -1
- package/dist/server_pages/otp.d.ts +42 -0
- package/dist/server_pages/otp.d.ts.map +1 -0
- package/dist/server_pages/otp.js +38 -0
- package/dist/server_pages/register.d.ts +1 -1
- package/dist/server_pages/register.d.ts.map +1 -1
- package/dist/server_pages/register.js +2 -1
- package/dist/server_pages/reset_password.d.ts +1 -1
- package/dist/server_pages/reset_password.d.ts.map +1 -1
- package/dist/server_pages/reset_password.js +2 -1
- package/dist/server_pages/verify_email.d.ts +1 -1
- package/dist/server_pages/verify_email.d.ts.map +1 -1
- package/dist/server_pages/verify_email.js +2 -1
- package/package.json +20 -3
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
PermissionError,
|
|
11
11
|
type TenantAuthOptions,
|
|
12
12
|
type TenantAuthResult,
|
|
13
|
-
type
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
38
|
+
* Authenticated branch with guaranteed non-null selected_scope
|
|
39
39
|
*/
|
|
40
|
-
export type
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
52
|
-
* Narrows auth type to
|
|
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:
|
|
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
|
|
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.
|
|
165
|
-
* const data = await getData(auth.
|
|
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.
|
|
230
|
+
if (options.require_tenant && !auth.selected_scope) {
|
|
231
231
|
return NextResponse.json(
|
|
232
232
|
{
|
|
233
|
-
error: "
|
|
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>
|
|
@@ -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;
|
|
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"}
|