@spfn/auth 0.3.0-beta.7 → 0.3.0-beta.8

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.
@@ -96,7 +96,8 @@ var authLogger = {
96
96
  general: rootLogger.child("@spfn/auth:interceptor:general"),
97
97
  login: rootLogger.child("@spfn/auth:interceptor:login"),
98
98
  keyRotation: rootLogger.child("@spfn/auth:interceptor:key-rotation"),
99
- oauth: rootLogger.child("@spfn/auth:interceptor:oauth")
99
+ oauth: rootLogger.child("@spfn/auth:interceptor:oauth"),
100
+ csrf: rootLogger.child("@spfn/auth:interceptor:csrf")
100
101
  },
101
102
  session: rootLogger.child("@spfn/auth:session"),
102
103
  service: rootLogger.child("@spfn/auth:service"),
@@ -214,6 +215,10 @@ var COOKIE_NAMES = {
214
215
  /** Password-setup session for verified-email signup — temporary, single-purpose */
215
216
  get SIGNUP_SETUP() {
216
217
  return `spfn_signup_setup${getCookieSuffix()}`;
218
+ },
219
+ /** CSRF token — the only cookie here the browser can read */
220
+ get CSRF() {
221
+ return `spfn_csrf${getCookieSuffix()}`;
217
222
  }
218
223
  };
219
224
  function parseDuration(duration) {
@@ -256,6 +261,28 @@ function getSessionTtl(override) {
256
261
  }
257
262
  return 7 * 24 * 60 * 60;
258
263
  }
264
+ var CSRF_MODES = ["off", "warn", "enforce"];
265
+ var unrecognizedCsrfModeReported = false;
266
+ function getCsrfMode() {
267
+ const configured = globalConfig.csrf?.mode ?? env2.SPFN_AUTH_CSRF;
268
+ if (!configured) {
269
+ return "warn";
270
+ }
271
+ const normalized = String(configured).trim().toLowerCase();
272
+ if (!CSRF_MODES.includes(normalized)) {
273
+ if (!unrecognizedCsrfModeReported) {
274
+ unrecognizedCsrfModeReported = true;
275
+ authLogger.interceptor.csrf.error(
276
+ `Unrecognized CSRF mode "${configured}" \u2014 expected off | warn | enforce. Enforcing.`
277
+ );
278
+ }
279
+ return "enforce";
280
+ }
281
+ return normalized;
282
+ }
283
+ function getCsrfExemptPaths() {
284
+ return globalConfig.csrf?.exemptPaths ?? [];
285
+ }
259
286
 
260
287
  // src/nextjs/interceptors/cookie-options.ts
