najm-auth 2.0.3 → 2.0.5

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.
package/README.md CHANGED
@@ -167,6 +167,7 @@ All routes are prefixed with `/auth` and auto-registered by the plugin.
167
167
  | `POST` | `/auth/register` | Register new user | None |
168
168
  | `POST` | `/auth/login` | Login with email/password | None |
169
169
  | `POST` | `/auth/refresh` | Refresh access token (cookie) | None (uses refresh cookie) |
170
+ | `POST` | `/auth/session/recover` | Reissue signed session without token rotation | Refresh cookie + recovery header |
170
171
  | `POST` | `/auth/logout` | Logout and revoke tokens | ✅ Required |
171
172
  | `GET` | `/auth/me` | Get current user profile | ✅ Required |
172
173
  | `POST` | `/auth/forgot-password` | Request password reset | None |
@@ -600,6 +601,7 @@ limits are active when `auth()` is registered.
600
601
  | `POST /auth/register` | 5 | 15 minutes | IP |
601
602
  | `POST /auth/login` | 5 | 15 minutes | IP |
602
603
  | `POST /auth/refresh` | 15 | 15 minutes | Cookie fingerprint |
604
+ | `POST /auth/session/recover` | 120 | 1 minute | Cookie fingerprint |
603
605
  | `POST /auth/logout` | 10 | 15 minutes | User ID |
604
606
  | `GET /auth/me` | 30 | 1 minute | User ID |
605
607
  | `POST /auth/forgot-password` | 3 | 15 minutes | IP |
@@ -675,8 +677,12 @@ throw new HttpError(403, 'Insufficient permissions for this action');
675
677
  - Login uses a dummy password hash for missing users to reduce timing leaks.
676
678
  - Forgot-password responses avoid email enumeration.
677
679
  - Auth routes register `najm-rate` and ship route-level brute-force limits.
678
- - Session cookies are signed, short-lived, and checked against session version
679
- invalidation.
680
+ - Session cookies are signed and short-lived; server auth resolution checks
681
+ their session version.
682
+ - Expired signed sessions recover through authoritative, non-rotating refresh
683
+ validation; middleware verifies the reissued HMAC before using its claims.
684
+ - `verifyAlways` forces that authoritative check on every protected request;
685
+ the default bounds cached role/status staleness to `session.maxAge`.
680
686
 
681
687
  ### Password Reset Tokens
682
688
 
@@ -11,16 +11,29 @@ interface AuthMiddlewareConfig {
11
11
  roleRoutes?: Record<string, string[]>;
12
12
  /** Refresh token cookie name (default: 'refreshToken') */
13
13
  cookieName?: string;
14
- /** Session cookie name to clear on redirect (default: 'najm.session') */
14
+ /** API base URL used by session recovery (default: '/api'). */
15
+ apiBaseURL?: string;
16
+ /** Auth endpoint prefix used by session recovery (default: '/auth'). */
17
+ authPrefix?: string;
18
+ /** Signed session cookie name (default: 'najm.session') */
15
19
  sessionCookieName?: string;
16
- /** URL of the verify endpoint (default: derived from request) */
20
+ /** @deprecated Session cookies are verified locally at the Edge. */
17
21
  verifyURL?: string;
22
+ /** Secret for verifying the session cookie HMAC. Falls back to env vars. */
23
+ sessionSecret?: string;
24
+ /** Must match the auth plugin's session.maxAge. Default: 300 seconds. */
25
+ sessionMaxAge?: number;
18
26
  /**
19
- * When true, call the verify endpoint on EVERY protected route (not just
20
- * roleRoutes). Redirects to loginRoute if the session is invalid. Adds one
21
- * fetch per navigation — trade latency for stronger guarantees.
27
+ * Force authoritative refresh-session validation on every protected request.
28
+ * This reissues the signed session cookie without rotating refresh tokens.
22
29
  */
23
30
  verifyAlways?: boolean;
31
+ /**
32
+ * Session-recovery endpoint. Relative values resolve against the request
33
+ * origin. Defaults to `${apiBaseURL}${authPrefix}/session/recover`.
34
+ * Set to false to disable automatic recovery.
35
+ */
36
+ recoveryURL?: string | false;
24
37
  }
