@everystack/auth 0.1.0 → 0.2.1

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 CHANGED
@@ -18,9 +18,9 @@
18
18
  * const auth = createAuthHandlers(db, { users, profiles }, jwtSecret, { mode: 'postgres' });
19
19
  *
20
20
  * With DB-backed refresh tokens (recommended for production):
21
- * import { sessions } from '@everystack/auth/schema';
21
+ * import { refreshTokens } from '@everystack/auth/schema';
22
22
  *
23
- * const auth = createAuthHandlers(db, { users, profiles, sessions }, jwtSecret);
23
+ * const auth = createAuthHandlers(db, { users, profiles, refreshTokens }, jwtSecret);
24
24
  */
25
25
 
26
26
  import { createJwt } from './jwt';
@@ -32,16 +32,46 @@ import {
32
32
  revokeAllRefreshTokens,
33
33
  TokenError,
34
34
  } from './refresh';
35
- import type { OAuthProvider } from './oauth';
35
+ import type { RefreshTokenOptions } from './refresh';
36
+ import type { OAuthProvider, OAuthUserInfo } from './oauth';
36
37
  export type { OAuthProvider, OAuthUserInfo } from './oauth';
37
38
  export { createAppleProvider, createGoogleProvider } from './oauth';
38
39
  import { createPostgresAuthHandlers } from './postgres';
39
40
  import { getRefreshFromCookies } from './cookies';
40
41
  import { authResponse, signoutResponse } from './response';
41
42
  import type { CookieConfig } from './response';
43
+ import type { EnrichClaimsFn } from './plugin';
44
+ export type { EnrichClaimsFn } from './plugin';
45
+ import { validateCsrfOrigin } from './csrf';
42
46
 
43
47
  const TOKEN_EXPIRY_SEC = 3600; // 1h
44
- const REFRESH_EXPIRY_SEC = 7 * 24 * 3600; // 7d
48
+ const REFRESH_EXPIRY_SEC = 30 * 24 * 3600; // 30d
49
+
50
+ /**
51
+ * Dummy bcrypt hash for timing-safe user enumeration protection.
52
+ * When a user is not found, we still run bcrypt against this hash
53
+ * so response time is constant regardless of whether the email exists.
54
+ */
55
+ const DUMMY_BCRYPT_HASH = '$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012345';
56
+
57
+ /**
58
+ * Read the password hash from a user row.
59
+ * Supports both default schema (encryptedPassword) and app schemas (passwordHash).
60
+ */
61
+ export function getPasswordHash(user: any): string | null {
62
+ return user.encryptedPassword ?? user.passwordHash ?? null;
63
+ }
64
+
65
+ /**
66
+ * Build insert values with the correct password column name.
67
+ * Checks which column exists on the Drizzle table definition.
68
+ */
69
+ export function withPassword(users: any, values: Record<string, any>, hash: string | null): Record<string, any> {
70
+ if ('encryptedPassword' in users) {
71
+ return { ...values, encryptedPassword: hash };
72
+ }
73
+ return { ...values, passwordHash: hash };
74
+ }
45
75
 
