@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
@@ -1,8 +1,9 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode } from 'react';
3
- import { S as SessionData } from '../session-Dfwu5g2W.js';
4
- export { i as isSafeReturnPath } from '../session-Dfwu5g2W.js';
5
- import { K as KeyAlgorithmType } from '../types-DYyhze28.js';
3
+ import { authApi } from '@spfn/auth';
4
+ import { S as SessionData } from '../session-BbhAGZtA.js';
5
+ export { i as isSafeReturnPath } from '../session-BbhAGZtA.js';
6
+ import { K as KeyAlgorithmType } from '../types-CTdoTOxM.js';
6
7
  import { NextResponse, NextRequest } from 'next/server';
7
8
 
8
9
  interface RequireAuthProps {
@@ -19,6 +20,17 @@ interface RequireAuthProps {
19
20
  * Fallback UI to show instead of redirecting
20
21
  */
21
22
  fallback?: ReactNode;
23
+ /**
24
+ * Path to send a bound session whose key has run out (#97)
25
+ *
26
+ * Not the sign-in page: the person is still signed in and one WebAuthn
27
+ * ceremony puts a live key back in the cookie. The page this names is the
28
+ * app's own, and all it has to do is render a client component that calls
29
+ * `renewSession(api)` and returns them to where they were.
30
+ *
31
+ * @default env SPFN_AUTH_SESSION_RENEW_PATH, or '/auth/renew'
32
+ */
33
+ renewalPath?: string;
22
34
  }
23
35
  /**
24
36
  * Require Authentication Guard
@@ -38,8 +50,17 @@ interface RequireAuthProps {
38
50
  * <PrivateContent />
39
51
  * </RequireAuth>
40
52
  * ```
53
+ *
54
+ * @example A bound session whose key ran out
55
+ * ```tsx
56
+ * // Sent to /account/renew instead of the sign-in page. That page renders a
57
+ * // client component calling renewSession(api).
58
+ * <RequireAuth renewalPath="/account/renew">
59
+ * <DashboardContent />
60
+ * </RequireAuth>
61
+ * ```
41
62
  */
42
- declare function RequireAuth({ children, redirectTo, fallback, }: RequireAuthProps): Promise<react_jsx_runtime.JSX.Element>;
63
+ declare function RequireAuth({ children, redirectTo, renewalPath, fallback, }: RequireAuthProps): Promise<react_jsx_runtime.JSX.Element>;
43
64
 
44
65
  interface RequireRoleProps {
45
66
  /**
@@ -140,29 +161,26 @@ declare function RequirePermission({ permissions, children, redirectTo, fallback
140
161
  *
141
162
  * Uses authApi to check permissions in real-time
142
163
  */
164
+
165
+ /**
166
+ * A bound session whose key has run out, seen from a server component.
167
+ *
168
+ * The third state `getAuthSessionData` answers with, and the reason it is a
169
+ * sentinel rather than `null`: a server component's `api.` call goes through the
170
+ * same proxy a browser's does, so it meets the same renewal-required refusal —
171
+ * and `null` there would read as "not signed in" and send the person to the
172
+ * sign-in page, which is precisely the thing renewal exists to avoid. A server
173
+ * component cannot run a WebAuthn ceremony, so the guard hands the work to a page
174
+ * that can.
175
+ */
176
+ declare const RENEWAL_REQUIRED = "renewal-required";
177
+ type AuthSessionData = Awaited<ReturnType<typeof authApi.getAuthSession.call>>;
178
+ /** What a guard gets back: the session, the renewal sentinel, or nothing. */
179
+ type AuthSessionState = AuthSessionData | typeof RENEWAL_REQUIRED | null;
143
180
  /**
144
181
  * Get current auth session with roles and permissions via API
145
182
  */
146
- declare function getAuthSessionData(): Promise<{
147
- role: {
148
- id: number;
149
- name: string;
150
- displayName: string;
151
- priority: number;
152
- };
153
- permissions: {
154
- id: number;
155
- name: string;
156
- displayName: string;
157
- category: "auth" | "custom" | "user" | "rbac" | "system" | undefined;
158
- }[];
159
- userId: number;
160
- publicId: string;
161
- email: string | null;
162
- emailVerified: boolean;
163
- phoneVerified: boolean;
164
- hasPassword: boolean;
165
- } | null>;
183
+ declare function getAuthSessionData(): Promise<AuthSessionState>;
166
184
  /**
167
185
  * Get user role
168
186
  */
@@ -351,6 +369,18 @@ interface OAuthCallbackOptions {
351
369
  * @default '/auth/error'
352
370
  */
353
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;
354
384
  }
355
385
  /**
356
386
  * Create OAuth callback handler for Next.js API Route
@@ -361,6 +391,11 @@ interface OAuthCallbackOptions {
361
391
  * 3. Creates full session and saves to cookie
362
392
  * 4. Redirects to returnUrl
363
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
+ *
364
399
  * @example
365
400
  * ```typescript
366
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()}`;
@@ -215,6 +227,10 @@ function getSessionTtl(override) {
215
227
  }
216
228
  return 7 * 24 * 60 * 60;
217
229
  }
230
+ function getSessionRenewPath() {
231
+ return env3.SPFN_AUTH_SESSION_RENEW_PATH?.trim() || DEFAULT_SESSION_RENEW_PATH;
232
+ }
233
+ var DEFAULT_SESSION_RENEW_PATH = "/auth/renew";
218
234
 
219
235
  // src/nextjs/session-helpers.ts
220
236
  import { env as env4 } from "@spfn/auth/config";
@@ -268,20 +284,21 @@ async function clearSession() {
268
284
  cookieStore.delete(COOKIE_NAMES.SESSION_KEY_ID);
269
285
  cookieStore.delete(COOKIE_NAMES.CSRF);
270
286
  }
271
- async function getPendingSessionKey() {
287
+ async function getPendingSessionKey(purpose) {
272
288
  const secret = env4.SPFN_AUTH_SESSION_SECRET;
273
289
  const encoder = new TextEncoder();
274
- const data = encoder.encode(`oauth-pending:${secret}`);
290
+ const data = encoder.encode(purpose === "oauth" ? `oauth-pending:${secret}` : `mfa-pending:${secret}`);
275
291
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
276
292
  return new Uint8Array(hashBuffer);
277
293
  }
278
294
  async function sealPendingSession(data, ttl = 600) {
279
- const key = await getPendingSessionKey();
280
- 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));
281
299
  }
282
300
  async function unsealPendingSession(jwt) {
283
- const key = await getPendingSessionKey();
284
- const { payload } = await jose2.jwtDecrypt(jwt, key, {
301
+ const { payload } = await jose2.jwtDecrypt(jwt, await getPendingSessionKey("oauth"), {
285
302
  issuer: "spfn-auth",
286
303
  audience: "spfn-oauth"
287
304
  });
@@ -309,36 +326,48 @@ async function clearPendingSession() {
309
326
 
310
327
  // src/nextjs/guards/auth-utils.ts
311
328
  import { authApi } from "@spfn/auth";
329
+ import { SessionRenewalRequiredError } from "@spfn/auth/errors";
330
+ var RENEWAL_REQUIRED = "renewal-required";
312
331
  async function getAuthSessionData() {
313
332
  try {
314
333
  const session = await authApi.getAuthSession.call();
315
334
  authLogger.middleware.debug("Auth session retrieved", { name: session.role?.name });
316
335
  return session;
317
336
  } catch (error) {
337
+ if (isRenewalRequired(error)) {
338
+ authLogger.middleware.debug("Auth session needs renewing");
339
+ return RENEWAL_REQUIRED;
340
+ }
318
341
  authLogger.middleware.error("Failed to get auth session", { error });
319
342
  return null;
320
343
  }
321
344
  }
345
+ function isRenewalRequired(error) {
346
+ return error instanceof SessionRenewalRequiredError || error?.name === "SessionRenewalRequiredError";
347
+ }
348
+ function resolvedSession(state) {
349
+ return state && state !== RENEWAL_REQUIRED ? state : null;
350
+ }
322
351
  async function getUserRole() {
323
- const session = await getAuthSessionData();
352
+ const session = resolvedSession(await getAuthSessionData());
324
353
  return session?.role?.name || null;
325
354
  }
326
355
  async function getUserPermissions() {
327
- const session = await getAuthSessionData();
356
+ const session = resolvedSession(await getAuthSessionData());
328
357
  if (!session) {
329
358
  return [];
330
359
  }
331
360
  return session.permissions?.map((p) => p.name) || [];
332
361
  }
333
362
  async function hasAnyRole(requiredRoles) {
334
- const session = await getAuthSessionData();
363
+ const session = resolvedSession(await getAuthSessionData());
335
364
  if (!session) {
336
365
  return false;
337
366
  }
338
367
  return requiredRoles.includes(session.role?.name);
339
368
  }
340
369
  async function hasAnyPermission(requiredPermissions) {
341
- const session = await getAuthSessionData();
370
+ const session = resolvedSession(await getAuthSessionData());
342
371
  if (!session) {
343
372
  return false;
344
373
  }
@@ -351,6 +380,7 @@ import { Fragment, jsx } from "react/jsx-runtime";
351
380
  async function RequireAuth({
352
381
  children,
353
382
  redirectTo = "/auth/login",
383
+ renewalPath,
354
384
  fallback
355
385
  }) {
356
386
  const session = await getSession();
@@ -361,6 +391,9 @@ async function RequireAuth({
361
391
  redirect(redirectTo);
362
392
  }
363
393
  const serverSession = await getAuthSessionData();
394
+ if (serverSession === RENEWAL_REQUIRED) {
395
+ redirect(renewalPath ?? getSessionRenewPath());
396
+ }
364
397
  if (!serverSession) {
365
398
  redirect(redirectTo);
366
399
  }
@@ -441,6 +474,7 @@ function clearSessionCookies(response) {
441
474
  import { NextResponse } from "next/server";
442
475
  import { cookies as cookies2 } from "next/headers.js";
443
476
  import { env as env5 } from "@spfn/core/config";
477
+ import { env as authEnv } from "@spfn/auth/config";
444
478
  import { logger as logger2 } from "@spfn/core/logger";
445
479
 
446
480
  // src/lib/return-path.ts
@@ -458,10 +492,65 @@ function isSafeReturnPath(returnPath) {
458
492
  return !/^\/[^/?#]*:/.test(returnPath);
459
493
  }
460
494
 
495
+ // src/nextjs/interceptors/session-binding.ts
496
+ import { SessionResealFailedError } from "@spfn/auth/errors";
497
+
498
+ // src/server/lib/ua-family.ts
499
+ var FAMILY_MARKERS = [
500
+ { family: "edge", marker: /\bEdg(?:A|iOS)?\// },
501
+ { family: "chrome", marker: /\b(?:Chrome|CriOS)\// },
502
+ { family: "firefox", marker: /\b(?:Firefox|FxiOS)\// },
503
+ { family: "safari", marker: /\bSafari\// }
504
+ ];
505
+ function uaFamily(userAgent) {
506
+ if (!userAgent) {
507
+ return "other";
508
+ }
509
+ return FAMILY_MARKERS.find((entry) => entry.marker.test(userAgent))?.family ?? "other";
510
+ }
511
+
512
+ // src/nextjs/interceptors/cookie-options.ts
513
+ function resolveSecure() {
514
+ const override = process.env.SPFN_AUTH_COOKIE_SECURE;
515
+ if (override !== void 0) {
516
+ return override === "true";
517
+ }
518
+ return process.env.NODE_ENV === "production";
519
+ }
520
+ var cookieSecure = resolveSecure();
521
+
522
+ // src/nextjs/interceptors/session-binding.ts
523
+ function bindingSessionFields(body, userAgent) {
524
+ if (body?.sessionBinding !== "passkey" || typeof body.keyExpiresAtMillis !== "number") {
525
+ return {};
526
+ }
527
+ return {
528
+ binding: "passkey",
529
+ keyExpiresAt: body.keyExpiresAtMillis,
530
+ ...userAgent ? { uaFamily: uaFamily(userAgent) } : {}
531
+ };
532
+ }
533
+
461
534
  // src/nextjs/oauth-handlers.ts
462
535
  function safeReturnUrl(requested, defaultRedirect) {
463
536
  return requested && isSafeReturnPath(requested) ? requested : defaultRedirect;
464
537
  }
538
+ function bindingFromQuery(searchParams) {
539
+ const expiresAt = Number(searchParams.get("keyExpiresAtMillis"));
540
+ if (searchParams.get("sessionBinding") !== "passkey" || !Number.isFinite(expiresAt)) {
541
+ return {};
542
+ }
543
+ return { sessionBinding: "passkey", keyExpiresAtMillis: expiresAt };
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";
465
554
  function createOAuthCallbackHandler(options) {
466
555
  const defaultRedirect = options?.defaultRedirectUrl || "/";
467
556
  const errorRedirect = options?.errorRedirectUrl || "/auth/error";
@@ -476,6 +565,10 @@ function createOAuthCallbackHandler(options) {
476
565
  errorUrl.searchParams.set("error", error);
477
566
  return NextResponse.redirect(errorUrl);
478
567
  }
568
+ const mfaChallenge = searchParams.get("mfaChallenge");
569
+ if (mfaChallenge) {
570
+ return mfaRedirect(request, mfaChallenge, returnUrl, options?.mfaPath);
571
+ }
479
572
  if (!userId || !keyId) {
480
573
  logger2.error("OAuth callback missing required params", { userId: !!userId, keyId: !!keyId });
481
574
  const errorUrl = new URL(errorRedirect, request.url);
@@ -497,7 +590,8 @@ function createOAuthCallbackHandler(options) {
497
590
  userId,
498
591
  privateKey: pendingSession.privateKey,
499
592
  keyId: pendingSession.keyId,
500
- algorithm: pendingSession.algorithm
593
+ algorithm: pendingSession.algorithm,
594
+ ...bindingSessionFields(bindingFromQuery(searchParams), request.headers.get("user-agent"))
501
595
  }, ttl);
502
596
  const redirectUrl = new URL(returnUrl, request.url);
503
597
  const response = NextResponse.redirect(redirectUrl);