-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathauth.ts
More file actions
233 lines (216 loc) · 7.5 KB
/
Copy pathauth.ts
File metadata and controls
233 lines (216 loc) · 7.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
/**
* NextAuth Configuration for University Library Management System
*
* This file handles user authentication using NextAuth.js with:
* - Credentials-based authentication (email/password)
* - Versioned scrypt password hashing with legacy rehash-on-login
* - JWT session strategy
* - Lazy imports keep database work out of request-policy evaluation
*
* Next.js 16 Proxy runs in Node.js, while lazy imports still avoid loading database
* modules until credential authorization or JWT persistence actually needs them.
*/
import NextAuth, { User } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { authorizeProxyPath } from "@/lib/auth/proxyAuthorization";
import {
hashPassword,
needsPasswordRehash,
verifyPassword,
} from "@/lib/auth/password";
// A fixed non-secret encoding gives unknown accounts the same memory-hard work
// factor as known current-format accounts without creating database state.
const UNKNOWN_ACCOUNT_PASSWORD =
"$scrypt$ln=15,r=8,p=3$AAAAAAAAAAAAAAAAAAAAAA==$GIpZ5EyglZOu7nqCf+1C1IkTDn311beDMPgSjF+YqTRR7/X8BNJzCx29t8Op97lMn0iZnX4SabswQxf6TasYQQ==";
/**
* Lazy import pattern for database connection
*
* WHY LAZY IMPORTS?
* - This file is imported by proxy.ts for request authorization
* - By using dynamic imports, we only load the database when actually needed
* - Database operations only happen in Node.js runtime (authorize/jwt callbacks)
*
* This prevents: "The edge runtime does not support Node.js 'crypto' module" errors
*/
async function getDb() {
const { db } = await import("@/database/drizzle");
return db;
}
async function getUsersSchema() {
const { users } = await import("@/database/schema");
return users;
}
async function getEq() {
const { eq } = await import("drizzle-orm");
return eq;
}
/**
* NextAuth configuration export
* Provides: handlers (for API routes), signIn, signOut, and auth (for server components)
*/
export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt", // Use JWT tokens instead of database sessions (faster, stateless)
},
providers: [
/**
* Credentials Provider - Email/Password Authentication
*
* Flow:
* 1. User submits email/password
* 2. Look up user in database by email
* 3. Verify the versioned password encoding
* 4. Return user object if valid, null if invalid
*/
CredentialsProvider({
async authorize(credentials) {
// Validate input
if (!credentials?.email || !credentials?.password) {
return null;
}
/**
* Lazy load database only when authorize is called (Node.js runtime)
* This is safe because authorize() only runs in API routes (Node.js runtime)
* Not in middleware (Edge runtime)
*/
const db = await getDb();
const users = await getUsersSchema();
const eq = await getEq();
// Query user by email
const user = await db
.select()
.from(users)
.where(eq(users.email, credentials.email.toString()))
.limit(1);
const plainPassword = credentials.password.toString();
if (user.length === 0) {
await verifyPassword(plainPassword, UNKNOWN_ACCOUNT_PASSWORD);
return null;
}
const isPasswordValid = await verifyPassword(
plainPassword,
user[0].password,
);
if (!isPasswordValid) return null;
// Compare-and-swap prevents concurrent valid logins from overwriting a newer hash.
// Never fail sign-in if upgrade write fails (schema drift / transient DB errors).
if (needsPasswordRehash(user[0].password)) {
try {
const { and } = await import("drizzle-orm");
const upgradedPassword = await hashPassword(plainPassword);
await db
.update(users)
.set({ password: upgradedPassword, updatedAt: new Date() })
.where(
and(
eq(users.id, user[0].id),
eq(users.password, user[0].password),
),
);
} catch {
// Login still succeeds; password upgrade can retry on a later sign-in.
}
}
// Return user object for NextAuth (will be stored in JWT token)
// CRITICAL: Include role + status for client gates / pending toasts
return {
id: user[0].id.toString(),
email: user[0].email,
name: user[0].fullName,
role: user[0].role,
status: user[0].status,
} as User & { role: string; status: string };
},
}),
],
pages: {
signIn: "/sign-in",
},
callbacks: {
authorized({ auth: currentSession, request }) {
return authorizeProxyPath(
request.nextUrl.pathname,
Boolean(currentSession?.user),
);
},
/**
* JWT Callback - Called when JWT token is created or updated
*
* This runs in Node.js runtime (API routes), so database access is safe
*
* Flow:
* 1. When user signs in, 'user' object is provided
* 2. Store user.id and user.name in JWT token
* 3. Update last_login timestamp in database
* 4. Return token (will be sent to client as cookie)
*/
async jwt({ token, user }) {
// Initial sign-in: seed JWT from authorize payload
if (user) {
token.id = user.id;
token.name = user.name;
const u = user as User & { role?: string; status?: string };
token.role = u.role;
token.status = u.status;
try {
const db = await getDb();
const users = await getUsersSchema();
const eq = await getEq();
if (user.id) {
await db
.update(users)
.set({ lastLogin: new Date() })
.where(eq(users.id, user.id));
}
} catch {
// Don't fail authentication if last_login update fails
}
} else if (token.id) {
// Always refresh role/status from DB (covers ADMIN→USER demotion + signup approve).
try {
const db = await getDb();
const users = await getUsersSchema();
const eq = await getEq();
const row = await db
.select({ status: users.status, role: users.role })
.from(users)
.where(eq(users.id, token.id as string))
.limit(1);
if (row[0]) {
token.status = row[0].status;
token.role = row[0].role;
}
} catch {
// Keep existing token claims if refresh fails
}
}
return token;
},
/**
* Session Callback - Called whenever session is accessed
*
* This transforms the JWT token into the session object
* that's available in Server Components via auth()
*
* Flow:
* 1. Extract data from JWT token
* 2. Add to session.user object
* 3. Return session (available in getServerSession(), auth(), etc.)
*/
async session({ session, token }) {
if (session.user) {
// Add user ID and name from JWT token to session
session.user.id = token.id as string;
session.user.name = token.name as string;
// CRITICAL: Add role + status for authorization / PENDING client gates
const sessionUser = session.user as {
role?: string;
status?: string;
};
sessionUser.role = token.role as string;
sessionUser.status = token.status as string;
}
return session;
},
},
});