@everystack/auth 0.2.1 → 0.2.2
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/package.json +1 -1
- package/src/index.ts +205 -2
- package/src/plugin.ts +25 -8
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -87,6 +87,10 @@ export interface AuthHandlers {
|
|
|
87
87
|
oauth?: (request: Request) => Promise<Response>;
|
|
88
88
|
verifyToken: (token: string) => Promise<TokenPayload | null>;
|
|
89
89
|
revokeRefreshTokens: (userId: string) => Promise<void>;
|
|
90
|
+
confirmEmail?: (request: Request) => Promise<Response>;
|
|
91
|
+
forgotPassword?: (request: Request) => Promise<Response>;
|
|
92
|
+
resetPassword?: (request: Request) => Promise<Response>;
|
|
93
|
+
changePassword?: (request: Request) => Promise<Response>;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/**
|
|
@@ -110,6 +114,11 @@ export interface OAuthCallbacks {
|
|
|
110
114
|
}>;
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
export interface TokenOperations {
|
|
118
|
+
create: (userId: string, type: string, relatesTo: string) => Promise<string>;
|
|
119
|
+
verify: (token: string, type: string) => Promise<{ userId: string; relatesTo: string }>;
|
|
120
|
+
}
|
|
121
|
+
|
|
113
122
|
export interface AuthOptions {
|
|
114
123
|
mode?: 'node' | 'postgres';
|
|
115
124
|
/** postgres.js sql client — required when mode is 'postgres' */
|
|
@@ -135,6 +144,15 @@ export interface AuthOptions {
|
|
|
135
144
|
/** Callback to add extra claims (client_id, aud) during token signing.
|
|
136
145
|
* Receives base claims and the original request; returns extra claims to merge. */
|
|
137
146
|
enrichClaims?: EnrichClaimsFn;
|
|
147
|
+
/** One-time token operations for email confirmation and password recovery.
|
|
148
|
+
* Required for confirmEmail, forgotPassword, and resetPassword handlers. */
|
|
149
|
+
tokens?: TokenOperations;
|
|
150
|
+
/** Called after successful signup when tokens are configured. App sends confirmation email. */
|
|
151
|
+
onConfirmationRequired?: (user: { id: string; email: string }, token: string) => Promise<void>;
|
|
152
|
+
/** Called on forgot-password — app sends recovery email. */
|
|
153
|
+
onRecoveryRequired?: (user: { id: string; email: string }, token: string) => Promise<void>;
|
|
154
|
+
/** Called after email confirmed via one-time token — app updates user record. */
|
|
155
|
+
onEmailConfirmed?: (userId: string) => Promise<void>;
|
|
138
156
|
}
|
|
139
157
|
|
|
140
158
|
export {
|
|
@@ -162,6 +180,186 @@ function csrfCheck(request: Request, cookies?: CookieConfig): Response | null {
|
|
|
162
180
|
return validateCsrfOrigin(request, cookies.allowedOrigins);
|
|
163
181
|
}
|
|
164
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Add mode-independent email confirmation and password management handlers.
|
|
185
|
+
* Called after base handlers are created (works with both node and postgres modes).
|
|
186
|
+
*/
|
|
187
|
+
function addEmailPasswordFlows(
|
|
188
|
+
handlers: AuthHandlers,
|
|
189
|
+
db: any,
|
|
190
|
+
users: any,
|
|
191
|
+
options?: AuthOptions,
|
|
192
|
+
): void {
|
|
193
|
+
if (options?.tokens) {
|
|
194
|
+
handlers.confirmEmail = async (request: Request): Promise<Response> => {
|
|
195
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
196
|
+
if (csrfError) return csrfError;
|
|
197
|
+
|
|
198
|
+
let body: any;
|
|
199
|
+
try {
|
|
200
|
+
body = await request.json();
|
|
201
|
+
} catch {
|
|
202
|
+
return Response.json({ message: 'Invalid request body' }, { status: 400 });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const { token } = body;
|
|
206
|
+
if (!token) {
|
|
207
|
+
return Response.json({ message: 'token is required' }, { status: 400 });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const result = await options.tokens!.verify(token, 'confirmation');
|
|
212
|
+
if (options.onEmailConfirmed) {
|
|
213
|
+
await options.onEmailConfirmed(result.userId);
|
|
214
|
+
}
|
|
215
|
+
return Response.json({ confirmed: true });
|
|
216
|
+
} catch {
|
|
217
|
+
return Response.json({ message: 'Invalid or expired token' }, { status: 401 });
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
handlers.forgotPassword = async (request: Request): Promise<Response> => {
|
|
222
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
223
|
+
if (csrfError) return csrfError;
|
|
224
|
+
|
|
225
|
+
let body: any;
|
|
226
|
+
try {
|
|
227
|
+
body = await request.json();
|
|
228
|
+
} catch {
|
|
229
|
+
return Response.json({ message: 'Invalid request body' }, { status: 400 });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { email } = body;
|
|
233
|
+
if (!email) {
|
|
234
|
+
return Response.json({ message: 'email is required' }, { status: 400 });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
|
238
|
+
|
|
239
|
+
if (user) {
|
|
240
|
+
try {
|
|
241
|
+
const token = await options.tokens!.create(user.id, 'recovery', email);
|
|
242
|
+
if (options.onRecoveryRequired) {
|
|
243
|
+
await options.onRecoveryRequired({ id: user.id, email: user.email }, token);
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Swallow — never reveal whether user exists
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
// Timing safety: consume roughly the same time as the success path
|
|
250
|
+
await verifyPassword('dummy', DUMMY_BCRYPT_HASH);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return Response.json({ sent: true });
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
handlers.resetPassword = async (request: Request): Promise<Response> => {
|
|
257
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
258
|
+
if (csrfError) return csrfError;
|
|
259
|
+
|
|
260
|
+
let body: any;
|
|
261
|
+
try {
|
|
262
|
+
body = await request.json();
|
|
263
|
+
} catch {
|
|
264
|
+
return Response.json({ message: 'Invalid request body' }, { status: 400 });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { token, password } = body;
|
|
268
|
+
if (!token || !password) {
|
|
269
|
+
return Response.json(
|
|
270
|
+
{ message: 'token and password are required' },
|
|
271
|
+
{ status: 400 },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let result: { userId: string; relatesTo: string };
|
|
276
|
+
try {
|
|
277
|
+
result = await options.tokens!.verify(token, 'recovery');
|
|
278
|
+
} catch {
|
|
279
|
+
return Response.json({ message: 'Invalid or expired token' }, { status: 401 });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const hash = await hashPassword(password);
|
|
283
|
+
await db.update(users).set(withPassword(users, {}, hash)).where(eq(users.id, result.userId));
|
|
284
|
+
await handlers.revokeRefreshTokens(result.userId);
|
|
285
|
+
|
|
286
|
+
return Response.json({ reset: true });
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
handlers.changePassword = async (request: Request): Promise<Response> => {
|
|
291
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
292
|
+
if (csrfError) return csrfError;
|
|
293
|
+
|
|
294
|
+
const authHeader = request.headers.get('authorization');
|
|
295
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
296
|
+
return Response.json({ message: 'Authentication required' }, { status: 401 });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const claims = await handlers.verifyToken(authHeader.slice(7));
|
|
300
|
+
if (!claims) {
|
|
301
|
+
return Response.json({ message: 'Invalid token' }, { status: 401 });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let body: any;
|
|
305
|
+
try {
|
|
306
|
+
body = await request.json();
|
|
307
|
+
} catch {
|
|
308
|
+
return Response.json({ message: 'Invalid request body' }, { status: 400 });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const { currentPassword, newPassword } = body;
|
|
312
|
+
if (!currentPassword || !newPassword) {
|
|
313
|
+
return Response.json(
|
|
314
|
+
{ message: 'currentPassword and newPassword are required' },
|
|
315
|
+
{ status: 400 },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const [user] = await db.select().from(users).where(eq(users.id, claims.sub)).limit(1);
|
|
320
|
+
if (!user) {
|
|
321
|
+
return Response.json({ message: 'User not found' }, { status: 404 });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const existingHash = getPasswordHash(user);
|
|
325
|
+
if (!existingHash) {
|
|
326
|
+
return Response.json(
|
|
327
|
+
{ message: 'Password change not available for this account' },
|
|
328
|
+
{ status: 400 },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const valid = await verifyPassword(currentPassword, existingHash);
|
|
333
|
+
if (!valid) {
|
|
334
|
+
return Response.json({ message: 'Current password is incorrect' }, { status: 401 });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const hash = await hashPassword(newPassword);
|
|
338
|
+
await db.update(users).set(withPassword(users, {}, hash)).where(eq(users.id, claims.sub));
|
|
339
|
+
await handlers.revokeRefreshTokens(claims.sub);
|
|
340
|
+
|
|
341
|
+
return Response.json({ changed: true });
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Wrap signup to trigger confirmation callback
|
|
345
|
+
if (options?.onConfirmationRequired && options?.tokens) {
|
|
346
|
+
const originalSignup = handlers.signup;
|
|
347
|
+
handlers.signup = async (request: Request): Promise<Response> => {
|
|
348
|
+
const response = await originalSignup(request);
|
|
349
|
+
if (response.status === 201) {
|
|
350
|
+
try {
|
|
351
|
+
const body = await response.clone().json();
|
|
352
|
+
const token = await options.tokens!.create(body.user.id, 'confirmation', body.user.email);
|
|
353
|
+
await options.onConfirmationRequired!({ id: body.user.id, email: body.user.email }, token);
|
|
354
|
+
} catch {
|
|
355
|
+
// Don't fail signup if confirmation email fails
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return response;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
165
363
|
/**
|
|
166
364
|
* Create JWT auth handlers for signup, signin, refresh, and token verification.
|
|
167
365
|
*
|
|
@@ -192,7 +390,9 @@ export function createAuthHandlers(
|
|
|
192
390
|
options?: AuthOptions
|
|
193
391
|
): AuthHandlers {
|
|
194
392
|
if (options?.mode === 'postgres') {
|
|
195
|
-
|
|
393
|
+
const handlers = createPostgresAuthHandlers(db, schema, jwtSecret, options);
|
|
394
|
+
addEmailPasswordFlows(handlers, db, schema.users, options);
|
|
395
|
+
return handlers;
|
|
196
396
|
}
|
|
197
397
|
|
|
198
398
|
const { users, profiles } = schema;
|
|
@@ -224,7 +424,7 @@ export function createAuthHandlers(
|
|
|
224
424
|
return refreshJwt.sign(payload as unknown as Record<string, unknown>);
|
|
225
425
|
}
|
|
226
426
|
|
|
227
|
-
|
|
427
|
+
const handlers: AuthHandlers = {
|
|
228
428
|
verifyToken: async (token: string): Promise<TokenPayload | null> => {
|
|
229
429
|
const claims = await accessJwt.verify(token);
|
|
230
430
|
return (claims as TokenPayload | null) ?? null;
|
|
@@ -753,4 +953,7 @@ export function createAuthHandlers(
|
|
|
753
953
|
}
|
|
754
954
|
},
|
|
755
955
|
};
|
|
956
|
+
|
|
957
|
+
addEmailPasswordFlows(handlers, db, users, options);
|
|
958
|
+
return handlers;
|
|
756
959
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -117,6 +117,10 @@ interface AuthContext extends PluginContext {
|
|
|
117
117
|
signin: (req: Request) => Promise<Response>;
|
|
118
118
|
refresh: (req: Request) => Promise<Response>;
|
|
119
119
|
signout: (req: Request) => Promise<Response>;
|
|
120
|
+
confirmEmail?: (req: Request) => Promise<Response>;
|
|
121
|
+
forgotPassword?: (req: Request) => Promise<Response>;
|
|
122
|
+
resetPassword?: (req: Request) => Promise<Response>;
|
|
123
|
+
changePassword?: (req: Request) => Promise<Response>;
|
|
120
124
|
};
|
|
121
125
|
enrichClaimsRef?: { current: EnrichClaimsFn | null };
|
|
122
126
|
}
|
|
@@ -203,14 +207,27 @@ export function authPlugin(options: AuthPluginOptions = {}): Plugin {
|
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
// --- Build contribution ---
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
210
|
+
const routes: Route[] = [
|
|
211
|
+
{ path: `${basePath}/signup`, method: 'POST', exact: true, handler: auth.signup },
|
|
212
|
+
{ path: `${basePath}/signin`, method: 'POST', exact: true, handler: auth.signin },
|
|
213
|
+
{ path: `${basePath}/refresh`, method: 'POST', exact: true, handler: auth.refresh },
|
|
214
|
+
{ path: `${basePath}/signout`, method: 'POST', exact: true, handler: auth.signout },
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
if (auth.confirmEmail) {
|
|
218
|
+
routes.push({ path: `${basePath}/confirm-email`, method: 'POST', exact: true, handler: auth.confirmEmail });
|
|
219
|
+
}
|
|
220
|
+
if (auth.forgotPassword) {
|
|
221
|
+
routes.push({ path: `${basePath}/forgot-password`, method: 'POST', exact: true, handler: auth.forgotPassword });
|
|
222
|
+
}
|
|
223
|
+
if (auth.resetPassword) {
|
|
224
|
+
routes.push({ path: `${basePath}/reset-password`, method: 'POST', exact: true, handler: auth.resetPassword });
|
|
225
|
+
}
|
|
226
|
+
if (auth.changePassword) {
|
|
227
|
+
routes.push({ path: `${basePath}/change-password`, method: 'POST', exact: true, handler: auth.changePassword });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const contribution: PluginContribution = { routes };
|
|
214
231
|
|
|
215
232
|
// --- Client management actions ---
|
|
216
233
|
if (clientCallbacks) {
|