@everystack/auth 0.2.1 → 0.2.4

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.4",
4
4
  "description": "JWT authentication handlers, password hashing, and React auth context",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -11,7 +11,7 @@
11
11
  * const { user, token, signIn, signUp, signOut, isLoading } = useAuth();
12
12
  */
13
13
 
14
- import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
14
+ import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
15
15
  import { refreshAuthToken } from '../api';
16
16
 
17
17
  export interface AuthUser {
@@ -81,11 +81,26 @@ export function isTokenExpired(token: string): boolean {
81
81
  } catch { return true; }
82
82
  }
83
83
 
84
- export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
84
+ export interface AuthProviderProps {
85
+ children: React.ReactNode;
86
+ /**
87
+ * Called after a successful auth transition — the seam for product-analytics
88
+ * emitters (e.g. createAnalyticsClient().track('login')). Optional; kept generic
89
+ * so the auth package never depends on the analytics client.
90
+ */
91
+ onAuthEvent?: (event: 'login' | 'signup' | 'logout', user: AuthUser | null) => void;
92
+ }
93
+
94
+ export function AuthProvider({ children, onAuthEvent }: AuthProviderProps): React.ReactElement {
85
95
  const [user, setUser] = useState<AuthUser | null>(null);
86
96
  const [token, setToken] = useState<string | null>(null);
87
97
  const [isLoading, setIsLoading] = useState(true);
88
98
 
99
+ // Ref-backed so signIn/signUp/signOut stay stable (no dep churn) yet always
100
+ // call the latest handler.
101
+ const onAuthEventRef = useRef(onAuthEvent);
102
+ onAuthEventRef.current = onAuthEvent;
103
+
89
104
  useEffect(() => {
90
105
  const init = async () => {
91
106
  const savedToken = storage.getToken();
@@ -129,6 +144,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }): React
129
144
  storage.save(body.token, body.refreshToken, body.user);
130
145
  setToken(body.token);
131
146
  setUser(body.user);
147
+ onAuthEventRef.current?.('login', body.user);
132
148
  }, []);
133
149
 
134
150
  const signUp = useCallback(async (email: string, password: string, username: string, displayName: string) => {
@@ -147,11 +163,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }): React
147
163
  storage.save(body.token, body.refreshToken, body.user);
148
164
  setToken(body.token);
149
165
  setUser(body.user);
166
+ onAuthEventRef.current?.('signup', body.user);
150
167
  }, []);
151
168
 
152
169
  const signOut = useCallback(() => {
153
170
  // Clear server-side cookies (best-effort, don't block UI)
154
171
  fetch('/api/auth/signout', { method: 'POST' }).catch(() => {});
172
+ onAuthEventRef.current?.('logout', storage.getUser());
155
173
  storage.clear();
156
174
  setToken(null);
157
175
  setUser(null);
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
  }
@@ -172,45 +176,69 @@ export function authPlugin(options: AuthPluginOptions = {}): Plugin {
172
176
 
173
177
  // --- Wire enrichClaims ref ---
174
178
  const authCtx = ctx as AuthContext;
175
- if (authCtx.enrichClaimsRef && clientCallbacks) {
179
+ if (authCtx.enrichClaimsRef && (clientCallbacks || options.audience)) {
176
180
  authCtx.enrichClaimsRef.current = async (
177
181
  baseClaims: Record<string, unknown>,
178
182
  request: Request,
179
183
  ): Promise<Record<string, unknown>> => {
180
- // Check baseClaims first (refresh path — client_id from session data)
181
- let clientId = baseClaims.client_id as string | undefined;
182
-
183
- // Fall back to request body (signin/signup path)
184
- if (!clientId) {
185
- try {
186
- const body = await request.clone().json();
187
- clientId = body?.client_id;
188
- } catch {
189
- // No JSON body or no client_id
184
+ if (clientCallbacks) {
185
+ // Check baseClaims first (refresh path — client_id from session data)
186
+ let clientId = baseClaims.client_id as string | undefined;
187
+
188
+ // Fall back to request body (signin/signup path)
189
+ if (!clientId) {
190
+ try {
191
+ const body = await request.clone().json();
192
+ clientId = body?.client_id;
193
+ } catch {
194
+ // No JSON body or no client_id
195
+ }
190
196
  }
191
- }
192
-
193
- if (!clientId) return {};
194
197
 
195
- const client = await clientCallbacks.resolve(clientId);
196
- if (!client || !client.active) return {};
198
+ if (clientId) {
199
+ // An explicit client_id is an assertion of client-credential auth:
200
+ // unknown or revoked clients get no claims, so under an audience
201
+ // config their tokens fail verification — never a silent downgrade
202
+ // to first-party.
203
+ const client = await clientCallbacks.resolve(clientId);
204
+ if (!client || !client.active) return {};
205
+
206
+ return {
207
+ client_id: clientId,
208
+ aud: client.audience ?? options.audience,
209
+ };
210
+ }
211
+ }
197
212
 
198
- return {
199
- client_id: clientId,
200
- aud: client.audience ?? options.audience,
201
- };
213
+ // First-party signin/refresh: tokens this app issues for its own
214
+ // users carry the app audience — the audience layer rejects any
215
+ // token without it, including the ones we mint ourselves.
216
+ return options.audience ? { aud: options.audience } : {};
202
217
  };
203
218
  }
204
219
 
205
220
  // --- 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
- };
221
+ const routes: Route[] = [
222
+ { path: `${basePath}/signup`, method: 'POST', exact: true, handler: auth.signup },
223
+ { path: `${basePath}/signin`, method: 'POST', exact: true, handler: auth.signin },
224
+ { path: `${basePath}/refresh`, method: 'POST', exact: true, handler: auth.refresh },
225
+ { path: `${basePath}/signout`, method: 'POST', exact: true, handler: auth.signout },
226
+ ];
227
+
228
+ if (auth.confirmEmail) {
229
+ routes.push({ path: `${basePath}/confirm-email`, method: 'POST', exact: true, handler: auth.confirmEmail });
230
+ }
231
+ if (auth.forgotPassword) {
232
+ routes.push({ path: `${basePath}/forgot-password`, method: 'POST', exact: true, handler: auth.forgotPassword });
233
+ }
234
+ if (auth.resetPassword) {
235
+ routes.push({ path: `${basePath}/reset-password`, method: 'POST', exact: true, handler: auth.resetPassword });
236
+ }
237
+ if (auth.changePassword) {
238
+ routes.push({ path: `${basePath}/change-password`, method: 'POST', exact: true, handler: auth.changePassword });
239
+ }
240
+
241
+ const contribution: PluginContribution = { routes };
214
242
 
215
243
  // --- Client management actions ---
216
244
  if (clientCallbacks) {