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

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.
Files changed (35) hide show
  1. package/README.md +328 -7
  2. package/dist/client-proof.d.ts +10 -1
  3. package/dist/client-proof.js +136 -11
  4. package/dist/client-proof.js.map +1 -1
  5. package/dist/client.d.ts +101 -1
  6. package/dist/client.js +65 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.d.ts +122 -0
  9. package/dist/config.js +53 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/errors.d.ts +159 -3
  13. package/dist/errors.js +95 -2
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.d.ts +52 -12
  16. package/dist/index.js +104 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/{machine-principals-CaEFq61K.d.ts → machine-principals-CdEgxOB1.d.ts} +2049 -771
  19. package/dist/nextjs/api.js +329 -37
  20. package/dist/nextjs/api.js.map +1 -1
  21. package/dist/nextjs/server.d.ts +59 -24
  22. package/dist/nextjs/server.js +105 -11
  23. package/dist/nextjs/server.js.map +1 -1
  24. package/dist/server.d.ts +415 -332
  25. package/dist/server.js +2933 -1545
  26. package/dist/server.js.map +1 -1
  27. package/dist/{session-Dfwu5g2W.d.ts → session-BbhAGZtA.d.ts} +57 -1
  28. package/dist/{types-DYyhze28.d.ts → types-CTdoTOxM.d.ts} +24 -1
  29. package/migrations/20260918184037_happy_mordo/migration.sql +4 -0
  30. package/migrations/20260918184037_happy_mordo/snapshot.json +6000 -0
  31. package/migrations/20260918184152_dear_rictor/migration.sql +3 -0
  32. package/migrations/20260918184152_dear_rictor/snapshot.json +6039 -0
  33. package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
  34. package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
  35. package/package.json +1 -1
@@ -209,6 +209,18 @@ var COOKIE_NAMES = {
209
209
  get OAUTH_PENDING() {
210
210
  return `spfn_oauth_pending${getCookieSuffix()}`;
211
211
  },
212
+ /**
213
+ * Pending second-factor session (privateKey, keyId, challengeHash) (#95)
214
+ *
215
+ * Its own name and its own audience, separate from OAUTH_PENDING. The two
216
+ * coexist: a person who starts a social login in one tab while a password
217
+ * step-up is outstanding in another has both flows live, and one name would
218
+ * mean the second overwrote the first — sealing a session with a private key
219
+ * that does not match the key being activated.
220
+ */
221
+ get MFA_PENDING() {
222
+ return `spfn_mfa_pending${getCookieSuffix()}`;
223
+ },
212
224
  /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
213
225
  get OAUTH_CSRF() {
214
226
  return `spfn_oauth_csrf${getCookieSuffix()}`;
@@ -438,10 +450,116 @@ function pushCsrfCookieRemoval(setCookies) {
438
450
  });
439
451
  }
440
452
 
