@contentgrowth/content-auth 0.2.9 → 0.3.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/dist/backend/index.d.ts +62 -1
- package/dist/backend/index.js +9 -3
- package/dist/{chunk-EU3UKKTM.js → chunk-6MYNRK2N.js} +79 -41
- package/dist/{chunk-N5OK3XPK.js → chunk-H37QRFRH.js} +192 -2
- package/dist/frontend/index.d.ts +2 -0
- package/dist/frontend/index.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +10 -4
- package/dist/styles.css +23 -0
- package/package.json +9 -2
- package/schema/auth.sql +2 -0
package/dist/backend/index.d.ts
CHANGED
|
@@ -6,6 +6,47 @@ import { Context, Hono } from 'hono';
|
|
|
6
6
|
export { Hono } from 'hono';
|
|
7
7
|
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Cloudflare Turnstile Verification Utility
|
|
11
|
+
*
|
|
12
|
+
* Verifies Turnstile tokens on the server-side using Cloudflare's Siteverify API.
|
|
13
|
+
* @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
|
14
|
+
*/
|
|
15
|
+
interface VerifyResult {
|
|
16
|
+
success: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
hostname?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Verifies a Turnstile token with Cloudflare's Siteverify API.
|
|
22
|
+
*
|
|
23
|
+
* @param secretKey - Your Turnstile secret key
|
|
24
|
+
* @param token - The token from the client-side Turnstile widget (cf-turnstile-response)
|
|
25
|
+
* @param remoteIp - Optional: The visitor's IP address for additional validation
|
|
26
|
+
* @returns Verification result with success status and any error messages
|
|
27
|
+
*/
|
|
28
|
+
declare function verifyTurnstile(secretKey: string, token: string, remoteIp?: string): Promise<VerifyResult>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Email Normalization Utilities
|
|
32
|
+
*
|
|
33
|
+
* Normalizes email addresses to prevent duplicate accounts through
|
|
34
|
+
* the "Gmail dot trick" and plus-addressing.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Normalizes an email address for duplicate detection.
|
|
38
|
+
* - Gmail addresses: strips dots and plus-addressing, normalizes googlemail.com
|
|
39
|
+
* - Other addresses: lowercases only
|
|
40
|
+
*
|
|
41
|
+
* @param email - The email address to normalize
|
|
42
|
+
* @returns The normalized email address
|
|
43
|
+
*/
|
|
44
|
+
declare function normalizeEmail(email: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Checks if an email is a Gmail address (including googlemail.com)
|
|
47
|
+
*/
|
|
48
|
+
declare function isGmailAddress(email: string): boolean;
|
|
49
|
+
|
|
9
50
|
declare const users: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
|
10
51
|
name: "users";
|
|
11
52
|
schema: undefined;
|
|
@@ -1084,6 +1125,16 @@ declare function getInvitationLink(data: any, baseUrl: string): {
|
|
|
1084
1125
|
*/
|
|
1085
1126
|
declare function getSessionToken(req: Request): string | null;
|
|
1086
1127
|
|
|
1128
|
+
interface TurnstileConfig {
|
|
1129
|
+
/** Cloudflare Turnstile secret key */
|
|
1130
|
+
secretKey: string;
|
|
1131
|
+
}
|
|
1132
|
+
interface EmailNormalizationConfig {
|
|
1133
|
+
/** Enable email normalization for duplicate detection. Default: false */
|
|
1134
|
+
enabled: boolean;
|
|
1135
|
+
/** Column name in users table. Default: 'normalized_email' */
|
|
1136
|
+
columnName?: string;
|
|
1137
|
+
}
|
|
1087
1138
|
interface AuthConfig {
|
|
1088
1139
|
/**
|
|
1089
1140
|
* The database instance or D1 binding.
|
|
@@ -1121,6 +1172,16 @@ interface AuthConfig {
|
|
|
1121
1172
|
token: string;
|
|
1122
1173
|
}, request: any) => Promise<void> | void;
|
|
1123
1174
|
};
|
|
1175
|
+
/**
|
|
1176
|
+
* Cloudflare Turnstile configuration for bot protection on email signups.
|
|
1177
|
+
* When configured, the AuthForm frontend will show a Turnstile challenge.
|
|
1178
|
+
*/
|
|
1179
|
+
turnstile?: TurnstileConfig;
|
|
1180
|
+
/**
|
|
1181
|
+
* Email normalization for duplicate prevention (Gmail dot trick, plus-addressing).
|
|
1182
|
+
* Requires a 'normalized_email' column in the users table.
|
|
1183
|
+
*/
|
|
1184
|
+
emailNormalization?: EmailNormalizationConfig;
|
|
1124
1185
|
[key: string]: any;
|
|
1125
1186
|
}
|
|
1126
1187
|
declare const createAuth: (config: AuthConfig) => better_auth.Auth<any>;
|
|
@@ -1130,4 +1191,4 @@ declare const createAuthApp: (config: AuthConfig) => {
|
|
|
1130
1191
|
auth: better_auth.Auth<any>;
|
|
1131
1192
|
};
|
|
1132
1193
|
|
|
1133
|
-
export { type AuthConfig, authMiddleware, createAuth, createAuthApp, getInvitationLink, getSessionToken, schema };
|
|
1194
|
+
export { type AuthConfig, type EmailNormalizationConfig, type TurnstileConfig, authMiddleware, createAuth, createAuthApp, getInvitationLink, getSessionToken, isGmailAddress, normalizeEmail, schema, verifyTurnstile };
|
package/dist/backend/index.js
CHANGED
|
@@ -5,8 +5,11 @@ import {
|
|
|
5
5
|
createAuthApp,
|
|
6
6
|
getInvitationLink,
|
|
7
7
|
getSessionToken,
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
isGmailAddress,
|
|
9
|
+
normalizeEmail,
|
|
10
|
+
schema_exports,
|
|
11
|
+
verifyTurnstile
|
|
12
|
+
} from "../chunk-H37QRFRH.js";
|
|
10
13
|
import "../chunk-R5U7XKVJ.js";
|
|
11
14
|
export {
|
|
12
15
|
Hono,
|
|
@@ -15,5 +18,8 @@ export {
|
|
|
15
18
|
createAuthApp,
|
|
16
19
|
getInvitationLink,
|
|
17
20
|
getSessionToken,
|
|
18
|
-
|
|
21
|
+
isGmailAddress,
|
|
22
|
+
normalizeEmail,
|
|
23
|
+
schema_exports as schema,
|
|
24
|
+
verifyTurnstile
|
|
19
25
|
};
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-NKDKDBM2.js";
|
|
4
4
|
|
|
5
5
|
// src/frontend/components/AuthForm.tsx
|
|
6
|
-
import
|
|
6
|
+
import { useState, useRef, useEffect } from "react";
|
|
7
7
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
var AuthForm = ({
|
|
9
9
|
client = authClient,
|
|
@@ -19,7 +19,8 @@ var AuthForm = ({
|
|
|
19
19
|
onSwitchMode,
|
|
20
20
|
defaultEmail = "",
|
|
21
21
|
lockEmail = false,
|
|
22
|
-
forgotPasswordUrl
|
|
22
|
+
forgotPasswordUrl,
|
|
23
|
+
turnstileSiteKey
|
|
23
24
|
}) => {
|
|
24
25
|
const [isLogin, setIsLogin] = useState(view !== "signup");
|
|
25
26
|
const [email, setEmail] = useState(defaultEmail);
|
|
@@ -27,13 +28,37 @@ var AuthForm = ({
|
|
|
27
28
|
const [name, setName] = useState("");
|
|
28
29
|
const [loading, setLoading] = useState(false);
|
|
29
30
|
const [error, setError] = useState(null);
|
|
31
|
+
const [turnstileToken, setTurnstileToken] = useState(null);
|
|
32
|
+
const turnstileRef = useRef(null);
|
|
33
|
+
const [TurnstileComponent, setTurnstileComponent] = useState(null);
|
|
30
34
|
const [mounted, setMounted] = useState(false);
|
|
31
|
-
|
|
35
|
+
useEffect(() => {
|
|
32
36
|
setMounted(true);
|
|
33
37
|
setIsLogin(view !== "signup");
|
|
34
|
-
|
|
38
|
+
if (turnstileSiteKey) {
|
|
39
|
+
import("@marsidev/react-turnstile").then((module) => setTurnstileComponent(() => module.Turnstile)).catch(() => setTurnstileComponent(null));
|
|
40
|
+
}
|
|
41
|
+
}, [view, turnstileSiteKey]);
|
|
42
|
+
const turnstileEnabled = turnstileSiteKey && TurnstileComponent;
|
|
43
|
+
const turnstileRequired = turnstileEnabled && !isLogin;
|
|
44
|
+
const canSubmit = !turnstileRequired || !!turnstileToken;
|
|
45
|
+
const handleTurnstileSuccess = (token) => {
|
|
46
|
+
setTurnstileToken(token);
|
|
47
|
+
};
|
|
48
|
+
const handleTurnstileError = () => {
|
|
49
|
+
setTurnstileToken(null);
|
|
50
|
+
setError("Security verification failed. Please try again.");
|
|
51
|
+
};
|
|
52
|
+
const handleTurnstileExpire = () => {
|
|
53
|
+
setTurnstileToken(null);
|
|
54
|
+
turnstileRef.current?.reset();
|
|
55
|
+
};
|
|
35
56
|
const handleSubmit = async (e) => {
|
|
36
57
|
e.preventDefault();
|
|
58
|
+
if (turnstileRequired && !turnstileToken) {
|
|
59
|
+
setError("Please complete the security challenge");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
37
62
|
setLoading(true);
|
|
38
63
|
setError(null);
|
|
39
64
|
try {
|
|
@@ -45,16 +70,24 @@ var AuthForm = ({
|
|
|
45
70
|
});
|
|
46
71
|
if (response.error) throw response.error;
|
|
47
72
|
} else {
|
|
48
|
-
|
|
73
|
+
const signupData = {
|
|
49
74
|
email,
|
|
50
75
|
password,
|
|
51
76
|
name
|
|
52
|
-
}
|
|
77
|
+
};
|
|
78
|
+
if (turnstileToken) {
|
|
79
|
+
signupData.turnstileToken = turnstileToken;
|
|
80
|
+
}
|
|
81
|
+
response = await client.signUp.email(signupData);
|
|
53
82
|
if (response.error) throw response.error;
|
|
54
83
|
}
|
|
55
84
|
onSuccess?.(response.data);
|
|
56
85
|
} catch (err) {
|
|
57
86
|
setError(err.message || "An error occurred");
|
|
87
|
+
if (turnstileRef.current) {
|
|
88
|
+
turnstileRef.current.reset();
|
|
89
|
+
setTurnstileToken(null);
|
|
90
|
+
}
|
|
58
91
|
} finally {
|
|
59
92
|
setLoading(false);
|
|
60
93
|
}
|
|
@@ -77,6 +110,26 @@ var AuthForm = ({
|
|
|
77
110
|
else if (width === "wide") widthClass = "ca-width-wide";
|
|
78
111
|
else widthClass = "ca-width-default";
|
|
79
112
|
const containerClass = `ca-container ${layout === "split" ? "ca-layout-split" : ""} ${widthClass} ${className || ""}`;
|
|
113
|
+
const renderTurnstile = () => {
|
|
114
|
+
if (!turnstileEnabled || isLogin) return null;
|
|
115
|
+
return /* @__PURE__ */ jsxs("div", { className: "ca-turnstile", children: [
|
|
116
|
+
/* @__PURE__ */ jsx(
|
|
117
|
+
TurnstileComponent,
|
|
118
|
+
{
|
|
119
|
+
ref: turnstileRef,
|
|
120
|
+
siteKey: turnstileSiteKey,
|
|
121
|
+
onSuccess: handleTurnstileSuccess,
|
|
122
|
+
onError: handleTurnstileError,
|
|
123
|
+
onExpire: handleTurnstileExpire,
|
|
124
|
+
options: {
|
|
125
|
+
theme: "light",
|
|
126
|
+
size: "normal"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
),
|
|
130
|
+
!turnstileToken && /* @__PURE__ */ jsx("p", { className: "ca-turnstile-hint", children: "Please complete the security check above" })
|
|
131
|
+
] });
|
|
132
|
+
};
|
|
80
133
|
const renderSocials = () => (
|
|
81
134
|
// Hide social logins when email is locked (e.g., invitation flow)
|
|
82
135
|
!lockEmail && socialProviders.length > 0 && /* @__PURE__ */ jsx("div", { className: socialClass, children: socialProviders.map((provider) => /* @__PURE__ */ jsxs(
|
|
@@ -154,8 +207,17 @@ var AuthForm = ({
|
|
|
154
207
|
}
|
|
155
208
|
)
|
|
156
209
|
] }),
|
|
210
|
+
renderTurnstile(),
|
|
157
211
|
error && /* @__PURE__ */ jsx("div", { className: "ca-error", children: error }),
|
|
158
|
-
/* @__PURE__ */ jsx(
|
|
212
|
+
/* @__PURE__ */ jsx(
|
|
213
|
+
"button",
|
|
214
|
+
{
|
|
215
|
+
type: "submit",
|
|
216
|
+
className: "ca-button",
|
|
217
|
+
disabled: loading || !canSubmit,
|
|
218
|
+
children: loading ? "Loading..." : isLogin ? "Sign In" : "Sign Up"
|
|
219
|
+
}
|
|
220
|
+
)
|
|
159
221
|
] });
|
|
160
222
|
};
|
|
161
223
|
const renderFooter = () => /* @__PURE__ */ jsxs("div", { className: "ca-footer", children: [
|
|
@@ -170,6 +232,10 @@ var AuthForm = ({
|
|
|
170
232
|
} else {
|
|
171
233
|
setIsLogin(!isLogin);
|
|
172
234
|
}
|
|
235
|
+
if (turnstileRef.current) {
|
|
236
|
+
turnstileRef.current.reset();
|
|
237
|
+
setTurnstileToken(null);
|
|
238
|
+
}
|
|
173
239
|
},
|
|
174
240
|
type: "button",
|
|
175
241
|
children: isLogin ? "Sign up" : "Sign in"
|
|
@@ -411,7 +477,7 @@ var ResetPasswordForm = ({
|
|
|
411
477
|
};
|
|
412
478
|
|
|
413
479
|
// src/frontend/components/Organization.tsx
|
|
414
|
-
import { useState as useState4, useEffect } from "react";
|
|
480
|
+
import { useState as useState4, useEffect as useEffect2 } from "react";
|
|
415
481
|
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
416
482
|
var CreateOrganizationForm = ({
|
|
417
483
|
client = authClient,
|
|
@@ -484,7 +550,7 @@ var OrganizationSwitcher = ({
|
|
|
484
550
|
}) => {
|
|
485
551
|
const [orgs, setOrgs] = useState4([]);
|
|
486
552
|
const [loading, setLoading] = useState4(true);
|
|
487
|
-
|
|
553
|
+
useEffect2(() => {
|
|
488
554
|
const fetchOrgs = async () => {
|
|
489
555
|
const { data } = await client.organization.list({});
|
|
490
556
|
if (data) setOrgs(data);
|
|
@@ -576,7 +642,7 @@ var InviteMemberForm = ({
|
|
|
576
642
|
};
|
|
577
643
|
|
|
578
644
|
// src/frontend/components/ProfileEditor.tsx
|
|
579
|
-
import { useState as useState5, useEffect as
|
|
645
|
+
import { useState as useState5, useEffect as useEffect3 } from "react";
|
|
580
646
|
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
581
647
|
var ProfileEditor = ({
|
|
582
648
|
client = authClient,
|
|
@@ -589,7 +655,7 @@ var ProfileEditor = ({
|
|
|
589
655
|
const [name, setName] = useState5(defaultName);
|
|
590
656
|
const [image, setImage] = useState5(defaultImage);
|
|
591
657
|
const [loading, setLoading] = useState5(false);
|
|
592
|
-
|
|
658
|
+
useEffect3(() => {
|
|
593
659
|
if (defaultName) setName(defaultName);
|
|
594
660
|
if (defaultImage) setImage(defaultImage);
|
|
595
661
|
}, [defaultName, defaultImage]);
|
|
@@ -661,7 +727,7 @@ var ProfileEditor = ({
|
|
|
661
727
|
};
|
|
662
728
|
|
|
663
729
|
// src/frontend/components/PasswordChanger.tsx
|
|
664
|
-
import
|
|
730
|
+
import { useState as useState6 } from "react";
|
|
665
731
|
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
666
732
|
var PasswordChanger = ({
|
|
667
733
|
client = authClient,
|
|
@@ -674,26 +740,6 @@ var PasswordChanger = ({
|
|
|
674
740
|
const [newPassword, setNewPassword] = useState6("");
|
|
675
741
|
const [confirmPassword, setConfirmPassword] = useState6("");
|
|
676
742
|
const [loading, setLoading] = useState6(false);
|
|
677
|
-
const [canChangePassword, setCanChangePassword] = useState6(true);
|
|
678
|
-
const [authMessage, setAuthMessage] = useState6(null);
|
|
679
|
-
React6.useEffect(() => {
|
|
680
|
-
const checkAccounts = async () => {
|
|
681
|
-
try {
|
|
682
|
-
const accounts = await client.listAccounts();
|
|
683
|
-
if (accounts?.data) {
|
|
684
|
-
const hasCredential = accounts.data.some((acc) => acc.providerId === "credential");
|
|
685
|
-
if (!hasCredential) {
|
|
686
|
-
setCanChangePassword(false);
|
|
687
|
-
const providers = accounts.data.map((acc) => acc.providerId).join(", ");
|
|
688
|
-
setAuthMessage(`You are signed in via ${providers}. You cannot change your password here because you don't have a password set.`);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
} catch (e) {
|
|
692
|
-
console.warn("Failed to list accounts", e);
|
|
693
|
-
}
|
|
694
|
-
};
|
|
695
|
-
checkAccounts();
|
|
696
|
-
}, [client]);
|
|
697
743
|
const handleSubmit = async (e) => {
|
|
698
744
|
e.preventDefault();
|
|
699
745
|
if (newPassword !== confirmPassword) {
|
|
@@ -715,9 +761,7 @@ var PasswordChanger = ({
|
|
|
715
761
|
onSuccess?.(res?.data);
|
|
716
762
|
} catch (err) {
|
|
717
763
|
if (err?.code === "CREDENTIAL_ACCOUNT_NOT_FOUND" || err?.message?.includes("Credential account not found")) {
|
|
718
|
-
|
|
719
|
-
setAuthMessage("You are logged in via a social provider and do not have a password set.");
|
|
720
|
-
onError?.("Password change unavailable for social logins.");
|
|
764
|
+
onError?.("You are logged in via a social provider (e.g. GitHub, Google) and do not have a password set.");
|
|
721
765
|
} else {
|
|
722
766
|
onError?.(err.message || "Failed to change password");
|
|
723
767
|
}
|
|
@@ -725,12 +769,6 @@ var PasswordChanger = ({
|
|
|
725
769
|
setLoading(false);
|
|
726
770
|
}
|
|
727
771
|
};
|
|
728
|
-
if (!canChangePassword) {
|
|
729
|
-
return /* @__PURE__ */ jsx6("div", { className: `ca-form ${className || ""}`, children: /* @__PURE__ */ jsxs6("div", { className: "ca-info-message", style: { textAlign: "center", padding: "2rem", background: "#f9fafb", borderRadius: "8px", border: "1px solid #e5e7eb" }, children: [
|
|
730
|
-
/* @__PURE__ */ jsx6("p", { style: { color: "#6b7280", marginBottom: "0.5rem" }, children: "Password Change Unavailable" }),
|
|
731
|
-
/* @__PURE__ */ jsx6("p", { style: { color: "#374151", fontSize: "0.95rem" }, children: authMessage || "Your account is managed by a third-party provider." })
|
|
732
|
-
] }) });
|
|
733
|
-
}
|
|
734
772
|
return /* @__PURE__ */ jsxs6("form", { onSubmit: handleSubmit, className: `ca-form ${className || ""}`, children: [
|
|
735
773
|
/* @__PURE__ */ jsxs6("div", { className: "ca-input-group", children: [
|
|
736
774
|
/* @__PURE__ */ jsx6("label", { className: "ca-label", htmlFor: "current-password", children: "Current Password" }),
|
|
@@ -9,6 +9,87 @@ import { drizzle } from "drizzle-orm/d1";
|
|
|
9
9
|
import { Hono } from "hono";
|
|
10
10
|
export * from "better-auth";
|
|
11
11
|
|
|
12
|
+
// src/backend/turnstile.ts
|
|
13
|
+
async function verifyTurnstile(secretKey, token, remoteIp) {
|
|
14
|
+
if (!secretKey) {
|
|
15
|
+
console.warn("[Turnstile] No secret key configured, skipping verification");
|
|
16
|
+
return { success: true };
|
|
17
|
+
}
|
|
18
|
+
if (!token) {
|
|
19
|
+
return { success: false, error: "Missing Turnstile token" };
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const formData = new URLSearchParams();
|
|
23
|
+
formData.append("secret", secretKey);
|
|
24
|
+
formData.append("response", token);
|
|
25
|
+
if (remoteIp) {
|
|
26
|
+
formData.append("remoteip", remoteIp);
|
|
27
|
+
}
|
|
28
|
+
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
32
|
+
},
|
|
33
|
+
body: formData.toString()
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
console.error(`[Turnstile] Siteverify API error: ${response.status}`);
|
|
37
|
+
return { success: false, error: `API error: ${response.status}` };
|
|
38
|
+
}
|
|
39
|
+
const result = await response.json();
|
|
40
|
+
if (result.success) {
|
|
41
|
+
return { success: true, hostname: result.hostname };
|
|
42
|
+
} else {
|
|
43
|
+
const errorCodes = result["error-codes"] || [];
|
|
44
|
+
console.warn(`[Turnstile] Verification failed: ${errorCodes.join(", ")}`);
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
error: mapTurnstileError(errorCodes)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`[Turnstile] Verification error: ${error.message}`);
|
|
52
|
+
return { success: false, error: "Verification service unavailable" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function mapTurnstileError(codes) {
|
|
56
|
+
if (codes.includes("timeout-or-duplicate")) {
|
|
57
|
+
return "Challenge expired or already used. Please try again.";
|
|
58
|
+
}
|
|
59
|
+
if (codes.includes("invalid-input-response")) {
|
|
60
|
+
return "Invalid challenge response. Please try again.";
|
|
61
|
+
}
|
|
62
|
+
if (codes.includes("bad-request")) {
|
|
63
|
+
return "Invalid request. Please refresh and try again.";
|
|
64
|
+
}
|
|
65
|
+
if (codes.includes("internal-error")) {
|
|
66
|
+
return "Verification service error. Please try again later.";
|
|
67
|
+
}
|
|
68
|
+
return "Challenge verification failed. Please try again.";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/backend/email-normalize.ts
|
|
72
|
+
function normalizeEmail(email) {
|
|
73
|
+
const lowerEmail = email.toLowerCase().trim();
|
|
74
|
+
const atIndex = lowerEmail.indexOf("@");
|
|
75
|
+
if (atIndex === -1) {
|
|
76
|
+
return lowerEmail;
|
|
77
|
+
}
|
|
78
|
+
const local = lowerEmail.substring(0, atIndex);
|
|
79
|
+
const domain = lowerEmail.substring(atIndex + 1);
|
|
80
|
+
const normalizedDomain = domain === "googlemail.com" ? "gmail.com" : domain;
|
|
81
|
+
if (normalizedDomain === "gmail.com") {
|
|
82
|
+
const localWithoutPlus = local.split("+")[0];
|
|
83
|
+
const normalizedLocal = localWithoutPlus.replace(/\./g, "");
|
|
84
|
+
return `${normalizedLocal}@gmail.com`;
|
|
85
|
+
}
|
|
86
|
+
return lowerEmail;
|
|
87
|
+
}
|
|
88
|
+
function isGmailAddress(email) {
|
|
89
|
+
const lowerEmail = email.toLowerCase();
|
|
90
|
+
return lowerEmail.endsWith("@gmail.com") || lowerEmail.endsWith("@googlemail.com");
|
|
91
|
+
}
|
|
92
|
+
|
|
12
93
|
// src/backend/schema.ts
|
|
13
94
|
var schema_exports = {};
|
|
14
95
|
__export(schema_exports, {
|
|
@@ -195,13 +276,24 @@ function getSessionToken(req) {
|
|
|
195
276
|
// src/backend/index.ts
|
|
196
277
|
var createAuth = (config) => {
|
|
197
278
|
let db;
|
|
279
|
+
let rawDb = config.database;
|
|
198
280
|
let provider = config.provider || "sqlite";
|
|
199
281
|
if (config.database && typeof config.database.prepare === "function") {
|
|
200
282
|
db = drizzle(config.database, { schema: schema_exports });
|
|
201
283
|
} else {
|
|
202
284
|
db = config.database;
|
|
203
285
|
}
|
|
204
|
-
const {
|
|
286
|
+
const {
|
|
287
|
+
database,
|
|
288
|
+
secret,
|
|
289
|
+
baseUrl,
|
|
290
|
+
provider: _,
|
|
291
|
+
useCloudflareNativeHashing = true,
|
|
292
|
+
emailVerification,
|
|
293
|
+
turnstile: turnstileConfig,
|
|
294
|
+
emailNormalization,
|
|
295
|
+
...rest
|
|
296
|
+
} = config;
|
|
205
297
|
let adapterOptions = {
|
|
206
298
|
provider,
|
|
207
299
|
schema: {
|
|
@@ -215,7 +307,7 @@ var createAuth = (config) => {
|
|
|
215
307
|
}
|
|
216
308
|
};
|
|
217
309
|
const emailConfig = rest.emailAndPassword || { enabled: true };
|
|
218
|
-
const { emailAndPassword, ...otherOptions } = rest;
|
|
310
|
+
const { emailAndPassword, hooks: userHooks, ...otherOptions } = rest;
|
|
219
311
|
const emailPasswordOptions = {
|
|
220
312
|
...emailConfig
|
|
221
313
|
};
|
|
@@ -225,6 +317,99 @@ var createAuth = (config) => {
|
|
|
225
317
|
verify: verifyPassword
|
|
226
318
|
};
|
|
227
319
|
}
|
|
320
|
+
const normalizedEmailColumn = emailNormalization?.columnName || "normalized_email";
|
|
321
|
+
const contentAuthHooks = {
|
|
322
|
+
before: async (context) => {
|
|
323
|
+
const path = context.path || "";
|
|
324
|
+
if (path.includes("/sign-up/email")) {
|
|
325
|
+
try {
|
|
326
|
+
let body;
|
|
327
|
+
if (context.body) {
|
|
328
|
+
body = context.body;
|
|
329
|
+
} else {
|
|
330
|
+
try {
|
|
331
|
+
body = await context.request.clone().json();
|
|
332
|
+
} catch (e) {
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (body) {
|
|
336
|
+
if (turnstileConfig?.secretKey) {
|
|
337
|
+
const turnstileToken = body.turnstileToken;
|
|
338
|
+
if (!turnstileToken) {
|
|
339
|
+
console.warn("[ContentAuth] Email signup missing Turnstile token");
|
|
340
|
+
return {
|
|
341
|
+
status: 400,
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
success: false,
|
|
344
|
+
code: "TURNSTILE_REQUIRED",
|
|
345
|
+
message: "Please complete the security challenge"
|
|
346
|
+
}),
|
|
347
|
+
headers: { "Content-Type": "application/json" }
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const { success, error } = await verifyTurnstile(turnstileConfig.secretKey, turnstileToken);
|
|
351
|
+
if (!success) {
|
|
352
|
+
console.warn(`[ContentAuth] Turnstile verification failed: ${error}`);
|
|
353
|
+
return {
|
|
354
|
+
status: 400,
|
|
355
|
+
body: JSON.stringify({
|
|
356
|
+
success: false,
|
|
357
|
+
code: "TURNSTILE_FAILED",
|
|
358
|
+
message: error || "Security challenge failed. Please try again."
|
|
359
|
+
}),
|
|
360
|
+
headers: { "Content-Type": "application/json" }
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (emailNormalization?.enabled && body.email && rawDb?.prepare) {
|
|
365
|
+
const normalized = normalizeEmail(body.email);
|
|
366
|
+
const existing = await rawDb.prepare(
|
|
367
|
+
`SELECT id FROM users WHERE ${normalizedEmailColumn} = ?`
|
|
368
|
+
).bind(normalized).first();
|
|
369
|
+
if (existing) {
|
|
370
|
+
console.warn(`[ContentAuth] Duplicate normalized email detected: ${normalized}`);
|
|
371
|
+
return {
|
|
372
|
+
status: 400,
|
|
373
|
+
body: JSON.stringify({
|
|
374
|
+
success: false,
|
|
375
|
+
code: "EMAIL_EXISTS",
|
|
376
|
+
message: "An account with this email already exists"
|
|
377
|
+
}),
|
|
378
|
+
headers: { "Content-Type": "application/json" }
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.error("[ContentAuth] Email signup hook error:", e.message);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (userHooks?.before) {
|
|
388
|
+
return userHooks.before(context);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
},
|
|
392
|
+
after: async (context) => {
|
|
393
|
+
const path = context.path || "";
|
|
394
|
+
const user = context.user || context.response?.user || context.data?.user || context.context?.returned?.user || context.context?.newSession?.user;
|
|
395
|
+
if (emailNormalization?.enabled && rawDb?.prepare) {
|
|
396
|
+
if ((path.includes("/sign-up") || path.includes("/callback")) && user?.id && user?.email) {
|
|
397
|
+
try {
|
|
398
|
+
const normalized = normalizeEmail(user.email);
|
|
399
|
+
await rawDb.prepare(
|
|
400
|
+
`UPDATE users SET ${normalizedEmailColumn} = ? WHERE id = ? AND (${normalizedEmailColumn} IS NULL OR ${normalizedEmailColumn} != ?)`
|
|
401
|
+
).bind(normalized, user.id, normalized).run();
|
|
402
|
+
} catch (e) {
|
|
403
|
+
console.error(`[ContentAuth] Failed to set normalized_email: ${e.message}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (userHooks?.after) {
|
|
408
|
+
return userHooks.after(context);
|
|
409
|
+
}
|
|
410
|
+
return {};
|
|
411
|
+
}
|
|
412
|
+
};
|
|
228
413
|
const auth = betterAuth({
|
|
229
414
|
database: drizzleAdapter(db, adapterOptions),
|
|
230
415
|
secret,
|
|
@@ -232,6 +417,8 @@ var createAuth = (config) => {
|
|
|
232
417
|
emailAndPassword: emailPasswordOptions,
|
|
233
418
|
// Pass emailVerification config if provided
|
|
234
419
|
...emailVerification ? { emailVerification } : {},
|
|
420
|
+
// Merge content-auth hooks with user hooks
|
|
421
|
+
hooks: contentAuthHooks,
|
|
235
422
|
...otherOptions
|
|
236
423
|
});
|
|
237
424
|
return auth;
|
|
@@ -251,6 +438,9 @@ var createAuthApp = (config) => {
|
|
|
251
438
|
};
|
|
252
439
|
|
|
253
440
|
export {
|
|
441
|
+
verifyTurnstile,
|
|
442
|
+
normalizeEmail,
|
|
443
|
+
isGmailAddress,
|
|
254
444
|
schema_exports,
|
|
255
445
|
getInvitationLink,
|
|
256
446
|
getSessionToken,
|
package/dist/frontend/index.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ interface AuthFormProps {
|
|
|
24
24
|
lockEmail?: boolean;
|
|
25
25
|
/** URL for the forgot password page (shows link on login form if provided) */
|
|
26
26
|
forgotPasswordUrl?: string;
|
|
27
|
+
/** Cloudflare Turnstile site key. When set, shows Turnstile widget on signup. */
|
|
28
|
+
turnstileSiteKey?: string;
|
|
27
29
|
}
|
|
28
30
|
declare const AuthForm: React.FC<AuthFormProps>;
|
|
29
31
|
|
package/dist/frontend/index.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AuthConfig, authMiddleware, createAuth, createAuthApp, getInvitationLink, getSessionToken, schema } from './backend/index.js';
|
|
1
|
+
export { AuthConfig, EmailNormalizationConfig, TurnstileConfig, authMiddleware, createAuth, createAuthApp, getInvitationLink, getSessionToken, isGmailAddress, normalizeEmail, schema, verifyTurnstile } from './backend/index.js';
|
|
2
2
|
export { AuthForm, CreateOrganizationForm, ForgotPasswordForm, InviteMemberForm, OrganizationSwitcher, PasswordChanger, PasswordChangerProps, ProfileEditor, ProfileEditorProps, ResetPasswordForm } from './frontend/index.js';
|
|
3
3
|
export { authClient, createClient } from './frontend/client.js';
|
|
4
4
|
export * from 'better-auth';
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,11 @@ import {
|
|
|
5
5
|
createAuthApp,
|
|
6
6
|
getInvitationLink,
|
|
7
7
|
getSessionToken,
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
isGmailAddress,
|
|
9
|
+
normalizeEmail,
|
|
10
|
+
schema_exports,
|
|
11
|
+
verifyTurnstile
|
|
12
|
+
} from "./chunk-H37QRFRH.js";
|
|
10
13
|
import {
|
|
11
14
|
AuthForm,
|
|
12
15
|
CreateOrganizationForm,
|
|
@@ -16,7 +19,7 @@ import {
|
|
|
16
19
|
PasswordChanger,
|
|
17
20
|
ProfileEditor,
|
|
18
21
|
ResetPasswordForm
|
|
19
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-6MYNRK2N.js";
|
|
20
23
|
import {
|
|
21
24
|
authClient,
|
|
22
25
|
createClient
|
|
@@ -39,5 +42,8 @@ export {
|
|
|
39
42
|
createClient,
|
|
40
43
|
getInvitationLink,
|
|
41
44
|
getSessionToken,
|
|
42
|
-
|
|
45
|
+
isGmailAddress,
|
|
46
|
+
normalizeEmail,
|
|
47
|
+
schema_exports as schema,
|
|
48
|
+
verifyTurnstile
|
|
43
49
|
};
|
package/dist/styles.css
CHANGED
|
@@ -346,4 +346,27 @@ button[type="submit"]:disabled {
|
|
|
346
346
|
flex-direction: column;
|
|
347
347
|
justify-content: center;
|
|
348
348
|
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* Turnstile Widget */
|
|
352
|
+
.ca-turnstile {
|
|
353
|
+
display: flex;
|
|
354
|
+
flex-direction: column;
|
|
355
|
+
align-items: center;
|
|
356
|
+
gap: 0.5rem;
|
|
357
|
+
margin: 0.5rem 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.ca-turnstile-hint {
|
|
361
|
+
font-size: 0.8125rem;
|
|
362
|
+
color: #6b7280;
|
|
363
|
+
text-align: center;
|
|
364
|
+
margin: 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* Locked Input */
|
|
368
|
+
.ca-input-locked {
|
|
369
|
+
background-color: #f3f4f6;
|
|
370
|
+
color: #6b7280;
|
|
371
|
+
cursor: not-allowed;
|
|
349
372
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentgrowth/content-auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Better Auth wrapper with UI components for Cloudflare Workers & Pages",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Better Auth wrapper with UI components for Cloudflare Workers & Pages. Includes Turnstile bot protection and email normalization.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -50,9 +50,15 @@
|
|
|
50
50
|
"author": "Content Growth",
|
|
51
51
|
"license": "MIT",
|
|
52
52
|
"peerDependencies": {
|
|
53
|
+
"@marsidev/react-turnstile": ">=0.5.0",
|
|
53
54
|
"react": ">=18.2.0",
|
|
54
55
|
"react-dom": ">=18.2.0"
|
|
55
56
|
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@marsidev/react-turnstile": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
56
62
|
"dependencies": {
|
|
57
63
|
"better-auth": "^1.4.9",
|
|
58
64
|
"cac": "^6.7.14",
|
|
@@ -61,6 +67,7 @@
|
|
|
61
67
|
},
|
|
62
68
|
"devDependencies": {
|
|
63
69
|
"@cloudflare/workers-types": "^4.20251231.0",
|
|
70
|
+
"@marsidev/react-turnstile": "^1.4.1",
|
|
64
71
|
"@types/node": "^25.0.3",
|
|
65
72
|
"@types/react": "^19.2.7",
|
|
66
73
|
"@types/react-dom": "^19.2.3",
|