@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 +1 -1
- package/src/client/index.tsx +20 -2
- package/src/index.ts +205 -2
- package/src/plugin.ts +56 -28
package/package.json
CHANGED
package/src/client/index.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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) {
|