453
+ // src/nextjs/interceptors/session-binding.ts
454
+ import { SessionResealFailedError } from "@spfn/auth/errors";
455
+
456
+ // src/server/lib/ua-family.ts
457
+ var FAMILY_MARKERS = [
458
+ { family: "edge", marker: /\bEdg(?:A|iOS)?\// },
459
+ { family: "chrome", marker: /\b(?:Chrome|CriOS)\// },
460
+ { family: "firefox", marker: /\b(?:Firefox|FxiOS)\// },
461
+ { family: "safari", marker: /\bSafari\// }
462
+ ];
463
+ function uaFamily(userAgent) {
464
+ if (!userAgent) {
465
+ return "other";
466
+ }
467
+ return FAMILY_MARKERS.find((entry) => entry.marker.test(userAgent))?.family ?? "other";
468
+ }
469
+
470
+ // src/nextjs/interceptors/error-envelope.ts
471
+ function refusalEnvelope(error, setCookies = []) {
472
+ return {
473
+ status: error.statusCode,
474
+ body: refusalBody(error),
475
+ setCookies
476
+ };
477
+ }
478
+ function refusalBody(error) {
479
+ const body = error.toJSON();
480
+ return {
481
+ ...body,
482
+ error: {
483
+ code: body.__type,
484
+ message: body.message,
485
+ requestId: mintRequestId()
486
+ }
487
+ };
488
+ }
489
+ function mintRequestId() {
490
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
491
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
492
+ }
493
+
494
+ // src/nextjs/interceptors/session-binding.ts
495
+ function bindingSessionFields(body, userAgent) {
496
+ if (body?.sessionBinding !== "passkey" || typeof body.keyExpiresAtMillis !== "number") {
497
+ return {};
498
+ }
499
+ return {
500
+ binding: "passkey",
501
+ keyExpiresAt: body.keyExpiresAtMillis,
502
+ ...userAgent ? { uaFamily: uaFamily(userAgent) } : {}
503
+ };
504
+ }
505
+ var sessionBindingInterceptor = {
506
+ pathPattern: "/_auth/session/binding",
507
+ method: "POST",
508
+ response: async (ctx, next) => {
509
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
510
+ if (ctx.response.status !== 200 || !sessionCookie) {
511
+ await next();
512
+ return;
513
+ }
514
+ try {
515
+ const session = await unsealSession(sessionCookie);
516
+ await pushResealed(ctx.setCookies, applyBinding(session, ctx.response.body, ctx.request.headers));
517
+ } catch (error) {
518
+ authLogger.interceptor.general.error("Failed to re-seal the session after a binding change", error);
519
+ refuseAsUnsealable(ctx);
520
+ }
521
+ await next();
522
+ }
523
+ };
524
+ function refuseAsUnsealable(ctx) {
525
+ const refusal2 = refusalEnvelope(new SessionResealFailedError());
526
+ ctx.response.status = refusal2.status;
527
+ ctx.response.ok = false;
528
+ ctx.response.body = refusal2.body;
529
+ for (const name of [COOKIE_NAMES.SESSION, COOKIE_NAMES.SESSION_KEY_ID]) {
530
+ ctx.setCookies.push({ name, value: "", options: { maxAge: 0, path: "/" } });
531
+ }
532
+ pushCsrfCookieRemoval(ctx.setCookies);
533
+ }
534
+ function applyBinding(session, body, requestHeaders) {
535
+ const { binding, keyExpiresAt, uaFamily: sealedFamily, ...unbound } = session;
536
+ if (body?.mode !== "passkey") {
537
+ return unbound;
538
+ }
539
+ const fields = bindingSessionFields(
540
+ { sessionBinding: body.mode, keyExpiresAtMillis: body.keyExpiresAtMillis },
541
+ requestHeaders["user-agent"]
542
+ );
543
+ return { ...unbound, ...fields, ...sealedFamily ? { uaFamily: sealedFamily } : {} };
544
+ }
545
+ async function pushResealed(setCookies, session) {
546
+ const ttl = getSessionTtl();
547
+ const options = {
548
+ httpOnly: true,
549
+ secure: cookieSecure,
550
+ sameSite: "lax",
551
+ maxAge: ttl,
552
+ path: "/"
553
+ };
554
+ setCookies.push({ name: COOKIE_NAMES.SESSION, value: await sealSession(session, ttl), options });
555
+ setCookies.push({ name: COOKIE_NAMES.SESSION_KEY_ID, value: session.keyId, options });
556
+ await pushCsrfCookie(setCookies, session.keyId, ttl);
557
+ }
558
+
441
559
  // src/nextjs/interceptors/login-register.ts
442
560
  var ROTATING_SIGN_IN_PATHS = /* @__PURE__ */ new Set(["/_auth/login", "/_auth/passkeys/login/verify"]);
443
561
  var loginRegisterInterceptor = {
444
- pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify)$/,
562
+ pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify|session\/renew\/verify)$/,
445
563
  method: "POST",
446
564
  request: async (ctx, next) => {
447
565
  const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);
@@ -482,7 +600,8 @@ var loginRegisterInterceptor = {
482
600
  userId: userData.userId,
483
601
  privateKey: ctx.metadata.privateKey,
484
602
  keyId: ctx.metadata.keyId,
485
- algorithm: ctx.metadata.algorithm
603
+ algorithm: ctx.metadata.algorithm,
604
+ ...bindingSessionFields(userData, ctx.request.headers["user-agent"])
486
605
  };
487
606
  const sealed = await sealSession(sessionData, ttl);
488
607
  ctx.setCookies.push({
@@ -516,6 +635,155 @@ var loginRegisterInterceptor = {
516
635
  }
517
636
  };
518
637
 
638
+ // src/nextjs/interceptors/mfa-verify.ts
639
+ import { SessionPendingExpiredError, SessionPendingMismatchError } from "@spfn/auth/errors";
640
+
641
+ // src/server/lib/link-credentials.ts
642
+ import crypto3 from "crypto";
643
+ import { env as env4 } from "@spfn/auth/config";
644
+ function hashCredential(secret) {
645
+ return crypto3.createHash("sha256").update(secret).digest("base64url");
646
+ }
647
+
648
+ // src/nextjs/session-helpers.ts
649
+ import * as jose2 from "jose";
650
+ import { cookies } from "next/headers.js";
651
+ import { env as env5 } from "@spfn/auth/config";
652
+ import { logger } from "@spfn/core/logger";
653
+ async function getPendingSessionKey(purpose) {
654
+ const secret = env5.SPFN_AUTH_SESSION_SECRET;
655
+ const encoder = new TextEncoder();
656
+ const data = encoder.encode(purpose === "oauth" ? `oauth-pending:${secret}` : `mfa-pending:${secret}`);
657
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
658
+ return new Uint8Array(hashBuffer);
659
+ }
660
+ async function sealPendingSession(data, ttl = 600) {
661
+ return await sealFor("oauth", data, ttl);
662
+ }
663
+ async function sealPendingMfaSession(data, ttl = 600) {
664
+ return await sealFor("mfa", data, ttl);
665
+ }
666
+ async function sealFor(purpose, data, ttl) {
667
+ return await new jose2.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience(purpose === "oauth" ? "spfn-oauth" : "spfn-mfa").encrypt(await getPendingSessionKey(purpose));
668
+ }
669
+ async function unsealPendingSession(jwt2) {
670
+ const { payload } = await jose2.jwtDecrypt(jwt2, await getPendingSessionKey("oauth"), {
671
+ issuer: "spfn-auth",
672
+ audience: "spfn-oauth"
673
+ });
674
+ return payload.data;
675
+ }
676
+ async function unsealPendingMfaSession(jwt2) {
677
+ const { payload } = await jose2.jwtDecrypt(jwt2, await getPendingSessionKey("mfa"), {
678
+ issuer: "spfn-auth",
679
+ audience: "spfn-mfa"
680
+ });
681
+ return payload.data;
682
+ }
683
+
684
+ // src/nextjs/interceptors/mfa-verify.ts
685
+ var MFA_PATH_PATTERN = /^\/_auth\/(login|password\/reset\/complete|oauth\/[\w-]+\/native|oauth\/finalize|mfa\/verify)$/;
686
+ var PENDING_TTL_SECONDS = 600;
687
+ function challengeSecretOf(body) {
688
+ const challenge = body?.challenge;
689
+ if (typeof challenge === "string") {
690
+ return challenge;
691
+ }
692
+ const secret = challenge?.secret;
693
+ return typeof secret === "string" ? secret : void 0;
694
+ }
695
+ async function pendingKeyFor(ctx) {
696
+ if (ctx.metadata.privateKey && ctx.metadata.keyId) {
697
+ return {
698
+ privateKey: ctx.metadata.privateKey,
699
+ keyId: ctx.metadata.keyId,
700
+ algorithm: ctx.metadata.algorithm
701
+ };
702
+ }
703
+ const oauthPending = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);
704
+ return oauthPending ? await unsealPendingSession(oauthPending) : null;
705
+ }
706
+ async function bakePendingCookie(ctx) {
707
+ const secret = challengeSecretOf(ctx.response.body);
708
+ const pending = secret ? await pendingKeyFor(ctx) : null;
709
+ if (!secret || !pending) {
710
+ return;
711
+ }
712
+ ctx.setCookies.push({
713
+ name: COOKIE_NAMES.MFA_PENDING,
714
+ value: await sealPendingMfaSession({ ...pending, challengeHash: hashCredential(secret) }, PENDING_TTL_SECONDS),
715
+ options: {
716
+ httpOnly: true,
717
+ secure: cookieSecure,
718
+ sameSite: "lax",
719
+ maxAge: PENDING_TTL_SECONDS,
720
+ path: "/"
721
+ }
722
+ });
723
+ authLogger.interceptor.login.debug("Second-factor pending cookie set", { keyId: pending.keyId });
724
+ }
725
+ function refuse(ctx, error) {
726
+ authLogger.interceptor.login.warn("Second-factor session not sealed", { reason: error.name });
727
+ ctx.response.ok = false;
728
+ ctx.response.status = error.statusCode;
729
+ ctx.response.statusText = "Unauthorized";
730
+ ctx.response.body = refusalBody(error);
731
+ }
732
+ async function sealVerifiedSession(ctx) {
733
+ const cookie = ctx.cookies.get(COOKIE_NAMES.MFA_PENDING);
734
+ if (!cookie) {
735
+ refuse(ctx, new SessionPendingExpiredError());
736
+ return;
737
+ }
738
+ const pending = await unsealPendingMfaSession(cookie);
739
+ const { userId, keyId, challengeHash } = ctx.response.body || {};
740
+ if (pending.challengeHash !== challengeHash || pending.keyId !== keyId) {
741
+ refuse(ctx, new SessionPendingMismatchError());
742
+ return;
743
+ }
744
+ const ttl = getSessionTtl();
745
+ const sealed = await sealSession({
746
+ userId,
747
+ privateKey: pending.privateKey,
748
+ keyId: pending.keyId,
749
+ algorithm: pending.algorithm,
750
+ ...bindingSessionFields(ctx.response.body, ctx.request.headers["user-agent"])
751
+ }, ttl);
752
+ pushSessionCookies(ctx, sealed, pending.keyId, ttl);
753
+ await pushCsrfCookie(ctx.setCookies, pending.keyId, ttl);
754
+ }
755
+ function pushSessionCookies(ctx, sealed, keyId, ttl) {
756
+ const options = { httpOnly: true, secure: cookieSecure, sameSite: "lax", path: "/" };
757
+ ctx.setCookies.push({ name: COOKIE_NAMES.SESSION, value: sealed, options: { ...options, maxAge: ttl } });
758
+ ctx.setCookies.push({ name: COOKIE_NAMES.SESSION_KEY_ID, value: keyId, options: { ...options, maxAge: ttl } });
759
+ ctx.setCookies.push({ name: COOKIE_NAMES.MFA_PENDING, value: "", options: { ...options, maxAge: 0 } });
760
+ }
761
+ var mfaVerifyInterceptor = {
762
+ pathPattern: MFA_PATH_PATTERN,
763
+ method: "POST",
764
+ response: async (ctx, next) => {
765
+ try {
766
+ if (ctx.response.status === 202) {
767
+ await bakePendingCookie(ctx);
768
+ } else if (ctx.response.status === 200 && ctx.path === "/_auth/mfa/verify") {
769
+ await sealVerifiedSession(ctx);
770
+ }
771
+ } catch (error) {
772
+ authLogger.interceptor.login.error("Second-factor session handling failed", error);
773
+ if (ctx.path === "/_auth/mfa/verify") {
774
+ refuse(ctx, new SessionPendingExpiredError());
775
+ }
776
+ }
777
+ await next();
778
+ }
779
+ };
780
+
781
+ // src/nextjs/interceptors/general-auth.ts
782
+ import { SessionContextChangedError, SessionRenewalRequiredError } from "@spfn/auth/errors";
783
+
784
+ // src/nextjs/interceptors/session-renew.ts
785
+ var SESSION_RENEW_PATH_PATTERN = /^\/_auth\/session\/renew\/(options|verify)$/;
786
+
519
787
  // src/nextjs/interceptors/general-auth.ts
520
788
  function requiresAuth(path) {
521
789
  const publicPaths = [
@@ -525,11 +793,38 @@ function requiresAuth(path) {
525
793
  // Send verification code
526
794
  /^\/_auth\/codes\/verify$/,
527
795
  // Verify code
528
- /^\/_auth\/exists$/
796
+ /^\/_auth\/exists$/,
529
797
  // Check account exists
798
+ // The two halves of a second-factor step-up (#95). Public for the same
799
+ // reason `login` is — the key they activate is inactive until they
800
+ // succeed, so there is nothing to sign them with — and public *here* for
801
+ // one more: a browser holding a stale session cookie would otherwise have
802
+ // this rule refresh or clear that session on the way out, over the fresh
803
+ // one `mfaVerifyInterceptor` just sealed.
804
+ /^\/_auth\/mfa\/verify$/,
805
+ /^\/_auth\/mfa\/verify\/options$/
530
806
  ];
531
807
  return !publicPaths.some((pattern) => pattern.test(path));
532
808
  }
809
+ function contextChanged(session, userAgent) {
810
+ return session.binding === "passkey" && Boolean(session.uaFamily) && Boolean(userAgent) && uaFamily(userAgent) !== session.uaFamily;
811
+ }
812
+ function refuseAsContextChanged(ctx) {
813
+ authLogger.interceptor.general.warn("Bound session presented from a different browser family", {
814
+ path: ctx.path,
815
+ sealed: ctx.metadata.sealedUaFamily,
816
+ presented: ctx.metadata.presentedUaFamily
817
+ });
818
+ const cleared = [
819
+ { name: COOKIE_NAMES.SESSION, value: "", options: { maxAge: 0, path: "/" } },
820
+ { name: COOKIE_NAMES.SESSION_KEY_ID, value: "", options: { maxAge: 0, path: "/" } },
821
+ { name: COOKIE_NAMES.CSRF, value: "", options: { maxAge: 0, path: "/" } }
822
+ ];
823
+ ctx.abort = refusalEnvelope(new SessionContextChangedError(), cleared);
824
+ }
825
+ function isKeyExpiredRefusal(body) {
826
+ return body?.__type === "KeyExpiredError";
827
+ }
533
828
  var generalAuthInterceptor = {
534
829
  pathPattern: "*",
535
830
  // Match all paths, filter by requiresAuth()
@@ -569,6 +864,13 @@ var generalAuthInterceptor = {
569
864
  if (await refuseInvalidCsrf(ctx, session.keyId)) {
570
865
  return;
571
866
  }
867
+ const presented = ctx.request.headers.get("user-agent");
868
+ if (contextChanged(session, presented)) {
869
+ ctx.metadata.sealedUaFamily = session.uaFamily;
870
+ ctx.metadata.presentedUaFamily = uaFamily(presented);
871
+ refuseAsContextChanged(ctx);
872
+ return;
873
+ }
572
874
  const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
573
875
  if (needsRefresh) {
574
876
  authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
@@ -591,6 +893,7 @@ var generalAuthInterceptor = {
591
893
  ctx.metadata.userId = session.userId;
592
894
  ctx.metadata.keyId = session.keyId;
593
895
  ctx.metadata.sessionValid = true;
896
+ ctx.metadata.sessionBound = session.binding === "passkey";
594
897
  } catch (error) {
595
898
  const err = error;
596
899
  const msg = err.message.toLowerCase();
@@ -611,7 +914,12 @@ var generalAuthInterceptor = {
611
914
  await next();
612
915
  },
613
916
  response: async (ctx, next) => {
614
- if (ctx.response.status === 401 && ctx.metadata.sessionValid) {
917
+ if (ctx.response.status === 401 && ctx.metadata.sessionValid && ctx.metadata.sessionBound && isKeyExpiredRefusal(ctx.response.body)) {
918
+ ctx.response.body = refusalEnvelope(new SessionRenewalRequiredError()).body;
919
+ await next();
920
+ return;
921
+ }
922
+ if (ctx.response.status === 401 && ctx.metadata.sessionValid && !SESSION_RENEW_PATH_PATTERN.test(ctx.path)) {
615
923
  authLogger.interceptor.general.warn("Backend returned 401, clearing session");
616
924
  ctx.setCookies.push({
617
925
  name: COOKIE_NAMES.SESSION,
@@ -761,6 +1069,11 @@ var keyRotationInterceptor = {
761
1069
  ctx.metadata.newKeyId = newKeyPair.keyId;
762
1070
  ctx.metadata.newAlgorithm = newKeyPair.algorithm;
763
1071
  ctx.metadata.userId = currentSession.userId;
1072
+ ctx.metadata.bindingFields = currentSession.binding ? {
1073
+ binding: currentSession.binding,
1074
+ keyExpiresAt: currentSession.keyExpiresAt,
1075
+ ...currentSession.uaFamily ? { uaFamily: currentSession.uaFamily } : {}
1076
+ } : {};
764
1077
  } catch (error) {
765
1078
  const err = error;
766
1079
  authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
@@ -783,7 +1096,8 @@ var keyRotationInterceptor = {
783
1096
  userId: ctx.metadata.userId,
784
1097
  privateKey: ctx.metadata.newPrivateKey,
785
1098
  keyId: ctx.metadata.newKeyId,
786
- algorithm: ctx.metadata.newAlgorithm
1099
+ algorithm: ctx.metadata.newAlgorithm,
1100
+ ...ctx.metadata.bindingFields
787
1101
  };
788
1102
  const sealed = await sealSession(newSessionData, ttl);
789
1103
  ctx.setCookies.push({
@@ -818,10 +1132,10 @@ var keyRotationInterceptor = {
818
1132
  };
819
1133
 
820
1134
  // src/server/lib/oauth/state.ts
821
- import * as jose2 from "jose";
822
- import { env as env4 } from "@spfn/auth/config";
1135
+ import * as jose3 from "jose";
1136
+ import { env as env6 } from "@spfn/auth/config";
823
1137
  async function getStateKey() {
824
- const secret = env4.SPFN_AUTH_SESSION_SECRET;
1138
+ const secret = env6.SPFN_AUTH_SESSION_SECRET;
825
1139
  const encoder = new TextEncoder();
826
1140
  const data = encoder.encode(`oauth-state:${secret}`);
827
1141
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -847,7 +1161,7 @@ async function createOAuthState(params) {
847
1161
  algorithm: params.algorithm,
848
1162
  metadata: params.metadata
849
1163
  };
850
- const jwe = await new jose2.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
1164
+ const jwe = await new jose3.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
851
1165
  return encodeURIComponent(jwe);
852
1166
  }
853
1167
 
@@ -866,31 +1180,6 @@ function isSafeReturnPath(returnPath) {
866
1180
  return !/^\/[^/?#]*:/.test(returnPath);
867
1181
  }
868
1182
 
869
- // src/nextjs/session-helpers.ts
870
- import * as jose3 from "jose";
871
- import { cookies } from "next/headers.js";
872
- import { env as env5 } from "@spfn/auth/config";
873
- import { logger } from "@spfn/core/logger";
874
- async function getPendingSessionKey() {
875
- const secret = env5.SPFN_AUTH_SESSION_SECRET;
876
- const encoder = new TextEncoder();
877
- const data = encoder.encode(`oauth-pending:${secret}`);
878
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
879
- return new Uint8Array(hashBuffer);
880
- }
881
- async function sealPendingSession(data, ttl = 600) {
882
- const key = await getPendingSessionKey();
883
- return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
884
- }
885
- async function unsealPendingSession(jwt2) {
886
- const key = await getPendingSessionKey();
887
- const { payload } = await jose3.jwtDecrypt(jwt2, key, {
888
- issuer: "spfn-auth",
889
- audience: "spfn-oauth"
890
- });
891
- return payload.data;
892
- }
893
-
894
1183
  // src/nextjs/interceptors/oauth.ts
895
1184
  var UNSAFE_RETURN_URL_MESSAGE = "returnUrl must be a relative path within the app";
896
1185
  function refuseUnsafeReturnUrl() {
@@ -1010,7 +1299,7 @@ var oauthFinalizeInterceptor = {
1010
1299
  pathPattern: /^\/_auth\/oauth\/finalize$/,
1011
1300
  method: "POST",
1012
1301
  response: async (ctx, next) => {
1013
- if (!ctx.response.ok) {
1302
+ if (!ctx.response.ok || ctx.response.status === 202) {
1014
1303
  await next();
1015
1304
  return;
1016
1305
  }
@@ -1044,7 +1333,8 @@ var oauthFinalizeInterceptor = {
1044
1333
  userId,
1045
1334
  privateKey: pendingSession.privateKey,
1046
1335
  keyId: pendingSession.keyId,
1047
- algorithm: pendingSession.algorithm
1336
+ algorithm: pendingSession.algorithm,
1337
+ ...bindingSessionFields(ctx.response.body, ctx.request.headers["user-agent"])
1048
1338
  }, ttl);
1049
1339
  ctx.setCookies.push({
1050
1340
  name: COOKIE_NAMES.SESSION,
@@ -1202,10 +1492,12 @@ var authInterceptors = [
1202
1492
  signupLinkInterceptor,
1203
1493
  passwordResetInterceptor,
1204
1494
  loginRegisterInterceptor,
1495
+ mfaVerifyInterceptor,
1205
1496
  keyRotationInterceptor,
1206
1497
  oauthUrlInterceptor,
1207
1498
  oauthFinalizeInterceptor,
1208
- generalAuthInterceptor
1499
+ generalAuthInterceptor,
1500
+ sessionBindingInterceptor
1209
1501
  ];
1210
1502
 
1211
1503
  // src/nextjs/api.ts