@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/auth",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "JWT authentication handlers, password hashing, and React auth context",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
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
- return createPostgresAuthHandlers(db, schema, jwtSecret, options);
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
- return {
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 contribution: PluginContribution = {
207
- routes: [
208
- { path: `${basePath}/signup`, method: 'POST', exact: true, handler: auth.signup },
209
- { path: `${basePath}/signin`, method: 'POST', exact: true, handler: auth.signin },
210
- { path: `${basePath}/refresh`, method: 'POST', exact: true, handler: auth.refresh },
211
- { path: `${basePath}/signout`, method: 'POST', exact: true, handler: auth.signout },
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) {