@spfn/auth 0.3.0-beta.24 → 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.
@@ -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("");
@@ -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.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
+
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
  }
@@ -966,10 +1132,10 @@ var keyRotationInterceptor = {
966
1132
  };
967
1133
 
968
1134
  // src/server/lib/oauth/state.ts
969
- import * as jose2 from "jose";
970
- import { env as env4 } from "@spfn/auth/config";
1135
+ import * as jose3 from "jose";
1136
+ import { env as env6 } from "@spfn/auth/config";
971
1137
  async function getStateKey() {
972
- const secret = env4.SPFN_AUTH_SESSION_SECRET;
1138
+ const secret = env6.SPFN_AUTH_SESSION_SECRET;
973
1139
  const encoder = new TextEncoder();
974
1140
  const data = encoder.encode(`oauth-state:${secret}`);
975
1141
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -995,7 +1161,7 @@ async function createOAuthState(params) {
995
1161
  algorithm: params.algorithm,
996
1162
  metadata: params.metadata
997
1163
  };
998
- 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);
999
1165
  return encodeURIComponent(jwe);
1000
1166
  }
1001
1167
 
@@ -1014,31 +1180,6 @@ function isSafeReturnPath(returnPath) {
1014
1180
  return !/^\/[^/?#]*:/.test(returnPath);
1015
1181
  }
1016
1182
 
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
1183
  // src/nextjs/interceptors/oauth.ts
1043
1184
  var UNSAFE_RETURN_URL_MESSAGE = "returnUrl must be a relative path within the app";
1044
1185
  function refuseUnsafeReturnUrl() {
@@ -1158,7 +1299,7 @@ var oauthFinalizeInterceptor = {
1158
1299
  pathPattern: /^\/_auth\/oauth\/finalize$/,
1159
1300
  method: "POST",
1160
1301
  response: async (ctx, next) => {
1161
- if (!ctx.response.ok) {
1302
+ if (!ctx.response.ok || ctx.response.status === 202) {
1162
1303
  await next();
1163
1304
  return;
1164
1305
  }
@@ -1351,6 +1492,7 @@ var authInterceptors = [
1351
1492
  signupLinkInterceptor,
1352
1493
  passwordResetInterceptor,
1353
1494
  loginRegisterInterceptor,
1495
+ mfaVerifyInterceptor,
1354
1496
  keyRotationInterceptor,
1355
1497
  oauthUrlInterceptor,
1356
1498
  oauthFinalizeInterceptor,