46
76
  export interface TokenPayload {
47
77
  sub: string;
@@ -59,13 +89,52 @@ export interface AuthHandlers {
59
89
  revokeRefreshTokens: (userId: string) => Promise<void>;
60
90
  }
61
91
 
92
+ /**
93
+ * Callbacks for OAuth user resolution. Enables apps to control where
94
+ * OAuth provider links are stored (their tables, their schema).
95
+ */
96
+ export interface OAuthCallbacks {
97
+ /** Find an existing user linked to this OAuth provider + UID. */
98
+ findByProvider: (provider: string, providerUid: string) => Promise<{ userId: string } | null>;
99
+ /** Create a new user from OAuth info. Return the created user. */
100
+ createFromOAuth: (
101
+ userInfo: OAuthUserInfo,
102
+ provider: string,
103
+ ) => Promise<{
104
+ id: string;
105
+ email: string;
106
+ username?: string;
107
+ displayName?: string;
108
+ avatarUrl?: string;
109
+ role?: string;
110
+ }>;
111
+ }
112
+
62
113
  export interface AuthOptions {
63
114
  mode?: 'node' | 'postgres';
64
115
  /** postgres.js sql client — required when mode is 'postgres' */
65
116
  sql?: any;
66
- oauth?: { providers: OAuthProvider[] };
117
+ /** Repository ID for multi-tenant scoping. When set, refresh tokens and
118
+ * device registrations are isolated per repository. */
119
+ repositoryId?: string;
120
+ oauth?: {
121
+ providers: OAuthProvider[];
122
+ /** Callback-based OAuth user resolution (recommended).
123
+ * When provided, identities/credentials tables are not needed. */
124
+ callbacks?: OAuthCallbacks;
125
+ /** Optional nonce validator for web OAuth flows.
126
+ * Called with the nonce from the request body and verified user info.
127
+ * Return true if valid, false to reject. */
128
+ validateNonce?: (nonce: string, userInfo: OAuthUserInfo) => Promise<boolean>;
129
+ };
67
130
  /** When set, auth responses also set HttpOnly cookies. `true` uses defaults. */
68
131
  cookies?: CookieConfig;
132
+ /** When true, signup returns the same 200 response whether the email exists or not.
133
+ * Prevents email enumeration attacks at the cost of no immediate feedback. */
134
+ uniformSignupResponse?: boolean;
135
+ /** Callback to add extra claims (client_id, aud) during token signing.
136
+ * Receives base claims and the original request; returns extra claims to merge. */
137
+ enrichClaims?: EnrichClaimsFn;
69
138
  }
70
139
 
71
140
  export {
@@ -76,24 +145,49 @@ export {
76
145
  } from './passwords';
77
146
  import { hashPassword, verifyPassword } from './passwords';
78
147
 
148
+ /**
149
+ * Extract client IP and user agent from a request for refresh token metadata.
150
+ * When repositoryId is provided, it's included for multi-tenant scoping.
151
+ */
152
+ function extractRequestMeta(request: Request, repositoryId?: string): RefreshTokenOptions {
153
+ const forwarded = request.headers.get('x-forwarded-for');
154
+ const ipAddress = forwarded ? forwarded.split(',')[0].trim() : undefined;
155
+ const userAgent = request.headers.get('user-agent') ?? undefined;
156
+ return { repositoryId, ipAddress, userAgent };
157
+ }
158
+
159
+ /** Run CSRF origin check if cookies are configured with allowedOrigins. */
160
+ function csrfCheck(request: Request, cookies?: CookieConfig): Response | null {
161
+ if (!cookies || cookies === true) return null;
162
+ return validateCsrfOrigin(request, cookies.allowedOrigins);
163
+ }
164
+
79
165
  /**
80
166
  * Create JWT auth handlers for signup, signin, refresh, and token verification.
81
167
  *
82
168
  * When `options.mode` is `'postgres'`, delegates password hashing and JWT signing
83
169
  * to PostgreSQL functions via pgcrypto. Requires auth SQL functions from `@everystack/auth/sql`.
84
170
  *
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).
171
+ * When `schema.refreshTokens` is provided, refresh tokens are stored as SHA-256
172
+ * hashes with one-time-use rotation and reuse detection. When omitted, refresh
173
+ * tokens are stateless JWTs (original behavior).
88
174
  *
89
175
  * @param db - Drizzle database instance
90
- * @param schema - Object with `users`, `profiles`, and optional `sessions`/`credentials`/`identities` table references
176
+ * @param schema - Object with `users`, `profiles`, and optional `refreshTokens` table reference
91
177
  * @param jwtSecret - Secret key for JWT signing (Uint8Array)
92
178
  * @param options - Mode ('node' | 'postgres') and OAuth config
93
179
  */
94
180
  export function createAuthHandlers(
95
181
  db: any,
96
- schema: { users: any; profiles: any; sessions?: any; credentials?: any; identities?: any },
182
+ schema: {
183
+ users: any;
184
+ profiles: any;
185
+ refreshTokens?: any;
186
+ /** Legacy OAuth tables. Use `oauth.callbacks` instead for new apps. */
187
+ credentials?: any;
188
+ /** Legacy OAuth tables. Use `oauth.callbacks` instead for new apps. */
189
+ identities?: any;
190
+ },
97
191
  jwtSecret: Uint8Array,
98
192
  options?: AuthOptions
99
193
  ): AuthHandlers {
@@ -102,6 +196,7 @@ export function createAuthHandlers(
102
196
  }
103
197
 
104
198
  const { users, profiles } = schema;
199
+ const refreshTable = schema.refreshTokens;
105
200
 
106
201
  const accessJwt = createJwt({
107
202
  secret: jwtSecret,
@@ -114,8 +209,15 @@ export function createAuthHandlers(
114
209
  ttlSec: REFRESH_EXPIRY_SEC,
115
210
  });
116
211
 
117
- async function signToken(payload: TokenPayload): Promise<string> {
118
- return accessJwt.sign(payload as unknown as Record<string, unknown>);
212
+ async function signToken(payload: TokenPayload, request?: Request): Promise<string> {
213
+ let claims: Record<string, unknown> = payload as unknown as Record<string, unknown>;
214
+ if (options?.enrichClaims && request) {
215
+ const extra = await options.enrichClaims(claims, request);
216
+ if (extra && Object.keys(extra).length > 0) {
217
+ claims = { ...claims, ...extra };
218
+ }
219
+ }
220
+ return accessJwt.sign(claims);
119
221
  }
120
222
 
121
223
  async function signRefreshToken(payload: { sub: string }): Promise<string> {
@@ -129,6 +231,10 @@ export function createAuthHandlers(
129
231
  },
130
232
 
131
233
  signup: async (request: Request): Promise<Response> => {
234
+ const csrfError = csrfCheck(request, options?.cookies);
235
+ if (csrfError) return csrfError;
236
+
237
+ const enrichRequest = options?.enrichClaims ? request.clone() : undefined;
132
238
  const { email, password, username, displayName } = await request.json();
133
239
 
134
240
  if (!email || !password || !username || !displayName) {
@@ -144,15 +250,21 @@ export function createAuthHandlers(
144
250
  .where(eq(users.email, email))
145
251
  .limit(1);
146
252
  if (existing.length > 0) {
253
+ if (options?.uniformSignupResponse) {
254
+ return Response.json(
255
+ { message: 'Check your email to continue.' },
256
+ { status: 200 }
257
+ );
258
+ }
147
259
  return Response.json(
148
260
  { message: 'Email already registered' },
149
261
  { status: 409 }
150
262
  );
151
263
  }
152
264
 
153
- const passwordHash = await hashPassword(password);
265
+ const hash = await hashPassword(password);
154
266
  const { user, profile } = await db.transaction(async (tx: any) => {
155
- const [u] = await tx.insert(users).values({ email, passwordHash }).returning();
267
+ const [u] = await tx.insert(users).values(withPassword(users, { email }, hash)).returning();
156
268
  const [p] = await tx.insert(profiles).values({ id: u.id, username, displayName }).returning();
157
269
  return { user: u, profile: p };
158
270
  });
@@ -161,9 +273,10 @@ export function createAuthHandlers(
161
273
  sub: user.id,
162
274
  email: user.email,
163
275
  role: user.role ?? 'authenticated',
164
- });
165
- const refreshToken = schema.sessions
166
- ? (await createRefreshToken(db, schema.sessions, user.id)).token
276
+ }, enrichRequest);
277
+ const meta = extractRequestMeta(request, options?.repositoryId);
278
+ const refreshToken = refreshTable
279
+ ? (await createRefreshToken(db, refreshTable, user.id, undefined, meta)).token
167
280
  : await signRefreshToken({ sub: user.id });
168
281
 
169
282
  return authResponse(
@@ -184,6 +297,10 @@ export function createAuthHandlers(
184
297
  },
185
298
 
186
299
  signin: async (request: Request): Promise<Response> => {
300
+ const csrfError = csrfCheck(request, options?.cookies);
301
+ if (csrfError) return csrfError;
302
+
303
+ const enrichRequest = options?.enrichClaims ? request.clone() : undefined;
187
304
  const { email, password } = await request.json();
188
305
 
189
306
  if (!email || !password) {
@@ -198,14 +315,18 @@ export function createAuthHandlers(
198
315
  .from(users)
199
316
  .where(eq(users.email, email))
200
317
  .limit(1);
318
+
201
319
  if (!user) {
320
+ // Timing-safe: run bcrypt even when user doesn't exist
321
+ // so response time is constant regardless of email existence
322
+ await verifyPassword(password, DUMMY_BCRYPT_HASH);
202
323
  return Response.json(
203
324
  { message: 'Invalid email or password' },
204
325
  { status: 401 }
205
326
  );
206
327
  }
207
328
 
208
- const valid = await verifyPassword(password, user.passwordHash);
329
+ const valid = await verifyPassword(password, getPasswordHash(user)!);
209
330
  if (!valid) {
210
331
  return Response.json(
211
332
  { message: 'Invalid email or password' },
@@ -223,9 +344,10 @@ export function createAuthHandlers(
223
344
  sub: user.id,
224
345
  email: user.email,
225
346
  role: user.role ?? 'authenticated',
226
- });
227
- const refreshToken = schema.sessions
228
- ? (await createRefreshToken(db, schema.sessions, user.id)).token
347
+ }, enrichRequest);
348
+ const meta = extractRequestMeta(request, options?.repositoryId);
349
+ const refreshToken = refreshTable
350
+ ? (await createRefreshToken(db, refreshTable, user.id, undefined, meta)).token
229
351
  : await signRefreshToken({ sub: user.id });
230
352
 
231
353
  return authResponse(
@@ -246,6 +368,9 @@ export function createAuthHandlers(
246
368
  },
247
369
 
248
370
  refresh: async (request: Request): Promise<Response> => {
371
+ const csrfError = csrfCheck(request, options?.cookies);
372
+ if (csrfError) return csrfError;
373
+
249
374
  // Read refresh token from JSON body (mobile) or cookie (web SSR)
250
375
  let refreshToken: string | undefined;
251
376
  try {
@@ -264,10 +389,14 @@ export function createAuthHandlers(
264
389
  );
265
390
  }
266
391
 
267
- if (schema.sessions) {
392
+ if (refreshTable) {
268
393
  // DB-backed rotation path
269
394
  try {
270
- const result = await rotateRefreshToken(db, schema.sessions, refreshToken);
395
+ const meta = extractRequestMeta(request, options?.repositoryId);
396
+ const result = await rotateRefreshToken(db, refreshTable, refreshToken, {
397
+ ipAddress: meta.ipAddress,
398
+ userAgent: meta.userAgent,
399
+ });
271
400
 
272
401
  const [user] = await db
273
402
  .select()
@@ -275,7 +404,7 @@ export function createAuthHandlers(
275
404
  .where(eq(users.id, result.userId))
276
405
  .limit(1);
277
406
  if (!user) {
278
- await revokeAllRefreshTokens(db, schema.sessions, result.userId);
407
+ await revokeAllRefreshTokens(db, refreshTable, result.userId, options?.repositoryId);
279
408
  return Response.json({ message: 'User not found' }, { status: 401 });
280
409
  }
281
410
 
@@ -283,7 +412,7 @@ export function createAuthHandlers(
283
412
  sub: user.id,
284
413
  email: user.email,
285
414
  role: user.role ?? 'authenticated',
286
- });
415
+ }, request);
287
416
 
288
417
  return authResponse(
289
418
  { token, refreshToken: result.token },
@@ -321,7 +450,7 @@ export function createAuthHandlers(
321
450
  sub: user.id,
322
451
  email: user.email,
323
452
  role: user.role ?? 'authenticated',
324
- });
453
+ }, request);
325
454
  const newRefreshToken = await signRefreshToken({ sub: user.id });
326
455
 
327
456
  return authResponse(
@@ -340,14 +469,21 @@ export function createAuthHandlers(
340
469
 
341
470
  oauth: options?.oauth?.providers.length
342
471
  ? async (request: Request): Promise<Response> => {
343
- if (!schema.credentials || !schema.identities) {
472
+ const csrfError = csrfCheck(request, options?.cookies);
473
+ if (csrfError) return csrfError;
474
+
475
+ const enrichRequest = options?.enrichClaims ? request.clone() : undefined;
476
+ const oauthCallbacks = options?.oauth?.callbacks;
477
+
478
+ // Require either callbacks or legacy tables
479
+ if (!oauthCallbacks && (!schema.credentials || !schema.identities)) {
344
480
  return Response.json(
345
- { message: 'OAuth not configured: credentials and identities tables required in schema' },
481
+ { message: 'OAuth not configured: provide oauth.callbacks or credentials/identities tables' },
346
482
  { status: 500 }
347
483
  );
348
484
  }
349
485
 
350
- let body: { provider?: string; token?: string };
486
+ let body: { provider?: string; token?: string; nonce?: string };
351
487
  try {
352
488
  body = await request.json();
353
489
  } catch {
@@ -357,7 +493,7 @@ export function createAuthHandlers(
357
493
  );
358
494
  }
359
495
 
360
- const { provider: providerName, token } = body;
496
+ const { provider: providerName, token, nonce } = body;
361
497
  if (!providerName || !token) {
362
498
  return Response.json(
363
499
  { message: 'provider and token are required' },
@@ -383,8 +519,100 @@ export function createAuthHandlers(
383
519
  );
384
520
  }
385
521
 
386
- // Look up existing OAuth credential by (type='oauth', credentialId=providerUid)
387
- // Join credentials → identities to get userId
522
+ // Optional nonce validation for web OAuth flows
523
+ if (options?.oauth?.validateNonce) {
524
+ if (!nonce) {
525
+ return Response.json(
526
+ { message: 'Nonce is required when nonce validation is configured' },
527
+ { status: 400 }
528
+ );
529
+ }
530
+ const nonceValid = await options.oauth.validateNonce(nonce, userInfo);
531
+ if (!nonceValid) {
532
+ return Response.json(
533
+ { message: 'Nonce validation failed' },
534
+ { status: 401 }
535
+ );
536
+ }
537
+ }
538
+
539
+ const meta = extractRequestMeta(request, options?.repositoryId);
540
+
541
+ if (oauthCallbacks) {
542
+ // Callback-based path (recommended) — no identities/credentials tables needed
543
+ const existing = await oauthCallbacks.findByProvider(providerName, userInfo.uid);
544
+
545
+ if (existing) {
546
+ const [existingUser] = await db
547
+ .select()
548
+ .from(users)
549
+ .where(eq(users.id, existing.userId))
550
+ .limit(1);
551
+
552
+ const [profile] = await db
553
+ .select()
554
+ .from(profiles)
555
+ .where(eq(profiles.id, existing.userId))
556
+ .limit(1);
557
+
558
+ const accessToken = await signToken({
559
+ sub: existingUser.id,
560
+ email: existingUser.email,
561
+ role: existingUser.role ?? 'authenticated',
562
+ }, enrichRequest);
563
+ const refreshTokenStr = refreshTable
564
+ ? (await createRefreshToken(db, refreshTable, existingUser.id, undefined, meta)).token
565
+ : await signRefreshToken({ sub: existingUser.id });
566
+
567
+ return authResponse(
568
+ {
569
+ token: accessToken,
570
+ refreshToken: refreshTokenStr,
571
+ user: {
572
+ id: existingUser.id,
573
+ email: existingUser.email,
574
+ username: profile?.username,
575
+ displayName: profile?.displayName,
576
+ avatarUrl: profile?.avatarUrl,
577
+ role: existingUser.role ?? 'authenticated',
578
+ },
579
+ },
580
+ 200,
581
+ options?.cookies,
582
+ );
583
+ }
584
+
585
+ // New user via callback
586
+ const newUser = await oauthCallbacks.createFromOAuth(userInfo, providerName);
587
+
588
+ const accessToken = await signToken({
589
+ sub: newUser.id,
590
+ email: newUser.email,
591
+ role: newUser.role ?? 'authenticated',
592
+ }, enrichRequest);
593
+ const refreshTokenStr = refreshTable
594
+ ? (await createRefreshToken(db, refreshTable, newUser.id, undefined, meta)).token
595
+ : await signRefreshToken({ sub: newUser.id });
596
+
597
+ return authResponse(
598
+ {
599
+ token: accessToken,
600
+ refreshToken: refreshTokenStr,
601
+ user: {
602
+ id: newUser.id,
603
+ email: newUser.email,
604
+ username: newUser.username,
605
+ displayName: newUser.displayName,
606
+ avatarUrl: newUser.avatarUrl,
607
+ role: newUser.role ?? 'authenticated',
608
+ },
609
+ },
610
+ 200,
611
+ options?.cookies,
612
+ );
613
+ }
614
+
615
+ // Legacy path: identities + credentials tables
388
616
  const existingResults = await db
389
617
  .select({
390
618
  credentialId: schema.credentials.id,
@@ -401,7 +629,6 @@ export function createAuthHandlers(
401
629
  const existingAccount = existingResults[0];
402
630
 
403
631
  if (existingAccount) {
404
- // Existing linked account — sign in the associated user
405
632
  const [existingUser] = await db
406
633
  .select()
407
634
  .from(users)
@@ -418,15 +645,15 @@ export function createAuthHandlers(
418
645
  sub: existingUser.id,
419
646
  email: existingUser.email,
420
647
  role: existingUser.role ?? 'authenticated',
421
- });
422
- const refreshToken = schema.sessions
423
- ? (await createRefreshToken(db, schema.sessions, existingUser.id)).token
648
+ }, enrichRequest);
649
+ const refreshTokenStr = refreshTable
650
+ ? (await createRefreshToken(db, refreshTable, existingUser.id, undefined, meta)).token
424
651
  : await signRefreshToken({ sub: existingUser.id });
425
652
 
426
653
  return authResponse(
427
654
  {
428
655
  token: accessToken,
429
- refreshToken,
656
+ refreshToken: refreshTokenStr,
430
657
  user: {
431
658
  id: existingUser.id,
432
659
  email: existingUser.email,
@@ -441,7 +668,7 @@ export function createAuthHandlers(
441
668
  );
442
669
  }
443
670
 
444
- // New user — create account + identity + OAuth credential
671
+ // New user — create account + identity + OAuth credential (legacy path)
445
672
  const displayName = [userInfo.firstName, userInfo.lastName]
446
673
  .filter(Boolean)
447
674
  .join(' ') || userInfo.email.split('@')[0];
@@ -450,7 +677,7 @@ export function createAuthHandlers(
450
677
  const { user, profile } = await db.transaction(async (tx: any) => {
451
678
  const [u] = await tx
452
679
  .insert(users)
453
- .values({ email: userInfo.email, passwordHash: null })
680
+ .values(withPassword(users, { email: userInfo.email }, null))
454
681
  .returning();
455
682
 
456
683
  const [ident] = await tx
@@ -489,15 +716,15 @@ export function createAuthHandlers(
489
716
  sub: user.id,
490
717
  email: user.email,
491
718
  role: user.role ?? 'authenticated',
492
- });
493
- const refreshToken = schema.sessions
494
- ? (await createRefreshToken(db, schema.sessions, user.id)).token
719
+ }, enrichRequest);
720
+ const refreshTokenStr = refreshTable
721
+ ? (await createRefreshToken(db, refreshTable, user.id, undefined, meta)).token
495
722
  : await signRefreshToken({ sub: user.id });
496
723
 
497
724
  return authResponse(
498
725
  {
499
726
  token: accessToken,
500
- refreshToken,
727
+ refreshToken: refreshTokenStr,
501
728
  user: {
502
729
  id: user.id,
503
730
  email: user.email,
@@ -513,13 +740,16 @@ export function createAuthHandlers(
513
740
  }
514
741
  : undefined,
515
742
 
516
- signout: async (_request: Request): Promise<Response> => {
743
+ signout: async (request: Request): Promise<Response> => {
744
+ const csrfError = csrfCheck(request, options?.cookies);
745
+ if (csrfError) return csrfError;
746
+
517
747
  return signoutResponse(options?.cookies);
518
748
  },
519
749
 
520
750
  revokeRefreshTokens: async (userId: string): Promise<void> => {
521
- if (schema.sessions) {
522
- await revokeAllRefreshTokens(db, schema.sessions, userId);
751
+ if (refreshTable) {
752
+ await revokeAllRefreshTokens(db, refreshTable, userId, options?.repositoryId);
523
753
  }
524
754
  },
525
755
  };
package/src/jwt/index.ts CHANGED
@@ -89,15 +89,16 @@ export function createJwt<
89
89
  if (parts.length !== 3) return null;
90
90
  const [headerB64, payloadB64, sigB64] = parts;
91
91
 
92
- // Header must decode to exactly the canonical HS256 header. This rejects
93
- // alg=none, alg=RS256 confusion, and any other algorithm variation.
92
+ // Header must specify HS256. This rejects alg=none, alg=RS256 confusion,
93
+ // and any other algorithm variation. typ is optional per RFC 7519 §5.1 but
94
+ // if present must be "JWT".
94
95
  let header: { alg?: string; typ?: string };
95
96
  try {
96
97
  header = JSON.parse(textDecode(base64urlDecode(headerB64)));
97
98
  } catch {
98
99
  return null;
99
100
  }
100
- if (header.alg !== 'HS256' || header.typ !== 'JWT') return null;
101
+ if (header.alg !== 'HS256' || (header.typ && header.typ !== 'JWT')) return null;
101
102
 
102
103
  const expected = await hmac(secretBytes, textEncode(`${headerB64}.${payloadB64}`));
103
104
  let actual: Uint8Array;
package/src/passwords.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  import bcrypt from 'bcryptjs';
20
20
 
21
21
  const DEFAULT_BCRYPT_COST = 10;
22
- export const BCRYPT_COST = Math.max(4, Math.min(31,
22
+ export const BCRYPT_COST = Math.max(10, Math.min(31,
23
23
  parseInt(process.env.BCRYPT_COST || '', 10) || DEFAULT_BCRYPT_COST
24
24
  ));
25
25
  export const BCRYPT_MAX_BYTES = 72;