261
288
  function resolveSecure() {
@@ -267,6 +294,140 @@ function resolveSecure() {
267
294
  }
268
295
  var cookieSecure = resolveSecure();
269
296
 
297
+ // src/server/lib/csrf.ts
298
+ import { env as env3 } from "@spfn/auth/config";
299
+ var CSRF_HEADER = "x-spfn-csrf";
300
+ var CSRF_SUBKEY_LABEL = "spfn-auth-csrf-token-v1";
301
+ var MAX_CANDIDATES = 32;
302
+ function sessionSecret() {
303
+ const secret = env3.SPFN_AUTH_SESSION_SECRET;
304
+ if (!secret) {
305
+ throw new Error(
306
+ "SPFN_AUTH_SESSION_SECRET is required for CSRF protection. Set it (sessions need it anyway), or set SPFN_AUTH_CSRF=off."
307
+ );
308
+ }
309
+ return secret;
310
+ }
311
+ async function hmacSha256(key, message) {
312
+ const cryptoKey = await crypto.subtle.importKey(
313
+ "raw",
314
+ key.buffer,
315
+ { name: "HMAC", hash: "SHA-256" },
316
+ false,
317
+ ["sign"]
318
+ );
319
+ const signature = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
320
+ return new Uint8Array(signature);
321
+ }
322
+ function toHex(bytes) {
323
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
324
+ }
325
+ async function deriveCsrfToken(keyId) {
326
+ const subkey = await hmacSha256(new TextEncoder().encode(sessionSecret()), CSRF_SUBKEY_LABEL);
327
+ return toHex(await hmacSha256(subkey, keyId));
328
+ }
329
+ function timingSafeEqualString(a, b) {
330
+ if (a.length !== b.length) {
331
+ return false;
332
+ }
333
+ let difference = 0;
334
+ for (let i = 0; i < a.length; i++) {
335
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
336
+ }
337
+ return difference === 0;
338
+ }
339
+ function matchesCsrfToken(expected, presented) {
340
+ if (!presented) {
341
+ return false;
342
+ }
343
+ return presented.split(",", MAX_CANDIDATES).some((candidate) => timingSafeEqualString(expected, candidate.trim()));
344
+ }
345
+
346
+ // src/nextjs/interceptors/csrf.ts
347
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
348
+ function csrfCookie(token, ttl) {
349
+ return {
350
+ name: COOKIE_NAMES.CSRF,
351
+ value: token,
352
+ options: {
353
+ httpOnly: false,
354
+ secure: cookieSecure,
355
+ sameSite: "lax",
356
+ maxAge: ttl,
357
+ path: "/"
358
+ }
359
+ };
360
+ }
361
+ function refusal() {
362
+ return {
363
+ status: 403,
364
+ body: {
365
+ error: "Forbidden",
366
+ message: "CSRF token missing or invalid"
367
+ },
368
+ setCookies: []
369
+ };
370
+ }
371
+ async function refuseInvalidCsrf(ctx, keyId) {
372
+ const mode = getCsrfMode();
373
+ if (mode === "off" || SAFE_METHODS.has(ctx.method.toUpperCase())) {
374
+ return false;
375
+ }
376
+ if (getCsrfExemptPaths().includes(ctx.path)) {
377
+ authLogger.interceptor.csrf.debug("Path is CSRF-exempt", { path: ctx.path });
378
+ return false;
379
+ }
380
+ let expected;
381
+ try {
382
+ expected = await deriveCsrfToken(keyId);
383
+ } catch (error) {
384
+ authLogger.interceptor.csrf.error(
385
+ "Cannot derive the CSRF token \u2014 refusing regardless of mode",
386
+ error
387
+ );
388
+ ctx.abort = refusal();
389
+ return true;
390
+ }
391
+ const presented = ctx.request.headers.get(CSRF_HEADER);
392
+ if (matchesCsrfToken(expected, presented)) {
393
+ return false;
394
+ }
395
+ const detail = {
396
+ method: ctx.method,
397
+ path: ctx.path,
398
+ headerPresent: !!presented
399
+ };
400
+ if (mode === "warn") {
401
+ authLogger.interceptor.csrf.warn("CSRF check would refuse this request (mode=warn)", detail);
402
+ return false;
403
+ }
404
+ authLogger.interceptor.csrf.warn("CSRF check refused this request", detail);
405
+ ctx.abort = refusal();
406
+ ctx.abort.setCookies = [csrfCookie(expected, getSessionTtl())];
407
+ return true;
408
+ }
409
+ async function pushCsrfCookie(setCookies, keyId, ttl) {
410
+ setCookies.push(csrfCookie(await deriveCsrfToken(keyId), ttl));
411
+ }
412
+ async function pushCsrfCookieIfStale(setCookies, presented, keyId) {
413
+ try {
414
+ const token = await deriveCsrfToken(keyId);
415
+ if (presented && timingSafeEqualString(token, presented)) {
416
+ return;
417
+ }
418
+ setCookies.push(csrfCookie(token, getSessionTtl()));
419
+ } catch (error) {
420
+ authLogger.interceptor.csrf.error("Cannot reissue the CSRF cookie", error);
421
+ }
422
+ }
423
+ function pushCsrfCookieRemoval(setCookies) {
424
+ setCookies.push({
425
+ name: COOKIE_NAMES.CSRF,
426
+ value: "",
427
+ options: { maxAge: 0, path: "/" }
428
+ });
429
+ }
430
+
270
431
  // src/nextjs/interceptors/login-register.ts
271
432
  var loginRegisterInterceptor = {
272
433
  pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password)$/,
@@ -335,6 +496,7 @@ var loginRegisterInterceptor = {
335
496
  path: "/"
336
497
  }
337
498
  });
499
+ await pushCsrfCookie(ctx.setCookies, ctx.metadata.keyId, ttl);
338
500
  } catch (error) {
339
501
  const err = error;
340
502
  authLogger.interceptor.login.error("Failed to save session", err);
@@ -393,6 +555,9 @@ var generalAuthInterceptor = {
393
555
  userId: session.userId,
394
556
  keyId: session.keyId
395
557
  });
558
+ if (await refuseInvalidCsrf(ctx, session.keyId)) {
559
+ return;
560
+ }
396
561
  const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
397
562
  if (needsRefresh) {
398
563
  authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
@@ -413,6 +578,7 @@ var generalAuthInterceptor = {
413
578
  ctx.headers["Authorization"] = `Bearer ${token}`;
414
579
  ctx.headers["X-Key-Id"] = session.keyId;
415
580
  ctx.metadata.userId = session.userId;
581
+ ctx.metadata.keyId = session.keyId;
416
582
  ctx.metadata.sessionValid = true;
417
583
  } catch (error) {
418
584
  const err = error;
@@ -446,6 +612,7 @@ var generalAuthInterceptor = {
446
612
  value: "",
447
613
  options: { maxAge: 0, path: "/" }
448
614
  });
615
+ pushCsrfCookieRemoval(ctx.setCookies);
449
616
  await next();
450
617
  return;
451
618
  }
