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

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.
@@ -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()}`;
@@ -457,20 +469,23 @@ function uaFamily(userAgent) {
457
469
 
458
470
  // src/nextjs/interceptors/error-envelope.ts
459
471
  function refusalEnvelope(error, setCookies = []) {
460
- const body = error.toJSON();
461
472
  return {
462
473
  status: error.statusCode,
463
- body: {
464
- ...body,
465
- error: {
466
- code: body.__type,
467
- message: body.message,
468
- requestId: mintRequestId()
469
- }
470
- },
474
+ body: refusalBody(error),
471
475
  setCookies
472
476
  };
473
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
+ }
474
489
  function mintRequestId() {
475
490
  const bytes = crypto.getRandomValues(new Uint8Array(16));
476
491
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -562,9 +577,9 @@ var loginRegisterInterceptor = {
562
577
  ctx.body.oldKeyId = oldKeyId;
563
578
  }
564
579
  delete ctx.body.remember;
565
- ctx.metadata.privateKey = keyPair.privateKey;
566
- ctx.metadata.keyId = keyPair.keyId;
567
- ctx.metadata.algorithm = keyPair.algorithm;
580
+ ctx.metadata.newPrivateKey = keyPair.privateKey;
581
+ ctx.metadata.newKeyId = keyPair.keyId;
582
+ ctx.metadata.newAlgorithm = keyPair.algorithm;
568
583
  ctx.metadata.remember = remember;
569
584
  await next();
570
585
  },
@@ -583,9 +598,9 @@ var loginRegisterInterceptor = {
583
598
  const ttl = getSessionTtl(ctx.metadata.remember);
584
599
  const sessionData = {
585
600
  userId: userData.userId,
586
- privateKey: ctx.metadata.privateKey,
587
- keyId: ctx.metadata.keyId,
588
- algorithm: ctx.metadata.algorithm,
601
+ privateKey: ctx.metadata.newPrivateKey,
602
+ keyId: ctx.metadata.newKeyId,
603
+ algorithm: ctx.metadata.newAlgorithm,
589
604
  ...bindingSessionFields(userData, ctx.request.headers["user-agent"])
590
605
  };
591
606
  const sealed = await sealSession(sessionData, ttl);
@@ -602,7 +617,7 @@ var loginRegisterInterceptor = {
602
617
  });
603
618
  ctx.setCookies.push({
604
619
  name: COOKIE_NAMES.SESSION_KEY_ID,
605
- value: ctx.metadata.keyId,
620
+ value: ctx.metadata.newKeyId,
606
621
  options: {
607
622
  httpOnly: true,
608
623
  secure: cookieSecure,
@@ -611,7 +626,7 @@ var loginRegisterInterceptor = {
611
626
  path: "/"
612
627
  }
613
628
  });
614
- await pushCsrfCookie(ctx.setCookies, ctx.metadata.keyId, ttl);
629
+ await pushCsrfCookie(ctx.setCookies, ctx.metadata.newKeyId, ttl);
615
630
  } catch (error) {
616
631
  const err = error;
617
632
  authLogger.interceptor.login.error("Failed to save session", err);
@@ -620,6 +635,149 @@ var loginRegisterInterceptor = {
620
635
  }
621
636
  };
622
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.newPrivateKey && ctx.metadata.newKeyId) {
697
+ return {
698
+ privateKey: ctx.metadata.newPrivateKey,
699
+ keyId: ctx.metadata.newKeyId,
700
+ algorithm: ctx.metadata.newAlgorithm
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
+
623
781
  // src/nextjs/interceptors/general-auth.ts
624
782
  import { SessionContextChangedError, SessionRenewalRequiredError } from "@spfn/auth/errors";
625
783
 
@@ -635,8 +793,16 @@ function requiresAuth(path) {
635
793
  // Send verification code
636
794
  /^\/_auth\/codes\/verify$/,
637
795
  // Verify code
638
- /^\/_auth\/exists$/
796
+ /^\/_auth\/exists$/,
639
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$/
640
806
  ];
641
807
  return !publicPaths.some((pattern) => pattern.test(path));
642
808
  }
@@ -659,6 +825,9 @@ function refuseAsContextChanged(ctx) {
659
825
  function isKeyExpiredRefusal(body) {
660
826
  return body?.__type === "KeyExpiredError";
661
827
  }
828
+ function sessionQueued(setCookies) {
829
+ return setCookies.some((cookie) => cookie.name === COOKIE_NAMES.SESSION);
830
+ }
662
831
  var generalAuthInterceptor = {
663
832
  pathPattern: "*",
664
833
  // Match all paths, filter by requiresAuth()
@@ -787,7 +956,7 @@ var generalAuthInterceptor = {
787
956
  }
788
957
  });
789
958
  pushCsrfCookieRemoval(ctx.setCookies);
790
- } else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
959
+ } else if (ctx.metadata.refreshSession && ctx.response.status === 200 && !sessionQueued(ctx.setCookies)) {
791
960
  try {
792
961
  const sessionData = ctx.metadata.sessionData;
793
962
  const ttl = getSessionTtl();
@@ -966,10 +1135,10 @@ var keyRotationInterceptor = {
966
1135
  };
967
1136
 
968
1137
  // src/server/lib/oauth/state.ts
969
- import * as jose2 from "jose";
970
- import { env as env4 } from "@spfn/auth/config";
1138
+ import * as jose3 from "jose";
1139
+ import { env as env6 } from "@spfn/auth/config";
971
1140
  async function getStateKey() {
972
- const secret = env4.SPFN_AUTH_SESSION_SECRET;
1141
+ const secret = env6.SPFN_AUTH_SESSION_SECRET;
973
1142
  const encoder = new TextEncoder();
974
1143
  const data = encoder.encode(`oauth-state:${secret}`);
975
1144
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -995,7 +1164,7 @@ async function createOAuthState(params) {
995
1164
  algorithm: params.algorithm,
996
1165
  metadata: params.metadata
997
1166
  };
998
- const jwe = await new jose2.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
1167
+ const jwe = await new jose3.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
999
1168
  return encodeURIComponent(jwe);
1000
1169
  }
1001
1170
 
@@ -1014,31 +1183,6 @@ function isSafeReturnPath(returnPath) {
1014
1183
  return !/^\/[^/?#]*:/.test(returnPath);
1015
1184
  }
1016
1185
 
1017
- // src/nextjs/session-helpers.ts
1018
- import * as jose3 from "jose";
1019
- import { cookies } from "next/headers.js";
1020
- import { env as env5 } from "@spfn/auth/config";
1021
- import { logger } from "@spfn/core/logger";
1022
- async function getPendingSessionKey() {
1023
- const secret = env5.SPFN_AUTH_SESSION_SECRET;
1024
- const encoder = new TextEncoder();
1025
- const data = encoder.encode(`oauth-pending:${secret}`);
1026
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1027
- return new Uint8Array(hashBuffer);
1028
- }
1029
- async function sealPendingSession(data, ttl = 600) {
1030
- const key = await getPendingSessionKey();
1031
- return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
1032
- }
1033
- async function unsealPendingSession(jwt2) {
1034
- const key = await getPendingSessionKey();
1035
- const { payload } = await jose3.jwtDecrypt(jwt2, key, {
1036
- issuer: "spfn-auth",
1037
- audience: "spfn-oauth"
1038
- });
1039
- return payload.data;
1040
- }
1041
-
1042
1186
  // src/nextjs/interceptors/oauth.ts
1043
1187
  var UNSAFE_RETURN_URL_MESSAGE = "returnUrl must be a relative path within the app";
1044
1188
  function refuseUnsafeReturnUrl() {
@@ -1158,7 +1302,7 @@ var oauthFinalizeInterceptor = {
1158
1302
  pathPattern: /^\/_auth\/oauth\/finalize$/,
1159
1303
  method: "POST",
1160
1304
  response: async (ctx, next) => {
1161
- if (!ctx.response.ok) {
1305
+ if (!ctx.response.ok || ctx.response.status === 202) {
1162
1306
  await next();
1163
1307
  return;
1164
1308
  }
@@ -1351,6 +1495,7 @@ var authInterceptors = [
1351
1495
  signupLinkInterceptor,
1352
1496
  passwordResetInterceptor,
1353
1497
  loginRegisterInterceptor,
1498
+ mfaVerifyInterceptor,
1354
1499
  keyRotationInterceptor,
1355
1500
  oauthUrlInterceptor,
1356
1501
  oauthFinalizeInterceptor,