@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Schmidt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,394 @@
1
+ # @cfast/auth
2
+
3
+ **Authentication for Cloudflare Workers. Magic email, passkeys, roles, impersonation. Built on Better Auth.**
4
+
5
+ `@cfast/auth` is a pre-configured [Better Auth](https://better-auth.com) setup purpose-built for Cloudflare Workers with D1. It takes the decisions out of authentication: magic email links and passkeys for login, Mailgun for delivery, D1 for storage, and a complete role management system that plugs directly into `@cfast/permissions`.
6
+
7
+ You don't configure an auth library. You tell cfast what roles your app has, and auth just works.
8
+
9
+ ## Design Goals
10
+
11
+ - **Zero-config for the common case.** Magic email + passkeys out of the box. No OAuth provider configuration unless you want it.
12
+ - **Email-first login.** The user enters their email, then chooses between passkey or magic link. Both methods are passwordless.
13
+ - **Overridable UI.** Default components use MUI Joy UI. Override individual component slots to customize the login experience without rebuilding from scratch.
14
+ - **Cookie-based redirect-back.** When an unauthenticated user hits a protected route, the intended path is stored in a cookie and restored after login.
15
+ - **Roles are the bridge.** `@cfast/auth` assigns roles to users. `@cfast/permissions` defines what those roles can do. The two packages share the same role type definitions.
16
+ - **Workers-native.** Session storage on D1, email via Mailgun (Worker-friendly HTTP API), passkeys via WebAuthn. No Node.js dependencies.
17
+ - **Admin-ready.** Role management and user impersonation built in, not bolted on.
18
+
19
+ ## API
20
+
21
+ ### Server Setup
22
+
23
+ ```typescript
24
+ import { createAuth } from "@cfast/auth";
25
+ import { permissions } from "./permissions"; // from @cfast/permissions
26
+
27
+ export const initAuth = createAuth({
28
+ permissions, // Roles are inferred from your permission definitions
29
+ magicLink: {
30
+ sendMagicLink: async ({ email, url }) => {
31
+ // Send email with your provider (Mailgun, Resend, etc.)
32
+ },
33
+ },
34
+ passkeys: {
35
+ rpName: "MyApp",
36
+ rpId: "myapp.com",
37
+ },
38
+ session: {
39
+ expiresIn: "30d",
40
+ },
41
+ redirects: {
42
+ afterLogin: "/", // default redirect after successful login
43
+ loginPath: "/login", // where to send unauthenticated users
44
+ },
45
+ });
46
+
47
+ // In your request handler, initialize with D1:
48
+ const auth = initAuth({ d1: env.DB, appUrl: "https://myapp.com" });
49
+ ```
50
+
51
+ ### Route Integration (routes.ts Helper)
52
+
53
+ Auth routes (magic link callback, passkey endpoints) are added via a helper in your `routes.ts`. You create a handler file that forwards requests to Better Auth.
54
+
55
+ ```typescript
56
+ // app/routes.ts
57
+ import type { RouteConfig } from "@react-router/dev/routes";
58
+ import { authRoutes } from "@cfast/auth/plugin";
59
+
60
+ export default [
61
+ ...authRoutes({ handlerFile: "routes/auth.$.tsx" }),
62
+ // ... other routes
63
+ ] satisfies RouteConfig;
64
+ ```
65
+
66
+ The handler file (`routes/auth.$.tsx`) uses `createAuthRouteHandlers`:
67
+ ```typescript
68
+ import { createAuthRouteHandlers } from "@cfast/auth";
69
+ const { loader, action } = createAuthRouteHandlers(() => getAuth());
70
+ export { loader, action };
71
+ ```
72
+
73
+ ### Protecting Routes with AuthGuard
74
+
75
+ `AuthGuard` is a layout-level component. It takes a `user` prop from the loader and provides it to all child routes via context.
76
+
77
+ ```typescript
78
+ // routes/_protected.tsx
79
+ import { AuthGuard } from "@cfast/auth/client";
80
+ import { requireAuthContext } from "~/auth.helpers.server";
81
+ import { Outlet, useLoaderData } from "react-router";
82
+
83
+ export async function loader({ request }) {
84
+ const ctx = await requireAuthContext(request);
85
+ // Sets a cfast_redirect_to cookie with the current path
86
+ // Throws a redirect to /login if not authenticated
87
+ return { user: ctx.user };
88
+ }
89
+
90
+ export default function ProtectedLayout() {
91
+ const { user } = useLoaderData<typeof loader>();
92
+ return (
93
+ <AuthGuard user={user}>
94
+ <Outlet />
95
+ </AuthGuard>
96
+ );
97
+ }
98
+ ```
99
+
100
+ Any route nested under `_protected` is automatically guarded. The login page lives outside this layout as a normal route file.
101
+
102
+ ### Client-Side Providers
103
+
104
+ Two providers wrap the app root:
105
+
106
+ - **`AuthClientProvider`** — holds the Better Auth client instance. Required for `useAuth()`.
107
+ - **`AuthProvider`** — holds the current user from loader data. Required for `useCurrentUser()`.
108
+
109
+ ```typescript
110
+ // root.tsx
111
+ import { AuthClientProvider } from "@cfast/auth/client";
112
+ import { authClient } from "~/auth.client";
113
+ import { Outlet } from "react-router";
114
+
115
+ export default function App() {
116
+ return (
117
+ <AuthClientProvider authClient={authClient}>
118
+ <Outlet />
119
+ </AuthClientProvider>
120
+ );
121
+ }
122
+ ```
123
+
124
+ `AuthProvider` is typically used inside layout routes (via `AuthGuard`) rather than at the root, since user data comes from loaders.
125
+
126
+ ### useCurrentUser Hook
127
+
128
+ ```typescript
129
+ import { useCurrentUser } from "@cfast/auth/client";
130
+
131
+ function Header() {
132
+ const user = useCurrentUser();
133
+ // Inside AuthGuard: returns User (non-null, type-enforced)
134
+ // Outside AuthGuard: returns User | null
135
+ return <span>{user?.email}</span>;
136
+ }
137
+ ```
138
+
139
+ ### Login Page
140
+
141
+ The consumer creates their own login route and renders `<LoginPage>`. The component accepts an `authClient` prop and a `components` prop for UI slot overrides. Default slots render plain HTML — use `@cfast/ui/joy` for Joy UI styling.
142
+
143
+ ```typescript
144
+ // routes/login.tsx
145
+ import { LoginPage } from "@cfast/auth/client";
146
+ import { joyLoginComponents } from "@cfast/ui/joy";
147
+ import { authClient } from "~/auth.client";
148
+
149
+ export default function Login() {
150
+ return (
151
+ <LoginPage
152
+ authClient={authClient}
153
+ components={joyLoginComponents}
154
+ title="Sign In"
155
+ subtitle="Sign in to My App"
156
+ />
157
+ );
158
+ }
159
+ ```
160
+
161
+ The login page shows:
162
+ 1. An email input
163
+ 2. A "Send Magic Link" button
164
+ 3. A "Sign in with Passkey" button
165
+ 4. Success/error feedback messages
166
+
167
+ ### Component Slot Overrides
168
+
169
+ Override individual pieces of the login UI. Unspecified slots use the plain HTML defaults.
170
+
171
+ ```typescript
172
+ import { LoginPage } from "@cfast/auth/client";
173
+ import type { LoginComponents } from "@cfast/auth/client";
174
+
175
+ const components: LoginComponents = {
176
+ Layout: ({ children }) => <MyCustomCard>{children}</MyCustomCard>,
177
+ EmailInput: ({ value, onChange, error }) => (
178
+ <MyInput value={value} onChange={onChange} error={error} />
179
+ ),
180
+ PasskeyButton: ({ onClick, loading }) => (
181
+ <MyButton onClick={onClick} loading={loading}>Use Passkey</MyButton>
182
+ ),
183
+ MagicLinkButton: ({ onClick, loading }) => (
184
+ <MyButton onClick={onClick} loading={loading}>Email Me a Link</MyButton>
185
+ ),
186
+ SuccessMessage: ({ email }) => (
187
+ <MyAlert>Check {email} for your login link</MyAlert>
188
+ ),
189
+ ErrorMessage: ({ error }) => (
190
+ <MyAlert color="danger">{error}</MyAlert>
191
+ ),
192
+ };
193
+
194
+ export default function Login() {
195
+ return <LoginPage authClient={authClient} components={components} />;
196
+ }
197
+ ```
198
+
199
+ For Joy UI, use the pre-built `joyLoginComponents` from `@cfast/ui/joy` instead of writing custom slots.
200
+
201
+ ### Redirect Flow
202
+
203
+ The full redirect cycle:
204
+
205
+ 1. **User visits `/dashboard/settings` unauthenticated** — the `_protected` layout loader calls `auth.requireUser(request)` — sets a `cfast_redirect_to=/dashboard/settings` cookie — throws a redirect to `/login`.
206
+
207
+ 2. **User is on `/login`** — enters email — clicks "Send Magic Link" or "Sign in with Passkey".
208
+
209
+ 3. **Magic Link path:** user clicks link in email — hits `/auth/callback` (injected by plugin) — server verifies token, creates session, reads `cfast_redirect_to` cookie, clears it, redirects to `/dashboard/settings`.
210
+
211
+ 4. **Passkey path:** WebAuthn ceremony completes on client — server verifies, creates session — client-side redirect reads cookie and navigates to `/dashboard/settings`.
212
+
213
+ 5. **Direct visit to `/login`** (no prior redirect) — no cookie set — after login, redirects to the `afterLogin` default from config (defaults to `/`).
214
+
215
+ The `cfast_redirect_to` cookie is `HttpOnly`, `Secure`, `SameSite=Lax`, with a 10-minute TTL.
216
+
217
+ ### useAuth Hook
218
+
219
+ `useAuth()` provides auth actions from the `AuthClientProvider` context. Takes no arguments.
220
+
221
+ ```typescript
222
+ import { useAuth } from "@cfast/auth/client";
223
+
224
+ const {
225
+ signOut, // Sign out the current user
226
+ registerPasskey, // Register a new passkey (WebAuthn)
227
+ deletePasskey, // Delete a passkey by ID
228
+ stopImpersonating, // Stop impersonating (admin only)
229
+ authClient, // Raw Better Auth client for escape-hatch usage
230
+ } = useAuth();
231
+ ```
232
+
233
+ ### Authentication Methods
234
+
235
+ #### Magic Email Link
236
+ ```typescript
237
+ // Server: send magic link
238
+ await auth.sendMagicLink({ email: "user@example.com" });
239
+
240
+ // With custom callback URL:
241
+ await auth.sendMagicLink({ email: "user@example.com", callbackURL: "/welcome" });
242
+
243
+ // The link hits /auth/callback (injected by plugin)
244
+ // Auth handles verification and creates/updates the user + session automatically
245
+ ```
246
+
247
+ #### Passkeys (WebAuthn)
248
+ ```typescript
249
+ // Client: register a passkey (from a settings page, after login)
250
+ import { useAuth } from "@cfast/auth/client";
251
+
252
+ function SecuritySettings({ passkeys }) {
253
+ // passkeys come from loader data (server query), not from the hook
254
+ const { registerPasskey, deletePasskey } = useAuth();
255
+
256
+ return (
257
+ <div>
258
+ <button onClick={() => registerPasskey()}>Add Passkey</button>
259
+ {passkeys.map((pk) => (
260
+ <div key={pk.id}>
261
+ {pk.name} - {pk.createdAt}
262
+ <button onClick={() => deletePasskey(pk.id)}>Remove</button>
263
+ </div>
264
+ ))}
265
+ </div>
266
+ );
267
+ }
268
+
269
+ // Client: sign in with passkey (from the login page, handled by LoginPage component)
270
+ // The LoginPage component manages the WebAuthn ceremony internally
271
+ ```
272
+
273
+ ### Role Management
274
+
275
+ Roles are shared with `@cfast/permissions`. Assigning a role to a user immediately changes what they can do across the entire app:
276
+
277
+ ```typescript
278
+ // Promote a user to editor
279
+ await auth.setRole(userId, "editor");
280
+
281
+ // Assign multiple roles
282
+ await auth.setRoles(userId, ["editor", "moderator"]);
283
+
284
+ // In React Router loaders, the user's roles are always available:
285
+ export async function loader({ request }) {
286
+ const user = await auth.requireUser(request);
287
+ // user.roles -> ["editor"]
288
+ // This user object feeds into createDb({ user }) — it determines
289
+ // which permission grants apply to every Operation
290
+ }
291
+ ```
292
+
293
+ ### Role Grant Rules
294
+
295
+ Control who can assign which roles. An editor shouldn't be able to promote someone to admin:
296
+
297
+ ```typescript
298
+ export const auth = createAuth({
299
+ permissions,
300
+ roleGrants: {
301
+ admin: ["admin", "editor", "user"], // Admins can assign any role
302
+ editor: ["user"], // Editors can only assign "user"
303
+ // Users can't assign roles at all (not listed)
304
+ },
305
+ });
306
+ ```
307
+
308
+ ### User Impersonation
309
+
310
+ For debugging and support. Admins can see exactly what a user sees:
311
+
312
+ ```typescript
313
+ // Server: start impersonation (requires admin role by default)
314
+ await auth.impersonate(adminUserId, targetUserId);
315
+
316
+ // The admin's session now behaves as the target user
317
+ // All permission checks use the target user's roles
318
+ // An "impersonating" flag is set so the UI can show a banner
319
+
320
+ // Client: check impersonation state via useCurrentUser
321
+ const user = useCurrentUser();
322
+ // user.isImpersonating — true when admin is impersonating
323
+ // user.realUser — { id, name } of the admin doing the impersonating
324
+
325
+ // Client: stop impersonating via useAuth
326
+ const { stopImpersonating } = useAuth();
327
+ await stopImpersonating();
328
+ ```
329
+
330
+ ## Email Templates
331
+
332
+ Auth emails (magic links) can use custom HTML templates. The template function receives the magic link URL and email address and returns an HTML string:
333
+
334
+ ```typescript
335
+ createAuth({
336
+ // ...
337
+ templates: {
338
+ magicLink: ({ url, email }) =>
339
+ `<p>Hi ${email}, <a href="${url}">click here to sign in</a>.</p>`,
340
+ },
341
+ });
342
+ ```
343
+
344
+ Templates are plain functions returning strings — no React or Node.js dependencies required, so they work in Workers.
345
+
346
+ ## Package Exports
347
+
348
+ ```
349
+ @cfast/auth
350
+ ├── . → Server: createAuth, createRoleManager,
351
+ │ createImpersonationManager, createAuthRouteHandlers, types
352
+ ├── /client → Client: AuthProvider, AuthClientProvider, AuthGuard,
353
+ │ LoginPage, useCurrentUser, useAuth, createAuthClient,
354
+ │ LoginComponents, UseAuthReturn, AuthClientInstance types
355
+ ├── /plugin → Route helper: authRoutes() for routes.ts
356
+ └── /schema → Drizzle schema: auth tables for migrations
357
+ ```
358
+
359
+ Server code stays out of client bundles. The `/plugin` entrypoint is only used in `routes.ts` (build-time). The `/schema` entrypoint lets `@cfast/db` include auth tables in migrations without importing the full auth package.
360
+
361
+ ## Integration
362
+
363
+ The auth → db → operations flow:
364
+
365
+ ```typescript
366
+ // In a React Router loader:
367
+ export async function loader({ request, context }) {
368
+ const user = await auth.requireUser(request);
369
+
370
+ const db = createDb({
371
+ d1: context.env.DB,
372
+ schema,
373
+ permissions,
374
+ user, // ← from auth. Determines which grants apply to every Operation.
375
+ });
376
+
377
+ // Operations now check permissions against this user's roles automatically
378
+ const posts = db.query(postsTable).findMany();
379
+ const results = await posts.run({}); // permission filters applied based on user.roles
380
+ return { user, posts: results };
381
+ }
382
+ ```
383
+
384
+ Changing a user's role (via `auth.setRole`) immediately affects which Operations they can run. No cache to clear, no separate sync step — the next `createDb({ user })` call picks up the new roles.
385
+
386
+ ## Schema
387
+
388
+ `@cfast/auth` adds its tables to your Drizzle schema automatically. The tables follow Better Auth conventions but are managed through cfast's migration system:
389
+
390
+ - `user` - Users with email, name, avatar
391
+ - `session` - Active sessions
392
+ - `passkey` - Registered WebAuthn credentials
393
+ - `role` - User-to-role assignments
394
+ - `impersonation_log` - Audit trail for impersonation events
@@ -0,0 +1,123 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { A as AuthUser } from './types-ghXti5CW.js';
3
+ import { ReactNode, ComponentType } from 'react';
4
+ export { createAuthClient } from 'better-auth/react';
5
+ export { magicLinkClient } from 'better-auth/client/plugins';
6
+ import '@cfast/permissions';
7
+
8
+ type AuthProviderProps = {
9
+ user: AuthUser | null;
10
+ loginPath?: string;
11
+ children: ReactNode;
12
+ };
13
+ type AuthClientInstance = {
14
+ signOut: () => Promise<unknown>;
15
+ passkey?: {
16
+ addPasskey: () => Promise<{
17
+ error?: {
18
+ message?: string;
19
+ } | null;
20
+ } | undefined>;
21
+ deletePasskey: (opts: {
22
+ id: string;
23
+ }) => Promise<{
24
+ error?: {
25
+ message?: string;
26
+ } | null;
27
+ } | undefined>;
28
+ };
29
+ admin?: {
30
+ stopImpersonating: () => Promise<unknown>;
31
+ };
32
+ };
33
+ type AuthClientProviderProps = {
34
+ authClient: AuthClientInstance;
35
+ children: ReactNode;
36
+ };
37
+ type UseAuthReturn = {
38
+ signOut: () => Promise<void>;
39
+ registerPasskey: () => Promise<{
40
+ error?: {
41
+ message?: string;
42
+ } | null;
43
+ } | undefined>;
44
+ deletePasskey: (id: string) => Promise<{
45
+ error?: {
46
+ message?: string;
47
+ } | null;
48
+ } | undefined>;
49
+ stopImpersonating: () => Promise<void>;
50
+ authClient: AuthClientInstance;
51
+ };
52
+ type LoginComponents = {
53
+ Layout?: ComponentType<{
54
+ children: ReactNode;
55
+ }>;
56
+ EmailInput?: ComponentType<{
57
+ value: string;
58
+ onChange: (value: string) => void;
59
+ error?: string;
60
+ }>;
61
+ PasskeyButton?: ComponentType<{
62
+ onClick: () => void;
63
+ loading: boolean;
64
+ }>;
65
+ MagicLinkButton?: ComponentType<{
66
+ onClick: () => void;
67
+ loading: boolean;
68
+ }>;
69
+ SuccessMessage?: ComponentType<{
70
+ email: string;
71
+ }>;
72
+ ErrorMessage?: ComponentType<{
73
+ error: string;
74
+ }>;
75
+ };
76
+ type LoginPageProps = {
77
+ authClient: {
78
+ signIn: {
79
+ magicLink: (opts: {
80
+ email: string;
81
+ }) => Promise<{
82
+ error?: {
83
+ message?: string;
84
+ } | null;
85
+ }>;
86
+ passkey?: () => Promise<{
87
+ error?: {
88
+ message?: string;
89
+ } | null;
90
+ } | undefined>;
91
+ };
92
+ };
93
+ components?: LoginComponents;
94
+ title?: string;
95
+ subtitle?: string;
96
+ onSuccess?: () => void;
97
+ };
98
+
99
+ declare function AuthProvider({ user, loginPath, children, }: AuthProviderProps): react_jsx_runtime.JSX.Element;
100
+ /**
101
+ * Access the current user from the nearest AuthProvider.
102
+ * Returns `AuthUser | null` — null when not authenticated.
103
+ *
104
+ * Must be used within an `<AuthProvider>`.
105
+ */
106
+ declare function useCurrentUser(): AuthUser | null;
107
+ /**
108
+ * Access the login path configured in the nearest AuthProvider.
109
+ */
110
+ declare function useLoginPath(): string;
111
+
112
+ type AuthGuardProps = {
113
+ user: AuthUser;
114
+ children: ReactNode;
115
+ };
116
+ declare function AuthGuard({ user, children }: AuthGuardProps): react_jsx_runtime.JSX.Element;
117
+
118
+ declare function AuthClientProvider({ authClient, children, }: AuthClientProviderProps): react_jsx_runtime.JSX.Element;
119
+ declare function useAuth(): UseAuthReturn;
120
+
121
+ declare function LoginPage({ authClient, components, title, subtitle, onSuccess, }: LoginPageProps): react_jsx_runtime.JSX.Element;
122
+
123
+ export { type AuthClientInstance, AuthClientProvider, type AuthClientProviderProps, AuthGuard, type AuthGuardProps, AuthProvider, type AuthProviderProps, type LoginComponents, LoginPage, type LoginPageProps, type UseAuthReturn, useAuth, useCurrentUser, useLoginPath };