@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.
@@ -369,6 +369,18 @@ interface OAuthCallbackOptions {
369
369
  * @default '/auth/error'
370
370
  */
371
371
  errorRedirectUrl?: string;
372
+ /**
373
+ * App page that asks for the second factor, when the callback carries a
374
+ * step-up challenge instead of a session (#95).
375
+ *
376
+ * An override for `SPFN_AUTH_MFA_CONFIRM_PATH`, which is where every other
377
+ * app-page path in this package lives; the env var is the one to set, and
378
+ * this exists for an app mounting two handlers on different screens. The
379
+ * handler redirects to it with `?challenge=` and `?returnUrl=`.
380
+ *
381
+ * @default env SPFN_AUTH_MFA_CONFIRM_PATH, then '/auth/mfa'
382
+ */
383
+ mfaPath?: string;
372
384
  }
373
385
  /**
374
386
  * Create OAuth callback handler for Next.js API Route
@@ -379,6 +391,11 @@ interface OAuthCallbackOptions {
379
391
  * 3. Creates full session and saves to cookie
380
392
  * 4. Redirects to returnUrl
381
393
  *
394
+ * When the account has a second factor and this device is new to it (#95) the
395
+ * backend sends `mfaChallenge` in place of `userId` and `keyId`. No session is
396
+ * sealed; the browser goes to `SPFN_AUTH_MFA_CONFIRM_PATH` with the challenge,
397
+ * and the session is sealed by `mfaVerifyInterceptor` once the page proves it.
398
+ *
382
399
  * @example
383
400
  * ```typescript
384
401
  * // /api/auth/callback/route.ts
@@ -158,6 +158,18 @@ var COOKIE_NAMES = {
158
158
  get OAUTH_PENDING() {
159
159
  return `spfn_oauth_pending${getCookieSuffix()}`;
160
160
  },
161
+ /**
162
+ * Pending second-factor session (privateKey, keyId, challengeHash) (#95)
163
+ *
164
+ * Its own name and its own audience, separate from OAUTH_PENDING. The two
165
+ * coexist: a person who starts a social login in one tab while a password
166
+ * step-up is outstanding in another has both flows live, and one name would
167
+ * mean the second overwrote the first — sealing a session with a private key
168
+ * that does not match the key being activated.
169
+ */
170
+ get MFA_PENDING() {
171
+ return `spfn_mfa_pending${getCookieSuffix()}`;
172
+ },
161
173
  /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
162
174
  get OAUTH_CSRF() {
163
175
  return `spfn_oauth_csrf${getCookieSuffix()}`;
@@ -272,20 +284,21 @@ async function clearSession() {
272
284
  cookieStore.delete(COOKIE_NAMES.SESSION_KEY_ID);
273
285
  cookieStore.delete(COOKIE_NAMES.CSRF);
274
286
  }
275
- async function getPendingSessionKey() {
287
+ async function getPendingSessionKey(purpose) {
276
288
  const secret = env4.SPFN_AUTH_SESSION_SECRET;
277
289
  const encoder = new TextEncoder();
278
- const data = encoder.encode(`oauth-pending:${secret}`);
290
+ const data = encoder.encode(purpose === "oauth" ? `oauth-pending:${secret}` : `mfa-pending:${secret}`);
279
291
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
280
292
  return new Uint8Array(hashBuffer);
281
293
  }
282
294
  async function sealPendingSession(data, ttl = 600) {
283
- const key = await getPendingSessionKey();
284
- return await new jose2.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
295
+ return await sealFor("oauth", data, ttl);
296
+ }
297
+ async function sealFor(purpose, data, ttl) {
298
+ 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));
285
299
  }
286
300
  async function unsealPendingSession(jwt) {
287
- const key = await getPendingSessionKey();
288
- const { payload } = await jose2.jwtDecrypt(jwt, key, {
301
+ const { payload } = await jose2.jwtDecrypt(jwt, await getPendingSessionKey("oauth"), {
289
302
  issuer: "spfn-auth",
290
303
  audience: "spfn-oauth"
291
304
  });
@@ -461,6 +474,7 @@ function clearSessionCookies(response) {
461
474
  import { NextResponse } from "next/server";
462
475
  import { cookies as cookies2 } from "next/headers.js";
463
476
  import { env as env5 } from "@spfn/core/config";
477
+ import { env as authEnv } from "@spfn/auth/config";
464
478
  import { logger as logger2 } from "@spfn/core/logger";
465
479
 
466
480
  // src/lib/return-path.ts
@@ -528,6 +542,15 @@ function bindingFromQuery(searchParams) {
528
542
  }
529
543
  return { sessionBinding: "passkey", keyExpiresAtMillis: expiresAt };
530
544
  }
545
+ function mfaRedirect(request, challenge, returnUrl, configured) {
546
+ const path = configured || authEnv.SPFN_AUTH_MFA_CONFIRM_PATH || DEFAULT_MFA_CONFIRM_PATH;
547
+ const target = new URL(path, request.url);
548
+ target.searchParams.set("challenge", challenge);
549
+ target.searchParams.set("returnUrl", returnUrl);
550
+ logger2.debug("OAuth callback needs a second factor", { path });
551
+ return NextResponse.redirect(target);
552
+ }
553
+ var DEFAULT_MFA_CONFIRM_PATH = "/auth/mfa";
531
554
  function createOAuthCallbackHandler(options) {
532
555
  const defaultRedirect = options?.defaultRedirectUrl || "/";
533
556
  const errorRedirect = options?.errorRedirectUrl || "/auth/error";
@@ -542,6 +565,10 @@ function createOAuthCallbackHandler(options) {
542
565
  errorUrl.searchParams.set("error", error);
543
566
  return NextResponse.redirect(errorUrl);
544
567
  }
568
+ const mfaChallenge = searchParams.get("mfaChallenge");
569
+ if (mfaChallenge) {
570
+ return mfaRedirect(request, mfaChallenge, returnUrl, options?.mfaPath);
571
+ }
545
572
  if (!userId || !keyId) {
546
573
  logger2.error("OAuth callback missing required params", { userId: !!userId, keyId: !!keyId });
547
574
  const errorUrl = new URL(errorRedirect, request.url);