@everystack/auth 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,526 @@
1
+ /**
2
+ * @everystack/auth — JWT authentication handlers and password hashing.
3
+ *
4
+ * Two modes:
5
+ * - 'node' (default): bcrypt for passwords (cost via BCRYPT_COST env, default 10), in-house HS256 for JWTs
6
+ * - 'postgres': pgcrypto bcrypt (cost 10) for passwords, pgcrypto HMAC for JWTs
7
+ *
8
+ * Both modes produce byte-compatible bcrypt hashes and HS256 JWTs — credentials
9
+ * and tokens are interchangeable across modes.
10
+ *
11
+ * Usage:
12
+ * import { createAuthHandlers, hashPassword, verifyPassword } from '@everystack/auth';
13
+ *
14
+ * // Node mode (default):
15
+ * const auth = createAuthHandlers(db, { users, profiles }, jwtSecret);
16
+ *
17
+ * // Postgres mode (requires pgcrypto + auth SQL functions):
18
+ * const auth = createAuthHandlers(db, { users, profiles }, jwtSecret, { mode: 'postgres' });
19
+ *
20
+ * With DB-backed refresh tokens (recommended for production):
21
+ * import { sessions } from '@everystack/auth/schema';
22
+ *
23
+ * const auth = createAuthHandlers(db, { users, profiles, sessions }, jwtSecret);
24
+ */
25
+
26
+ import { createJwt } from './jwt';
27
+ import { hmacNode } from './jwt/hmac-node';
28
+ import { and, eq } from 'drizzle-orm';
29
+ import {
30
+ createRefreshToken,
31
+ rotateRefreshToken,
32
+ revokeAllRefreshTokens,
33
+ TokenError,
34
+ } from './refresh';
35
+ import type { OAuthProvider } from './oauth';
36
+ export type { OAuthProvider, OAuthUserInfo } from './oauth';
37
+ export { createAppleProvider, createGoogleProvider } from './oauth';
38
+ import { createPostgresAuthHandlers } from './postgres';
39
+ import { getRefreshFromCookies } from './cookies';
40
+ import { authResponse, signoutResponse } from './response';
41
+ import type { CookieConfig } from './response';
42
+
43
+ const TOKEN_EXPIRY_SEC = 3600; // 1h
44
+ const REFRESH_EXPIRY_SEC = 7 * 24 * 3600; // 7d
45
+
46
+ export interface TokenPayload {
47
+ sub: string;
48
+ email: string;
49
+ role: string;
50
+ }
51
+
52
+ export interface AuthHandlers {
53
+ signup: (request: Request) => Promise<Response>;
54
+ signin: (request: Request) => Promise<Response>;
55
+ refresh: (request: Request) => Promise<Response>;
56
+ signout: (request: Request) => Promise<Response>;
57
+ oauth?: (request: Request) => Promise<Response>;
58
+ verifyToken: (token: string) => Promise<TokenPayload | null>;
59
+ revokeRefreshTokens: (userId: string) => Promise<void>;
60
+ }
61
+
62
+ export interface AuthOptions {
63
+ mode?: 'node' | 'postgres';
64
+ /** postgres.js sql client — required when mode is 'postgres' */
65
+ sql?: any;
66
+ oauth?: { providers: OAuthProvider[] };
67
+ /** When set, auth responses also set HttpOnly cookies. `true` uses defaults. */
68
+ cookies?: CookieConfig;
69
+ }
70
+
71
+ export {
72
+ hashPassword,
73
+ verifyPassword,
74
+ BCRYPT_COST,
75
+ BCRYPT_MAX_BYTES,
76
+ } from './passwords';
77
+ import { hashPassword, verifyPassword } from './passwords';
78
+
79
+ /**
80
+ * Create JWT auth handlers for signup, signin, refresh, and token verification.
81
+ *
82
+ * When `options.mode` is `'postgres'`, delegates password hashing and JWT signing
83
+ * to PostgreSQL functions via pgcrypto. Requires auth SQL functions from `@everystack/auth/sql`.
84
+ *
85
+ * When `schema.sessions` is provided, refresh tokens are stored as
86
+ * SHA-256 hashes in auth.sessions with one-time-use rotation.
87
+ * When omitted, refresh tokens are stateless JWTs (original behavior).
88
+ *
89
+ * @param db - Drizzle database instance
90
+ * @param schema - Object with `users`, `profiles`, and optional `sessions`/`credentials`/`identities` table references
91
+ * @param jwtSecret - Secret key for JWT signing (Uint8Array)
92
+ * @param options - Mode ('node' | 'postgres') and OAuth config
93
+ */
94
+ export function createAuthHandlers(
95
+ db: any,
96
+ schema: { users: any; profiles: any; sessions?: any; credentials?: any; identities?: any },
97
+ jwtSecret: Uint8Array,
98
+ options?: AuthOptions
99
+ ): AuthHandlers {
100
+ if (options?.mode === 'postgres') {
101
+ return createPostgresAuthHandlers(db, schema, jwtSecret, options);
102
+ }
103
+
104
+ const { users, profiles } = schema;
105
+
106
+ const accessJwt = createJwt({
107
+ secret: jwtSecret,
108
+ hmac: hmacNode,
109
+ ttlSec: TOKEN_EXPIRY_SEC,
110
+ });
111
+ const refreshJwt = createJwt({
112
+ secret: jwtSecret,
113
+ hmac: hmacNode,
114
+ ttlSec: REFRESH_EXPIRY_SEC,
115
+ });
116
+
117
+ async function signToken(payload: TokenPayload): Promise<string> {
118
+ return accessJwt.sign(payload as unknown as Record<string, unknown>);
119
+ }
120
+
121
+ async function signRefreshToken(payload: { sub: string }): Promise<string> {
122
+ return refreshJwt.sign(payload as unknown as Record<string, unknown>);
123
+ }
124
+
125
+ return {
126
+ verifyToken: async (token: string): Promise<TokenPayload | null> => {
127
+ const claims = await accessJwt.verify(token);
128
+ return (claims as TokenPayload | null) ?? null;
129
+ },
130
+
131
+ signup: async (request: Request): Promise<Response> => {
132
+ const { email, password, username, displayName } = await request.json();
133
+
134
+ if (!email || !password || !username || !displayName) {
135
+ return Response.json(
136
+ { message: 'email, password, username, and displayName are required' },
137
+ { status: 400 }
138
+ );
139
+ }
140
+
141
+ const existing = await db
142
+ .select()
143
+ .from(users)
144
+ .where(eq(users.email, email))
145
+ .limit(1);
146
+ if (existing.length > 0) {
147
+ return Response.json(
148
+ { message: 'Email already registered' },
149
+ { status: 409 }
150
+ );
151
+ }
152
+
153
+ const passwordHash = await hashPassword(password);
154
+ const { user, profile } = await db.transaction(async (tx: any) => {
155
+ const [u] = await tx.insert(users).values({ email, passwordHash }).returning();
156
+ const [p] = await tx.insert(profiles).values({ id: u.id, username, displayName }).returning();
157
+ return { user: u, profile: p };
158
+ });
159
+
160
+ const token = await signToken({
161
+ sub: user.id,
162
+ email: user.email,
163
+ role: user.role ?? 'authenticated',
164
+ });
165
+ const refreshToken = schema.sessions
166
+ ? (await createRefreshToken(db, schema.sessions, user.id)).token
167
+ : await signRefreshToken({ sub: user.id });
168
+
169
+ return authResponse(
170
+ {
171
+ token,
172
+ refreshToken,
173
+ user: {
174
+ id: user.id,
175
+ email: user.email,
176
+ username: profile.username,
177
+ displayName: profile.displayName,
178
+ role: user.role ?? 'authenticated',
179
+ },
180
+ },
181
+ 201,
182
+ options?.cookies,
183
+ );
184
+ },
185
+
186
+ signin: async (request: Request): Promise<Response> => {
187
+ const { email, password } = await request.json();
188
+
189
+ if (!email || !password) {
190
+ return Response.json(
191
+ { message: 'email and password are required' },
192
+ { status: 400 }
193
+ );
194
+ }
195
+
196
+ const [user] = await db
197
+ .select()
198
+ .from(users)
199
+ .where(eq(users.email, email))
200
+ .limit(1);
201
+ if (!user) {
202
+ return Response.json(
203
+ { message: 'Invalid email or password' },
204
+ { status: 401 }
205
+ );
206
+ }
207
+
208
+ const valid = await verifyPassword(password, user.passwordHash);
209
+ if (!valid) {
210
+ return Response.json(
211
+ { message: 'Invalid email or password' },
212
+ { status: 401 }
213
+ );
214
+ }
215
+
216
+ const [profile] = await db
217
+ .select()
218
+ .from(profiles)
219
+ .where(eq(profiles.id, user.id))
220
+ .limit(1);
221
+
222
+ const token = await signToken({
223
+ sub: user.id,
224
+ email: user.email,
225
+ role: user.role ?? 'authenticated',
226
+ });
227
+ const refreshToken = schema.sessions
228
+ ? (await createRefreshToken(db, schema.sessions, user.id)).token
229
+ : await signRefreshToken({ sub: user.id });
230
+
231
+ return authResponse(
232
+ {
233
+ token,
234
+ refreshToken,
235
+ user: {
236
+ id: user.id,
237
+ email: user.email,
238
+ username: profile?.username,
239
+ displayName: profile?.displayName,
240
+ role: user.role ?? 'authenticated',
241
+ },
242
+ },
243
+ 200,
244
+ options?.cookies,
245
+ );
246
+ },
247
+
248
+ refresh: async (request: Request): Promise<Response> => {
249
+ // Read refresh token from JSON body (mobile) or cookie (web SSR)
250
+ let refreshToken: string | undefined;
251
+ try {
252
+ const body = await request.clone().json();
253
+ refreshToken = body?.refreshToken;
254
+ } catch {
255
+ // Empty body is fine — fall through to cookie
256
+ }
257
+ if (!refreshToken) {
258
+ refreshToken = getRefreshFromCookies(request.headers.get('cookie')) ?? undefined;
259
+ }
260
+ if (!refreshToken) {
261
+ return Response.json(
262
+ { message: 'refreshToken is required' },
263
+ { status: 400 }
264
+ );
265
+ }
266
+
267
+ if (schema.sessions) {
268
+ // DB-backed rotation path
269
+ try {
270
+ const result = await rotateRefreshToken(db, schema.sessions, refreshToken);
271
+
272
+ const [user] = await db
273
+ .select()
274
+ .from(users)
275
+ .where(eq(users.id, result.userId))
276
+ .limit(1);
277
+ if (!user) {
278
+ await revokeAllRefreshTokens(db, schema.sessions, result.userId);
279
+ return Response.json({ message: 'User not found' }, { status: 401 });
280
+ }
281
+
282
+ const token = await signToken({
283
+ sub: user.id,
284
+ email: user.email,
285
+ role: user.role ?? 'authenticated',
286
+ });
287
+
288
+ return authResponse(
289
+ { token, refreshToken: result.token },
290
+ 200,
291
+ options?.cookies,
292
+ );
293
+ } catch (err) {
294
+ if (err instanceof TokenError) {
295
+ return Response.json(
296
+ { message: 'Invalid or expired refresh token' },
297
+ { status: 401 }
298
+ );
299
+ }
300
+ throw err;
301
+ }
302
+ } else {
303
+ // Legacy JWT-based path
304
+ try {
305
+ const payload = await refreshJwt.verify(refreshToken);
306
+ if (!payload) {
307
+ return Response.json({ message: 'Invalid refresh token' }, { status: 401 });
308
+ }
309
+ const userId = payload.sub as string;
310
+
311
+ const [user] = await db
312
+ .select()
313
+ .from(users)
314
+ .where(eq(users.id, userId))
315
+ .limit(1);
316
+ if (!user) {
317
+ return Response.json({ message: 'User not found' }, { status: 401 });
318
+ }
319
+
320
+ const token = await signToken({
321
+ sub: user.id,
322
+ email: user.email,
323
+ role: user.role ?? 'authenticated',
324
+ });
325
+ const newRefreshToken = await signRefreshToken({ sub: user.id });
326
+
327
+ return authResponse(
328
+ { token, refreshToken: newRefreshToken },
329
+ 200,
330
+ options?.cookies,
331
+ );
332
+ } catch {
333
+ return Response.json(
334
+ { message: 'Invalid or expired refresh token' },
335
+ { status: 401 }
336
+ );
337
+ }
338
+ }
339
+ },
340
+
341
+ oauth: options?.oauth?.providers.length
342
+ ? async (request: Request): Promise<Response> => {
343
+ if (!schema.credentials || !schema.identities) {
344
+ return Response.json(
345
+ { message: 'OAuth not configured: credentials and identities tables required in schema' },
346
+ { status: 500 }
347
+ );
348
+ }
349
+
350
+ let body: { provider?: string; token?: string };
351
+ try {
352
+ body = await request.json();
353
+ } catch {
354
+ return Response.json(
355
+ { message: 'Invalid request body' },
356
+ { status: 400 }
357
+ );
358
+ }
359
+
360
+ const { provider: providerName, token } = body;
361
+ if (!providerName || !token) {
362
+ return Response.json(
363
+ { message: 'provider and token are required' },
364
+ { status: 400 }
365
+ );
366
+ }
367
+
368
+ const provider = options!.oauth!.providers.find(
369
+ (p) => p.name === providerName
370
+ );
371
+ if (!provider) {
372
+ return Response.json(
373
+ { message: `Unsupported provider: ${providerName}` },
374
+ { status: 400 }
375
+ );
376
+ }
377
+
378
+ const userInfo = await provider.verify(token);
379
+ if (!userInfo) {
380
+ return Response.json(
381
+ { message: 'Token verification failed' },
382
+ { status: 401 }
383
+ );
384
+ }
385
+
386
+ // Look up existing OAuth credential by (type='oauth', credentialId=providerUid)
387
+ // Join credentials → identities to get userId
388
+ const existingResults = await db
389
+ .select({
390
+ credentialId: schema.credentials.id,
391
+ userId: schema.identities.userId,
392
+ })
393
+ .from(schema.credentials)
394
+ .innerJoin(schema.identities, eq(schema.credentials.identityId, schema.identities.id))
395
+ .where(and(
396
+ eq(schema.credentials.type, 'oauth'),
397
+ eq(schema.credentials.credentialId, userInfo.uid),
398
+ ))
399
+ .limit(1);
400
+
401
+ const existingAccount = existingResults[0];
402
+
403
+ if (existingAccount) {
404
+ // Existing linked account — sign in the associated user
405
+ const [existingUser] = await db
406
+ .select()
407
+ .from(users)
408
+ .where(eq(users.id, existingAccount.userId))
409
+ .limit(1);
410
+
411
+ const [profile] = await db
412
+ .select()
413
+ .from(profiles)
414
+ .where(eq(profiles.id, existingAccount.userId))
415
+ .limit(1);
416
+
417
+ const accessToken = await signToken({
418
+ sub: existingUser.id,
419
+ email: existingUser.email,
420
+ role: existingUser.role ?? 'authenticated',
421
+ });
422
+ const refreshToken = schema.sessions
423
+ ? (await createRefreshToken(db, schema.sessions, existingUser.id)).token
424
+ : await signRefreshToken({ sub: existingUser.id });
425
+
426
+ return authResponse(
427
+ {
428
+ token: accessToken,
429
+ refreshToken,
430
+ user: {
431
+ id: existingUser.id,
432
+ email: existingUser.email,
433
+ username: profile?.username,
434
+ displayName: profile?.displayName,
435
+ avatarUrl: profile?.avatarUrl,
436
+ role: existingUser.role ?? 'authenticated',
437
+ },
438
+ },
439
+ 200,
440
+ options?.cookies,
441
+ );
442
+ }
443
+
444
+ // New user — create account + identity + OAuth credential
445
+ const displayName = [userInfo.firstName, userInfo.lastName]
446
+ .filter(Boolean)
447
+ .join(' ') || userInfo.email.split('@')[0];
448
+ const username = userInfo.email.split('@')[0];
449
+
450
+ const { user, profile } = await db.transaction(async (tx: any) => {
451
+ const [u] = await tx
452
+ .insert(users)
453
+ .values({ email: userInfo.email, passwordHash: null })
454
+ .returning();
455
+
456
+ const [ident] = await tx
457
+ .insert(schema.identities)
458
+ .values({
459
+ type: 'user',
460
+ userId: u.id,
461
+ name: userInfo.email,
462
+ })
463
+ .returning();
464
+
465
+ await tx
466
+ .insert(schema.credentials)
467
+ .values({
468
+ type: 'oauth',
469
+ identityId: ident.id,
470
+ credentialId: userInfo.uid,
471
+ name: providerName,
472
+ metadata: { email: userInfo.email },
473
+ });
474
+
475
+ const [p] = await tx
476
+ .insert(profiles)
477
+ .values({
478
+ id: u.id,
479
+ username,
480
+ displayName,
481
+ avatarUrl: userInfo.avatarUrl || null,
482
+ })
483
+ .returning();
484
+
485
+ return { user: u, profile: p };
486
+ });
487
+
488
+ const accessToken = await signToken({
489
+ sub: user.id,
490
+ email: user.email,
491
+ role: user.role ?? 'authenticated',
492
+ });
493
+ const refreshToken = schema.sessions
494
+ ? (await createRefreshToken(db, schema.sessions, user.id)).token
495
+ : await signRefreshToken({ sub: user.id });
496
+
497
+ return authResponse(
498
+ {
499
+ token: accessToken,
500
+ refreshToken,
501
+ user: {
502
+ id: user.id,
503
+ email: user.email,
504
+ username: profile.username,
505
+ displayName: profile.displayName,
506
+ avatarUrl: profile.avatarUrl,
507
+ role: user.role ?? 'authenticated',
508
+ },
509
+ },
510
+ 200,
511
+ options?.cookies,
512
+ );
513
+ }
514
+ : undefined,
515
+
516
+ signout: async (_request: Request): Promise<Response> => {
517
+ return signoutResponse(options?.cookies);
518
+ },
519
+
520
+ revokeRefreshTokens: async (userId: string): Promise<void> => {
521
+ if (schema.sessions) {
522
+ await revokeAllRefreshTokens(db, schema.sessions, userId);
523
+ }
524
+ },
525
+ };
526
+ }
@@ -0,0 +1,11 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import type { HmacFn } from './hmac';
3
+
4
+ /**
5
+ * HMAC-SHA256 via node:crypto. Synchronous under the hood; wrapped in a
6
+ * resolved promise to satisfy the async HmacFn contract.
7
+ */
8
+ export const hmacNode: HmacFn = async (key, message) => {
9
+ const mac = createHmac('sha256', key).update(message).digest();
10
+ return new Uint8Array(mac.buffer, mac.byteOffset, mac.byteLength);
11
+ };
@@ -0,0 +1,17 @@
1
+ import type { HmacFn } from './hmac';
2
+
3
+ /**
4
+ * HMAC-SHA256 via Web Crypto (SubtleCrypto). Works in browsers, Workers,
5
+ * Deno, Bun, and Node 20+ (where SubtleCrypto is exposed on globalThis.crypto).
6
+ */
7
+ export const hmacWeb: HmacFn = async (key, message) => {
8
+ const cryptoKey = await crypto.subtle.importKey(
9
+ 'raw',
10
+ key as BufferSource,
11
+ { name: 'HMAC', hash: 'SHA-256' },
12
+ false,
13
+ ['sign']
14
+ );
15
+ const sig = await crypto.subtle.sign('HMAC', cryptoKey, message as BufferSource);
16
+ return new Uint8Array(sig);
17
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * HMAC adapter contract.
3
+ *
4
+ * Both node:crypto and SubtleCrypto adapters implement this signature.
5
+ * Async-only to keep one seam for both runtimes.
6
+ */
7
+ export type HmacFn = (
8
+ key: Uint8Array,
9
+ message: Uint8Array
10
+ ) => Promise<Uint8Array>;