@spfn/auth 0.3.0-beta.23 → 0.3.0-beta.24

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.
@@ -438,10 +438,113 @@ function pushCsrfCookieRemoval(setCookies) {
438
438
  });
439
439
  }
440
440
 
441
+ // src/nextjs/interceptors/session-binding.ts
442
+ import { SessionResealFailedError } from "@spfn/auth/errors";
443
+
444
+ // src/server/lib/ua-family.ts
445
+ var FAMILY_MARKERS = [
446
+ { family: "edge", marker: /\bEdg(?:A|iOS)?\// },
447
+ { family: "chrome", marker: /\b(?:Chrome|CriOS)\// },
448
+ { family: "firefox", marker: /\b(?:Firefox|FxiOS)\// },
449
+ { family: "safari", marker: /\bSafari\// }
450
+ ];
451
+ function uaFamily(userAgent) {
452
+ if (!userAgent) {
453
+ return "other";
454
+ }
455
+ return FAMILY_MARKERS.find((entry) => entry.marker.test(userAgent))?.family ?? "other";
456
+ }
457
+
458
+ // src/nextjs/interceptors/error-envelope.ts
459
+ function refusalEnvelope(error, setCookies = []) {
460
+ const body = error.toJSON();
461
+ return {
462
+ status: error.statusCode,
463
+ body: {
464
+ ...body,
465
+ error: {
466
+ code: body.__type,
467
+ message: body.message,
468
+ requestId: mintRequestId()
469
+ }
470
+ },
471
+ setCookies
472
+ };
473
+ }
474
+ function mintRequestId() {
475
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
476
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
477
+ }
478
+
479
+ // src/nextjs/interceptors/session-binding.ts
480
+ function bindingSessionFields(body, userAgent) {
481
+ if (body?.sessionBinding !== "passkey" || typeof body.keyExpiresAtMillis !== "number") {
482
+ return {};
483
+ }
484
+ return {
485
+ binding: "passkey",
486
+ keyExpiresAt: body.keyExpiresAtMillis,
487
+ ...userAgent ? { uaFamily: uaFamily(userAgent) } : {}
488
+ };
489
+ }
490
+ var sessionBindingInterceptor = {
491
+ pathPattern: "/_auth/session/binding",
492
+ method: "POST",
493
+ response: async (ctx, next) => {
494
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
495
+ if (ctx.response.status !== 200 || !sessionCookie) {
496
+ await next();
497
+ return;
498
+ }
499
+ try {
500
+ const session = await unsealSession(sessionCookie);
501
+ await pushResealed(ctx.setCookies, applyBinding(session, ctx.response.body, ctx.request.headers));
502
+ } catch (error) {
503
+ authLogger.interceptor.general.error("Failed to re-seal the session after a binding change", error);
504
+ refuseAsUnsealable(ctx);
505
+ }
506
+ await next();
507
+ }
508
+ };
509
+ function refuseAsUnsealable(ctx) {
510
+ const refusal2 = refusalEnvelope(new SessionResealFailedError());
511
+ ctx.response.status = refusal2.status;
512
+ ctx.response.ok = false;
513
+ ctx.response.body = refusal2.body;
514
+ for (const name of [COOKIE_NAMES.SESSION, COOKIE_NAMES.SESSION_KEY_ID]) {
515
+ ctx.setCookies.push({ name, value: "", options: { maxAge: 0, path: "/" } });
516
+ }
517
+ pushCsrfCookieRemoval(ctx.setCookies);
518
+ }
519
+ function applyBinding(session, body, requestHeaders) {
520
+ const { binding, keyExpiresAt, uaFamily: sealedFamily, ...unbound } = session;
521
+ if (body?.mode !== "passkey") {
522
+ return unbound;
523
+ }
524
+ const fields = bindingSessionFields(
525
+ { sessionBinding: body.mode, keyExpiresAtMillis: body.keyExpiresAtMillis },
526
+ requestHeaders["user-agent"]
527
+ );
528
+ return { ...unbound, ...fields, ...sealedFamily ? { uaFamily: sealedFamily } : {} };
529
+ }
530
+ async function pushResealed(setCookies, session) {
531
+ const ttl = getSessionTtl();
532
+ const options = {
533
+ httpOnly: true,
534
+ secure: cookieSecure,
535
+ sameSite: "lax",
536
+ maxAge: ttl,
537
+ path: "/"
538
+ };
539
+ setCookies.push({ name: COOKIE_NAMES.SESSION, value: await sealSession(session, ttl), options });
540
+ setCookies.push({ name: COOKIE_NAMES.SESSION_KEY_ID, value: session.keyId, options });
541
+ await pushCsrfCookie(setCookies, session.keyId, ttl);
542
+ }
543
+
441
544
  // src/nextjs/interceptors/login-register.ts
442
545
  var ROTATING_SIGN_IN_PATHS = /* @__PURE__ */ new Set(["/_auth/login", "/_auth/passkeys/login/verify"]);
443
546
  var loginRegisterInterceptor = {
444
- pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify)$/,
547
+ pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify|session\/renew\/verify)$/,
445
548
  method: "POST",
446
549
  request: async (ctx, next) => {
447
550
  const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);
@@ -482,7 +585,8 @@ var loginRegisterInterceptor = {
482
585
  userId: userData.userId,
483
586
  privateKey: ctx.metadata.privateKey,
484
587
  keyId: ctx.metadata.keyId,
485
- algorithm: ctx.metadata.algorithm
588
+ algorithm: ctx.metadata.algorithm,
589
+ ...bindingSessionFields(userData, ctx.request.headers["user-agent"])
486
590
  };
487
591
  const sealed = await sealSession(sessionData, ttl);
488
592
  ctx.setCookies.push({
@@ -516,6 +620,12 @@ var loginRegisterInterceptor = {
516
620
  }
517
621
  };
518
622
 
623
+ // src/nextjs/interceptors/general-auth.ts
624
+ import { SessionContextChangedError, SessionRenewalRequiredError } from "@spfn/auth/errors";
625
+
626
+ // src/nextjs/interceptors/session-renew.ts
627
+ var SESSION_RENEW_PATH_PATTERN = /^\/_auth\/session\/renew\/(options|verify)$/;
628
+
519
629
  // src/nextjs/interceptors/general-auth.ts
520
630
  function requiresAuth(path) {
521
631
  const publicPaths = [
@@ -530,6 +640,25 @@ function requiresAuth(path) {
530
640
  ];
531
641
  return !publicPaths.some((pattern) => pattern.test(path));
532
642
  }
643
+ function contextChanged(session, userAgent) {
644
+ return session.binding === "passkey" && Boolean(session.uaFamily) && Boolean(userAgent) && uaFamily(userAgent) !== session.uaFamily;
645
+ }
646
+ function refuseAsContextChanged(ctx) {
647
+ authLogger.interceptor.general.warn("Bound session presented from a different browser family", {
648
+ path: ctx.path,
649
+ sealed: ctx.metadata.sealedUaFamily,
650
+ presented: ctx.metadata.presentedUaFamily
651
+ });
652
+ const cleared = [
653
+ { name: COOKIE_NAMES.SESSION, value: "", options: { maxAge: 0, path: "/" } },
654
+ { name: COOKIE_NAMES.SESSION_KEY_ID, value: "", options: { maxAge: 0, path: "/" } },
655
+ { name: COOKIE_NAMES.CSRF, value: "", options: { maxAge: 0, path: "/" } }
656
+ ];
657
+ ctx.abort = refusalEnvelope(new SessionContextChangedError(), cleared);
658
+ }
659
+ function isKeyExpiredRefusal(body) {
660
+ return body?.__type === "KeyExpiredError";
661
+ }
533
662
  var generalAuthInterceptor = {
534
663
  pathPattern: "*",
535
664
  // Match all paths, filter by requiresAuth()
@@ -569,6 +698,13 @@ var generalAuthInterceptor = {
569
698
  if (await refuseInvalidCsrf(ctx, session.keyId)) {
570
699
  return;
571
700
  }
701
+ const presented = ctx.request.headers.get("user-agent");
702
+ if (contextChanged(session, presented)) {
703
+ ctx.metadata.sealedUaFamily = session.uaFamily;
704
+ ctx.metadata.presentedUaFamily = uaFamily(presented);
705
+ refuseAsContextChanged(ctx);
706
+ return;
707
+ }
572
708
  const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
573
709
  if (needsRefresh) {
574
710
  authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
@@ -591,6 +727,7 @@ var generalAuthInterceptor = {
591
727
  ctx.metadata.userId = session.userId;
592
728
  ctx.metadata.keyId = session.keyId;
593
729
  ctx.metadata.sessionValid = true;
730
+ ctx.metadata.sessionBound = session.binding === "passkey";
594
731
  } catch (error) {
595
732
  const err = error;
596
733
  const msg = err.message.toLowerCase();
@@ -611,7 +748,12 @@ var generalAuthInterceptor = {
611
748
  await next();
612
749
  },
613
750
  response: async (ctx, next) => {
614
- if (ctx.response.status === 401 && ctx.metadata.sessionValid) {
751
+ if (ctx.response.status === 401 && ctx.metadata.sessionValid && ctx.metadata.sessionBound && isKeyExpiredRefusal(ctx.response.body)) {
752
+ ctx.response.body = refusalEnvelope(new SessionRenewalRequiredError()).body;
753
+ await next();
754
+ return;
755
+ }
756
+ if (ctx.response.status === 401 && ctx.metadata.sessionValid && !SESSION_RENEW_PATH_PATTERN.test(ctx.path)) {
615
757
  authLogger.interceptor.general.warn("Backend returned 401, clearing session");
616
758
  ctx.setCookies.push({
617
759
  name: COOKIE_NAMES.SESSION,
@@ -761,6 +903,11 @@ var keyRotationInterceptor = {
761
903
  ctx.metadata.newKeyId = newKeyPair.keyId;
762
904
  ctx.metadata.newAlgorithm = newKeyPair.algorithm;
763
905
  ctx.metadata.userId = currentSession.userId;
906
+ ctx.metadata.bindingFields = currentSession.binding ? {
907
+ binding: currentSession.binding,
908
+ keyExpiresAt: currentSession.keyExpiresAt,
909
+ ...currentSession.uaFamily ? { uaFamily: currentSession.uaFamily } : {}
910
+ } : {};
764
911
  } catch (error) {
765
912
  const err = error;
766
913
  authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
@@ -783,7 +930,8 @@ var keyRotationInterceptor = {
783
930
  userId: ctx.metadata.userId,
784
931
  privateKey: ctx.metadata.newPrivateKey,
785
932
  keyId: ctx.metadata.newKeyId,
786
- algorithm: ctx.metadata.newAlgorithm
933
+ algorithm: ctx.metadata.newAlgorithm,
934
+ ...ctx.metadata.bindingFields
787
935
  };
788
936
  const sealed = await sealSession(newSessionData, ttl);
789
937
  ctx.setCookies.push({
@@ -1044,7 +1192,8 @@ var oauthFinalizeInterceptor = {
1044
1192
  userId,
1045
1193
  privateKey: pendingSession.privateKey,
1046
1194
  keyId: pendingSession.keyId,
1047
- algorithm: pendingSession.algorithm
1195
+ algorithm: pendingSession.algorithm,
1196
+ ...bindingSessionFields(ctx.response.body, ctx.request.headers["user-agent"])
1048
1197
  }, ttl);
1049
1198
  ctx.setCookies.push({
1050
1199
  name: COOKIE_NAMES.SESSION,
@@ -1205,7 +1354,8 @@ var authInterceptors = [
1205
1354
  keyRotationInterceptor,
1206
1355
  oauthUrlInterceptor,
1207
1356
  oauthFinalizeInterceptor,
1208
- generalAuthInterceptor
1357
+ generalAuthInterceptor,
1358
+ sessionBindingInterceptor
1209
1359
  ];
1210
1360
 
1211
1361
  // src/nextjs/api.ts