@@ -466,6 +633,7 @@ var generalAuthInterceptor = {
466
633
  path: "/"
467
634
  }
468
635
  });
636
+ pushCsrfCookieRemoval(ctx.setCookies);
469
637
  } else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
470
638
  try {
471
639
  const sessionData = ctx.metadata.sessionData;
@@ -493,6 +661,7 @@ var generalAuthInterceptor = {
493
661
  path: "/"
494
662
  }
495
663
  });
664
+ await pushCsrfCookie(ctx.setCookies, sessionData.keyId, ttl);
496
665
  authLogger.interceptor.general.info("Session refreshed", {
497
666
  userId: sessionData.userId,
498
667
  sealedLength: sealed.length,
@@ -524,6 +693,15 @@ var generalAuthInterceptor = {
524
693
  value: "",
525
694
  options: { ...base, sameSite: "lax" }
526
695
  });
696
+ pushCsrfCookieRemoval(ctx.setCookies);
697
+ }
698
+ const csrfQueued = ctx.setCookies.some((cookie) => cookie.name === COOKIE_NAMES.CSRF);
699
+ if (ctx.metadata.sessionValid && !csrfQueued) {
700
+ await pushCsrfCookieIfStale(
701
+ ctx.setCookies,
702
+ ctx.cookies.get(COOKIE_NAMES.CSRF),
703
+ ctx.metadata.keyId
704
+ );
527
705
  }
528
706
  await next();
529
707
  }
@@ -550,10 +728,11 @@ var keyRotationInterceptor = {
550
728
  ctx.body.fingerprint = newKeyPair.fingerprint;
551
729
  ctx.body.algorithm = newKeyPair.algorithm;
552
730
  ctx.body.keySize = Buffer.from(newKeyPair.publicKey, "base64").length;
553
- console.log("New key generated:", newKeyPair);
554
- console.log("publicKey:", newKeyPair.publicKey);
555
- console.log("keyId:", newKeyPair.keyId);
556
- console.log("fingerprint:", newKeyPair.fingerprint);
731
+ authLogger.interceptor.keyRotation.debug("Generated a new key pair", {
732
+ keyId: newKeyPair.keyId,
733
+ fingerprint: newKeyPair.fingerprint,
734
+ algorithm: newKeyPair.algorithm
735
+ });
557
736
  const token = generateClientToken(
558
737
  {
559
738
  userId: currentSession.userId,
@@ -618,6 +797,7 @@ var keyRotationInterceptor = {
618
797
  path: "/"
619
798
  }
620
799
  });
800
+ await pushCsrfCookie(ctx.setCookies, ctx.metadata.newKeyId, ttl);
621
801
  } catch (error) {
622
802
  const err = error;
623
803
  authLogger.interceptor.keyRotation.error("Failed to update session after rotation", err);
@@ -628,9 +808,9 @@ var keyRotationInterceptor = {
628
808
 
629
809
  // src/server/lib/oauth/state.ts
630
810
  import * as jose2 from "jose";
631
- import { env as env3 } from "@spfn/auth/config";
811
+ import { env as env4 } from "@spfn/auth/config";
632
812
  async function getStateKey() {
633
- const secret = env3.SPFN_AUTH_SESSION_SECRET;
813
+ const secret = env4.SPFN_AUTH_SESSION_SECRET;
634
814
  const encoder = new TextEncoder();
635
815
  const data = encoder.encode(`oauth-state:${secret}`);
636
816
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -663,10 +843,10 @@ async function createOAuthState(params) {
663
843
  // src/nextjs/session-helpers.ts
664
844
  import * as jose3 from "jose";
665
845
  import { cookies } from "next/headers.js";
666
- import { env as env4 } from "@spfn/auth/config";
846
+ import { env as env5 } from "@spfn/auth/config";
667
847
  import { logger } from "@spfn/core/logger";
668
848
  async function getPendingSessionKey() {
669
- const secret = env4.SPFN_AUTH_SESSION_SECRET;
849
+ const secret = env5.SPFN_AUTH_SESSION_SECRET;
670
850
  const encoder = new TextEncoder();
671
851
  const data = encoder.encode(`oauth-pending:${secret}`);
672
852
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -841,6 +1021,7 @@ var oauthFinalizeInterceptor = {
841
1021
  path: "/"
842
1022
  }
843
1023
  });
1024
+ await pushCsrfCookie(ctx.setCookies, keyId, ttl);
844
1025
  ctx.setCookies.push({
845
1026
  name: COOKIE_NAMES.OAUTH_PENDING,
846
1027
  value: "",