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

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.
@@ -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
  */
@@ -215,6 +215,10 @@ function getSessionTtl(override) {
215
215
  }
216
216
  return 7 * 24 * 60 * 60;
217
217
  }
218
+ function getSessionRenewPath() {
219
+ return env3.SPFN_AUTH_SESSION_RENEW_PATH?.trim() || DEFAULT_SESSION_RENEW_PATH;
220
+ }
221
+ var DEFAULT_SESSION_RENEW_PATH = "/auth/renew";
218
222
 
219
223
  // src/nextjs/session-helpers.ts
220
224
  import { env as env4 } from "@spfn/auth/config";
@@ -309,36 +313,48 @@ async function clearPendingSession() {
309
313
 
310
314
  // src/nextjs/guards/auth-utils.ts
311
315
  import { authApi } from "@spfn/auth";
316
+ import { SessionRenewalRequiredError } from "@spfn/auth/errors";
317
+ var RENEWAL_REQUIRED = "renewal-required";
312
318
  async function getAuthSessionData() {
313
319
  try {
314
320
  const session = await authApi.getAuthSession.call();
315
321
  authLogger.middleware.debug("Auth session retrieved", { name: session.role?.name });
316
322
  return session;
317
323
  } catch (error) {
324
+ if (isRenewalRequired(error)) {
325
+ authLogger.middleware.debug("Auth session needs renewing");
326
+ return RENEWAL_REQUIRED;
327
+ }
318
328
  authLogger.middleware.error("Failed to get auth session", { error });
319
329
  return null;
320
330
  }
321
331
  }
332
+ function isRenewalRequired(error) {
333
+ return error instanceof SessionRenewalRequiredError || error?.name === "SessionRenewalRequiredError";
334
+ }
335
+ function resolvedSession(state) {
336
+ return state && state !== RENEWAL_REQUIRED ? state : null;
337
+ }
322
338
  async function getUserRole() {
323
- const session = await getAuthSessionData();
339
+ const session = resolvedSession(await getAuthSessionData());
324
340
  return session?.role?.name || null;
325
341
  }
326
342
  async function getUserPermissions() {
327
- const session = await getAuthSessionData();
343
+ const session = resolvedSession(await getAuthSessionData());
328
344
  if (!session) {
329
345
  return [];
330
346
  }
331
347
  return session.permissions?.map((p) => p.name) || [];
332
348
  }
333
349
  async function hasAnyRole(requiredRoles) {
334
- const session = await getAuthSessionData();
350
+ const session = resolvedSession(await getAuthSessionData());
335
351
  if (!session) {
336
352
  return false;
337
353
  }
338
354
  return requiredRoles.includes(session.role?.name);
339
355
  }
340
356
  async function hasAnyPermission(requiredPermissions) {
341
- const session = await getAuthSessionData();
357
+ const session = resolvedSession(await getAuthSessionData());
342
358
  if (!session) {
343
359
  return false;
344
360
  }
@@ -351,6 +367,7 @@ import { Fragment, jsx } from "react/jsx-runtime";
351
367
  async function RequireAuth({
352
368
  children,
353
369
  redirectTo = "/auth/login",
370
+ renewalPath,
354
371
  fallback
355
372
  }) {
356
373
  const session = await getSession();
@@ -361,6 +378,9 @@ async function RequireAuth({
361
378
  redirect(redirectTo);
362
379
  }
363
380
  const serverSession = await getAuthSessionData();
381
+ if (serverSession === RENEWAL_REQUIRED) {
382
+ redirect(renewalPath ?? getSessionRenewPath());
383
+ }
364
384
  if (!serverSession) {
365
385
  redirect(redirectTo);
366
386
  }
@@ -458,10 +478,56 @@ function isSafeReturnPath(returnPath) {
458
478
  return !/^\/[^/?#]*:/.test(returnPath);
459
479
  }
460
480
 
481
+ // src/nextjs/interceptors/session-binding.ts
482
+ import { SessionResealFailedError } from "@spfn/auth/errors";
483
+
484
+ // src/server/lib/ua-family.ts
485
+ var FAMILY_MARKERS = [
486
+ { family: "edge", marker: /\bEdg(?:A|iOS)?\// },
487
+ { family: "chrome", marker: /\b(?:Chrome|CriOS)\// },
488
+ { family: "firefox", marker: /\b(?:Firefox|FxiOS)\// },
489
+ { family: "safari", marker: /\bSafari\// }
490
+ ];
491
+ function uaFamily(userAgent) {
492
+ if (!userAgent) {
493
+ return "other";
494
+ }
495
+ return FAMILY_MARKERS.find((entry) => entry.marker.test(userAgent))?.family ?? "other";
496
+ }
497
+
498
+ // src/nextjs/interceptors/cookie-options.ts
499
+ function resolveSecure() {
500
+ const override = process.env.SPFN_AUTH_COOKIE_SECURE;
501
+ if (override !== void 0) {
502
+ return override === "true";
503
+ }
504
+ return process.env.NODE_ENV === "production";
505
+ }
506
+ var cookieSecure = resolveSecure();
507
+
508
+ // src/nextjs/interceptors/session-binding.ts
509
+ function bindingSessionFields(body, userAgent) {
510
+ if (body?.sessionBinding !== "passkey" || typeof body.keyExpiresAtMillis !== "number") {
511
+ return {};
512
+ }
513
+ return {
514
+ binding: "passkey",
515
+ keyExpiresAt: body.keyExpiresAtMillis,
516
+ ...userAgent ? { uaFamily: uaFamily(userAgent) } : {}
517
+ };
518
+ }
519
+
461
520
  // src/nextjs/oauth-handlers.ts
462
521
  function safeReturnUrl(requested, defaultRedirect) {
463
522
  return requested && isSafeReturnPath(requested) ? requested : defaultRedirect;
464
523
  }
524
+ function bindingFromQuery(searchParams) {
525
+ const expiresAt = Number(searchParams.get("keyExpiresAtMillis"));
526
+ if (searchParams.get("sessionBinding") !== "passkey" || !Number.isFinite(expiresAt)) {
527
+ return {};
528
+ }
529
+ return { sessionBinding: "passkey", keyExpiresAtMillis: expiresAt };
530
+ }
465
531
  function createOAuthCallbackHandler(options) {
466
532
  const defaultRedirect = options?.defaultRedirectUrl || "/";
467
533
  const errorRedirect = options?.errorRedirectUrl || "/auth/error";
@@ -497,7 +563,8 @@ function createOAuthCallbackHandler(options) {
497
563
  userId,
498
564
  privateKey: pendingSession.privateKey,
499
565
  keyId: pendingSession.keyId,
500
- algorithm: pendingSession.algorithm
566
+ algorithm: pendingSession.algorithm,
567
+ ...bindingSessionFields(bindingFromQuery(searchParams), request.headers.get("user-agent"))
501
568
  }, ttl);
502
569
  const redirectUrl = new URL(returnUrl, request.url);
503
570
  const response = NextResponse.redirect(redirectUrl);