25
38
  /**
26
39
  * Create a Next.js middleware function that protects routes based on auth state.
@@ -28,7 +41,7 @@ interface AuthMiddlewareConfig {
28
41
  * @example
29
42
  * ```ts
30
43
  * // middleware.ts
31
- * import { withAuthMiddleware } from 'najm-auth/client/server';
44
+ * import { withAuthMiddleware } from 'najm-auth/client/edge';
32
45
  *
33
46
  * export default withAuthMiddleware({
34
47
  * protectedRoutes: ['/dashboard/:path*', '/admin/:path*'],
@@ -1,6 +1,229 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
+ // src/client/sessionCookie.ts
5
+ var DEFAULT_SESSION_MAX_AGE_SECONDS = 300;
6
+ var MAX_CLOCK_SKEW_MS = 3e4;
7
+ var HMAC_SHA256_BASE64URL_LENGTH = 43;
8
+ var BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
9
+ function resolveSessionSecret(explicit) {
10
+ if (explicit !== void 0) return explicit || void 0;
11
+ if (typeof process === "undefined") return void 0;
12
+ return process.env.NAJM_SESSION_SECRET || process.env.JWT_ACCESS_SECRET || void 0;
13
+ }
14
+ __name(resolveSessionSecret, "resolveSessionSecret");
15
+ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_AGE_SECONDS, now = Date.now()) {
16
+ if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) return null;
17
+ try {
18
+ const data = JSON.parse(payload);
19
+ if (!isRecord(data) || !isValidUser(data.user)) return null;
20
+ if (!isStringArray(data.roles) || !isStringArray(data.permissions)) return null;
21
+ if (!Number.isInteger(data.sessionVersion) || data.sessionVersion < 0) return null;
22
+ if (!Number.isFinite(data.iat) || !Number.isInteger(data.iat) || data.iat <= 0) return null;
23
+ const issuedAt = data.iat;
24
+ if (issuedAt > now + MAX_CLOCK_SKEW_MS) return null;
25
+ if (now - issuedAt >= maxAgeSeconds * 1e3) return null;
26
+ return {
27
+ user: data.user,
28
+ roles: [...data.roles],
29
+ permissions: [...data.permissions],
30
+ sessionVersion: data.sessionVersion,
31
+ iat: issuedAt
32
+ };
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ __name(parseSessionCookiePayload, "parseSessionCookiePayload");
38
+ async function verifySessionCookie(rawCookieValue, options) {
39
+ if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) return null;
40
+ for (const signedValue of cookieValueCandidates(rawCookieValue)) {
41
+ const lastDot = signedValue.lastIndexOf(".");
42
+ if (lastDot <= 0 || lastDot === signedValue.length - 1) continue;
43
+ const payload = signedValue.slice(0, lastDot);
44
+ const signature = signedValue.slice(lastDot + 1);
45
+ if (!await verifyHmac(payload, signature, options.secret)) continue;
46
+ return parseSessionCookiePayload(
47
+ payload,
48
+ options.maxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
49
+ options.now
50
+ );
51
+ }
52
+ return null;
53
+ }
54
+ __name(verifySessionCookie, "verifySessionCookie");
55
+ function readCookieValue(cookieHeader, name) {
56
+ for (const part of cookieHeader.split(";")) {
57
+ const separator = part.indexOf("=");
58
+ if (separator === -1) continue;
59
+ if (part.slice(0, separator).trim() === name) {
60
+ const value = part.slice(separator + 1).trim();
61
+ return value || void 0;
62
+ }
63
+ }
64
+ return void 0;
65
+ }
66
+ __name(readCookieValue, "readCookieValue");
67
+ async function verifyHmac(payload, signature, secret) {
68
+ if (signature.length !== HMAC_SHA256_BASE64URL_LENGTH) return false;
69
+ const signatureBytes = decodeBase64Url(signature);
70
+ if (!signatureBytes || signatureBytes.length !== 32) return false;
71
+ try {
72
+ const encoder = new TextEncoder();
73
+ const key = await globalThis.crypto.subtle.importKey(
74
+ "raw",
75
+ encoder.encode(secret),
76
+ { name: "HMAC", hash: "SHA-256" },
77
+ false,
78
+ ["verify"]
79
+ );
80
+ return await globalThis.crypto.subtle.verify(
81
+ "HMAC",
82
+ key,
83
+ signatureBytes,
84
+ encoder.encode(payload)
85
+ );
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+ __name(verifyHmac, "verifyHmac");
91
+ function cookieValueCandidates(raw) {
92
+ const candidates = [raw];
93
+ try {
94
+ const decoded = decodeURIComponent(raw);
95
+ if (decoded !== raw) candidates.push(decoded);
96
+ } catch {
97
+ }
98
+ return candidates;
99
+ }
100
+ __name(cookieValueCandidates, "cookieValueCandidates");
101
+ function decodeBase64Url(value) {
102
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
103
+ const bytes = [];
104
+ let accumulator = 0;
105
+ let bits = 0;
106
+ for (const char of value) {
107
+ const digit = BASE64URL_ALPHABET.indexOf(char);
108
+ if (digit === -1) return null;
109
+ accumulator = accumulator << 6 | digit;
110
+ bits += 6;
111
+ if (bits >= 8) {
112
+ bits -= 8;
113
+ bytes.push(accumulator >> bits & 255);
114
+ accumulator &= (1 << bits) - 1;
115
+ }
116
+ }
117
+ if (bits > 0 && accumulator !== 0) return null;
118
+ return new Uint8Array(bytes);
119
+ }
120
+ __name(decodeBase64Url, "decodeBase64Url");
121
+ function isRecord(value) {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value);
123
+ }
124
+ __name(isRecord, "isRecord");
125
+ function isValidUser(value) {
126
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.email === "string" && value.email.length > 0 && (value.role === void 0 || value.role === null || typeof value.role === "string");
127
+ }
128
+ __name(isValidUser, "isValidUser");
129
+ function isStringArray(value) {
130
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
131
+ }
132
+ __name(isStringArray, "isStringArray");
133
+
134
+ // src/client/sessionRecovery.ts
135
+ async function requestSessionRecovery(options) {
136
+ if (!isCookieName(options.refreshCookieName) || !isCookieName(options.sessionCookieName)) {
137
+ return { status: "unavailable" };
138
+ }
139
+ if (!isCookieValue(options.refreshCookieValue)) {
140
+ return { status: "invalid" };
141
+ }
142
+ if (!isSafeRecoveryEndpoint(options.endpoint)) {
143
+ return { status: "unavailable" };
144
+ }
145
+ let response;
146
+ try {
147
+ response = await fetch(options.endpoint, {
148
+ method: "POST",
149
+ headers: {
150
+ Accept: "application/json",
151
+ Cookie: `${options.refreshCookieName}=${options.refreshCookieValue}`,
152
+ "X-Najm-Session-Recovery": "1"
153
+ },
154
+ cache: "no-store",
155
+ redirect: "manual"
156
+ });
157
+ } catch {
158
+ return { status: "unavailable" };
159
+ }
160
+ if (!response.ok) {
161
+ const status = response.status;
162
+ return {
163
+ status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
164
+ httpStatus: status
165
+ };
166
+ }
167
+ const setCookie = response.headers.get("set-cookie");
168
+ if (!setCookie) return { status: "unavailable", httpStatus: response.status };
169
+ const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
170
+ if (!sessionCookieValue) {
171
+ return { status: "unavailable", httpStatus: response.status };
172
+ }
173
+ const claims = await verifySessionCookie(sessionCookieValue, {
174
+ secret: options.sessionSecret,
175
+ maxAgeSeconds: options.sessionMaxAge
176
+ });
177
+ if (!claims) return { status: "unavailable", httpStatus: response.status };
178
+ return {
179
+ status: "recovered",
180
+ claims,
181
+ setCookie,
182
+ sessionCookieValue
183
+ };
184
+ }
185
+ __name(requestSessionRecovery, "requestSessionRecovery");
186
+ function authEndpoint(baseURL, authPrefix, suffix, requestURL) {
187
+ const normalizedBase = baseURL.replace(/\/+$/, "");
188
+ const normalizedPrefix = `/${authPrefix.replace(/^\/+|\/+$/g, "")}`;
189
+ const normalizedSuffix = `/${suffix.replace(/^\/+/, "")}`;
190
+ const value = `${normalizedBase}${normalizedPrefix}${normalizedSuffix}`;
191
+ return requestURL ? new URL(value, requestURL).toString() : value;
192
+ }
193
+ __name(authEndpoint, "authEndpoint");
194
+ function replaceCookieValue(cookieHeader, name, value) {
195
+ const parts = cookieHeader.split(";").map((part) => part.trim()).filter(Boolean).filter((part) => part.slice(0, part.indexOf("=")).trim() !== name);
196
+ parts.push(`${name}=${value}`);
197
+ return parts.join("; ");
198
+ }
199
+ __name(replaceCookieValue, "replaceCookieValue");
200
+ function readSetCookieValue(setCookie, name) {
201
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
202
+ const match = setCookie.match(new RegExp(`(?:^|,\\s*)${escaped}=([^;]*)`));
203
+ return match?.[1] || void 0;
204
+ }
205
+ __name(readSetCookieValue, "readSetCookieValue");
206
+ function isCookieName(value) {
207
+ return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value);
208
+ }
209
+ __name(isCookieName, "isCookieName");
210
+ function isCookieValue(value) {
211
+ return value.length > 0 && !/[\r\n;]/.test(value);
212
+ }
213
+ __name(isCookieValue, "isCookieValue");
214
+ function isSafeRecoveryEndpoint(value) {
215
+ try {
216
+ const url = new URL(value);
217
+ if (url.username || url.password) return false;
218
+ if (url.protocol === "https:") return true;
219
+ if (url.protocol !== "http:") return false;
220
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+ __name(isSafeRecoveryEndpoint, "isSafeRecoveryEndpoint");
226
+
4
227
  // src/client/server/withAuthMiddleware.ts
5
228
  function withAuthMiddleware(config) {
6
229
  const {
@@ -9,54 +232,86 @@ function withAuthMiddleware(config) {
9
232
  loginRoute = "/login",
10
233
  roleRoutes = {},
11
234
  cookieName = "refreshToken",
235
+ apiBaseURL = "/api",
236
+ authPrefix = "/auth",
12
237
  sessionCookieName = "najm.session",
13
- verifyAlways = false
238
+ sessionSecret,
239
+ sessionMaxAge,
240
+ verifyAlways = false,
241
+ recoveryURL
14
242
  } = config;
15
243
  return /* @__PURE__ */ __name(async function middleware(request) {
16
244
  const { NextResponse } = await import("next/server");
17
- const redirectToLogin = /* @__PURE__ */ __name((pathname2, clearCookies) => {
245
+ const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
18
246
  const loginUrl = new URL(loginRoute, request.url);
19
- loginUrl.searchParams.set("from", pathname2);
247
+ loginUrl.searchParams.set("from", returnPath2);
20
248
  const res = NextResponse.redirect(loginUrl);
21
- if (clearCookies) {
249
+ if (clearCookies.includes("refresh")) {
22
250
  res.cookies.delete(cookieName);
251
+ }
252
+ if (clearCookies.includes("session")) {
23
253
  res.cookies.delete(sessionCookieName);
24
254
  }
25
255
  return res;
26
256
  }, "redirectToLogin");
27
257
  const url = new URL(request.url);
28
258
  const pathname = url.pathname;
259
+ const returnPath = `${url.pathname}${url.search}`;
29
260
  if (matchesAny(pathname, publicRoutes)) {
30
261
  return NextResponse.next();
31
262
  }
32
263
  const isProtected = protectedRoutes.length === 0 || matchesAny(pathname, protectedRoutes);
33
264
  if (!isProtected) return NextResponse.next();
34
265
  const cookie = request.headers.get("cookie") ?? "";
35
- const hasToken = cookieRegex(cookieName).test(cookie);
36
- if (!hasToken) {
37
- return redirectToLogin(pathname, true);
266
+ const sessionCookie = readCookieValue(cookie, sessionCookieName);
267
+ const secret = resolveSessionSecret(sessionSecret);
268
+ if (!secret) {
269
+ return redirectToLogin(returnPath, ["session"]);
270
+ }
271
+ let session = sessionCookie ? await verifySessionCookie(sessionCookie, {
272
+ secret,
273
+ maxAgeSeconds: sessionMaxAge
274
+ }) : null;
275
+ let recovery = null;
276
+ if (!session || verifyAlways) {
277
+ const refreshCookie = readCookieValue(cookie, cookieName);
278
+ if (!refreshCookie || recoveryURL === false) {
279
+ return redirectToLogin(returnPath, ["refresh", "session"]);
280
+ }
281
+ const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
282
+ recovery = await requestSessionRecovery({
283
+ endpoint,
284
+ refreshCookieName: cookieName,
285
+ refreshCookieValue: refreshCookie,
286
+ sessionCookieName,
287
+ sessionSecret: secret,
288
+ sessionMaxAge
289
+ });
290
+ if (recovery.status !== "recovered") {
291
+ return redirectToLogin(
292
+ returnPath,
293
+ recovery.status === "invalid" ? ["refresh", "session"] : ["session"]
294
+ );
295
+ }
296
+ session = recovery.claims;
38
297
  }
39
298
  const requiredRoles = findMatchingRoles(pathname, roleRoutes);
40
- const needsVerify = verifyAlways || !!requiredRoles;
41
- if (needsVerify) {
42
- const verifyURL = config.verifyURL ?? `${url.origin}/api/auth/me`;
43
- try {
44
- const res = await fetch(verifyURL, {
45
- headers: { "Cookie": cookie, "Accept": "application/json" }
46
- });
47
- if (!res.ok) {
48
- return redirectToLogin(pathname, true);
49
- }
50
- if (requiredRoles) {
51
- const body = await res.json();
52
- const userRole = body?.data?.role;
53
- if (!userRole || !requiredRoles.includes(userRole)) {
54
- return new NextResponse(null, { status: 403 });
55
- }
56
- }
57
- } catch {
58
- return redirectToLogin(pathname, true);
299
+ if (requiredRoles && !session.roles.some((role) => requiredRoles.includes(role))) {
300
+ const forbidden = new NextResponse(null, { status: 403 });
301
+ if (recovery?.status === "recovered") {
302
+ forbidden.headers.append("Set-Cookie", recovery.setCookie);
59
303
  }
304
+ return forbidden;
305
+ }
306
+ if (recovery?.status === "recovered") {
307
+ const requestHeaders = new Headers(request.headers);
308
+ requestHeaders.set(
309
+ "cookie",
310
+ replaceCookieValue(cookie, sessionCookieName, recovery.sessionCookieValue)
311
+ );
312
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
313
+ response.headers.append("Set-Cookie", recovery.setCookie);
314
+ return response;
60
315
  }
61
316
  return NextResponse.next();
62
317
  }, "middleware");
@@ -72,10 +327,6 @@ function matchPattern(pathname, pattern) {
72
327
  return new RegExp(`^${regex}$`).test(pathname);
73
328
  }
74
329
  __name(matchPattern, "matchPattern");
75
- function cookieRegex(name) {
76
- return new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=[^;]`);
77
- }
78
- __name(cookieRegex, "cookieRegex");
79
330
  function findMatchingRoles(pathname, roleRoutes) {
80
331
  for (const [pattern, roles] of Object.entries(roleRoutes)) {
81
332
  if (matchPattern(pathname, pattern)) return roles;
@@ -75,27 +75,27 @@ interface GetSessionConfig {
75
75
  * Defaults to `${NEXT_PUBLIC_API_URL || http://localhost:${PORT||3000}}/api`.
76
76
  */
77
77
  baseURL?: string;
78
- /**
79
- * Auth route prefix appended to baseURL (default: '/auth').
80
- */
78
+ /** Auth route prefix appended to baseURL (default: '/auth'). */
81
79
  authPrefix?: string;
82
- /**
83
- * Refresh token cookie name to check before making the network call.
84
- * If absent we skip and return null immediately.
85
- * Default: 'refreshToken'
86
- */
80
+ /** Refresh token cookie name (default: 'refreshToken'). */
87
81
  cookieName?: string;
88
- /**
89
- * Session cookie name (default: 'najm.session').
90
- * When present and valid, skips the /auth/me fetch entirely.
91
- */
82
+ /** Signed session cookie name (default: 'najm.session'). */
92
83
  sessionCookieName?: string;
93
84
  /**
94
85
  * Secret used to verify the session cookie HMAC signature.
95
- * Must match the `jwt.accessSecret` used by the auth plugin.
96
86
  * Falls back to NAJM_SESSION_SECRET or JWT_ACCESS_SECRET env vars.
97
87
  */
98
88
  sessionSecret?: string;
89
+ /**
90
+ * Maximum accepted session-cookie age in seconds.
91
+ * Must match the auth plugin's `session.maxAge`. Default: 300.
92
+ */
93
+ sessionMaxAge?: number;
94
+ /**
95
+ * Session-recovery endpoint. Defaults to
96
+ * `${baseURL}${authPrefix}/session/recover`. Set to false to disable fallback.
97
+ */
98
+ recoveryURL?: string | false;
99
99
  /**
100
100
  * Error handling mode:
101
101
  * - 'nullable' (default): returns null on any failure
@@ -117,29 +117,10 @@ declare class AuthTransportError extends Error {
117
117
  constructor(message: string, status?: number);
118
118
  }
119
119
  /**
120
- * Resolve the current session inside a Next.js Server Component, Route Handler,
121
- * or Server Action.
122
- *
123
- * **Fast path**: If a signed `najm.session` cookie exists and is valid,
124
- * returns the session instantly with zero network calls.
125
- *
126
- * **Fallback**: Checks the refresh token cookie and calls `/auth/me`.
127
- *
128
- * @example
129
- * ```tsx
130
- * import { getSession } from 'najm-auth/client/server';
131
- *
132
- * export default async function RootLayout({ children }) {
133
- * const session = await getSession();
134
- * return (
135
- * <html><body>
136
- * <AuthProvider client={authClient} initialSession={session}>
137
- * {children}
138
- * </AuthProvider>
139
- * </body></html>
140
- * );
141
- * }
142
- * ```
120
+ * Resolve a session in a Next.js Server Component, Route Handler, or Server
121
+ * Action. Recovery returns claims for the current render but cannot persist
122
+ * response cookies during Server Component rendering; middleware performs that
123
+ * persistence for protected navigation.
143
124
  */
144
125
  declare function getSession(config?: GetSessionConfig): Promise<ServerSession | null>;
145
126
 
@@ -193,12 +174,18 @@ interface DefineAuthConfig {
193
174
  sessionCookieName?: string;
194
175
  /** Secret for verifying session cookie HMAC. Falls back to env vars. */
195
176
  sessionSecret?: string;
177
+ /** Must match the auth plugin's session.maxAge. Default: 300 seconds. */
178
+ sessionMaxAge?: number;
179
+ /**
180
+ * Session-recovery endpoint. Defaults to
181
+ * `${apiBaseURL}${authPrefix}/session/recover`; false disables recovery.
182
+ */
183
+ recoveryURL?: string | false;
196
184
  /** Next.js middleware matcher (default: exclude _next, favicon, api) */
197
185
  matcher?: string[];
198
186
  /**
199
- * When true, call the verify endpoint on every protected route (not just
200
- * roleRoutes). Redirects to loginRoute if the session is invalid. One extra
201
- * fetch per navigation in exchange for guaranteed fresh auth state.
187
+ * Force authoritative refresh-session validation on every protected request.
188
+ * Recovery reissues the signed cookie without rotating refresh tokens.
202
189
  */
203
190
  verifyAlways?: boolean;
204
191
  }
@@ -212,7 +199,7 @@ interface AuthKit {
212
199
  readonly client: NajmAuthClient;
213
200
  /** Shortcut for `client.api` — the underlying FetchClient with auth attached. */
214
201
  readonly api: FetchClient;
215
- /** Resolve session — reads signed cookie first (instant), falls back to /auth/me */
202
+ /** Resolve session — signed-cookie first, then non-rotating recovery. */
216
203
  getSession: (opts?: Pick<GetSessionConfig, 'mode'>) => Promise<ServerSession | null>;
217
204
  /** Require session — throws if unauthenticated */
218
205
  requireSession: () => Promise<ServerSession>;