@cfast/auth 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +394 -0
- package/dist/client.d.ts +123 -0
- package/dist/client.js +226 -0
- package/dist/index.d.ts +167 -0
- package/dist/index.js +260 -0
- package/dist/plugin.d.ts +41 -0
- package/dist/plugin.js +14 -0
- package/dist/schema.d.ts +1052 -0
- package/dist/schema.js +85 -0
- package/dist/types-19GeiMs4.d.ts +64 -0
- package/dist/types-8eZilolN.d.ts +63 -0
- package/dist/types-DZ7AeznW.d.ts +74 -0
- package/dist/types-DdbPIOVK.d.ts +85 -0
- package/dist/types-DrnTPiku.d.ts +62 -0
- package/dist/types-ghXti5CW.d.ts +191 -0
- package/package.json +66 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/client/auth-provider.tsx
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
var AuthContext = createContext(null);
|
|
5
|
+
function AuthProvider({
|
|
6
|
+
user,
|
|
7
|
+
loginPath = "/login",
|
|
8
|
+
children
|
|
9
|
+
}) {
|
|
10
|
+
return /* @__PURE__ */ jsx(AuthContext.Provider, { value: { user, loginPath }, children });
|
|
11
|
+
}
|
|
12
|
+
function useAuthContext(hookName) {
|
|
13
|
+
const ctx = useContext(AuthContext);
|
|
14
|
+
if (ctx === null) {
|
|
15
|
+
throw new Error(`${hookName} must be used within an <AuthProvider>`);
|
|
16
|
+
}
|
|
17
|
+
return ctx;
|
|
18
|
+
}
|
|
19
|
+
function useCurrentUser() {
|
|
20
|
+
return useAuthContext("useCurrentUser").user;
|
|
21
|
+
}
|
|
22
|
+
function useLoginPath() {
|
|
23
|
+
return useAuthContext("useLoginPath").loginPath;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/client/auth-guard.tsx
|
|
27
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
28
|
+
function AuthGuard({ user, children }) {
|
|
29
|
+
return /* @__PURE__ */ jsx2(AuthProvider, { user, children });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/client/auth-client-provider.tsx
|
|
33
|
+
import { createContext as createContext2, useContext as useContext2 } from "react";
|
|
34
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
35
|
+
var AuthClientContext = createContext2(null);
|
|
36
|
+
function AuthClientProvider({
|
|
37
|
+
authClient,
|
|
38
|
+
children
|
|
39
|
+
}) {
|
|
40
|
+
return /* @__PURE__ */ jsx3(AuthClientContext.Provider, { value: authClient, children });
|
|
41
|
+
}
|
|
42
|
+
function useAuth() {
|
|
43
|
+
const authClient = useContext2(AuthClientContext);
|
|
44
|
+
if (authClient === null) {
|
|
45
|
+
throw new Error("useAuth must be used within an <AuthClientProvider>");
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
signOut: async () => {
|
|
49
|
+
await authClient.signOut();
|
|
50
|
+
},
|
|
51
|
+
registerPasskey: async () => {
|
|
52
|
+
if (!authClient.passkey) {
|
|
53
|
+
throw new Error("Passkey plugin not configured on auth client");
|
|
54
|
+
}
|
|
55
|
+
return authClient.passkey.addPasskey();
|
|
56
|
+
},
|
|
57
|
+
deletePasskey: async (id) => {
|
|
58
|
+
if (!authClient.passkey) {
|
|
59
|
+
throw new Error("Passkey plugin not configured on auth client");
|
|
60
|
+
}
|
|
61
|
+
return authClient.passkey.deletePasskey({ id });
|
|
62
|
+
},
|
|
63
|
+
stopImpersonating: async () => {
|
|
64
|
+
if (!authClient.admin) {
|
|
65
|
+
throw new Error("Admin plugin not configured on auth client");
|
|
66
|
+
}
|
|
67
|
+
await authClient.admin.stopImpersonating();
|
|
68
|
+
},
|
|
69
|
+
authClient
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/client/login-page.tsx
|
|
74
|
+
import { useState } from "react";
|
|
75
|
+
import { jsx as jsx4, jsxs } from "react/jsx-runtime";
|
|
76
|
+
function DefaultLayout({ children }) {
|
|
77
|
+
return /* @__PURE__ */ jsx4(
|
|
78
|
+
"div",
|
|
79
|
+
{
|
|
80
|
+
style: {
|
|
81
|
+
display: "flex",
|
|
82
|
+
justifyContent: "center",
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
minHeight: "100vh"
|
|
85
|
+
},
|
|
86
|
+
children: /* @__PURE__ */ jsx4("div", { style: { maxWidth: 400, width: "100%", padding: 32 }, children })
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
function DefaultEmailInput({
|
|
91
|
+
value,
|
|
92
|
+
onChange
|
|
93
|
+
}) {
|
|
94
|
+
return /* @__PURE__ */ jsx4(
|
|
95
|
+
"input",
|
|
96
|
+
{
|
|
97
|
+
type: "email",
|
|
98
|
+
placeholder: "you@example.com",
|
|
99
|
+
value,
|
|
100
|
+
onChange: (e) => onChange(e.target.value),
|
|
101
|
+
required: true,
|
|
102
|
+
style: { width: "100%", padding: 8, boxSizing: "border-box" }
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
function DefaultMagicLinkButton({
|
|
107
|
+
onClick,
|
|
108
|
+
loading
|
|
109
|
+
}) {
|
|
110
|
+
return /* @__PURE__ */ jsx4(
|
|
111
|
+
"button",
|
|
112
|
+
{
|
|
113
|
+
type: "button",
|
|
114
|
+
onClick,
|
|
115
|
+
disabled: loading,
|
|
116
|
+
style: { width: "100%", padding: 8 },
|
|
117
|
+
children: loading ? "Sending..." : "Send Magic Link"
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
function DefaultPasskeyButton({
|
|
122
|
+
onClick,
|
|
123
|
+
loading
|
|
124
|
+
}) {
|
|
125
|
+
return /* @__PURE__ */ jsx4(
|
|
126
|
+
"button",
|
|
127
|
+
{
|
|
128
|
+
type: "button",
|
|
129
|
+
onClick,
|
|
130
|
+
disabled: loading,
|
|
131
|
+
style: { width: "100%", padding: 8 },
|
|
132
|
+
children: loading ? "Verifying..." : "Sign in with Passkey"
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
function DefaultSuccessMessage({ email }) {
|
|
137
|
+
return /* @__PURE__ */ jsxs("div", { role: "status", children: [
|
|
138
|
+
"Check your email (",
|
|
139
|
+
email,
|
|
140
|
+
") for a magic link to sign in."
|
|
141
|
+
] });
|
|
142
|
+
}
|
|
143
|
+
function DefaultErrorMessage({ error }) {
|
|
144
|
+
return /* @__PURE__ */ jsx4("div", { role: "alert", style: { color: "red" }, children: error });
|
|
145
|
+
}
|
|
146
|
+
function LoginPage({
|
|
147
|
+
authClient,
|
|
148
|
+
components = {},
|
|
149
|
+
title = "Sign In",
|
|
150
|
+
subtitle,
|
|
151
|
+
onSuccess
|
|
152
|
+
}) {
|
|
153
|
+
const Layout = components.Layout ?? DefaultLayout;
|
|
154
|
+
const EmailInput = components.EmailInput ?? DefaultEmailInput;
|
|
155
|
+
const MagicLinkBtn = components.MagicLinkButton ?? DefaultMagicLinkButton;
|
|
156
|
+
const PasskeyBtn = components.PasskeyButton ?? DefaultPasskeyButton;
|
|
157
|
+
const SuccessMsg = components.SuccessMessage ?? DefaultSuccessMessage;
|
|
158
|
+
const ErrorMsg = components.ErrorMessage ?? DefaultErrorMessage;
|
|
159
|
+
const [email, setEmail] = useState("");
|
|
160
|
+
const [sent, setSent] = useState(false);
|
|
161
|
+
const [loading, setLoading] = useState(false);
|
|
162
|
+
const [error, setError] = useState(null);
|
|
163
|
+
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
|
164
|
+
async function handleMagicLink() {
|
|
165
|
+
if (!email.trim()) {
|
|
166
|
+
setError("Please enter your email address.");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
setLoading(true);
|
|
170
|
+
setError(null);
|
|
171
|
+
try {
|
|
172
|
+
const result = await authClient.signIn.magicLink({ email });
|
|
173
|
+
if (result.error) {
|
|
174
|
+
setError(result.error.message ?? "Failed to send magic link.");
|
|
175
|
+
} else {
|
|
176
|
+
setSent(true);
|
|
177
|
+
onSuccess?.();
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
setError("An unexpected error occurred. Please try again.");
|
|
181
|
+
} finally {
|
|
182
|
+
setLoading(false);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function handlePasskey() {
|
|
186
|
+
setPasskeyLoading(true);
|
|
187
|
+
setError(null);
|
|
188
|
+
try {
|
|
189
|
+
const result = await authClient.signIn.passkey?.();
|
|
190
|
+
if (result?.error) {
|
|
191
|
+
setError(result.error.message ?? "Passkey sign-in failed.");
|
|
192
|
+
} else {
|
|
193
|
+
onSuccess?.();
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
setError("Passkey sign-in failed. Please try again.");
|
|
197
|
+
} finally {
|
|
198
|
+
setPasskeyLoading(false);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return /* @__PURE__ */ jsxs(Layout, { children: [
|
|
202
|
+
/* @__PURE__ */ jsx4("h2", { children: title }),
|
|
203
|
+
subtitle && /* @__PURE__ */ jsx4("p", { children: subtitle }),
|
|
204
|
+
error && /* @__PURE__ */ jsx4(ErrorMsg, { error }),
|
|
205
|
+
sent ? /* @__PURE__ */ jsx4(SuccessMsg, { email }) : /* @__PURE__ */ jsxs("div", { children: [
|
|
206
|
+
/* @__PURE__ */ jsx4(EmailInput, { value: email, onChange: setEmail }),
|
|
207
|
+
/* @__PURE__ */ jsx4("div", { style: { marginTop: 8 }, children: /* @__PURE__ */ jsx4(MagicLinkBtn, { onClick: handleMagicLink, loading }) }),
|
|
208
|
+
authClient?.signIn?.passkey && /* @__PURE__ */ jsx4("div", { style: { marginTop: 8 }, children: /* @__PURE__ */ jsx4(PasskeyBtn, { onClick: handlePasskey, loading: passkeyLoading }) })
|
|
209
|
+
] })
|
|
210
|
+
] });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/client/create-auth-client.ts
|
|
214
|
+
import { createAuthClient } from "better-auth/react";
|
|
215
|
+
import { magicLinkClient } from "better-auth/client/plugins";
|
|
216
|
+
export {
|
|
217
|
+
AuthClientProvider,
|
|
218
|
+
AuthGuard,
|
|
219
|
+
AuthProvider,
|
|
220
|
+
LoginPage,
|
|
221
|
+
createAuthClient,
|
|
222
|
+
magicLinkClient,
|
|
223
|
+
useAuth,
|
|
224
|
+
useCurrentUser,
|
|
225
|
+
useLoginPath
|
|
226
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { a as AuthConfig, b as AuthEnvConfig, c as AuthInstance } from './types-ghXti5CW.js';
|
|
2
|
+
export { d as AuthContext, A as AuthUser, e as AuthenticatedContext } from './types-ghXti5CW.js';
|
|
3
|
+
import '@cfast/permissions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a pre-configured auth factory for Cloudflare Workers.
|
|
7
|
+
*
|
|
8
|
+
* Returns an `initAuth()` function that accepts per-request environment bindings
|
|
9
|
+
* ({@link AuthEnvConfig}) and produces a fully initialized {@link AuthInstance}
|
|
10
|
+
* with session management, role assignment, impersonation, and magic link support.
|
|
11
|
+
*
|
|
12
|
+
* @param config - The auth configuration including permissions, authentication methods, and role rules.
|
|
13
|
+
* @returns A factory function `(env: AuthEnvConfig) => AuthInstance` to call per-request.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { createAuth } from "@cfast/auth";
|
|
18
|
+
* import { permissions } from "./permissions";
|
|
19
|
+
*
|
|
20
|
+
* export const initAuth = createAuth({
|
|
21
|
+
* permissions,
|
|
22
|
+
* magicLink: {
|
|
23
|
+
* sendMagicLink: async ({ email, url }) => {
|
|
24
|
+
* await sendEmail({ to: email, html: `<a href="${url}">Sign in</a>` });
|
|
25
|
+
* },
|
|
26
|
+
* },
|
|
27
|
+
* redirects: { afterLogin: "/", loginPath: "/login" },
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // Per-request initialization:
|
|
31
|
+
* const auth = initAuth({ d1: env.DB, appUrl: "https://myapp.com" });
|
|
32
|
+
* const ctx = await auth.requireUser(request);
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
declare function createAuth(config: AuthConfig): (env: AuthEnvConfig) => AuthInstance;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for configuring the role manager's storage and authorization rules.
|
|
39
|
+
*/
|
|
40
|
+
type RoleManagerOptions = {
|
|
41
|
+
/** Custom table name for role storage. Defaults to `"roles"`. */
|
|
42
|
+
tableName?: string;
|
|
43
|
+
/** Maps each role to the roles it is allowed to assign. Used to enforce role grant rules. */
|
|
44
|
+
roleGrants?: Record<string, string[]>;
|
|
45
|
+
};
|
|
46
|
+
/** Options identifying the caller for role grant authorization checks. */
|
|
47
|
+
type CallerOptions = {
|
|
48
|
+
/** The roles of the user attempting the role assignment. */
|
|
49
|
+
callerRoles?: string[];
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Creates a role manager backed by a Cloudflare D1 database.
|
|
53
|
+
*
|
|
54
|
+
* Provides methods to query, assign, replace, and remove user roles.
|
|
55
|
+
* When `roleGrants` is configured, assignment methods enforce authorization
|
|
56
|
+
* rules so that, for example, an editor cannot promote someone to admin.
|
|
57
|
+
*
|
|
58
|
+
* @param d1 - The Cloudflare D1 database binding.
|
|
59
|
+
* @param options - Optional configuration for table name and role grant rules.
|
|
60
|
+
* @returns An object with `getRoles`, `setRole`, `setRoles`, and `removeRole` methods.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { createRoleManager } from "@cfast/auth";
|
|
65
|
+
*
|
|
66
|
+
* const roles = createRoleManager(env.DB, {
|
|
67
|
+
* roleGrants: {
|
|
68
|
+
* admin: ["admin", "editor", "user"],
|
|
69
|
+
* editor: ["user"],
|
|
70
|
+
* },
|
|
71
|
+
* });
|
|
72
|
+
*
|
|
73
|
+
* const userRoles = await roles.getRoles(userId);
|
|
74
|
+
* await roles.setRole(userId, "editor", { callerRoles: ["admin"] });
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
declare function createRoleManager(d1: D1Database, options?: RoleManagerOptions): {
|
|
78
|
+
getRoles(userId: string): Promise<string[]>;
|
|
79
|
+
setRole(userId: string, role: string, caller?: CallerOptions): Promise<void>;
|
|
80
|
+
setRoles(userId: string, roles: string[], caller?: CallerOptions): Promise<void>;
|
|
81
|
+
removeRole(userId: string, role: string): Promise<void>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Options for customizing the impersonation manager's D1 table and column names.
|
|
86
|
+
*/
|
|
87
|
+
type ImpersonationManagerOptions = {
|
|
88
|
+
/** Custom table name for impersonation logs. Defaults to `"impersonation_logs"`. */
|
|
89
|
+
tableName?: string;
|
|
90
|
+
/** Column name for the admin user ID. Defaults to `"admin_id"`. */
|
|
91
|
+
adminIdColumn?: string;
|
|
92
|
+
/** Column name for the target (impersonated) user ID. Defaults to `"target_user_id"`. */
|
|
93
|
+
targetUserIdColumn?: string;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Creates an impersonation manager backed by a Cloudflare D1 database.
|
|
97
|
+
*
|
|
98
|
+
* Manages an audit trail of impersonation sessions, allowing admins to
|
|
99
|
+
* temporarily act as another user for debugging and support. Each session
|
|
100
|
+
* is logged with start and end timestamps.
|
|
101
|
+
*
|
|
102
|
+
* @param d1 - The Cloudflare D1 database binding.
|
|
103
|
+
* @param options - Optional configuration for table and column names.
|
|
104
|
+
* @returns An object with `impersonate`, `stopImpersonating`, and `getActiveImpersonation` methods.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* import { createImpersonationManager } from "@cfast/auth";
|
|
109
|
+
*
|
|
110
|
+
* const impersonation = createImpersonationManager(env.DB);
|
|
111
|
+
*
|
|
112
|
+
* // Start impersonating a user
|
|
113
|
+
* await impersonation.impersonate(adminUserId, targetUserId);
|
|
114
|
+
*
|
|
115
|
+
* // Check if admin is currently impersonating someone
|
|
116
|
+
* const active = await impersonation.getActiveImpersonation(adminUserId);
|
|
117
|
+
* // active?.targetUserId
|
|
118
|
+
*
|
|
119
|
+
* // Stop impersonating
|
|
120
|
+
* await impersonation.stopImpersonating(adminUserId);
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
declare function createImpersonationManager(d1: D1Database, options?: ImpersonationManagerOptions): {
|
|
124
|
+
impersonate(adminUserId: string, targetUserId: string): Promise<void>;
|
|
125
|
+
stopImpersonating(adminUserId: string): Promise<void>;
|
|
126
|
+
getActiveImpersonation(adminUserId: string): Promise<{
|
|
127
|
+
targetUserId: string;
|
|
128
|
+
} | null>;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Creates `loader` and `action` handlers for a React Router auth catch-all route.
|
|
133
|
+
*
|
|
134
|
+
* The returned handlers forward all requests to the Better Auth handler via
|
|
135
|
+
* the {@link AuthInstance} obtained from the `getAuth` callback. Use this in
|
|
136
|
+
* a splat route (e.g., `routes/auth.$.tsx`) to handle magic link callbacks,
|
|
137
|
+
* passkey endpoints, and other Better Auth API routes.
|
|
138
|
+
*
|
|
139
|
+
* @param getAuth - A factory function that returns a fully initialized {@link AuthInstance}.
|
|
140
|
+
* Called on every request so that the instance uses the correct per-request D1 binding.
|
|
141
|
+
* @returns An object with `loader` and `action` functions compatible with React Router route modules.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* // routes/auth.$.tsx
|
|
146
|
+
* import { createAuthRouteHandlers } from "@cfast/auth";
|
|
147
|
+
* import { initAuth } from "~/auth.setup.server";
|
|
148
|
+
* import { env } from "~/env";
|
|
149
|
+
*
|
|
150
|
+
* const { loader, action } = createAuthRouteHandlers(() => {
|
|
151
|
+
* const e = env.get();
|
|
152
|
+
* return initAuth({ d1: e.DB, appUrl: e.APP_URL });
|
|
153
|
+
* });
|
|
154
|
+
*
|
|
155
|
+
* export { loader, action };
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
declare function createAuthRouteHandlers(getAuth: () => AuthInstance): {
|
|
159
|
+
loader: ({ request }: {
|
|
160
|
+
request: Request;
|
|
161
|
+
}) => Promise<Response>;
|
|
162
|
+
action: ({ request }: {
|
|
163
|
+
request: Request;
|
|
164
|
+
}) => Promise<Response>;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export { AuthConfig, AuthEnvConfig, AuthInstance, createAuth, createAuthRouteHandlers, createImpersonationManager, createRoleManager };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// src/create-auth.ts
|
|
2
|
+
import { betterAuth } from "better-auth";
|
|
3
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
4
|
+
import { magicLink } from "better-auth/plugins/magic-link";
|
|
5
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
6
|
+
import { resolveGrants } from "@cfast/permissions";
|
|
7
|
+
|
|
8
|
+
// src/roles.ts
|
|
9
|
+
function checkRoleGrants(roleGrants, callerRoles, targetRole) {
|
|
10
|
+
const allowed = callerRoles.some((callerRole) => {
|
|
11
|
+
const permitted = roleGrants[callerRole];
|
|
12
|
+
return permitted !== void 0 && permitted.includes(targetRole);
|
|
13
|
+
});
|
|
14
|
+
if (!allowed) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Not authorized to assign role "${targetRole}"`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function createRoleManager(d1, options) {
|
|
21
|
+
const table = options?.tableName ?? "roles";
|
|
22
|
+
const roleGrants = options?.roleGrants;
|
|
23
|
+
return {
|
|
24
|
+
async getRoles(userId) {
|
|
25
|
+
const stmt = d1.prepare(
|
|
26
|
+
`SELECT role FROM ${table} WHERE user_id = ?`
|
|
27
|
+
);
|
|
28
|
+
const result = await stmt.bind(userId).all();
|
|
29
|
+
return result.results.map((r) => r.role);
|
|
30
|
+
},
|
|
31
|
+
async setRole(userId, role, caller) {
|
|
32
|
+
if (roleGrants && caller?.callerRoles) {
|
|
33
|
+
checkRoleGrants(roleGrants, caller.callerRoles, role);
|
|
34
|
+
}
|
|
35
|
+
const stmt = d1.prepare(
|
|
36
|
+
`INSERT OR IGNORE INTO ${table} (user_id, role) VALUES (?, ?)`
|
|
37
|
+
);
|
|
38
|
+
await stmt.bind(userId, role).run();
|
|
39
|
+
},
|
|
40
|
+
async setRoles(userId, roles, caller) {
|
|
41
|
+
if (roleGrants && caller?.callerRoles) {
|
|
42
|
+
for (const role of roles) {
|
|
43
|
+
checkRoleGrants(roleGrants, caller.callerRoles, role);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const deleteStmt = d1.prepare(`DELETE FROM ${table} WHERE user_id = ?`).bind(userId);
|
|
47
|
+
if (roles.length === 0) {
|
|
48
|
+
await deleteStmt.run();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const insertStmts = roles.map(
|
|
52
|
+
(role) => d1.prepare(`INSERT INTO ${table} (user_id, role) VALUES (?, ?)`).bind(userId, role)
|
|
53
|
+
);
|
|
54
|
+
await d1.batch([deleteStmt, ...insertStmts]);
|
|
55
|
+
},
|
|
56
|
+
async removeRole(userId, role) {
|
|
57
|
+
const stmt = d1.prepare(
|
|
58
|
+
`DELETE FROM ${table} WHERE user_id = ? AND role = ?`
|
|
59
|
+
);
|
|
60
|
+
await stmt.bind(userId, role).run();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/impersonation.ts
|
|
66
|
+
function createImpersonationManager(d1, options) {
|
|
67
|
+
const table = options?.tableName ?? "impersonation_logs";
|
|
68
|
+
const adminCol = options?.adminIdColumn ?? "admin_id";
|
|
69
|
+
const targetCol = options?.targetUserIdColumn ?? "target_user_id";
|
|
70
|
+
return {
|
|
71
|
+
async impersonate(adminUserId, targetUserId) {
|
|
72
|
+
const id = crypto.randomUUID();
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
await d1.prepare(
|
|
75
|
+
`INSERT INTO ${table} (id, ${adminCol}, ${targetCol}, started_at) VALUES (?, ?, ?, ?)`
|
|
76
|
+
).bind(id, adminUserId, targetUserId, now).run();
|
|
77
|
+
},
|
|
78
|
+
async stopImpersonating(adminUserId) {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
await d1.prepare(
|
|
81
|
+
`UPDATE ${table} SET ended_at = ? WHERE ${adminCol} = ? AND ended_at IS NULL`
|
|
82
|
+
).bind(now, adminUserId).run();
|
|
83
|
+
},
|
|
84
|
+
async getActiveImpersonation(adminUserId) {
|
|
85
|
+
const result = await d1.prepare(
|
|
86
|
+
`SELECT ${targetCol} as target_user_id FROM ${table} WHERE ${adminCol} = ? AND ended_at IS NULL LIMIT 1`
|
|
87
|
+
).bind(adminUserId).first();
|
|
88
|
+
return result ? { targetUserId: result.target_user_id } : null;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/create-auth.ts
|
|
94
|
+
function parseExpiresIn(value) {
|
|
95
|
+
const match = value.match(/^(\d+)(s|m|h|d)$/);
|
|
96
|
+
if (!match) return 60 * 60 * 24 * 30;
|
|
97
|
+
const num = parseInt(match[1], 10);
|
|
98
|
+
switch (match[2]) {
|
|
99
|
+
case "s":
|
|
100
|
+
return num;
|
|
101
|
+
case "m":
|
|
102
|
+
return num * 60;
|
|
103
|
+
case "h":
|
|
104
|
+
return num * 3600;
|
|
105
|
+
case "d":
|
|
106
|
+
return num * 86400;
|
|
107
|
+
default:
|
|
108
|
+
return num;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function createAuth(config) {
|
|
112
|
+
const anonymousRoles = config.anonymousRoles ?? [];
|
|
113
|
+
const anonymousGrants = resolveGrants(
|
|
114
|
+
config.permissions,
|
|
115
|
+
anonymousRoles
|
|
116
|
+
);
|
|
117
|
+
const loginPath = config.redirects?.loginPath ?? "/login";
|
|
118
|
+
return function initAuth(env) {
|
|
119
|
+
const roleManager = createRoleManager(env.d1, {
|
|
120
|
+
tableName: config.roleTableName,
|
|
121
|
+
roleGrants: config.roleGrants
|
|
122
|
+
});
|
|
123
|
+
const impersonationManager = createImpersonationManager(env.d1);
|
|
124
|
+
const plugins = [];
|
|
125
|
+
if (config.magicLink) {
|
|
126
|
+
plugins.push(
|
|
127
|
+
magicLink({ sendMagicLink: config.magicLink.sendMagicLink })
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const expiresIn = config.session?.expiresIn ? parseExpiresIn(config.session.expiresIn) : void 0;
|
|
131
|
+
const auth = betterAuth({
|
|
132
|
+
baseURL: env.appUrl,
|
|
133
|
+
database: drizzleAdapter(drizzle(env.d1), {
|
|
134
|
+
provider: "sqlite",
|
|
135
|
+
usePlural: true,
|
|
136
|
+
...config.schema ? { schema: config.schema } : {}
|
|
137
|
+
}),
|
|
138
|
+
emailAndPassword: { enabled: true },
|
|
139
|
+
plugins,
|
|
140
|
+
...expiresIn !== void 0 ? { session: { expiresIn } } : {}
|
|
141
|
+
});
|
|
142
|
+
async function createContext(request) {
|
|
143
|
+
try {
|
|
144
|
+
const sessionResult = await auth.api.getSession({
|
|
145
|
+
headers: request.headers
|
|
146
|
+
});
|
|
147
|
+
if (!sessionResult) {
|
|
148
|
+
return { user: null, grants: anonymousGrants };
|
|
149
|
+
}
|
|
150
|
+
const { user, session: _session } = sessionResult;
|
|
151
|
+
const impersonation = await impersonationManager.getActiveImpersonation(user.id);
|
|
152
|
+
if (impersonation) {
|
|
153
|
+
let targetRoles = await roleManager.getRoles(
|
|
154
|
+
impersonation.targetUserId
|
|
155
|
+
);
|
|
156
|
+
if (targetRoles.length === 0) {
|
|
157
|
+
targetRoles = config.defaultRoles ?? ["reader"];
|
|
158
|
+
}
|
|
159
|
+
const grants2 = resolveGrants(config.permissions, targetRoles);
|
|
160
|
+
return {
|
|
161
|
+
user: {
|
|
162
|
+
id: impersonation.targetUserId,
|
|
163
|
+
email: user.email,
|
|
164
|
+
name: user.name ?? "",
|
|
165
|
+
avatarUrl: user.image ?? null,
|
|
166
|
+
roles: targetRoles,
|
|
167
|
+
isImpersonating: true,
|
|
168
|
+
realUser: { id: user.id, name: user.name ?? "" }
|
|
169
|
+
},
|
|
170
|
+
grants: grants2
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
let roles = await roleManager.getRoles(user.id);
|
|
174
|
+
if (roles.length === 0) {
|
|
175
|
+
roles = config.defaultRoles ?? ["reader"];
|
|
176
|
+
}
|
|
177
|
+
const grants = resolveGrants(config.permissions, roles);
|
|
178
|
+
return {
|
|
179
|
+
user: {
|
|
180
|
+
id: user.id,
|
|
181
|
+
email: user.email,
|
|
182
|
+
name: user.name ?? "",
|
|
183
|
+
avatarUrl: user.image ?? null,
|
|
184
|
+
roles
|
|
185
|
+
},
|
|
186
|
+
grants
|
|
187
|
+
};
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error("[cfast/auth] createContext error:", err);
|
|
190
|
+
return { user: null, grants: anonymousGrants };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function requireUser(request) {
|
|
194
|
+
const ctx = await createContext(request);
|
|
195
|
+
if (ctx.user === null) {
|
|
196
|
+
const redirectTo = new URL(request.url).pathname;
|
|
197
|
+
const headers = new Headers();
|
|
198
|
+
headers.set("Location", loginPath);
|
|
199
|
+
headers.set(
|
|
200
|
+
"Set-Cookie",
|
|
201
|
+
`cfast_redirect_to=${encodeURIComponent(redirectTo)}; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`
|
|
202
|
+
);
|
|
203
|
+
throw new Response(null, { status: 302, headers });
|
|
204
|
+
}
|
|
205
|
+
const { user, grants } = ctx;
|
|
206
|
+
return { user, grants };
|
|
207
|
+
}
|
|
208
|
+
const allowedImpersonationRoles = config.impersonation?.allowedRoles ?? ["admin"];
|
|
209
|
+
return {
|
|
210
|
+
createContext,
|
|
211
|
+
requireUser,
|
|
212
|
+
getRoles: roleManager.getRoles,
|
|
213
|
+
setRole: roleManager.setRole,
|
|
214
|
+
setRoles: roleManager.setRoles,
|
|
215
|
+
removeRole: roleManager.removeRole,
|
|
216
|
+
impersonate: async (adminUserId, targetUserId) => {
|
|
217
|
+
const adminRoles = await roleManager.getRoles(adminUserId);
|
|
218
|
+
const canImpersonate = adminRoles.some(
|
|
219
|
+
(r) => allowedImpersonationRoles.includes(r)
|
|
220
|
+
);
|
|
221
|
+
if (!canImpersonate) {
|
|
222
|
+
throw new Error("Not authorized to impersonate");
|
|
223
|
+
}
|
|
224
|
+
await impersonationManager.impersonate(adminUserId, targetUserId);
|
|
225
|
+
},
|
|
226
|
+
stopImpersonating: impersonationManager.stopImpersonating,
|
|
227
|
+
sendMagicLink: async (params) => {
|
|
228
|
+
if (!config.magicLink) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"Magic link plugin not configured. Add magicLink to createAuth config."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const magicLinkApi = auth.api;
|
|
234
|
+
await magicLinkApi.signInMagicLink({
|
|
235
|
+
body: {
|
|
236
|
+
email: params.email,
|
|
237
|
+
callbackURL: params.callbackURL ?? config.redirects?.afterLogin ?? "/"
|
|
238
|
+
},
|
|
239
|
+
headers: new Headers()
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
handler: (request) => auth.handler(request),
|
|
243
|
+
api: auth
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/route-handlers.ts
|
|
249
|
+
function createAuthRouteHandlers(getAuth) {
|
|
250
|
+
function handleRequest({ request }) {
|
|
251
|
+
return getAuth().handler(request);
|
|
252
|
+
}
|
|
253
|
+
return { loader: handleRequest, action: handleRequest };
|
|
254
|
+
}
|
|
255
|
+
export {
|
|
256
|
+
createAuth,
|
|
257
|
+
createAuthRouteHandlers,
|
|
258
|
+
createImpersonationManager,
|
|
259
|
+
createRoleManager
|
|
260
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route helper for React Router v7's routes.ts.
|
|
3
|
+
*
|
|
4
|
+
* Generates a catch-all route entry for Better Auth's API endpoints.
|
|
5
|
+
* The consumer must create a handler file that uses `createAuthRouteHandlers`.
|
|
6
|
+
*
|
|
7
|
+
* Usage in `app/routes.ts`:
|
|
8
|
+
* ```ts
|
|
9
|
+
* import type { RouteConfig } from "@react-router/dev/routes";
|
|
10
|
+
* import { authRoutes } from "@cfast/auth/plugin";
|
|
11
|
+
*
|
|
12
|
+
* export default [
|
|
13
|
+
* ...authRoutes({ handlerFile: "routes/auth.$.tsx" }),
|
|
14
|
+
* // ... other routes
|
|
15
|
+
* ] satisfies RouteConfig;
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The handler file (`routes/auth.$.tsx`):
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { createAuthRouteHandlers } from "@cfast/auth";
|
|
21
|
+
* const { loader, action } = createAuthRouteHandlers(() => getAuth());
|
|
22
|
+
* export { loader, action };
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
type RouteConfigEntry = {
|
|
26
|
+
id?: string;
|
|
27
|
+
path?: string;
|
|
28
|
+
index?: boolean;
|
|
29
|
+
caseSensitive?: boolean;
|
|
30
|
+
file: string;
|
|
31
|
+
children?: RouteConfigEntry[];
|
|
32
|
+
};
|
|
33
|
+
type AuthRoutesOptions = {
|
|
34
|
+
/** Path to the auth handler file, relative to appDirectory */
|
|
35
|
+
handlerFile: string;
|
|
36
|
+
/** Base path for auth routes. Default: "auth" */
|
|
37
|
+
basePath?: string;
|
|
38
|
+
};
|
|
39
|
+
declare function authRoutes(options: AuthRoutesOptions): RouteConfigEntry[];
|
|
40
|
+
|
|
41
|
+
export { authRoutes };
|