authjs-config-cli 1.0.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/README.md +73 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1590 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1590 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import chalk8 from "chalk";
|
|
6
|
+
|
|
7
|
+
// src/commands/init.ts
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
|
|
12
|
+
// src/templates/frameworks/nextjs.ts
|
|
13
|
+
function nextjsTemplate(options) {
|
|
14
|
+
const strategy = options.strategy === "session" ? "database" : "jwt";
|
|
15
|
+
return `// Auth.js v5 configuration for Next.js (App Router)
|
|
16
|
+
// Generated by authjs-config-cli
|
|
17
|
+
// Docs: https://authjs.dev/getting-started/installation?framework=next.js
|
|
18
|
+
|
|
19
|
+
import NextAuth from 'next-auth';
|
|
20
|
+
import type { NextAuthConfig } from 'next-auth';
|
|
21
|
+
${options.rbac ? "import type { JWT } from 'next-auth/jwt';\n" : ""}
|
|
22
|
+
export const authConfig: NextAuthConfig = {
|
|
23
|
+
// Secret must be set in production: AUTH_SECRET env var
|
|
24
|
+
// Generate: openssl rand -base64 32
|
|
25
|
+
|
|
26
|
+
session: {
|
|
27
|
+
strategy: '${strategy}',
|
|
28
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
29
|
+
updateAge: 24 * 60 * 60, // 24 hours
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
providers: [
|
|
33
|
+
// Add providers here:
|
|
34
|
+
// import GitHubProvider from 'next-auth/providers/github';
|
|
35
|
+
// GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET! }),
|
|
36
|
+
],
|
|
37
|
+
|
|
38
|
+
callbacks: {
|
|
39
|
+
async jwt({ token, user, account${options.rbac ? ", profile" : ""} }) {
|
|
40
|
+
if (user) {
|
|
41
|
+
token.id = user.id;
|
|
42
|
+
${options.rbac ? ` // Persist role into token
|
|
43
|
+
token.role = (user as any).role ?? 'user';
|
|
44
|
+
` : ""} }
|
|
45
|
+
${options.refreshTokens ? ` // Refresh token rotation
|
|
46
|
+
if (account?.access_token) {
|
|
47
|
+
token.accessToken = account.access_token;
|
|
48
|
+
token.refreshToken = account.refresh_token;
|
|
49
|
+
token.expiresAt = account.expires_at;
|
|
50
|
+
}
|
|
51
|
+
if (Date.now() < ((token.expiresAt as number) ?? 0) * 1000) {
|
|
52
|
+
return token;
|
|
53
|
+
}
|
|
54
|
+
// TODO: implement refreshAccessToken(token.refreshToken)
|
|
55
|
+
` : ""} return token;
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async session({ session, token }) {
|
|
59
|
+
if (token?.id) session.user.id = token.id as string;
|
|
60
|
+
${options.rbac ? ` if (token?.role) (session.user as any).role = token.role;
|
|
61
|
+
` : ""} return session;
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
pages: {
|
|
66
|
+
signIn: '/auth/signin',
|
|
67
|
+
error: '/auth/error',
|
|
68
|
+
// signOut: '/auth/signout',
|
|
69
|
+
// verifyRequest: '/auth/verify-request',
|
|
70
|
+
// newUser: '/auth/new-user',
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// debug: process.env.NODE_ENV === 'development',
|
|
74
|
+
trustHost: true,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
|
|
78
|
+
|
|
79
|
+
// app/api/auth/[...nextauth]/route.ts:
|
|
80
|
+
// export { GET, POST } from '@/auth';
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/templates/frameworks/sveltekit.ts
|
|
85
|
+
function sveltekitTemplate(options) {
|
|
86
|
+
const strategy = options.strategy === "session" ? "database" : "jwt";
|
|
87
|
+
return `// Auth.js v5 configuration for SvelteKit
|
|
88
|
+
// Generated by authjs-config-cli
|
|
89
|
+
// Docs: https://authjs.dev/getting-started/installation?framework=sveltekit
|
|
90
|
+
|
|
91
|
+
import { SvelteKitAuth } from '@auth/sveltekit';
|
|
92
|
+
import type { Handle } from '@sveltejs/kit';
|
|
93
|
+
${options.rbac ? "import { redirect } from '@sveltejs/kit';\n" : ""}
|
|
94
|
+
export const { handle, signIn, signOut } = SvelteKitAuth({
|
|
95
|
+
// AUTH_SECRET env var required in production
|
|
96
|
+
|
|
97
|
+
session: {
|
|
98
|
+
strategy: '${strategy}',
|
|
99
|
+
maxAge: 30 * 24 * 60 * 60,
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
providers: [
|
|
103
|
+
// Add providers here:
|
|
104
|
+
// import GitHub from '@auth/sveltekit/providers/github';
|
|
105
|
+
// GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }),
|
|
106
|
+
],
|
|
107
|
+
|
|
108
|
+
callbacks: {
|
|
109
|
+
async jwt({ token, user, account }) {
|
|
110
|
+
if (user) {
|
|
111
|
+
token.id = user.id;
|
|
112
|
+
${options.rbac ? ` token.role = (user as any).role ?? 'user';
|
|
113
|
+
` : ""} }
|
|
114
|
+
${options.refreshTokens ? ` if (account?.access_token) {
|
|
115
|
+
token.accessToken = account.access_token;
|
|
116
|
+
token.expiresAt = account.expires_at;
|
|
117
|
+
}
|
|
118
|
+
` : ""} return token;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async session({ session, token }) {
|
|
122
|
+
if (token?.id) session.user.id = token.id as string;
|
|
123
|
+
${options.rbac ? ` if (token?.role) (session.user as any).role = token.role;
|
|
124
|
+
` : ""} return session;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
trustHost: true,
|
|
129
|
+
}) satisfies { handle: Handle };
|
|
130
|
+
|
|
131
|
+
// src/hooks.server.ts should export handle directly.
|
|
132
|
+
// src/app.d.ts \u2014 extend the session type:
|
|
133
|
+
// declare module '@auth/core/types' {
|
|
134
|
+
// interface Session { user: { id: string${options.rbac ? "; role: string" : ""} } & DefaultSession['user'] }
|
|
135
|
+
// }
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/templates/frameworks/express.ts
|
|
140
|
+
function expressTemplate(options) {
|
|
141
|
+
const rbacSection = options.rbac ? `// RBAC middleware
|
|
142
|
+
function requireRole(role: string) {
|
|
143
|
+
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
144
|
+
const session = res.locals.session;
|
|
145
|
+
if (!session?.user) return res.status(401).json({ error: 'Unauthorized' });
|
|
146
|
+
if ((session.user as any).role !== role) return res.status(403).json({ error: 'Forbidden' });
|
|
147
|
+
next();
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Protected route example:
|
|
152
|
+
// app.get('/admin', requireRole('admin'), (req, res) => res.json({ ok: true }));
|
|
153
|
+
` : `// Protect routes:
|
|
154
|
+
// app.get('/api/protected', async (req, res) => {
|
|
155
|
+
// const session = res.locals.session;
|
|
156
|
+
// if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
|
157
|
+
// res.json({ user: session.user });
|
|
158
|
+
// });
|
|
159
|
+
`;
|
|
160
|
+
const rbacJwtLine = options.rbac ? ` token.role = (user as any).role ?? 'user';
|
|
161
|
+
` : "";
|
|
162
|
+
const rbacSessionLine = options.rbac ? ` if (token?.role) (session.user as any).role = token.role;
|
|
163
|
+
` : "";
|
|
164
|
+
const refreshSection = options.refreshTokens ? ` if (account?.access_token) {
|
|
165
|
+
token.accessToken = account.access_token;
|
|
166
|
+
token.expiresAt = account.expires_at;
|
|
167
|
+
}
|
|
168
|
+
` : "";
|
|
169
|
+
return `// Auth.js configuration for Express
|
|
170
|
+
// Generated by authjs-config-cli
|
|
171
|
+
// Docs: https://authjs.dev/getting-started/installation?framework=express
|
|
172
|
+
|
|
173
|
+
import express from 'express';
|
|
174
|
+
import { ExpressAuth } from '@auth/express';
|
|
175
|
+
import type { AuthConfig } from '@auth/core';
|
|
176
|
+
|
|
177
|
+
const app = express();
|
|
178
|
+
|
|
179
|
+
app.set('trust proxy', true);
|
|
180
|
+
app.use(express.json());
|
|
181
|
+
|
|
182
|
+
const authConfig: AuthConfig = {
|
|
183
|
+
// AUTH_SECRET env var required in production
|
|
184
|
+
|
|
185
|
+
providers: [
|
|
186
|
+
// Add providers here:
|
|
187
|
+
// import GitHub from '@auth/express/providers/github';
|
|
188
|
+
// GitHub({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET! }),
|
|
189
|
+
],
|
|
190
|
+
|
|
191
|
+
callbacks: {
|
|
192
|
+
async jwt({ token, user, account }) {
|
|
193
|
+
if (user) {
|
|
194
|
+
token.id = user.id;
|
|
195
|
+
${rbacJwtLine} }
|
|
196
|
+
${refreshSection} return token;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async session({ session, token }) {
|
|
200
|
+
if (token?.id) session.user.id = token.id as string;
|
|
201
|
+
${rbacSessionLine} return session;
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
trustHost: true,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Mount the Auth.js handler
|
|
209
|
+
app.use('/auth/*', ExpressAuth(authConfig));
|
|
210
|
+
|
|
211
|
+
${rbacSection}
|
|
212
|
+
const PORT = process.env.PORT ?? 3000;
|
|
213
|
+
app.listen(PORT, () => console.log(\`Server running on port \${PORT}\`));
|
|
214
|
+
|
|
215
|
+
export default app;
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/templates/frameworks/solid-start.ts
|
|
220
|
+
function solidStartTemplate(options) {
|
|
221
|
+
return `// Auth.js configuration for SolidStart
|
|
222
|
+
// Generated by authjs-config-cli
|
|
223
|
+
// Docs: https://authjs.dev/getting-started/installation?framework=solid-start
|
|
224
|
+
|
|
225
|
+
import { SolidAuth } from '@auth/solid-start';
|
|
226
|
+
import type { SolidAuthConfig } from '@auth/solid-start';
|
|
227
|
+
|
|
228
|
+
export const authOptions: SolidAuthConfig = {
|
|
229
|
+
// AUTH_SECRET env var required in production
|
|
230
|
+
|
|
231
|
+
providers: [
|
|
232
|
+
// Add providers here:
|
|
233
|
+
// import GitHub from '@auth/core/providers/github';
|
|
234
|
+
// GitHub({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET! }),
|
|
235
|
+
],
|
|
236
|
+
|
|
237
|
+
callbacks: {
|
|
238
|
+
async jwt({ token, user, account }) {
|
|
239
|
+
if (user) {
|
|
240
|
+
token.id = user.id;
|
|
241
|
+
${options.rbac ? ` token.role = (user as any).role ?? 'user';
|
|
242
|
+
` : ""} }
|
|
243
|
+
${options.refreshTokens ? ` if (account?.access_token) {
|
|
244
|
+
token.accessToken = account.access_token;
|
|
245
|
+
token.expiresAt = account.expires_at;
|
|
246
|
+
}
|
|
247
|
+
` : ""} return token;
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async session({ session, token }) {
|
|
251
|
+
if (token?.id) session.user.id = token.id as string;
|
|
252
|
+
${options.rbac ? ` if (token?.role) (session.user as any).role = token.role;
|
|
253
|
+
` : ""} return session;
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
trustHost: true,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export const { GET, POST } = SolidAuth(authOptions);
|
|
261
|
+
|
|
262
|
+
// Place this file at: src/routes/api/auth/[...solidauth].ts
|
|
263
|
+
// Then use getSession(request, authOptions) in server functions.
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/templates/frameworks/nuxt.ts
|
|
268
|
+
function nuxtTemplate(options) {
|
|
269
|
+
return `// Auth.js configuration for Nuxt
|
|
270
|
+
// Generated by authjs-config-cli
|
|
271
|
+
// Docs: https://authjs.dev/getting-started/installation?framework=nuxt
|
|
272
|
+
|
|
273
|
+
import { NuxtAuthHandler } from '#auth';
|
|
274
|
+
import type { AuthOptions } from 'next-auth';
|
|
275
|
+
|
|
276
|
+
// server/plugins/auth.ts \u2014 or use server/api/auth/[...].ts
|
|
277
|
+
|
|
278
|
+
export default NuxtAuthHandler({
|
|
279
|
+
secret: process.env.AUTH_SECRET,
|
|
280
|
+
|
|
281
|
+
providers: [
|
|
282
|
+
// Add providers here (use .default for Nuxt compatibility):
|
|
283
|
+
// const { default: GithubProvider } = await import('next-auth/providers/github');
|
|
284
|
+
// GithubProvider.default({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET! }),
|
|
285
|
+
],
|
|
286
|
+
|
|
287
|
+
callbacks: {
|
|
288
|
+
async jwt({ token, user, account }) {
|
|
289
|
+
if (user) {
|
|
290
|
+
token.id = user.id;
|
|
291
|
+
${options.rbac ? ` token.role = (user as any).role ?? 'user';
|
|
292
|
+
` : ""} }
|
|
293
|
+
${options.refreshTokens ? ` if (account?.access_token) {
|
|
294
|
+
token.accessToken = account.access_token;
|
|
295
|
+
token.expiresAt = account.expires_at;
|
|
296
|
+
}
|
|
297
|
+
` : ""} return token;
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
async session({ session, token }) {
|
|
301
|
+
if (token?.id) (session.user as any).id = token.id;
|
|
302
|
+
${options.rbac ? ` if (token?.role) (session.user as any).role = token.role;
|
|
303
|
+
` : ""} return session;
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
pages: {
|
|
308
|
+
signIn: '/auth/signin',
|
|
309
|
+
error: '/auth/error',
|
|
310
|
+
},
|
|
311
|
+
} satisfies AuthOptions);
|
|
312
|
+
|
|
313
|
+
// nuxt.config.ts:
|
|
314
|
+
// modules: ['@sidebase/nuxt-auth'],
|
|
315
|
+
// auth: { provider: { type: 'authjs' } }
|
|
316
|
+
`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/commands/init.ts
|
|
320
|
+
var FRAMEWORKS = ["nextjs", "sveltekit", "express", "solid-start", "nuxt"];
|
|
321
|
+
function initCommand(framework, options) {
|
|
322
|
+
if (!FRAMEWORKS.includes(framework)) {
|
|
323
|
+
console.error(chalk.red(`\u2717 Unknown framework: ${chalk.bold(framework)}`));
|
|
324
|
+
console.error(chalk.dim(` Supported: ${FRAMEWORKS.join(" | ")}`));
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
console.log(chalk.cyan(`
|
|
328
|
+
\u25C6 Generating Auth.js config for ${chalk.bold(framework)}
|
|
329
|
+
`));
|
|
330
|
+
const fw = framework;
|
|
331
|
+
let content;
|
|
332
|
+
switch (fw) {
|
|
333
|
+
case "nextjs":
|
|
334
|
+
content = nextjsTemplate(options);
|
|
335
|
+
break;
|
|
336
|
+
case "sveltekit":
|
|
337
|
+
content = sveltekitTemplate(options);
|
|
338
|
+
break;
|
|
339
|
+
case "express":
|
|
340
|
+
content = expressTemplate(options);
|
|
341
|
+
break;
|
|
342
|
+
case "solid-start":
|
|
343
|
+
content = solidStartTemplate(options);
|
|
344
|
+
break;
|
|
345
|
+
case "nuxt":
|
|
346
|
+
content = nuxtTemplate(options);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
const outputMap = {
|
|
350
|
+
nextjs: { file: "auth.ts", dir: "" },
|
|
351
|
+
sveltekit: { file: "hooks.server.ts", dir: "src" },
|
|
352
|
+
express: { file: "auth.ts", dir: "src" },
|
|
353
|
+
"solid-start": { file: "server.ts", dir: "src/routes/api/auth" },
|
|
354
|
+
nuxt: { file: "auth.ts", dir: "server/plugins" }
|
|
355
|
+
};
|
|
356
|
+
const { file, dir } = outputMap[fw];
|
|
357
|
+
const outDir = dir ? join(process.cwd(), dir) : process.cwd();
|
|
358
|
+
if (dir && !existsSync(outDir)) {
|
|
359
|
+
mkdirSync(outDir, { recursive: true });
|
|
360
|
+
}
|
|
361
|
+
const outPath = join(outDir, file);
|
|
362
|
+
writeFileSync(outPath, content, "utf-8");
|
|
363
|
+
console.log(chalk.green(` \u2713 Created ${chalk.bold(dir ? `${dir}/${file}` : file)}`));
|
|
364
|
+
if (options.rbac) {
|
|
365
|
+
const rbacContent = generateRBACHelper();
|
|
366
|
+
const rbacPath = join(process.cwd(), "lib", "rbac.ts");
|
|
367
|
+
mkdirSync(join(process.cwd(), "lib"), { recursive: true });
|
|
368
|
+
writeFileSync(rbacPath, rbacContent, "utf-8");
|
|
369
|
+
console.log(chalk.green(` \u2713 Created ${chalk.bold("lib/rbac.ts")} (RBAC helpers)`));
|
|
370
|
+
}
|
|
371
|
+
if (options.refreshTokens) {
|
|
372
|
+
console.log(chalk.yellow(` \u26A0 Refresh token rotation: add jwt callback with refreshAccessToken logic`));
|
|
373
|
+
console.log(chalk.dim(` Run: authjs-config add-callback jwt`));
|
|
374
|
+
}
|
|
375
|
+
console.log(chalk.dim(`
|
|
376
|
+
Next steps:`));
|
|
377
|
+
console.log(chalk.dim(` authjs-config add-provider github`));
|
|
378
|
+
console.log(chalk.dim(` authjs-config add-adapter prisma`));
|
|
379
|
+
console.log(chalk.dim(` authjs-config add-middleware
|
|
380
|
+
`));
|
|
381
|
+
}
|
|
382
|
+
function generateRBACHelper() {
|
|
383
|
+
return `import type { Session } from 'next-auth';
|
|
384
|
+
|
|
385
|
+
export type Role = 'admin' | 'moderator' | 'user' | 'guest';
|
|
386
|
+
|
|
387
|
+
export const ROLE_HIERARCHY: Record<Role, number> = {
|
|
388
|
+
admin: 4,
|
|
389
|
+
moderator: 3,
|
|
390
|
+
user: 2,
|
|
391
|
+
guest: 1,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export function hasRole(session: Session | null, requiredRole: Role): boolean {
|
|
395
|
+
if (!session?.user) return false;
|
|
396
|
+
const userRole = (session.user as { role?: Role }).role ?? 'guest';
|
|
397
|
+
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function requireRole(requiredRole: Role) {
|
|
401
|
+
return function (session: Session | null): boolean {
|
|
402
|
+
return hasRole(session, requiredRole);
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export const isAdmin = requireRole('admin');
|
|
407
|
+
export const isModerator = requireRole('moderator');
|
|
408
|
+
export const isUser = requireRole('user');
|
|
409
|
+
`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/commands/add-provider.ts
|
|
413
|
+
import chalk2 from "chalk";
|
|
414
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
415
|
+
import { join as join2 } from "path";
|
|
416
|
+
var PROVIDERS = [
|
|
417
|
+
"google",
|
|
418
|
+
"github",
|
|
419
|
+
"discord",
|
|
420
|
+
"apple",
|
|
421
|
+
"twitter",
|
|
422
|
+
"facebook",
|
|
423
|
+
"linkedin",
|
|
424
|
+
"auth0",
|
|
425
|
+
"keycloak",
|
|
426
|
+
"credentials",
|
|
427
|
+
"email"
|
|
428
|
+
];
|
|
429
|
+
function addProviderCommand(name, options) {
|
|
430
|
+
if (!PROVIDERS.includes(name)) {
|
|
431
|
+
console.error(chalk2.red(`\u2717 Unknown provider: ${chalk2.bold(name)}`));
|
|
432
|
+
console.error(chalk2.dim(` Supported: ${PROVIDERS.join(" | ")}`));
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
console.log(chalk2.cyan(`
|
|
436
|
+
\u25C6 Adding provider: ${chalk2.bold(name)}
|
|
437
|
+
`));
|
|
438
|
+
const provider = name;
|
|
439
|
+
const snippet = generateProviderSnippet(provider, options);
|
|
440
|
+
const outFile = join2(process.cwd(), `auth.providers.${name}.ts`);
|
|
441
|
+
writeFileSync2(outFile, snippet, "utf-8");
|
|
442
|
+
console.log(chalk2.green(` \u2713 Created ${chalk2.bold(`auth.providers.${name}.ts`)}`));
|
|
443
|
+
printEnvHints(provider, options);
|
|
444
|
+
printIntegrationHint(name);
|
|
445
|
+
}
|
|
446
|
+
function generateProviderSnippet(provider, options) {
|
|
447
|
+
const header = `// Auth.js provider configuration \u2014 ${provider}
|
|
448
|
+
// Generated by authjs-config-cli
|
|
449
|
+
|
|
450
|
+
`;
|
|
451
|
+
switch (provider) {
|
|
452
|
+
case "google":
|
|
453
|
+
return header + `import GoogleProvider from 'next-auth/providers/google';
|
|
454
|
+
|
|
455
|
+
export const googleProvider = GoogleProvider({
|
|
456
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
457
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
458
|
+
authorization: {
|
|
459
|
+
params: {
|
|
460
|
+
prompt: 'consent',
|
|
461
|
+
access_type: 'offline',
|
|
462
|
+
response_type: 'code',
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
profile(profile) {
|
|
466
|
+
return {
|
|
467
|
+
id: profile.sub,
|
|
468
|
+
name: profile.name,
|
|
469
|
+
email: profile.email,
|
|
470
|
+
image: profile.picture,
|
|
471
|
+
// Custom profile mapping:
|
|
472
|
+
// role: 'user',
|
|
473
|
+
};
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
`;
|
|
477
|
+
case "github":
|
|
478
|
+
return header + `import GitHubProvider from 'next-auth/providers/github';
|
|
479
|
+
|
|
480
|
+
export const githubProvider = GitHubProvider({
|
|
481
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
482
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
483
|
+
profile(profile) {
|
|
484
|
+
return {
|
|
485
|
+
id: String(profile.id),
|
|
486
|
+
name: profile.name ?? profile.login,
|
|
487
|
+
email: profile.email,
|
|
488
|
+
image: profile.avatar_url,
|
|
489
|
+
};
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
`;
|
|
493
|
+
case "discord":
|
|
494
|
+
return header + `import DiscordProvider from 'next-auth/providers/discord';
|
|
495
|
+
|
|
496
|
+
export const discordProvider = DiscordProvider({
|
|
497
|
+
clientId: process.env.DISCORD_CLIENT_ID!,
|
|
498
|
+
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
|
|
499
|
+
});
|
|
500
|
+
`;
|
|
501
|
+
case "apple":
|
|
502
|
+
return header + `import AppleProvider from 'next-auth/providers/apple';
|
|
503
|
+
|
|
504
|
+
export const appleProvider = AppleProvider({
|
|
505
|
+
clientId: process.env.APPLE_ID!,
|
|
506
|
+
clientSecret: process.env.APPLE_SECRET!,
|
|
507
|
+
});
|
|
508
|
+
`;
|
|
509
|
+
case "twitter":
|
|
510
|
+
return header + `import TwitterProvider from 'next-auth/providers/twitter';
|
|
511
|
+
|
|
512
|
+
export const twitterProvider = TwitterProvider({
|
|
513
|
+
clientId: process.env.TWITTER_CLIENT_ID!,
|
|
514
|
+
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
|
|
515
|
+
version: '2.0',
|
|
516
|
+
});
|
|
517
|
+
`;
|
|
518
|
+
case "facebook":
|
|
519
|
+
return header + `import FacebookProvider from 'next-auth/providers/facebook';
|
|
520
|
+
|
|
521
|
+
export const facebookProvider = FacebookProvider({
|
|
522
|
+
clientId: process.env.FACEBOOK_CLIENT_ID!,
|
|
523
|
+
clientSecret: process.env.FACEBOOK_CLIENT_SECRET!,
|
|
524
|
+
});
|
|
525
|
+
`;
|
|
526
|
+
case "linkedin":
|
|
527
|
+
return header + `import LinkedInProvider from 'next-auth/providers/linkedin';
|
|
528
|
+
|
|
529
|
+
export const linkedinProvider = LinkedInProvider({
|
|
530
|
+
clientId: process.env.LINKEDIN_CLIENT_ID!,
|
|
531
|
+
clientSecret: process.env.LINKEDIN_CLIENT_SECRET!,
|
|
532
|
+
});
|
|
533
|
+
`;
|
|
534
|
+
case "auth0":
|
|
535
|
+
return header + `import Auth0Provider from 'next-auth/providers/auth0';
|
|
536
|
+
|
|
537
|
+
export const auth0Provider = Auth0Provider({
|
|
538
|
+
clientId: process.env.AUTH0_CLIENT_ID!,
|
|
539
|
+
clientSecret: process.env.AUTH0_CLIENT_SECRET!,
|
|
540
|
+
issuer: process.env.AUTH0_ISSUER!,
|
|
541
|
+
});
|
|
542
|
+
`;
|
|
543
|
+
case "keycloak":
|
|
544
|
+
return header + `import KeycloakProvider from 'next-auth/providers/keycloak';
|
|
545
|
+
|
|
546
|
+
export const keycloakProvider = KeycloakProvider({
|
|
547
|
+
clientId: process.env.KEYCLOAK_ID!,
|
|
548
|
+
clientSecret: process.env.KEYCLOAK_SECRET!,
|
|
549
|
+
issuer: process.env.KEYCLOAK_ISSUER!,
|
|
550
|
+
});
|
|
551
|
+
`;
|
|
552
|
+
case "credentials":
|
|
553
|
+
return header + `import CredentialsProvider from 'next-auth/providers/credentials';
|
|
554
|
+
import { z } from 'zod';
|
|
555
|
+
|
|
556
|
+
const loginSchema = z.object({
|
|
557
|
+
email: z.string().email(),
|
|
558
|
+
password: z.string().min(8),
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
export const credentialsProvider = CredentialsProvider({
|
|
562
|
+
name: 'Credentials',
|
|
563
|
+
credentials: {
|
|
564
|
+
email: { label: 'Email', type: 'email', placeholder: 'user@example.com' },
|
|
565
|
+
password: { label: 'Password', type: 'password' },
|
|
566
|
+
},
|
|
567
|
+
async authorize(credentials) {
|
|
568
|
+
const parsed = loginSchema.safeParse(credentials);
|
|
569
|
+
if (!parsed.success) return null;
|
|
570
|
+
|
|
571
|
+
// TODO: replace with your actual user lookup
|
|
572
|
+
// const user = await db.user.findUnique({ where: { email: parsed.data.email } });
|
|
573
|
+
// const valid = await bcrypt.compare(parsed.data.password, user.passwordHash);
|
|
574
|
+
// if (!valid) return null;
|
|
575
|
+
// return user;
|
|
576
|
+
|
|
577
|
+
return null;
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
`;
|
|
581
|
+
case "email":
|
|
582
|
+
if (options.magicLink) {
|
|
583
|
+
return header + `import EmailProvider from 'next-auth/providers/email';
|
|
584
|
+
|
|
585
|
+
// Magic Link provider \u2014 sends passwordless sign-in emails
|
|
586
|
+
export const emailProvider = EmailProvider({
|
|
587
|
+
server: {
|
|
588
|
+
host: process.env.EMAIL_SERVER_HOST!,
|
|
589
|
+
port: Number(process.env.EMAIL_SERVER_PORT ?? 587),
|
|
590
|
+
auth: {
|
|
591
|
+
user: process.env.EMAIL_SERVER_USER!,
|
|
592
|
+
pass: process.env.EMAIL_SERVER_PASSWORD!,
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
from: process.env.EMAIL_FROM!,
|
|
596
|
+
// Custom email template:
|
|
597
|
+
async sendVerificationRequest({ identifier, url, provider }) {
|
|
598
|
+
// Use nodemailer, resend, sendgrid, etc.
|
|
599
|
+
console.log(\`Send magic link to \${identifier}: \${url}\`);
|
|
600
|
+
},
|
|
601
|
+
// Token expiry (default 24h)
|
|
602
|
+
maxAge: 24 * 60 * 60,
|
|
603
|
+
});
|
|
604
|
+
`;
|
|
605
|
+
}
|
|
606
|
+
return header + `import EmailProvider from 'next-auth/providers/email';
|
|
607
|
+
|
|
608
|
+
export const emailProvider = EmailProvider({
|
|
609
|
+
server: process.env.EMAIL_SERVER!,
|
|
610
|
+
from: process.env.EMAIL_FROM!,
|
|
611
|
+
});
|
|
612
|
+
`;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function printEnvHints(provider, _options) {
|
|
616
|
+
const envMap = {
|
|
617
|
+
google: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
|
|
618
|
+
github: ["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET"],
|
|
619
|
+
discord: ["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET"],
|
|
620
|
+
apple: ["APPLE_ID", "APPLE_SECRET"],
|
|
621
|
+
twitter: ["TWITTER_CLIENT_ID", "TWITTER_CLIENT_SECRET"],
|
|
622
|
+
facebook: ["FACEBOOK_CLIENT_ID", "FACEBOOK_CLIENT_SECRET"],
|
|
623
|
+
linkedin: ["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"],
|
|
624
|
+
auth0: ["AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_ISSUER"],
|
|
625
|
+
keycloak: ["KEYCLOAK_ID", "KEYCLOAK_SECRET", "KEYCLOAK_ISSUER"],
|
|
626
|
+
email: ["EMAIL_SERVER", "EMAIL_FROM"]
|
|
627
|
+
};
|
|
628
|
+
const envVars = envMap[provider];
|
|
629
|
+
if (envVars) {
|
|
630
|
+
console.log(chalk2.dim(`
|
|
631
|
+
Required env vars:`));
|
|
632
|
+
envVars.forEach((v) => console.log(chalk2.dim(` ${v}=`)));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function printIntegrationHint(name) {
|
|
636
|
+
console.log(chalk2.dim(`
|
|
637
|
+
Add to your auth config providers array:`));
|
|
638
|
+
console.log(chalk2.dim(` import { ${name}Provider } from './auth.providers.${name}'`));
|
|
639
|
+
console.log(chalk2.dim(` providers: [..., ${name}Provider]
|
|
640
|
+
`));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// src/commands/add-adapter.ts
|
|
644
|
+
import chalk3 from "chalk";
|
|
645
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
646
|
+
import { join as join3 } from "path";
|
|
647
|
+
var ADAPTERS = ["prisma", "drizzle", "typeorm", "mongodb", "dynamodb", "supabase", "firebase"];
|
|
648
|
+
function addAdapterCommand(type) {
|
|
649
|
+
if (!ADAPTERS.includes(type)) {
|
|
650
|
+
console.error(chalk3.red(`\u2717 Unknown adapter: ${chalk3.bold(type)}`));
|
|
651
|
+
console.error(chalk3.dim(` Supported: ${ADAPTERS.join(" | ")}`));
|
|
652
|
+
process.exit(1);
|
|
653
|
+
}
|
|
654
|
+
console.log(chalk3.cyan(`
|
|
655
|
+
\u25C6 Generating adapter config: ${chalk3.bold(type)}
|
|
656
|
+
`));
|
|
657
|
+
const adapter = type;
|
|
658
|
+
const content = generateAdapterConfig(adapter);
|
|
659
|
+
const outFile = join3(process.cwd(), `auth.adapter.${type}.ts`);
|
|
660
|
+
writeFileSync3(outFile, content, "utf-8");
|
|
661
|
+
console.log(chalk3.green(` \u2713 Created ${chalk3.bold(`auth.adapter.${type}.ts`)}`));
|
|
662
|
+
printAdapterInstallHint(adapter);
|
|
663
|
+
printAdapterIntegrationHint(type);
|
|
664
|
+
}
|
|
665
|
+
function generateAdapterConfig(adapter) {
|
|
666
|
+
const header = `// Auth.js adapter configuration \u2014 ${adapter}
|
|
667
|
+
// Generated by authjs-config-cli
|
|
668
|
+
|
|
669
|
+
`;
|
|
670
|
+
switch (adapter) {
|
|
671
|
+
case "prisma":
|
|
672
|
+
return header + `import { PrismaAdapter } from '@auth/prisma-adapter';
|
|
673
|
+
import { PrismaClient } from '@prisma/client';
|
|
674
|
+
|
|
675
|
+
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
|
676
|
+
|
|
677
|
+
export const prisma =
|
|
678
|
+
globalForPrisma.prisma ||
|
|
679
|
+
new PrismaClient({
|
|
680
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
|
684
|
+
|
|
685
|
+
export const adapter = PrismaAdapter(prisma);
|
|
686
|
+
|
|
687
|
+
/*
|
|
688
|
+
Required Prisma schema models \u2014 add to your schema.prisma:
|
|
689
|
+
|
|
690
|
+
model Account {
|
|
691
|
+
id String @id @default(cuid())
|
|
692
|
+
userId String
|
|
693
|
+
type String
|
|
694
|
+
provider String
|
|
695
|
+
providerAccountId String
|
|
696
|
+
refresh_token String? @db.Text
|
|
697
|
+
access_token String? @db.Text
|
|
698
|
+
expires_at Int?
|
|
699
|
+
token_type String?
|
|
700
|
+
scope String?
|
|
701
|
+
id_token String? @db.Text
|
|
702
|
+
session_state String?
|
|
703
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
704
|
+
@@unique([provider, providerAccountId])
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
model Session {
|
|
708
|
+
id String @id @default(cuid())
|
|
709
|
+
sessionToken String @unique
|
|
710
|
+
userId String
|
|
711
|
+
expires DateTime
|
|
712
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
model User {
|
|
716
|
+
id String @id @default(cuid())
|
|
717
|
+
name String?
|
|
718
|
+
email String? @unique
|
|
719
|
+
emailVerified DateTime?
|
|
720
|
+
image String?
|
|
721
|
+
role String @default("user")
|
|
722
|
+
accounts Account[]
|
|
723
|
+
sessions Session[]
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
model VerificationToken {
|
|
727
|
+
identifier String
|
|
728
|
+
token String @unique
|
|
729
|
+
expires DateTime
|
|
730
|
+
@@unique([identifier, token])
|
|
731
|
+
}
|
|
732
|
+
*/
|
|
733
|
+
`;
|
|
734
|
+
case "drizzle":
|
|
735
|
+
return header + `import { DrizzleAdapter } from '@auth/drizzle-adapter';
|
|
736
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
737
|
+
import postgres from 'postgres';
|
|
738
|
+
import * as schema from './db/schema.js';
|
|
739
|
+
|
|
740
|
+
const client = postgres(process.env.DATABASE_URL!);
|
|
741
|
+
export const db = drizzle(client, { schema });
|
|
742
|
+
export const adapter = DrizzleAdapter(db);
|
|
743
|
+
|
|
744
|
+
/*
|
|
745
|
+
Required Drizzle schema (db/schema.ts):
|
|
746
|
+
|
|
747
|
+
import { pgTable, text, timestamp, primaryKey, integer } from 'drizzle-orm/pg-core';
|
|
748
|
+
|
|
749
|
+
export const users = pgTable('user', {
|
|
750
|
+
id: text('id').notNull().primaryKey(),
|
|
751
|
+
name: text('name'),
|
|
752
|
+
email: text('email').notNull(),
|
|
753
|
+
emailVerified: timestamp('emailVerified', { mode: 'date' }),
|
|
754
|
+
image: text('image'),
|
|
755
|
+
role: text('role').notNull().default('user'),
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
export const accounts = pgTable('account', {
|
|
759
|
+
userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
760
|
+
type: text('type').notNull(),
|
|
761
|
+
provider: text('provider').notNull(),
|
|
762
|
+
providerAccountId: text('providerAccountId').notNull(),
|
|
763
|
+
refresh_token: text('refresh_token'),
|
|
764
|
+
access_token: text('access_token'),
|
|
765
|
+
expires_at: integer('expires_at'),
|
|
766
|
+
token_type: text('token_type'),
|
|
767
|
+
scope: text('scope'),
|
|
768
|
+
id_token: text('id_token'),
|
|
769
|
+
session_state: text('session_state'),
|
|
770
|
+
}, (account) => ({ compoundKey: primaryKey({ columns: [account.provider, account.providerAccountId] }) }));
|
|
771
|
+
|
|
772
|
+
export const sessions = pgTable('session', {
|
|
773
|
+
sessionToken: text('sessionToken').notNull().primaryKey(),
|
|
774
|
+
userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
775
|
+
expires: timestamp('expires', { mode: 'date' }).notNull(),
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
export const verificationTokens = pgTable('verificationToken', {
|
|
779
|
+
identifier: text('identifier').notNull(),
|
|
780
|
+
token: text('token').notNull(),
|
|
781
|
+
expires: timestamp('expires', { mode: 'date' }).notNull(),
|
|
782
|
+
}, (vt) => ({ compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }) }));
|
|
783
|
+
*/
|
|
784
|
+
`;
|
|
785
|
+
case "typeorm":
|
|
786
|
+
return header + `import { TypeORMAdapter } from '@auth/typeorm-adapter';
|
|
787
|
+
|
|
788
|
+
export const adapter = TypeORMAdapter(
|
|
789
|
+
process.env.DATABASE_URL ?? {
|
|
790
|
+
type: 'postgres',
|
|
791
|
+
host: process.env.DB_HOST ?? 'localhost',
|
|
792
|
+
port: Number(process.env.DB_PORT ?? 5432),
|
|
793
|
+
username: process.env.DB_USER,
|
|
794
|
+
password: process.env.DB_PASS,
|
|
795
|
+
database: process.env.DB_NAME,
|
|
796
|
+
synchronize: process.env.NODE_ENV !== 'production',
|
|
797
|
+
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
|
798
|
+
}
|
|
799
|
+
);
|
|
800
|
+
`;
|
|
801
|
+
case "mongodb":
|
|
802
|
+
return header + `import { MongoDBAdapter } from '@auth/mongodb-adapter';
|
|
803
|
+
import { MongoClient } from 'mongodb';
|
|
804
|
+
|
|
805
|
+
if (!process.env.MONGODB_URI) throw new Error('MONGODB_URI is not set');
|
|
806
|
+
|
|
807
|
+
const client = new MongoClient(process.env.MONGODB_URI);
|
|
808
|
+
const clientPromise = client.connect();
|
|
809
|
+
|
|
810
|
+
export const adapter = MongoDBAdapter(clientPromise, {
|
|
811
|
+
databaseName: process.env.MONGODB_DB ?? 'auth',
|
|
812
|
+
});
|
|
813
|
+
`;
|
|
814
|
+
case "dynamodb":
|
|
815
|
+
return header + `import { DynamoDBAdapter } from '@auth/dynamodb-adapter';
|
|
816
|
+
import { DynamoDB, DynamoDBClientConfig } from '@aws-sdk/client-dynamodb';
|
|
817
|
+
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
|
|
818
|
+
|
|
819
|
+
const config: DynamoDBClientConfig = {
|
|
820
|
+
credentials: {
|
|
821
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
822
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
823
|
+
},
|
|
824
|
+
region: process.env.AWS_REGION ?? 'us-east-1',
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const client = DynamoDBDocument.from(new DynamoDB(config), {
|
|
828
|
+
marshallOptions: { convertEmptyValues: true, removeUndefinedValues: true },
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
export const adapter = DynamoDBAdapter(client, {
|
|
832
|
+
tableName: process.env.DYNAMODB_TABLE ?? 'next-auth',
|
|
833
|
+
});
|
|
834
|
+
`;
|
|
835
|
+
case "supabase":
|
|
836
|
+
return header + `import { SupabaseAdapter } from '@auth/supabase-adapter';
|
|
837
|
+
|
|
838
|
+
export const adapter = SupabaseAdapter({
|
|
839
|
+
url: process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
840
|
+
secret: process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
/*
|
|
844
|
+
Run the following SQL in your Supabase SQL editor to create the required tables:
|
|
845
|
+
https://authjs.dev/getting-started/adapters/supabase
|
|
846
|
+
*/
|
|
847
|
+
`;
|
|
848
|
+
case "firebase":
|
|
849
|
+
return header + `import { FirestoreAdapter } from '@auth/firebase-adapter';
|
|
850
|
+
import { initializeApp, getApps, cert } from 'firebase-admin/app';
|
|
851
|
+
import { getFirestore } from 'firebase-admin/firestore';
|
|
852
|
+
|
|
853
|
+
if (!getApps().length) {
|
|
854
|
+
initializeApp({
|
|
855
|
+
credential: cert({
|
|
856
|
+
projectId: process.env.FIREBASE_PROJECT_ID,
|
|
857
|
+
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
|
858
|
+
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\\\n/g, '\\n'),
|
|
859
|
+
}),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export const firestore = getFirestore();
|
|
864
|
+
export const adapter = FirestoreAdapter(firestore);
|
|
865
|
+
`;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function printAdapterInstallHint(adapter) {
|
|
869
|
+
const installMap = {
|
|
870
|
+
prisma: "npm install @auth/prisma-adapter @prisma/client",
|
|
871
|
+
drizzle: "npm install @auth/drizzle-adapter drizzle-orm postgres",
|
|
872
|
+
typeorm: "npm install @auth/typeorm-adapter typeorm",
|
|
873
|
+
mongodb: "npm install @auth/mongodb-adapter mongodb",
|
|
874
|
+
dynamodb: "npm install @auth/dynamodb-adapter @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb",
|
|
875
|
+
supabase: "npm install @auth/supabase-adapter",
|
|
876
|
+
firebase: "npm install @auth/firebase-adapter firebase-admin"
|
|
877
|
+
};
|
|
878
|
+
console.log(chalk3.dim(`
|
|
879
|
+
Install dependency:`));
|
|
880
|
+
console.log(chalk3.dim(` ${installMap[adapter]}`));
|
|
881
|
+
}
|
|
882
|
+
function printAdapterIntegrationHint(type) {
|
|
883
|
+
console.log(chalk3.dim(`
|
|
884
|
+
Add to your auth config:`));
|
|
885
|
+
console.log(chalk3.dim(` import { adapter } from './auth.adapter.${type}'`));
|
|
886
|
+
console.log(chalk3.dim(` export const { handlers, auth, signIn, signOut } = NextAuth({ adapter, ... })
|
|
887
|
+
`));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// src/commands/add-callback.ts
|
|
891
|
+
import chalk4 from "chalk";
|
|
892
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
893
|
+
import { join as join4 } from "path";
|
|
894
|
+
var CALLBACKS = ["signIn", "redirect", "session", "jwt"];
|
|
895
|
+
function addCallbackCommand(type, options) {
|
|
896
|
+
if (!CALLBACKS.includes(type)) {
|
|
897
|
+
console.error(chalk4.red(`\u2717 Unknown callback: ${chalk4.bold(type)}`));
|
|
898
|
+
console.error(chalk4.dim(` Supported: ${CALLBACKS.join(" | ")}`));
|
|
899
|
+
process.exit(1);
|
|
900
|
+
}
|
|
901
|
+
console.log(chalk4.cyan(`
|
|
902
|
+
\u25C6 Generating callback: ${chalk4.bold(type)}
|
|
903
|
+
`));
|
|
904
|
+
const cb = type;
|
|
905
|
+
const content = generateCallback(cb, options);
|
|
906
|
+
const outFile = join4(process.cwd(), `auth.callback.${type}.ts`);
|
|
907
|
+
writeFileSync4(outFile, content, "utf-8");
|
|
908
|
+
console.log(chalk4.green(` \u2713 Created ${chalk4.bold(`auth.callback.${type}.ts`)}`));
|
|
909
|
+
console.log(chalk4.dim(`
|
|
910
|
+
Add to your auth config callbacks object:`));
|
|
911
|
+
console.log(chalk4.dim(` import { ${type}Callback } from './auth.callback.${type}'`));
|
|
912
|
+
console.log(chalk4.dim(` callbacks: { ${type}: ${type}Callback }
|
|
913
|
+
`));
|
|
914
|
+
}
|
|
915
|
+
function generateCallback(type, options) {
|
|
916
|
+
const header = `// Auth.js ${type} callback
|
|
917
|
+
// Generated by authjs-config-cli
|
|
918
|
+
|
|
919
|
+
import type { CallbacksOptions } from 'next-auth';
|
|
920
|
+
|
|
921
|
+
`;
|
|
922
|
+
switch (type) {
|
|
923
|
+
case "signIn":
|
|
924
|
+
return header + generateSignInCallback(options);
|
|
925
|
+
case "redirect":
|
|
926
|
+
return header + generateRedirectCallback();
|
|
927
|
+
case "session":
|
|
928
|
+
return header + generateSessionCallback(options);
|
|
929
|
+
case "jwt":
|
|
930
|
+
return header + generateJwtCallback(options);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function generateSignInCallback(options) {
|
|
934
|
+
let body = `export const signInCallback: CallbacksOptions['signIn'] = async ({
|
|
935
|
+
user,
|
|
936
|
+
account,
|
|
937
|
+
profile,
|
|
938
|
+
email,
|
|
939
|
+
credentials,
|
|
940
|
+
}) => {
|
|
941
|
+
`;
|
|
942
|
+
if (options.accountLinking) {
|
|
943
|
+
body += ` // Account linking: allow sign-in if user already exists with this email
|
|
944
|
+
if (account?.provider !== 'credentials') {
|
|
945
|
+
// TODO: check if user.email already linked to another provider
|
|
946
|
+
// const existingUser = await db.user.findUnique({ where: { email: user.email! } });
|
|
947
|
+
// if (existingUser && existingUser.provider !== account?.provider) {
|
|
948
|
+
// // Link accounts \u2014 handle based on your policy
|
|
949
|
+
// return '/auth/error?error=AccountExists';
|
|
950
|
+
// }
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
`;
|
|
954
|
+
}
|
|
955
|
+
body += ` // Allow sign-in by default
|
|
956
|
+
return true;
|
|
957
|
+
|
|
958
|
+
// To block: return false or a redirect URL string
|
|
959
|
+
// return '/auth/error?error=AccessDenied';
|
|
960
|
+
};
|
|
961
|
+
`;
|
|
962
|
+
return body;
|
|
963
|
+
}
|
|
964
|
+
function generateRedirectCallback() {
|
|
965
|
+
return `export const redirectCallback: CallbacksOptions['redirect'] = async ({ url, baseUrl }) => {
|
|
966
|
+
// Allow relative URLs
|
|
967
|
+
if (url.startsWith('/')) return \`\${baseUrl}\${url}\`;
|
|
968
|
+
|
|
969
|
+
// Allow same-origin URLs
|
|
970
|
+
if (new URL(url).origin === baseUrl) return url;
|
|
971
|
+
|
|
972
|
+
// Default: return to base
|
|
973
|
+
return baseUrl;
|
|
974
|
+
};
|
|
975
|
+
`;
|
|
976
|
+
}
|
|
977
|
+
function generateSessionCallback(options) {
|
|
978
|
+
let body = `export const sessionCallback: CallbacksOptions['session'] = async ({ session, token, user }) => {
|
|
979
|
+
`;
|
|
980
|
+
if (options.customProfile) {
|
|
981
|
+
body += ` // Merge custom profile fields into session
|
|
982
|
+
if (token) {
|
|
983
|
+
session.user.id = token.sub!;
|
|
984
|
+
// session.user.role = token.role as string;
|
|
985
|
+
// session.user.plan = token.plan as string;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// When using database sessions (user is available instead of token):
|
|
989
|
+
// if (user) {
|
|
990
|
+
// session.user.id = user.id;
|
|
991
|
+
// session.user.role = (user as any).role;
|
|
992
|
+
// }
|
|
993
|
+
|
|
994
|
+
`;
|
|
995
|
+
} else {
|
|
996
|
+
body += ` // Pass token.sub (user id) into session
|
|
997
|
+
if (token?.sub) session.user.id = token.sub;
|
|
998
|
+
|
|
999
|
+
`;
|
|
1000
|
+
}
|
|
1001
|
+
body += ` return session;
|
|
1002
|
+
};
|
|
1003
|
+
`;
|
|
1004
|
+
return body;
|
|
1005
|
+
}
|
|
1006
|
+
function generateJwtCallback(options) {
|
|
1007
|
+
let body = `export const jwtCallback: CallbacksOptions['jwt'] = async ({ token, user, account, profile, trigger, session }) => {
|
|
1008
|
+
`;
|
|
1009
|
+
if (options.customProfile) {
|
|
1010
|
+
body += ` // On first sign-in, persist custom fields
|
|
1011
|
+
if (user) {
|
|
1012
|
+
token.id = user.id;
|
|
1013
|
+
// token.role = (user as any).role;
|
|
1014
|
+
// token.plan = (user as any).plan;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
`;
|
|
1018
|
+
} else {
|
|
1019
|
+
body += ` if (user) token.id = user.id;
|
|
1020
|
+
|
|
1021
|
+
`;
|
|
1022
|
+
}
|
|
1023
|
+
body += ` // Refresh token rotation
|
|
1024
|
+
if (account?.access_token) {
|
|
1025
|
+
token.accessToken = account.access_token;
|
|
1026
|
+
token.refreshToken = account.refresh_token;
|
|
1027
|
+
token.expiresAt = account.expires_at;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Check token expiry and refresh
|
|
1031
|
+
if (token.expiresAt && Date.now() < (token.expiresAt as number) * 1000) {
|
|
1032
|
+
return token;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Token expired \u2014 attempt refresh
|
|
1036
|
+
// try {
|
|
1037
|
+
// const refreshed = await refreshAccessToken(token.refreshToken as string);
|
|
1038
|
+
// return { ...token, ...refreshed };
|
|
1039
|
+
// } catch {
|
|
1040
|
+
// return { ...token, error: 'RefreshAccessTokenError' };
|
|
1041
|
+
// }
|
|
1042
|
+
|
|
1043
|
+
// Handle session update trigger
|
|
1044
|
+
if (trigger === 'update' && session) {
|
|
1045
|
+
token = { ...token, ...session };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return token;
|
|
1049
|
+
};
|
|
1050
|
+
`;
|
|
1051
|
+
return body;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/commands/add-middleware.ts
|
|
1055
|
+
import chalk5 from "chalk";
|
|
1056
|
+
import { writeFileSync as writeFileSync5 } from "fs";
|
|
1057
|
+
import { join as join5 } from "path";
|
|
1058
|
+
function addMiddlewareCommand(options) {
|
|
1059
|
+
console.log(chalk5.cyan(`
|
|
1060
|
+
\u25C6 Generating auth middleware
|
|
1061
|
+
`));
|
|
1062
|
+
const publicRoutes = options.publicRoutes.split(",").map((r) => r.trim()).filter(Boolean);
|
|
1063
|
+
const content = generateMiddleware(options.rbac, publicRoutes);
|
|
1064
|
+
const outFile = join5(process.cwd(), "middleware.ts");
|
|
1065
|
+
writeFileSync5(outFile, content, "utf-8");
|
|
1066
|
+
console.log(chalk5.green(` \u2713 Created ${chalk5.bold("middleware.ts")}`));
|
|
1067
|
+
console.log(chalk5.dim(`
|
|
1068
|
+
Public routes configured: ${publicRoutes.join(", ")}`));
|
|
1069
|
+
console.log(chalk5.dim(` Protected: all other routes require authentication`));
|
|
1070
|
+
if (options.rbac) {
|
|
1071
|
+
console.log(chalk5.dim(` RBAC: role-based route guards included`));
|
|
1072
|
+
}
|
|
1073
|
+
console.log(chalk5.dim(`
|
|
1074
|
+
Ensure AUTH_SECRET is set in your environment.
|
|
1075
|
+
`));
|
|
1076
|
+
}
|
|
1077
|
+
function generateMiddleware(rbac, publicRoutes) {
|
|
1078
|
+
const publicPatternsStr = publicRoutes.map((r) => ` '${r}',`).join("\n");
|
|
1079
|
+
if (rbac) {
|
|
1080
|
+
return `// Auth.js middleware with RBAC route protection
|
|
1081
|
+
// Generated by authjs-config-cli
|
|
1082
|
+
|
|
1083
|
+
import { auth } from './auth';
|
|
1084
|
+
import { NextResponse } from 'next/server';
|
|
1085
|
+
import type { NextRequest } from 'next/server';
|
|
1086
|
+
|
|
1087
|
+
export type Role = 'admin' | 'moderator' | 'user' | 'guest';
|
|
1088
|
+
|
|
1089
|
+
// Routes accessible without authentication
|
|
1090
|
+
const publicRoutes: string[] = [
|
|
1091
|
+
${publicPatternsStr}
|
|
1092
|
+
'/auth/signin',
|
|
1093
|
+
'/auth/error',
|
|
1094
|
+
'/api/auth',
|
|
1095
|
+
];
|
|
1096
|
+
|
|
1097
|
+
// Role-based route restrictions
|
|
1098
|
+
const roleRoutes: Record<string, Role> = {
|
|
1099
|
+
'/admin': 'admin',
|
|
1100
|
+
'/dashboard/settings': 'admin',
|
|
1101
|
+
'/moderator': 'moderator',
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
function isPublic(pathname: string): boolean {
|
|
1105
|
+
return publicRoutes.some(
|
|
1106
|
+
(route) => pathname === route || pathname.startsWith(\`\${route}/\`)
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function requiredRole(pathname: string): Role | null {
|
|
1111
|
+
for (const [prefix, role] of Object.entries(roleRoutes)) {
|
|
1112
|
+
if (pathname.startsWith(prefix)) return role;
|
|
1113
|
+
}
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const ROLE_HIERARCHY: Record<Role, number> = {
|
|
1118
|
+
admin: 4,
|
|
1119
|
+
moderator: 3,
|
|
1120
|
+
user: 2,
|
|
1121
|
+
guest: 1,
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
function hasRole(userRole: string | undefined, required: Role): boolean {
|
|
1125
|
+
const r = (userRole ?? 'guest') as Role;
|
|
1126
|
+
return (ROLE_HIERARCHY[r] ?? 1) >= ROLE_HIERARCHY[required];
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
export default auth((req: NextRequest & { auth: any }) => {
|
|
1130
|
+
const { pathname } = req.nextUrl;
|
|
1131
|
+
|
|
1132
|
+
if (isPublic(pathname)) return NextResponse.next();
|
|
1133
|
+
|
|
1134
|
+
if (!req.auth) {
|
|
1135
|
+
const signInUrl = new URL('/auth/signin', req.url);
|
|
1136
|
+
signInUrl.searchParams.set('callbackUrl', req.url);
|
|
1137
|
+
return NextResponse.redirect(signInUrl);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const required = requiredRole(pathname);
|
|
1141
|
+
if (required) {
|
|
1142
|
+
const userRole = req.auth.user?.role as string | undefined;
|
|
1143
|
+
if (!hasRole(userRole, required)) {
|
|
1144
|
+
return NextResponse.redirect(new URL('/auth/error?error=AccessDenied', req.url));
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return NextResponse.next();
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
export const config = {
|
|
1152
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
|
1153
|
+
};
|
|
1154
|
+
`;
|
|
1155
|
+
}
|
|
1156
|
+
return `// Auth.js middleware for route protection
|
|
1157
|
+
// Generated by authjs-config-cli
|
|
1158
|
+
|
|
1159
|
+
import { auth } from './auth';
|
|
1160
|
+
import { NextResponse } from 'next/server';
|
|
1161
|
+
import type { NextRequest } from 'next/server';
|
|
1162
|
+
|
|
1163
|
+
// Routes accessible without authentication
|
|
1164
|
+
const publicRoutes: string[] = [
|
|
1165
|
+
${publicPatternsStr}
|
|
1166
|
+
'/auth/signin',
|
|
1167
|
+
'/auth/error',
|
|
1168
|
+
'/api/auth',
|
|
1169
|
+
];
|
|
1170
|
+
|
|
1171
|
+
function isPublic(pathname: string): boolean {
|
|
1172
|
+
return publicRoutes.some(
|
|
1173
|
+
(route) => pathname === route || pathname.startsWith(\`\${route}/\`)
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
export default auth((req: NextRequest & { auth: any }) => {
|
|
1178
|
+
const { pathname } = req.nextUrl;
|
|
1179
|
+
|
|
1180
|
+
if (isPublic(pathname)) return NextResponse.next();
|
|
1181
|
+
|
|
1182
|
+
if (!req.auth) {
|
|
1183
|
+
const signInUrl = new URL('/auth/signin', req.url);
|
|
1184
|
+
signInUrl.searchParams.set('callbackUrl', req.url);
|
|
1185
|
+
return NextResponse.redirect(signInUrl);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
return NextResponse.next();
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
export const config = {
|
|
1192
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
|
1193
|
+
};
|
|
1194
|
+
`;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// src/commands/add-pages.ts
|
|
1198
|
+
import chalk6 from "chalk";
|
|
1199
|
+
import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync2 } from "fs";
|
|
1200
|
+
import { join as join6 } from "path";
|
|
1201
|
+
var ALL_PAGES = ["signIn", "signOut", "error", "verifyRequest", "newUser"];
|
|
1202
|
+
function addPagesCommand(options) {
|
|
1203
|
+
const selected = options.all ? [...ALL_PAGES] : options.pages.split(",").map((p) => p.trim()).filter((p) => ALL_PAGES.includes(p));
|
|
1204
|
+
if (selected.length === 0) {
|
|
1205
|
+
console.error(chalk6.red(`\u2717 No pages specified`));
|
|
1206
|
+
console.error(chalk6.dim(` Use --all or --pages signIn,error`));
|
|
1207
|
+
console.error(chalk6.dim(` Available: ${ALL_PAGES.join(" | ")}`));
|
|
1208
|
+
process.exit(1);
|
|
1209
|
+
}
|
|
1210
|
+
console.log(chalk6.cyan(`
|
|
1211
|
+
\u25C6 Generating custom auth pages
|
|
1212
|
+
`));
|
|
1213
|
+
const pagesDir = join6(process.cwd(), "app", "auth");
|
|
1214
|
+
mkdirSync2(pagesDir, { recursive: true });
|
|
1215
|
+
for (const page of selected) {
|
|
1216
|
+
const subDir = join6(pagesDir, pageToRoute(page));
|
|
1217
|
+
mkdirSync2(subDir, { recursive: true });
|
|
1218
|
+
const content = generatePage(page);
|
|
1219
|
+
const outFile = join6(subDir, "page.tsx");
|
|
1220
|
+
writeFileSync6(outFile, content, "utf-8");
|
|
1221
|
+
console.log(chalk6.green(` \u2713 Created ${chalk6.bold(`app/auth/${pageToRoute(page)}/page.tsx`)}`));
|
|
1222
|
+
}
|
|
1223
|
+
console.log(chalk6.dim(`
|
|
1224
|
+
Register pages in your auth config:`));
|
|
1225
|
+
console.log(chalk6.dim(` pages: {`));
|
|
1226
|
+
selected.forEach((p) => {
|
|
1227
|
+
console.log(chalk6.dim(` ${p}: '/auth/${pageToRoute(p)}',`));
|
|
1228
|
+
});
|
|
1229
|
+
console.log(chalk6.dim(` }
|
|
1230
|
+
`));
|
|
1231
|
+
}
|
|
1232
|
+
function pageToRoute(page) {
|
|
1233
|
+
const map = {
|
|
1234
|
+
signIn: "signin",
|
|
1235
|
+
signOut: "signout",
|
|
1236
|
+
error: "error",
|
|
1237
|
+
verifyRequest: "verify-request",
|
|
1238
|
+
newUser: "new-user"
|
|
1239
|
+
};
|
|
1240
|
+
return map[page];
|
|
1241
|
+
}
|
|
1242
|
+
function generatePage(page) {
|
|
1243
|
+
switch (page) {
|
|
1244
|
+
case "signIn":
|
|
1245
|
+
return `'use client';
|
|
1246
|
+
|
|
1247
|
+
import { signIn } from 'next-auth/react';
|
|
1248
|
+
import { useState } from 'react';
|
|
1249
|
+
|
|
1250
|
+
export default function SignInPage() {
|
|
1251
|
+
const [email, setEmail] = useState('');
|
|
1252
|
+
const [password, setPassword] = useState('');
|
|
1253
|
+
const [loading, setLoading] = useState(false);
|
|
1254
|
+
const [error, setError] = useState('');
|
|
1255
|
+
|
|
1256
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
1257
|
+
e.preventDefault();
|
|
1258
|
+
setLoading(true);
|
|
1259
|
+
setError('');
|
|
1260
|
+
|
|
1261
|
+
const result = await signIn('credentials', {
|
|
1262
|
+
email,
|
|
1263
|
+
password,
|
|
1264
|
+
redirect: false,
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
if (result?.error) {
|
|
1268
|
+
setError('Invalid email or password');
|
|
1269
|
+
} else {
|
|
1270
|
+
window.location.href = '/dashboard';
|
|
1271
|
+
}
|
|
1272
|
+
setLoading(false);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
return (
|
|
1276
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
1277
|
+
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow">
|
|
1278
|
+
<h1 className="text-2xl font-bold mb-6">Sign In</h1>
|
|
1279
|
+
|
|
1280
|
+
{error && (
|
|
1281
|
+
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
|
|
1282
|
+
)}
|
|
1283
|
+
|
|
1284
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
1285
|
+
<div>
|
|
1286
|
+
<label htmlFor="email" className="block text-sm font-medium">Email</label>
|
|
1287
|
+
<input
|
|
1288
|
+
id="email"
|
|
1289
|
+
type="email"
|
|
1290
|
+
value={email}
|
|
1291
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
1292
|
+
required
|
|
1293
|
+
className="mt-1 block w-full border rounded px-3 py-2"
|
|
1294
|
+
/>
|
|
1295
|
+
</div>
|
|
1296
|
+
<div>
|
|
1297
|
+
<label htmlFor="password" className="block text-sm font-medium">Password</label>
|
|
1298
|
+
<input
|
|
1299
|
+
id="password"
|
|
1300
|
+
type="password"
|
|
1301
|
+
value={password}
|
|
1302
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
1303
|
+
required
|
|
1304
|
+
className="mt-1 block w-full border rounded px-3 py-2"
|
|
1305
|
+
/>
|
|
1306
|
+
</div>
|
|
1307
|
+
<button
|
|
1308
|
+
type="submit"
|
|
1309
|
+
disabled={loading}
|
|
1310
|
+
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
|
1311
|
+
>
|
|
1312
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
1313
|
+
</button>
|
|
1314
|
+
</form>
|
|
1315
|
+
|
|
1316
|
+
<div className="mt-6 space-y-2">
|
|
1317
|
+
<p className="text-sm text-gray-500 text-center">Or continue with</p>
|
|
1318
|
+
<button
|
|
1319
|
+
onClick={() => signIn('github', { callbackUrl: '/dashboard' })}
|
|
1320
|
+
className="w-full border py-2 rounded hover:bg-gray-50"
|
|
1321
|
+
>
|
|
1322
|
+
GitHub
|
|
1323
|
+
</button>
|
|
1324
|
+
<button
|
|
1325
|
+
onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
|
|
1326
|
+
className="w-full border py-2 rounded hover:bg-gray-50"
|
|
1327
|
+
>
|
|
1328
|
+
Google
|
|
1329
|
+
</button>
|
|
1330
|
+
</div>
|
|
1331
|
+
</div>
|
|
1332
|
+
</div>
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
`;
|
|
1336
|
+
case "signOut":
|
|
1337
|
+
return `'use client';
|
|
1338
|
+
|
|
1339
|
+
import { signOut } from 'next-auth/react';
|
|
1340
|
+
|
|
1341
|
+
export default function SignOutPage() {
|
|
1342
|
+
return (
|
|
1343
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
1344
|
+
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow text-center">
|
|
1345
|
+
<h1 className="text-2xl font-bold mb-4">Sign Out</h1>
|
|
1346
|
+
<p className="text-gray-600 mb-6">Are you sure you want to sign out?</p>
|
|
1347
|
+
<div className="flex gap-3 justify-center">
|
|
1348
|
+
<button
|
|
1349
|
+
onClick={() => signOut({ callbackUrl: '/' })}
|
|
1350
|
+
className="bg-red-600 text-white px-6 py-2 rounded hover:bg-red-700"
|
|
1351
|
+
>
|
|
1352
|
+
Sign Out
|
|
1353
|
+
</button>
|
|
1354
|
+
<button
|
|
1355
|
+
onClick={() => window.history.back()}
|
|
1356
|
+
className="border px-6 py-2 rounded hover:bg-gray-50"
|
|
1357
|
+
>
|
|
1358
|
+
Cancel
|
|
1359
|
+
</button>
|
|
1360
|
+
</div>
|
|
1361
|
+
</div>
|
|
1362
|
+
</div>
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
`;
|
|
1366
|
+
case "error":
|
|
1367
|
+
return `'use client';
|
|
1368
|
+
|
|
1369
|
+
import { useSearchParams } from 'next/navigation';
|
|
1370
|
+
import Link from 'next/link';
|
|
1371
|
+
|
|
1372
|
+
const ERROR_MESSAGES: Record<string, string> = {
|
|
1373
|
+
Configuration: 'There is a problem with the server configuration.',
|
|
1374
|
+
AccessDenied: 'You do not have permission to access this resource.',
|
|
1375
|
+
Verification: 'The verification link may have expired or been used already.',
|
|
1376
|
+
OAuthCallback: 'An error occurred during authentication. Please try again.',
|
|
1377
|
+
OAuthCreateAccount: 'Could not create your account. Please try again.',
|
|
1378
|
+
EmailCreateAccount: 'Could not create your account. Please try again.',
|
|
1379
|
+
Callback: 'An error occurred in the authentication callback.',
|
|
1380
|
+
OAuthAccountNotLinked: 'This email is already associated with another account.',
|
|
1381
|
+
EmailSignin: 'The email could not be sent. Please try again.',
|
|
1382
|
+
CredentialsSignin: 'Invalid credentials. Please check your email and password.',
|
|
1383
|
+
SessionRequired: 'Please sign in to access this page.',
|
|
1384
|
+
Default: 'An unexpected error occurred. Please try again.',
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
export default function ErrorPage() {
|
|
1388
|
+
const params = useSearchParams();
|
|
1389
|
+
const error = params.get('error') ?? 'Default';
|
|
1390
|
+
const message = ERROR_MESSAGES[error] ?? ERROR_MESSAGES.Default;
|
|
1391
|
+
|
|
1392
|
+
return (
|
|
1393
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
1394
|
+
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow text-center">
|
|
1395
|
+
<div className="text-red-500 text-5xl mb-4">\u26A0</div>
|
|
1396
|
+
<h1 className="text-2xl font-bold mb-2">Authentication Error</h1>
|
|
1397
|
+
<p className="text-gray-600 mb-6">{message}</p>
|
|
1398
|
+
<Link
|
|
1399
|
+
href="/auth/signin"
|
|
1400
|
+
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 inline-block"
|
|
1401
|
+
>
|
|
1402
|
+
Try Again
|
|
1403
|
+
</Link>
|
|
1404
|
+
</div>
|
|
1405
|
+
</div>
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
`;
|
|
1409
|
+
case "verifyRequest":
|
|
1410
|
+
return `export default function VerifyRequestPage() {
|
|
1411
|
+
return (
|
|
1412
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
1413
|
+
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow text-center">
|
|
1414
|
+
<div className="text-blue-500 text-5xl mb-4">\u2709</div>
|
|
1415
|
+
<h1 className="text-2xl font-bold mb-2">Check your email</h1>
|
|
1416
|
+
<p className="text-gray-600 mb-4">
|
|
1417
|
+
A sign-in link has been sent to your email address.
|
|
1418
|
+
</p>
|
|
1419
|
+
<p className="text-sm text-gray-500">
|
|
1420
|
+
The link expires in 24 hours. If you don't see the email,
|
|
1421
|
+
check your spam folder.
|
|
1422
|
+
</p>
|
|
1423
|
+
</div>
|
|
1424
|
+
</div>
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
`;
|
|
1428
|
+
case "newUser":
|
|
1429
|
+
return `'use client';
|
|
1430
|
+
|
|
1431
|
+
import { useSession } from 'next-auth/react';
|
|
1432
|
+
import { useRouter } from 'next/navigation';
|
|
1433
|
+
import { useState } from 'react';
|
|
1434
|
+
|
|
1435
|
+
export default function NewUserPage() {
|
|
1436
|
+
const { data: session } = useSession();
|
|
1437
|
+
const router = useRouter();
|
|
1438
|
+
const [username, setUsername] = useState('');
|
|
1439
|
+
const [loading, setLoading] = useState(false);
|
|
1440
|
+
|
|
1441
|
+
async function handleSetup(e: React.FormEvent) {
|
|
1442
|
+
e.preventDefault();
|
|
1443
|
+
setLoading(true);
|
|
1444
|
+
|
|
1445
|
+
// TODO: call your API to complete profile setup
|
|
1446
|
+
// await fetch('/api/user/setup', {
|
|
1447
|
+
// method: 'POST',
|
|
1448
|
+
// body: JSON.stringify({ username }),
|
|
1449
|
+
// });
|
|
1450
|
+
|
|
1451
|
+
router.push('/dashboard');
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return (
|
|
1455
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
1456
|
+
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow">
|
|
1457
|
+
<h1 className="text-2xl font-bold mb-2">Welcome!</h1>
|
|
1458
|
+
<p className="text-gray-600 mb-6">
|
|
1459
|
+
Hello {session?.user?.name ?? 'there'}! Let's set up your account.
|
|
1460
|
+
</p>
|
|
1461
|
+
|
|
1462
|
+
<form onSubmit={handleSetup} className="space-y-4">
|
|
1463
|
+
<div>
|
|
1464
|
+
<label htmlFor="username" className="block text-sm font-medium">
|
|
1465
|
+
Choose a username
|
|
1466
|
+
</label>
|
|
1467
|
+
<input
|
|
1468
|
+
id="username"
|
|
1469
|
+
type="text"
|
|
1470
|
+
value={username}
|
|
1471
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
1472
|
+
placeholder="your-username"
|
|
1473
|
+
required
|
|
1474
|
+
className="mt-1 block w-full border rounded px-3 py-2"
|
|
1475
|
+
/>
|
|
1476
|
+
</div>
|
|
1477
|
+
<button
|
|
1478
|
+
type="submit"
|
|
1479
|
+
disabled={loading}
|
|
1480
|
+
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
|
1481
|
+
>
|
|
1482
|
+
{loading ? 'Setting up...' : 'Complete Setup'}
|
|
1483
|
+
</button>
|
|
1484
|
+
</form>
|
|
1485
|
+
</div>
|
|
1486
|
+
</div>
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
`;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// src/commands/validate.ts
|
|
1494
|
+
import chalk7 from "chalk";
|
|
1495
|
+
import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
|
|
1496
|
+
import { join as join7, resolve } from "path";
|
|
1497
|
+
function validateCommand(file) {
|
|
1498
|
+
const target = file ? resolve(file) : findAuthFile(process.cwd());
|
|
1499
|
+
if (!target || !existsSync3(target)) {
|
|
1500
|
+
console.error(chalk7.red(`\u2717 Auth config file not found`));
|
|
1501
|
+
console.error(chalk7.dim(` Tried: ${target ?? "auth.ts, auth.js, src/auth.ts"}`));
|
|
1502
|
+
console.error(chalk7.dim(` Run: authjs-config init <framework>`));
|
|
1503
|
+
process.exit(1);
|
|
1504
|
+
}
|
|
1505
|
+
console.log(chalk7.cyan(`
|
|
1506
|
+
\u25C6 Validating: ${chalk7.bold(target)}
|
|
1507
|
+
`));
|
|
1508
|
+
const source = readFileSync2(target, "utf-8");
|
|
1509
|
+
const issues = [];
|
|
1510
|
+
if (!source.includes("AUTH_SECRET") && !source.includes("secret")) {
|
|
1511
|
+
issues.push({ level: "error", message: "Missing AUTH_SECRET \u2014 required for production security" });
|
|
1512
|
+
}
|
|
1513
|
+
if (!source.includes("trustHost") && !source.includes("NEXTAUTH_URL")) {
|
|
1514
|
+
issues.push({ level: "warn", message: "Consider setting trustHost: true or NEXTAUTH_URL for deployment" });
|
|
1515
|
+
}
|
|
1516
|
+
if (!source.includes("Provider") && !source.includes("providers")) {
|
|
1517
|
+
issues.push({ level: "warn", message: "No providers detected \u2014 add at least one provider" });
|
|
1518
|
+
}
|
|
1519
|
+
const secretPatterns = [
|
|
1520
|
+
/clientSecret:\s*['"][^'"]{8,}['"]/,
|
|
1521
|
+
/clientId:\s*['"][a-zA-Z0-9]{16,}['"]/
|
|
1522
|
+
];
|
|
1523
|
+
for (const pattern of secretPatterns) {
|
|
1524
|
+
if (pattern.test(source)) {
|
|
1525
|
+
issues.push({ level: "error", message: "Possible hardcoded secret detected \u2014 use environment variables" });
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
if (source.includes("strategy:") && !source.includes('"jwt"') && !source.includes("'jwt'") && !source.includes('"database"') && !source.includes("'database'")) {
|
|
1530
|
+
issues.push({ level: "warn", message: 'Session strategy value may be invalid \u2014 use "jwt" or "database"' });
|
|
1531
|
+
}
|
|
1532
|
+
if (source.includes("callbacks")) {
|
|
1533
|
+
issues.push({ level: "info", message: "Callbacks detected \u2014 ensure they return correct types" });
|
|
1534
|
+
}
|
|
1535
|
+
if (source.includes("adapter")) {
|
|
1536
|
+
issues.push({ level: "info", message: "Adapter detected \u2014 ensure database schema is migrated" });
|
|
1537
|
+
}
|
|
1538
|
+
if (source.includes("pages:")) {
|
|
1539
|
+
issues.push({ level: "info", message: "Custom pages configured" });
|
|
1540
|
+
}
|
|
1541
|
+
const errors = issues.filter((i) => i.level === "error");
|
|
1542
|
+
const warnings = issues.filter((i) => i.level === "warn");
|
|
1543
|
+
const infos = issues.filter((i) => i.level === "info");
|
|
1544
|
+
issues.forEach(({ level, message }) => {
|
|
1545
|
+
const icon = level === "error" ? chalk7.red(" \u2717") : level === "warn" ? chalk7.yellow(" \u26A0") : chalk7.blue(" \u2139");
|
|
1546
|
+
const text = level === "error" ? chalk7.red(message) : level === "warn" ? chalk7.yellow(message) : chalk7.dim(message);
|
|
1547
|
+
console.log(`${icon} ${text}`);
|
|
1548
|
+
});
|
|
1549
|
+
if (issues.length === 0) {
|
|
1550
|
+
console.log(chalk7.green(" \u2713 No issues found"));
|
|
1551
|
+
}
|
|
1552
|
+
console.log("");
|
|
1553
|
+
if (errors.length > 0) {
|
|
1554
|
+
console.log(chalk7.red(` ${errors.length} error(s) ${warnings.length} warning(s) ${infos.length} info`));
|
|
1555
|
+
process.exit(1);
|
|
1556
|
+
} else {
|
|
1557
|
+
console.log(chalk7.green(` ${errors.length} errors `) + chalk7.yellow(`${warnings.length} warning(s) `) + chalk7.dim(`${infos.length} info`));
|
|
1558
|
+
}
|
|
1559
|
+
console.log("");
|
|
1560
|
+
}
|
|
1561
|
+
function findAuthFile(cwd) {
|
|
1562
|
+
const candidates = [
|
|
1563
|
+
"auth.ts",
|
|
1564
|
+
"auth.js",
|
|
1565
|
+
"src/auth.ts",
|
|
1566
|
+
"src/auth.js",
|
|
1567
|
+
"lib/auth.ts",
|
|
1568
|
+
"lib/auth.js"
|
|
1569
|
+
];
|
|
1570
|
+
for (const c of candidates) {
|
|
1571
|
+
const full = join7(cwd, c);
|
|
1572
|
+
if (existsSync3(full)) return full;
|
|
1573
|
+
}
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// src/index.ts
|
|
1578
|
+
var program = new Command();
|
|
1579
|
+
program.name("authjs-config").description(chalk8.bold("Auth.js / NextAuth.js configuration generator")).version("1.0.0");
|
|
1580
|
+
program.command("init <framework>").description("Generate auth config for a framework: nextjs | sveltekit | express | solid-start | nuxt").option("--strategy <strategy>", "Session strategy: session | jwt", "jwt").option("--rbac", "Include role-based access control setup", false).option("--refresh-tokens", "Include refresh token rotation", false).action(initCommand);
|
|
1581
|
+
program.command("add-provider <name>").description("Add OAuth provider: google | github | discord | apple | twitter | facebook | linkedin | auth0 | keycloak | credentials | email").option("--magic-link", "Configure as magic link (email provider)", false).option("--2fa", "Add 2FA setup scaffold", false).action(addProviderCommand);
|
|
1582
|
+
program.command("add-adapter <type>").description("Generate adapter config: prisma | drizzle | typeorm | mongodb | dynamodb | supabase | firebase").action(addAdapterCommand);
|
|
1583
|
+
program.command("add-callback <type>").description("Generate callback handler: signIn | redirect | session | jwt").option("--account-linking", "Include account linking logic", false).option("--custom-profile", "Include custom profile mapping", false).action(addCallbackCommand);
|
|
1584
|
+
program.command("add-middleware").description("Generate auth middleware for route protection").option("--rbac", "Include role-based route guards", false).option("--public-routes <routes>", "Comma-separated public route patterns", "/").action(addMiddlewareCommand);
|
|
1585
|
+
program.command("add-pages").description("Generate custom auth pages: signIn | signOut | error | verifyRequest | newUser").option("--all", "Generate all custom pages", false).option("--pages <pages>", "Comma-separated page names to generate", "").action(addPagesCommand);
|
|
1586
|
+
program.command("validate [file]").description("Validate auth configuration file").action(validateCommand);
|
|
1587
|
+
program.parse(process.argv);
|
|
1588
|
+
if (process.argv.length < 3) {
|
|
1589
|
+
program.help();
|
|
1590
|
+
}
|