better-ts-stack 2.1.1 → 2.2.0
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 -21
- package/README.md +16 -8
- package/dist/args.js +7 -0
- package/dist/builder/buildPlanner.js +7 -1
- package/dist/builder/configGenerator.js +28 -3
- package/dist/builder/index.js +2 -1
- package/dist/builder/moduleSelector.js +6 -1
- package/dist/builder/readmeGenerator.js +8 -2
- package/dist/builder/templateContext.js +1 -0
- package/dist/output/nextSteps.js +2 -4
- package/dist/prompts/stack.js +6 -1
- package/dist/types/index.js +2 -0
- package/dist/ui/messages.js +2 -0
- package/package.json +71 -71
- package/templates/backend/express/.prettierignore +52 -52
- package/templates/backend/express/.prettierrc +16 -16
- package/templates/backend/express/gitignore +3 -1
- package/templates/backend/express/src/env.ts.hbs +13 -2
- package/templates/backend/express/src/index.ts.hbs +31 -2
- package/templates/backend/express/tsconfig.eslint.json +9 -9
- package/templates/backend/nestjs/.prettierignore +52 -52
- package/templates/backend/nestjs/gitignore +3 -1
- package/templates/backend/nestjs/src/app.module.ts.hbs +4 -1
- package/templates/backend/nestjs/src/env.ts.hbs +13 -2
- package/templates/backend/nestjs/src/health/health.controller.ts.hbs +7 -0
- package/templates/backend/nestjs/src/{main.ts → main.ts.hbs} +11 -0
- package/templates/backend/nestjs/tsconfig.eslint.json +9 -9
- package/templates/frontend/nextjs/app/globals.css +129 -89
- package/templates/frontend/nextjs/app/layout.tsx +36 -34
- package/templates/frontend/nextjs/app/page.tsx.hbs +63 -43
- package/templates/frontend/nextjs/components/ui/alert.tsx +75 -0
- package/templates/frontend/nextjs/components/ui/badge.tsx +51 -0
- package/templates/frontend/nextjs/components/ui/button.tsx +57 -51
- package/templates/frontend/nextjs/components/ui/card.tsx +102 -60
- package/templates/frontend/nextjs/components/ui/field.tsx +238 -67
- package/templates/frontend/nextjs/components/ui/input.tsx +19 -18
- package/templates/frontend/nextjs/components/ui/label.tsx +19 -0
- package/templates/frontend/nextjs/components/ui/separator.tsx +24 -0
- package/templates/frontend/nextjs/components/ui/skeleton.tsx +13 -0
- package/templates/frontend/nextjs/components/ui/spinner.tsx +10 -0
- package/templates/frontend/nextjs/components.json +11 -5
- package/templates/frontend/nextjs/config.json +36 -35
- package/templates/frontend/nextjs/eslint.config.mjs +11 -11
- package/templates/frontend/nextjs/lib/utils.ts +1 -6
- package/templates/frontend/nextjs/next.config.ts.hbs +12 -12
- package/templates/frontend/nextjs/postcss.config.mjs +7 -7
- package/templates/frontend/nextjs/public/file.svg +1 -1
- package/templates/frontend/nextjs/public/globe.svg +1 -1
- package/templates/frontend/nextjs/public/window.svg +1 -1
- package/templates/frontend/tanstack/components.json +25 -18
- package/templates/frontend/tanstack/config.json +5 -2
- package/templates/frontend/tanstack/eslint.config.mjs +23 -23
- package/templates/frontend/tanstack/gitignore +39 -39
- package/templates/frontend/tanstack/public/favicon.svg +7 -7
- package/templates/frontend/tanstack/src/components/ui/alert.tsx +75 -0
- package/templates/frontend/tanstack/src/components/ui/badge.tsx +51 -0
- package/templates/frontend/tanstack/src/components/ui/button.tsx +57 -51
- package/templates/frontend/tanstack/src/components/ui/card.tsx +102 -60
- package/templates/frontend/tanstack/src/components/ui/field.tsx +236 -67
- package/templates/frontend/tanstack/src/components/ui/input.tsx +19 -18
- package/templates/frontend/tanstack/src/components/ui/label.tsx +17 -0
- package/templates/frontend/tanstack/src/components/ui/separator.tsx +24 -0
- package/templates/frontend/tanstack/src/components/ui/skeleton.tsx +13 -0
- package/templates/frontend/tanstack/src/components/ui/spinner.tsx +10 -0
- package/templates/frontend/tanstack/src/lib/utils.ts +1 -6
- package/templates/frontend/tanstack/src/router.tsx +31 -31
- package/templates/frontend/tanstack/src/routes/__root.tsx +69 -69
- package/templates/frontend/tanstack/src/routes/index.tsx.hbs +64 -45
- package/templates/frontend/tanstack/src/styles/app.css +100 -55
- package/templates/frontend/tanstack/tsconfig.json +22 -22
- package/templates/modules/agents/AGENTS.md.hbs +245 -0
- package/templates/modules/agents/CLAUDE.md +1 -0
- package/templates/modules/agents/config.json +10 -0
- package/templates/modules/auth/express/config.json +12 -11
- package/templates/modules/auth/express/src/lib/auth.ts.hbs +143 -0
- package/templates/modules/auth/express/src/lib/email.ts +37 -0
- package/templates/modules/auth/express/src/middleware/requireAuth.ts +29 -0
- package/templates/modules/auth/express/src/routes/me.ts +13 -0
- package/templates/modules/auth/nestjs/config.json +13 -11
- package/templates/modules/auth/nestjs/src/auth/auth.module.ts.hbs +64 -14
- package/templates/modules/auth/nestjs/src/auth/auth.ts.hbs +152 -0
- package/templates/modules/auth/nestjs/src/auth/email.ts +37 -0
- package/templates/modules/auth/nestjs/src/me/me.controller.ts +15 -0
- package/templates/modules/auth/nextjs/app/api/auth/[...all]/route.ts +4 -4
- package/templates/modules/auth/nextjs/app/dashboard/page.tsx +101 -45
- package/templates/modules/auth/nextjs/app/forgot-password/page.tsx +11 -0
- package/templates/modules/auth/nextjs/app/reset-password/page.tsx +17 -0
- package/templates/modules/auth/nextjs/app/sign-in/page.tsx +23 -17
- package/templates/modules/auth/nextjs/app/sign-up/page.tsx +25 -19
- package/templates/modules/auth/nextjs/components/auth/change-password-form.tsx +125 -0
- package/templates/modules/auth/nextjs/components/auth/email-verification-card.tsx +91 -0
- package/templates/modules/auth/nextjs/components/auth/forgot-password-form.tsx +133 -0
- package/templates/modules/auth/nextjs/components/auth/reset-password-form.tsx +155 -0
- package/templates/modules/auth/nextjs/components/auth/sign-in-form.tsx +46 -32
- package/templates/modules/auth/nextjs/components/auth/sign-out-button.tsx +6 -5
- package/templates/modules/auth/nextjs/components/auth/sign-up-form.tsx +36 -30
- package/templates/modules/auth/nextjs/components/auth/social-buttons.tsx +73 -0
- package/templates/modules/auth/nextjs/config.json +24 -18
- package/templates/modules/auth/nextjs/lib/auth-client.ts +3 -3
- package/templates/modules/auth/nextjs/lib/auth-schema.ts +27 -2
- package/templates/modules/auth/nextjs/lib/auth.ts.hbs +65 -0
- package/templates/modules/auth/nextjs/lib/email.ts +43 -0
- package/templates/modules/auth/nextjs/lib/session.ts +12 -0
- package/templates/modules/auth/tanstack/config.json +25 -19
- package/templates/modules/auth/tanstack/src/components/auth/change-password-form.tsx +123 -0
- package/templates/modules/auth/tanstack/src/components/auth/email-verification-card.tsx +89 -0
- package/templates/modules/auth/tanstack/src/components/auth/forgot-password-form.tsx +131 -0
- package/templates/modules/auth/tanstack/src/components/auth/reset-password-form.tsx +149 -0
- package/templates/modules/auth/tanstack/src/components/auth/sign-in-form.tsx +44 -35
- package/templates/modules/auth/tanstack/src/components/auth/sign-out-button.tsx +7 -6
- package/templates/modules/auth/tanstack/src/components/auth/sign-up-form.tsx +37 -34
- package/templates/modules/auth/tanstack/src/components/auth/social-buttons.tsx +71 -0
- package/templates/modules/auth/tanstack/src/lib/auth-client.ts +2 -2
- package/templates/modules/auth/tanstack/src/lib/auth-functions.ts +14 -8
- package/templates/modules/auth/tanstack/src/lib/auth-schema.ts +27 -2
- package/templates/modules/auth/tanstack/src/lib/auth.ts.hbs +98 -38
- package/templates/modules/auth/tanstack/src/lib/email.ts +43 -0
- package/templates/modules/auth/tanstack/src/routeTree.gen.ts +61 -3
- package/templates/modules/auth/tanstack/src/routes/api/auth/$.ts +15 -15
- package/templates/modules/auth/tanstack/src/routes/dashboard.tsx +119 -111
- package/templates/modules/auth/tanstack/src/routes/forgot-password.tsx +17 -0
- package/templates/modules/auth/tanstack/src/routes/reset-password.tsx +25 -0
- package/templates/modules/auth/tanstack/src/routes/sign-in.tsx +44 -37
- package/templates/modules/auth/tanstack/src/routes/sign-up.tsx +44 -37
- package/templates/modules/docker/.dockerignore +2 -0
- package/templates/modules/docker/config.json +15 -15
- package/templates/modules/drizzle/express/config.json +26 -26
- package/templates/modules/drizzle/express/src/lib/schema.ts.hbs +90 -0
- package/templates/modules/drizzle/nestjs/config.json +26 -26
- package/templates/modules/drizzle/nestjs/src/database/schema.ts.hbs +90 -0
- package/templates/modules/drizzle/nextjs/config.json +26 -26
- package/templates/modules/drizzle/nextjs/drizzle.config.ts +13 -13
- package/templates/modules/drizzle/tanstack/config.json +26 -26
- package/templates/modules/drizzle/tanstack/drizzle.config.ts +12 -12
- package/templates/modules/drizzle/tanstack/src/lib/db.ts +13 -13
- package/templates/modules/mongoose/express/config.json +15 -15
- package/templates/modules/mongoose/express/src/lib/db.ts +43 -43
- package/templates/modules/mongoose/express/src/models/User.ts +48 -48
- package/templates/modules/mongoose/nestjs/config.json +16 -15
- package/templates/modules/mongoose/nextjs/config.json +15 -15
- package/templates/modules/mongoose/nextjs/models/User.ts +48 -48
- package/templates/modules/mongoose/tanstack/config.json +16 -16
- package/templates/modules/mongoose/tanstack/src/models/User.ts +48 -48
- package/templates/modules/prisma/express/config.json +27 -27
- package/templates/modules/prisma/express/prisma/schema.prisma.hbs +92 -0
- package/templates/modules/prisma/nestjs/config.json +27 -27
- package/templates/modules/prisma/nestjs/prisma/schema.prisma.hbs +91 -0
- package/templates/modules/prisma/nextjs/config.json +28 -28
- package/templates/modules/prisma/nextjs/lib/prisma.ts +18 -18
- package/templates/modules/prisma/nextjs/prisma.config.ts +14 -14
- package/templates/modules/prisma/tanstack/config.json +28 -28
- package/templates/modules/prisma/tanstack/prisma/schema.prisma.hbs +91 -91
- package/templates/modules/prisma/tanstack/prisma.config.ts +14 -14
- package/templates/modules/prisma/tanstack/src/lib/prisma.ts +17 -17
- package/templates/modules/rate-limit/express/src/middleware/rateLimit.ts +0 -8
- package/templates/modules/vscode/.vscode/extensions.json.hbs +29 -0
- package/templates/modules/vscode/.vscode/settings.json.hbs +22 -0
- package/templates/modules/vscode/config.json +10 -0
- package/templates/modules/auth/express/src/controllers/authController.ts.hbs +0 -116
- package/templates/modules/auth/express/src/lib/jwt.ts.hbs +0 -27
- package/templates/modules/auth/express/src/middleware/requireAuth.ts.hbs +0 -25
- package/templates/modules/auth/express/src/routes/auth.ts.hbs +0 -27
- package/templates/modules/auth/express/src/services/userStore.ts.hbs +0 -117
- package/templates/modules/auth/nestjs/src/auth/auth.controller.ts.hbs +0 -61
- package/templates/modules/auth/nestjs/src/auth/auth.guard.ts +0 -36
- package/templates/modules/auth/nestjs/src/auth/auth.schemas.ts +0 -35
- package/templates/modules/auth/nestjs/src/auth/auth.service.ts +0 -70
- package/templates/modules/auth/nestjs/src/auth/jwt.ts +0 -27
- package/templates/modules/auth/nestjs/src/auth/users.service.ts.hbs +0 -144
- package/templates/modules/auth/nestjs/src/auth/zod-validation.pipe.ts +0 -16
- package/templates/modules/drizzle/express/src/lib/schema.ts +0 -12
- package/templates/modules/drizzle/nestjs/src/database/schema.ts +0 -12
- package/templates/modules/prisma/express/prisma/schema.prisma +0 -24
- package/templates/modules/prisma/nestjs/prisma/schema.prisma +0 -23
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "auth",
|
|
3
|
-
"name": "
|
|
4
|
-
"description": "
|
|
3
|
+
"name": "Better Auth",
|
|
4
|
+
"description": "Better Auth for Express: email and password, email verification, password reset, GitHub and Google sign-in, and bearer tokens",
|
|
5
5
|
"type": "feature",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"
|
|
8
|
-
"jsonwebtoken": "^9.0.3",
|
|
9
|
-
"zod": "^4.6.7"
|
|
10
|
-
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
"@types/bcrypt": "^6.0.0",
|
|
13
|
-
"@types/jsonwebtoken": "^9.0.10"
|
|
7
|
+
"better-auth": "^1.7.18"
|
|
14
8
|
},
|
|
9
|
+
"devDependencies": {},
|
|
15
10
|
"scripts": {},
|
|
16
11
|
"envVars": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
12
|
+
"BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
|
|
13
|
+
"BETTER_AUTH_URL": "http://localhost:3000",
|
|
14
|
+
"GITHUB_CLIENT_ID": "",
|
|
15
|
+
"GITHUB_CLIENT_SECRET": "",
|
|
16
|
+
"GOOGLE_CLIENT_ID": "",
|
|
17
|
+
"GOOGLE_CLIENT_SECRET": "",
|
|
18
|
+
"RESEND_API_KEY": "",
|
|
19
|
+
"EMAIL_FROM": "onboarding@resend.dev"
|
|
19
20
|
}
|
|
20
21
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
{{#if (eq database 'none')}}
|
|
3
|
+
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
4
|
+
{{/if}}
|
|
5
|
+
{{#if (eq database 'prisma')}}
|
|
6
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
7
|
+
{{/if}}
|
|
8
|
+
{{#if (eq database 'drizzle')}}
|
|
9
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
10
|
+
{{/if}}
|
|
11
|
+
{{#if (eq database 'mongoose')}}
|
|
12
|
+
import { mongodbAdapter } from "better-auth/adapters/mongodb";
|
|
13
|
+
import { MongoClient } from "mongodb";
|
|
14
|
+
{{/if}}
|
|
15
|
+
import { bearer } from "better-auth/plugins";
|
|
16
|
+
|
|
17
|
+
import { env } from "../env";
|
|
18
|
+
{{#if (eq database 'drizzle')}}
|
|
19
|
+
import { db } from "./db";
|
|
20
|
+
{{/if}}
|
|
21
|
+
import { sendEmail } from "./email";
|
|
22
|
+
{{#if (eq database 'prisma')}}
|
|
23
|
+
import { prisma } from "./prisma";
|
|
24
|
+
{{/if}}
|
|
25
|
+
|
|
26
|
+
{{#if (eq database 'none')}}
|
|
27
|
+
// In-memory store for trying the API out: users and sessions are lost on
|
|
28
|
+
// restart, so add a database before going to production
|
|
29
|
+
const memoryDb = { user: [], session: [], account: [], verification: [] };
|
|
30
|
+
|
|
31
|
+
{{/if}}
|
|
32
|
+
{{#if (eq database 'mongoose')}}
|
|
33
|
+
// Better Auth talks to MongoDB through the driver, next to Mongoose
|
|
34
|
+
export const mongoClient = new MongoClient(env.MONGODB_URI);
|
|
35
|
+
|
|
36
|
+
{{/if}}
|
|
37
|
+
// Reports a failed email instead of failing the request: the sign-up or reset
|
|
38
|
+
// already happened, and the user can ask for the email again
|
|
39
|
+
function deliver(email: Parameters<typeof sendEmail>[0]): void {
|
|
40
|
+
sendEmail(email).catch((error: unknown) => {
|
|
41
|
+
console.error(`Could not send "${email.subject}" to ${email.to}:`, error);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Points a verification link at the API itself when the client gave no page
|
|
46
|
+
// to return to, so opening it answers with JSON instead of a redirect to "/"
|
|
47
|
+
function withoutDefaultCallback(url: string): string {
|
|
48
|
+
const link = new URL(url);
|
|
49
|
+
if (link.searchParams.get("callbackURL") === "/") {
|
|
50
|
+
link.searchParams.delete("callbackURL");
|
|
51
|
+
}
|
|
52
|
+
return link.toString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const auth = betterAuth({
|
|
56
|
+
{{#if (eq database 'none')}}
|
|
57
|
+
database: memoryAdapter(memoryDb),
|
|
58
|
+
{{/if}}
|
|
59
|
+
{{#if (eq database 'prisma')}}
|
|
60
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
61
|
+
{{/if}}
|
|
62
|
+
{{#if (eq database 'drizzle')}}
|
|
63
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
64
|
+
{{/if}}
|
|
65
|
+
{{#if (eq database 'mongoose')}}
|
|
66
|
+
// Without the client, writes run without transactions, which a standalone
|
|
67
|
+
// MongoDB server (no replica set) does not support
|
|
68
|
+
database: mongodbAdapter(mongoClient.db()),
|
|
69
|
+
{{/if}}
|
|
70
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
71
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
72
|
+
// Browser apps on these origins may call the auth routes with cookies
|
|
73
|
+
trustedOrigins: typeof env.CORS_ORIGIN === "string" ? [] : env.CORS_ORIGIN,
|
|
74
|
+
emailAndPassword: {
|
|
75
|
+
enabled: true,
|
|
76
|
+
// Set to true to block sign-in until the email address is verified
|
|
77
|
+
requireEmailVerification: false,
|
|
78
|
+
// A reset signs the user out everywhere
|
|
79
|
+
revokeSessionsOnPasswordReset: true,
|
|
80
|
+
sendResetPassword: ({ user, url, token }) => {
|
|
81
|
+
deliver({
|
|
82
|
+
to: user.email,
|
|
83
|
+
subject: "Reset your password",
|
|
84
|
+
text: `Open this link to choose a new password:\n${url}\n\nOr send this token to POST /api/auth/reset-password: ${token}`,
|
|
85
|
+
});
|
|
86
|
+
return Promise.resolve();
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
emailVerification: {
|
|
90
|
+
sendOnSignUp: true,
|
|
91
|
+
autoSignInAfterVerification: true,
|
|
92
|
+
sendVerificationEmail: ({ user, url }) => {
|
|
93
|
+
deliver({
|
|
94
|
+
to: user.email,
|
|
95
|
+
subject: "Verify your email address",
|
|
96
|
+
text: `Open this link to verify your email address:\n${withoutDefaultCallback(url)}`,
|
|
97
|
+
});
|
|
98
|
+
return Promise.resolve();
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
// Each provider is turned on once its client ID and secret are set
|
|
102
|
+
socialProviders: {
|
|
103
|
+
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
|
104
|
+
? {
|
|
105
|
+
github: {
|
|
106
|
+
clientId: env.GITHUB_CLIENT_ID,
|
|
107
|
+
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
: {}),
|
|
111
|
+
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
|
112
|
+
? {
|
|
113
|
+
google: {
|
|
114
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
115
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
119
|
+
},
|
|
120
|
+
// Clients without cookies send "Authorization: Bearer <token>"; the token
|
|
121
|
+
// comes back in the set-auth-token header of sign-in and sign-up
|
|
122
|
+
plugins: [bearer()],
|
|
123
|
+
{{#if useRateLimit}}
|
|
124
|
+
// Sign-up, sign-in, and password reset: 10 attempts per client IP in 15
|
|
125
|
+
// minutes, against password guessing
|
|
126
|
+
rateLimit: {
|
|
127
|
+
enabled: true,
|
|
128
|
+
window: 15 * 60,
|
|
129
|
+
max: 100,
|
|
130
|
+
customRules: {
|
|
131
|
+
"/sign-in/*": { window: 15 * 60, max: 10 },
|
|
132
|
+
"/sign-up/*": { window: 15 * 60, max: 10 },
|
|
133
|
+
"/request-password-reset": { window: 15 * 60, max: 10 },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{{/if}}
|
|
137
|
+
advanced: {
|
|
138
|
+
// Set by the server from req.ip on every request (see index.ts)
|
|
139
|
+
ipAddress: { ipAddressHeaders: ["x-client-ip"] },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
export type Session = typeof auth.$Infer.Session;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { env } from "../env";
|
|
2
|
+
|
|
3
|
+
interface Email {
|
|
4
|
+
to: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!env.RESEND_API_KEY && process.env.NODE_ENV === "production") {
|
|
10
|
+
console.warn(
|
|
11
|
+
"RESEND_API_KEY is not set: emails are printed to the log instead of sent"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Sends through Resend when RESEND_API_KEY is set. Without it the email is
|
|
16
|
+
// printed, so verification and reset links can be copied from the terminal
|
|
17
|
+
export async function sendEmail({ to, subject, text }: Email): Promise<void> {
|
|
18
|
+
if (!env.RESEND_API_KEY) {
|
|
19
|
+
console.log(`\n--- Email to ${to}: ${subject}\n${text}\n---\n`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({ from: env.EMAIL_FROM, to, subject, text }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Resend rejected the email (${response.status}): ${await response.text()}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fromNodeHeaders } from "better-auth/node";
|
|
2
|
+
import type { NextFunction, Request, Response } from "express";
|
|
3
|
+
|
|
4
|
+
import { auth, type Session } from "../lib/auth";
|
|
5
|
+
|
|
6
|
+
// Accepts a session cookie or an "Authorization: Bearer <token>" header and
|
|
7
|
+
// stores the session in res.locals.session; answers 401 without one
|
|
8
|
+
export function requireAuth(
|
|
9
|
+
req: Request,
|
|
10
|
+
res: Response,
|
|
11
|
+
next: NextFunction
|
|
12
|
+
): void {
|
|
13
|
+
auth.api
|
|
14
|
+
.getSession({ headers: fromNodeHeaders(req.headers) })
|
|
15
|
+
.then((session) => {
|
|
16
|
+
if (!session) {
|
|
17
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
res.locals.session = session;
|
|
21
|
+
next();
|
|
22
|
+
})
|
|
23
|
+
.catch(next);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The session requireAuth stored, for handlers that run after it
|
|
27
|
+
export function getSession(res: Response): Session {
|
|
28
|
+
return res.locals.session as Session;
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Request, Response, Router } from "express";
|
|
2
|
+
|
|
3
|
+
import { getSession, requireAuth } from "../middleware/requireAuth";
|
|
4
|
+
|
|
5
|
+
const router: Router = Router();
|
|
6
|
+
|
|
7
|
+
// Example of a protected route: returns the signed-in user
|
|
8
|
+
router.get("/", requireAuth, (_req: Request, res: Response) => {
|
|
9
|
+
const { user, session } = getSession(res);
|
|
10
|
+
res.status(200).json({ user, session: { expiresAt: session.expiresAt } });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export default router;
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "auth",
|
|
3
|
-
"name": "
|
|
4
|
-
"description": "
|
|
3
|
+
"name": "Better Auth",
|
|
4
|
+
"description": "Better Auth for NestJS: email and password, email verification, password reset, GitHub and Google sign-in, and bearer tokens",
|
|
5
5
|
"type": "feature",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"zod": "^4.6.7"
|
|
10
|
-
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
"@types/bcrypt": "^6.0.0",
|
|
13
|
-
"@types/jsonwebtoken": "^9.0.10"
|
|
7
|
+
"@thallesp/nestjs-better-auth": "^2.8.0",
|
|
8
|
+
"better-auth": "^1.7.18"
|
|
14
9
|
},
|
|
10
|
+
"devDependencies": {},
|
|
15
11
|
"scripts": {},
|
|
16
12
|
"envVars": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
13
|
+
"BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
|
|
14
|
+
"BETTER_AUTH_URL": "http://localhost:3000",
|
|
15
|
+
"GITHUB_CLIENT_ID": "",
|
|
16
|
+
"GITHUB_CLIENT_SECRET": "",
|
|
17
|
+
"GOOGLE_CLIENT_ID": "",
|
|
18
|
+
"GOOGLE_CLIENT_SECRET": "",
|
|
19
|
+
"RESEND_API_KEY": "",
|
|
20
|
+
"EMAIL_FROM": "onboarding@resend.dev"
|
|
19
21
|
}
|
|
20
22
|
}
|
|
@@ -1,22 +1,72 @@
|
|
|
1
|
-
import { Module } from "@nestjs/common";
|
|
2
1
|
{{#if (eq database 'mongoose')}}
|
|
3
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Injectable,
|
|
4
|
+
Module,
|
|
5
|
+
type OnApplicationShutdown,
|
|
6
|
+
} from "@nestjs/common";
|
|
7
|
+
{{/if}}
|
|
8
|
+
import { AuthModule as BetterAuthModule } from "@thallesp/nestjs-better-auth";
|
|
9
|
+
import type { Request, Response } from "express";
|
|
10
|
+
{{#if (eq database 'mongoose')}}
|
|
11
|
+
import { MongoClient } from "mongodb";
|
|
4
12
|
{{/if}}
|
|
5
13
|
|
|
14
|
+
{{#if (eq database 'drizzle')}}
|
|
15
|
+
import { type Database, DRIZZLE } from "../database/database.module.js";
|
|
16
|
+
{{/if}}
|
|
6
17
|
{{#if (eq database 'mongoose')}}
|
|
7
|
-
import {
|
|
18
|
+
import { env } from "../env.js";
|
|
8
19
|
{{/if}}
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
20
|
+
{{#if (eq database 'prisma')}}
|
|
21
|
+
import { PrismaService } from "../prisma/prisma.service.js";
|
|
22
|
+
{{/if}}
|
|
23
|
+
import { createAuth } from "./auth.js";
|
|
24
|
+
|
|
25
|
+
{{#if (eq database 'mongoose')}}
|
|
26
|
+
// Better Auth talks to MongoDB through the driver, next to Mongoose
|
|
27
|
+
const mongoClient = new MongoClient(env.MONGODB_URI);
|
|
12
28
|
|
|
13
|
-
@
|
|
29
|
+
@Injectable()
|
|
30
|
+
class MongoClientShutdown implements OnApplicationShutdown {
|
|
31
|
+
onApplicationShutdown(): Promise<void> {
|
|
32
|
+
return mongoClient.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Module({ providers: [MongoClientShutdown] })
|
|
37
|
+
class MongoClientModule {}
|
|
38
|
+
|
|
39
|
+
{{/if}}
|
|
40
|
+
// Mounts Better Auth on /api/auth and protects every other route: add
|
|
41
|
+
// @AllowAnonymous() to a controller or route that should stay public
|
|
42
|
+
export const AuthModule = BetterAuthModule.forRootAsync({
|
|
43
|
+
{{#if (eq database 'prisma')}}
|
|
44
|
+
inject: [PrismaService],
|
|
45
|
+
useFactory: (prisma: PrismaService) => ({
|
|
46
|
+
auth: createAuth(prisma),
|
|
47
|
+
{{/if}}
|
|
48
|
+
{{#if (eq database 'drizzle')}}
|
|
49
|
+
inject: [DRIZZLE],
|
|
50
|
+
useFactory: (db: Database) => ({
|
|
51
|
+
auth: createAuth(db),
|
|
52
|
+
{{/if}}
|
|
14
53
|
{{#if (eq database 'mongoose')}}
|
|
15
|
-
imports: [
|
|
16
|
-
|
|
17
|
-
|
|
54
|
+
imports: [MongoClientModule],
|
|
55
|
+
useFactory: () => ({
|
|
56
|
+
auth: createAuth(mongoClient.db()),
|
|
57
|
+
{{/if}}
|
|
58
|
+
{{#if (eq database 'none')}}
|
|
59
|
+
useFactory: () => ({
|
|
60
|
+
auth: createAuth(),
|
|
18
61
|
{{/if}}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
62
|
+
// CORS stays with app.enableCors() in main.ts, which reads CORS_ORIGIN
|
|
63
|
+
disableTrustedOriginsCors: true,
|
|
64
|
+
// Better Auth reads the client IP for its rate limits from this header. It
|
|
65
|
+
// is set from req.ip on every request, so a client cannot pick its own
|
|
66
|
+
// value. Behind a reverse proxy, enable "trust proxy" for the real client IP
|
|
67
|
+
middleware: (req: Request, _res: Response, next: () => void) => {
|
|
68
|
+
req.headers["x-client-ip"] = req.ip;
|
|
69
|
+
next();
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
{{#if (eq database 'none')}}
|
|
3
|
+
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
4
|
+
{{/if}}
|
|
5
|
+
{{#if (eq database 'prisma')}}
|
|
6
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
7
|
+
{{/if}}
|
|
8
|
+
{{#if (eq database 'drizzle')}}
|
|
9
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
10
|
+
{{/if}}
|
|
11
|
+
{{#if (eq database 'mongoose')}}
|
|
12
|
+
import { mongodbAdapter } from "better-auth/adapters/mongodb";
|
|
13
|
+
{{/if}}
|
|
14
|
+
import { bearer } from "better-auth/plugins";
|
|
15
|
+
{{#if (eq database 'mongoose')}}
|
|
16
|
+
import type { Db } from "mongodb";
|
|
17
|
+
{{/if}}
|
|
18
|
+
|
|
19
|
+
{{#if (eq database 'drizzle')}}
|
|
20
|
+
import type { Database } from "../database/database.module.js";
|
|
21
|
+
{{/if}}
|
|
22
|
+
import { env } from "../env.js";
|
|
23
|
+
{{#if (eq database 'prisma')}}
|
|
24
|
+
import type { PrismaService } from "../prisma/prisma.service.js";
|
|
25
|
+
{{/if}}
|
|
26
|
+
import { sendEmail } from "./email.js";
|
|
27
|
+
|
|
28
|
+
{{#if (eq database 'none')}}
|
|
29
|
+
// In-memory store for trying the API out: users and sessions are lost on
|
|
30
|
+
// restart, so add a database before going to production
|
|
31
|
+
const memoryDb = { user: [], session: [], account: [], verification: [] };
|
|
32
|
+
|
|
33
|
+
{{/if}}
|
|
34
|
+
// Reports a failed email instead of failing the request: the sign-up or reset
|
|
35
|
+
// already happened, and the user can ask for the email again
|
|
36
|
+
function deliver(email: Parameters<typeof sendEmail>[0]): void {
|
|
37
|
+
sendEmail(email).catch((error: unknown) => {
|
|
38
|
+
console.error(`Could not send "${email.subject}" to ${email.to}:`, error);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Points a verification link at the API itself when the client gave no page
|
|
43
|
+
// to return to, so opening it answers with JSON instead of a redirect to "/"
|
|
44
|
+
function withoutDefaultCallback(url: string): string {
|
|
45
|
+
const link = new URL(url);
|
|
46
|
+
if (link.searchParams.get("callbackURL") === "/") {
|
|
47
|
+
link.searchParams.delete("callbackURL");
|
|
48
|
+
}
|
|
49
|
+
return link.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Builds the Better Auth server; auth.module.ts calls it with the database
|
|
53
|
+
{{#if (eq database 'none')}}
|
|
54
|
+
export function createAuth() {
|
|
55
|
+
{{/if}}
|
|
56
|
+
{{#if (eq database 'prisma')}}
|
|
57
|
+
export function createAuth(prisma: PrismaService) {
|
|
58
|
+
{{/if}}
|
|
59
|
+
{{#if (eq database 'drizzle')}}
|
|
60
|
+
export function createAuth(db: Database) {
|
|
61
|
+
{{/if}}
|
|
62
|
+
{{#if (eq database 'mongoose')}}
|
|
63
|
+
export function createAuth(db: Db) {
|
|
64
|
+
{{/if}}
|
|
65
|
+
return betterAuth({
|
|
66
|
+
{{#if (eq database 'none')}}
|
|
67
|
+
database: memoryAdapter(memoryDb),
|
|
68
|
+
{{/if}}
|
|
69
|
+
{{#if (eq database 'prisma')}}
|
|
70
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
71
|
+
{{/if}}
|
|
72
|
+
{{#if (eq database 'drizzle')}}
|
|
73
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
74
|
+
{{/if}}
|
|
75
|
+
{{#if (eq database 'mongoose')}}
|
|
76
|
+
// Without the client, writes run without transactions, which a standalone
|
|
77
|
+
// MongoDB server (no replica set) does not support
|
|
78
|
+
database: mongodbAdapter(db),
|
|
79
|
+
{{/if}}
|
|
80
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
81
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
82
|
+
// Browser apps on these origins may call the auth routes with cookies
|
|
83
|
+
trustedOrigins: typeof env.CORS_ORIGIN === "string" ? [] : env.CORS_ORIGIN,
|
|
84
|
+
emailAndPassword: {
|
|
85
|
+
enabled: true,
|
|
86
|
+
// Set to true to block sign-in until the email address is verified
|
|
87
|
+
requireEmailVerification: false,
|
|
88
|
+
// A reset signs the user out everywhere
|
|
89
|
+
revokeSessionsOnPasswordReset: true,
|
|
90
|
+
sendResetPassword: ({ user, url, token }) => {
|
|
91
|
+
deliver({
|
|
92
|
+
to: user.email,
|
|
93
|
+
subject: "Reset your password",
|
|
94
|
+
text: `Open this link to choose a new password:\n${url}\n\nOr send this token to POST /api/auth/reset-password: ${token}`,
|
|
95
|
+
});
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
emailVerification: {
|
|
100
|
+
sendOnSignUp: true,
|
|
101
|
+
autoSignInAfterVerification: true,
|
|
102
|
+
sendVerificationEmail: ({ user, url }) => {
|
|
103
|
+
deliver({
|
|
104
|
+
to: user.email,
|
|
105
|
+
subject: "Verify your email address",
|
|
106
|
+
text: `Open this link to verify your email address:\n${withoutDefaultCallback(url)}`,
|
|
107
|
+
});
|
|
108
|
+
return Promise.resolve();
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
// Each provider is turned on once its client ID and secret are set
|
|
112
|
+
socialProviders: {
|
|
113
|
+
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
|
114
|
+
? {
|
|
115
|
+
github: {
|
|
116
|
+
clientId: env.GITHUB_CLIENT_ID,
|
|
117
|
+
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
|
122
|
+
? {
|
|
123
|
+
google: {
|
|
124
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
125
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
},
|
|
130
|
+
// Clients without cookies send "Authorization: Bearer <token>"; the token
|
|
131
|
+
// comes back in the set-auth-token header of sign-in and sign-up
|
|
132
|
+
plugins: [bearer()],
|
|
133
|
+
{{#if useRateLimit}}
|
|
134
|
+
// Sign-up, sign-in, and password reset: 10 attempts per client IP in 15
|
|
135
|
+
// minutes, against password guessing
|
|
136
|
+
rateLimit: {
|
|
137
|
+
enabled: true,
|
|
138
|
+
window: 15 * 60,
|
|
139
|
+
max: 100,
|
|
140
|
+
customRules: {
|
|
141
|
+
"/sign-in/*": { window: 15 * 60, max: 10 },
|
|
142
|
+
"/sign-up/*": { window: 15 * 60, max: 10 },
|
|
143
|
+
"/request-password-reset": { window: 15 * 60, max: 10 },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{{/if}}
|
|
147
|
+
advanced: {
|
|
148
|
+
// Set from req.ip on every request (see auth.module.ts)
|
|
149
|
+
ipAddress: { ipAddressHeaders: ["x-client-ip"] },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { env } from "../env.js";
|
|
2
|
+
|
|
3
|
+
interface Email {
|
|
4
|
+
to: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!env.RESEND_API_KEY && process.env.NODE_ENV === "production") {
|
|
10
|
+
console.warn(
|
|
11
|
+
"RESEND_API_KEY is not set: emails are printed to the log instead of sent"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Sends through Resend when RESEND_API_KEY is set. Without it the email is
|
|
16
|
+
// printed, so verification and reset links can be copied from the terminal
|
|
17
|
+
export async function sendEmail({ to, subject, text }: Email): Promise<void> {
|
|
18
|
+
if (!env.RESEND_API_KEY) {
|
|
19
|
+
console.log(`\n--- Email to ${to}: ${subject}\n${text}\n---\n`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({ from: env.EMAIL_FROM, to, subject, text }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Resend rejected the email (${response.status}): ${await response.text()}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller, Get } from "@nestjs/common";
|
|
2
|
+
import { Session, type UserSession } from "@thallesp/nestjs-better-auth";
|
|
3
|
+
|
|
4
|
+
// Example of a protected route: the global auth guard answers 401 without a
|
|
5
|
+
// session cookie or an "Authorization: Bearer <token>" header
|
|
6
|
+
@Controller("api/me")
|
|
7
|
+
export class MeController {
|
|
8
|
+
@Get()
|
|
9
|
+
me(@Session() { user, session }: UserSession): {
|
|
10
|
+
user: UserSession["user"];
|
|
11
|
+
session: { expiresAt: Date };
|
|
12
|
+
} {
|
|
13
|
+
return { user, session: { expiresAt: session.expiresAt } };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { auth } from "@/lib/auth";
|
|
2
|
-
import { toNextJsHandler } from "better-auth/next-js";
|
|
3
|
-
|
|
4
|
-
export const { GET, POST } = toNextJsHandler(auth);
|
|
1
|
+
import { auth } from "@/lib/auth";
|
|
2
|
+
import { toNextJsHandler } from "better-auth/next-js";
|
|
3
|
+
|
|
4
|
+
export const { GET, POST } = toNextJsHandler(auth);
|