najm-auth 2.0.6 → 2.0.9

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
@@ -684,6 +684,10 @@ throw new HttpError(403, 'Insufficient permissions for this action');
684
684
  - Server-side recovery sends only the configured refresh cookie and accepts
685
685
  relative or exact same-origin endpoints. URL credentials and any
686
686
  scheme/hostname/port change are rejected before the network request.
687
+ - Self-hosted apps may explicitly use a loopback-only `internalRecoveryURL`
688
+ when their public reverse-proxy origin is not reachable from the app process.
689
+ - `onRecoveryFailure` exposes structured, secret-free recovery diagnostics
690
+ without logging anything by default.
687
691
  - `verifyAlways` forces that authoritative check on every protected request;
688
692
  the default bounds cached role/status staleness to `session.maxAge`.
689
693
 
@@ -1,5 +1,22 @@
1
1
  import * as next_server from 'next/server';
2
2
 
3
+ type SessionRecoveryFailureReason = 'invalid-cookie-name' | 'invalid-refresh-cookie' | 'invalid-endpoint' | 'fetch-error' | 'http-status' | 'missing-set-cookie' | 'session-cookie-parse' | 'session-cookie-hmac' | 'session-cookie-payload';
4
+ interface SessionRecoveryErrorDetails {
5
+ name: string;
6
+ message: string;
7
+ code?: string;
8
+ cause?: {
9
+ name: string;
10
+ message: string;
11
+ code?: string;
12
+ };
13
+ }
14
+ interface SessionRecoveryFailure {
15
+ reason: SessionRecoveryFailureReason;
16
+ httpStatus?: number;
17
+ error?: SessionRecoveryErrorDetails;
18
+ }
19
+
3
20
  interface AuthMiddlewareConfig {
4
21
  /** Routes that require authentication (glob patterns) */
5
22
  protectedRoutes?: string[];
@@ -34,6 +51,14 @@ interface AuthMiddlewareConfig {
34
51
  * Set to false to disable automatic recovery.
35
52
  */
36
53
  recoveryURL?: string | false;
54
+ /**
55
+ * Optional loopback-only endpoint for self-hosted apps whose public origin
56
+ * cannot be reached from the app container. Takes precedence over
57
+ * `recoveryURL` for the server-side recovery request.
58
+ */
59
+ internalRecoveryURL?: string;
60
+ /** Secret-free diagnostic hook for failed authoritative recovery attempts. */
61
+ onRecoveryFailure?: (failure: SessionRecoveryFailure) => void;
37
62
  }
38
63
  /**
39
64
  * Create a Next.js middleware function that protects routes based on auth state.
@@ -57,4 +82,4 @@ interface AuthMiddlewareConfig {
57
82
  */
58
83
  declare function withAuthMiddleware(config: AuthMiddlewareConfig): (request: Request) => Promise<next_server.NextResponse<unknown>>;
59
84
 
60
- export { type AuthMiddlewareConfig, withAuthMiddleware };
85
+ export { type AuthMiddlewareConfig, type SessionRecoveryErrorDetails, type SessionRecoveryFailure, type SessionRecoveryFailureReason, withAuthMiddleware };
@@ -36,22 +36,36 @@ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_
36
36
  }
37
37
  __name(parseSessionCookiePayload, "parseSessionCookiePayload");
38
38
  async function verifySessionCookie(rawCookieValue, options) {
39
- if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) return null;
39
+ const result = await verifySessionCookieDetailed(rawCookieValue, options);
40
+ return result.status === "valid" ? result.claims : null;
41
+ }
42
+ __name(verifySessionCookie, "verifySessionCookie");
43
+ async function verifySessionCookieDetailed(rawCookieValue, options) {
44
+ if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) {
45
+ return { status: "invalid", reason: "format" };
46
+ }
47
+ let sawSignedFormat = false;
48
+ let sawValidHmac = false;
40
49
  for (const signedValue of cookieValueCandidates(rawCookieValue)) {
41
50
  const lastDot = signedValue.lastIndexOf(".");
42
51
  if (lastDot <= 0 || lastDot === signedValue.length - 1) continue;
52
+ sawSignedFormat = true;
43
53
  const payload = signedValue.slice(0, lastDot);
44
54
  const signature = signedValue.slice(lastDot + 1);
45
55
  if (!await verifyHmac(payload, signature, options.secret)) continue;
46
- return parseSessionCookiePayload(
56
+ sawValidHmac = true;
57
+ const claims = parseSessionCookiePayload(
47
58
  payload,
48
59
  options.maxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
49
60
  options.now
50
61
  );
62
+ if (claims) return { status: "valid", claims };
51
63
  }
52
- return null;
64
+ if (sawValidHmac) return { status: "invalid", reason: "payload" };
65
+ if (sawSignedFormat) return { status: "invalid", reason: "hmac" };
66
+ return { status: "invalid", reason: "format" };
53
67
  }
54
- __name(verifySessionCookie, "verifySessionCookie");
68
+ __name(verifySessionCookieDetailed, "verifySessionCookieDetailed");
55
69
  function readCookieValue(cookieHeader, name) {
56
70
  for (const part of cookieHeader.split(";")) {
57
71
  const separator = part.indexOf("=");
@@ -132,18 +146,28 @@ function isStringArray(value) {
132
146
  __name(isStringArray, "isStringArray");
133
147
 
134
148
  // src/client/sessionRecovery.ts
149
+ function resolveInternalRecoveryURL(explicit) {
150
+ if (explicit !== void 0) return explicit || void 0;
151
+ if (typeof process === "undefined") return void 0;
152
+ return process.env.NAJM_AUTH_INTERNAL_URL || void 0;
153
+ }
154
+ __name(resolveInternalRecoveryURL, "resolveInternalRecoveryURL");
135
155
  async function requestSessionRecovery(options) {
136
156
  if (!isCookieName(options.refreshCookieName) || !isCookieName(options.sessionCookieName)) {
157
+ reportFailure(options, { reason: "invalid-cookie-name" });
137
158
  return { status: "unavailable" };
138
159
  }
139
160
  if (!isCookieValue(options.refreshCookieValue)) {
161
+ reportFailure(options, { reason: "invalid-refresh-cookie" });
140
162
  return { status: "invalid" };
141
163
  }
142
164
  const endpoint = sameOriginRecoveryEndpoint(
143
165
  options.endpoint,
144
- options.requestOrigin
166
+ options.requestOrigin,
167
+ options.allowLoopbackEndpoint
145
168
  );
146
169
  if (!endpoint) {
170
+ reportFailure(options, { reason: "invalid-endpoint" });
147
171
  return { status: "unavailable" };
148
172
  }
149
173
  let response;
@@ -158,30 +182,51 @@ async function requestSessionRecovery(options) {
158
182
  cache: "no-store",
159
183
  redirect: "manual"
160
184
  });
161
- } catch {
185
+ } catch (error) {
186
+ reportFailure(options, {
187
+ reason: "fetch-error",
188
+ error: safeErrorDetails(error)
189
+ });
162
190
  return { status: "unavailable" };
163
191
  }
164
192
  if (!response.ok) {
165
193
  const status = response.status;
194
+ reportFailure(options, { reason: "http-status", httpStatus: status });
166
195
  return {
167
196
  status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
168
197
  httpStatus: status
169
198
  };
170
199
  }
171
200
  const setCookie = response.headers.get("set-cookie");
172
- if (!setCookie) return { status: "unavailable", httpStatus: response.status };
201
+ if (!setCookie) {
202
+ reportFailure(options, {
203
+ reason: "missing-set-cookie",
204
+ httpStatus: response.status
205
+ });
206
+ return { status: "unavailable", httpStatus: response.status };
207
+ }
173
208
  const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
174
209
  if (!sessionCookieValue) {
210
+ reportFailure(options, {
211
+ reason: "session-cookie-parse",
212
+ httpStatus: response.status
213
+ });
175
214
  return { status: "unavailable", httpStatus: response.status };
176
215
  }
177
- const claims = await verifySessionCookie(sessionCookieValue, {
216
+ const verification = await verifySessionCookieDetailed(sessionCookieValue, {
178
217
  secret: options.sessionSecret,
179
218
  maxAgeSeconds: options.sessionMaxAge
180
219
  });
181
- if (!claims) return { status: "unavailable", httpStatus: response.status };
220
+ if (verification.status === "invalid") {
221
+ reportFailure(options, {
222
+ reason: verification.reason === "hmac" ? "session-cookie-hmac" : verification.reason === "payload" ? "session-cookie-payload" : "session-cookie-parse",
223
+ httpStatus: response.status
224
+ });
225
+ return { status: "unavailable", httpStatus: response.status };
226
+ }
182
227
  return {
183
228
  status: "recovered",
184
- claims,
229
+ claims: verification.claims,
185
230
  setCookie,
186
231
  sessionCookieValue
187
232
  };
@@ -215,7 +260,7 @@ function isCookieValue(value) {
215
260
  return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
216
261
  }
217
262
  __name(isCookieValue, "isCookieValue");
218
- function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
263
+ function sameOriginRecoveryEndpoint(endpoint, requestOrigin, allowLoopback = false) {
219
264
  try {
220
265
  const trusted = new URL(requestOrigin);
221
266
  if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
@@ -223,13 +268,60 @@ function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
223
268
  }
224
269
  const resolved = new URL(endpoint, trusted.origin);
225
270
  if (resolved.username || resolved.password) return void 0;
226
- if (resolved.origin !== trusted.origin) return void 0;
271
+ if (resolved.origin !== trusted.origin && (!allowLoopback || !isLoopbackURL(resolved))) {
272
+ return void 0;
273
+ }
227
274
  return resolved.toString();
228
275
  } catch {
229
276
  return void 0;
230
277
  }
231
278
  }
232
279
  __name(sameOriginRecoveryEndpoint, "sameOriginRecoveryEndpoint");
280
+ function isLoopbackURL(url) {
281
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
282
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
283
+ }
284
+ __name(isLoopbackURL, "isLoopbackURL");
285
+ function reportFailure(options, failure) {
286
+ try {
287
+ options.onFailure?.(failure);
288
+ } catch {
289
+ }
290
+ }
291
+ __name(reportFailure, "reportFailure");
292
+ function safeErrorDetails(value) {
293
+ const error = isRecord2(value) ? value : {};
294
+ const details = {
295
+ name: safeText(error.name, "Error"),
296
+ message: safeText(error.message, "Session recovery fetch failed")
297
+ };
298
+ const code = safeOptionalText(error.code);
299
+ if (code) details.code = code;
300
+ if (isRecord2(error.cause)) {
301
+ const causeCode = safeOptionalText(error.cause.code);
302
+ details.cause = {
303
+ name: safeText(error.cause.name, "Error"),
304
+ message: safeText(error.cause.message, "Session recovery fetch failed"),
305
+ ...causeCode ? { code: causeCode } : {}
306
+ };
307
+ }
308
+ return details;
309
+ }
310
+ __name(safeErrorDetails, "safeErrorDetails");
311
+ function safeText(value, fallback) {
312
+ if (typeof value !== "string" || !value) return fallback;
313
+ return value.replace(/[\u0000-\u001F\u007F]/g, " ").slice(0, 300);
314
+ }
315
+ __name(safeText, "safeText");
316
+ function safeOptionalText(value) {
317
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
318
+ return safeText(String(value), "");
319
+ }
320
+ __name(safeOptionalText, "safeOptionalText");
321
+ function isRecord2(value) {
322
+ return typeof value === "object" && value !== null;
323
+ }
324
+ __name(isRecord2, "isRecord");
233
325
 
234
326
  // src/client/server/withAuthMiddleware.ts
235
327
  function withAuthMiddleware(config) {
@@ -245,8 +337,11 @@ function withAuthMiddleware(config) {
245
337
  sessionSecret,
246
338
  sessionMaxAge,
247
339
  verifyAlways = false,
248
- recoveryURL
340
+ recoveryURL,
341
+ internalRecoveryURL,
342
+ onRecoveryFailure
249
343
  } = config;
344
+ const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
250
345
  return /* @__PURE__ */ __name(async function middleware(request) {
251
346
  const { NextResponse } = await import("next/server");
252
347
  const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
@@ -282,18 +377,20 @@ function withAuthMiddleware(config) {
282
377
  let recovery = null;
283
378
  if (!session || verifyAlways) {
284
379
  const refreshCookie = readCookieValue(cookie, cookieName);
285
- if (!refreshCookie || recoveryURL === false) {
380
+ if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
286
381
  return redirectToLogin(returnPath, ["refresh", "session"]);
287
382
  }
288
- const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
383
+ const endpoint = resolvedInternalRecoveryURL ?? (recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url));
289
384
  recovery = await requestSessionRecovery({
290
385
  endpoint,
291
386
  requestOrigin: url.origin,
387
+ allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
292
388
  refreshCookieName: cookieName,
293
389
  refreshCookieValue: refreshCookie,
294
390
  sessionCookieName,
295
391
  sessionSecret: secret,
296
- sessionMaxAge
392
+ sessionMaxAge,
393
+ onFailure: onRecoveryFailure
297
394
  });
298
395
  if (recovery.status !== "recovered") {
299
396
  return redirectToLogin(
@@ -1,5 +1,6 @@
1
1
  import { f as AuthUser, F as FetchClient, N as NajmAuthClient, h as RetryConfig } from '../../NajmAuthClient-0yzFb9CR.js';
2
- export { withAuthMiddleware } from '../edge.js';
2
+ import { SessionRecoveryFailure } from '../edge.js';
3
+ export { SessionRecoveryErrorDetails, SessionRecoveryFailureReason, withAuthMiddleware } from '../edge.js';
3
4
  import 'next/server';
4
5
 
5
6
  interface GetServerSessionOptions {
@@ -96,12 +97,16 @@ interface GetSessionConfig {
96
97
  * `${baseURL}${authPrefix}/session/recover`. Set to false to disable fallback.
97
98
  */
98
99
  recoveryURL?: string | false;
100
+ /** Loopback-only recovery endpoint for self-hosted reverse-proxy setups. */
101
+ internalRecoveryURL?: string;
99
102
  /**
100
103
  * Error handling mode:
101
104
  * - 'nullable' (default): returns null on any failure
102
105
  * - 'strict': throws typed errors for debugging
103
106
  */
104
107
  mode?: 'nullable' | 'strict';
108
+ /** Secret-free diagnostic hook for failed recovery attempts. */
109
+ onRecoveryFailure?: (failure: SessionRecoveryFailure) => void;
105
110
  }
106
111
  declare class NoSessionError extends Error {
107
112
  readonly code = "NO_SESSION";
@@ -181,6 +186,8 @@ interface DefineAuthConfig {
181
186
  * `${apiBaseURL}${authPrefix}/session/recover`; false disables recovery.
182
187
  */
183
188
  recoveryURL?: string | false;
189
+ /** Loopback-only recovery endpoint for self-hosted reverse-proxy setups. */
190
+ internalRecoveryURL?: string;
184
191
  /** Next.js middleware matcher (default: exclude _next, favicon, api) */
185
192
  matcher?: string[];
186
193
  /**
@@ -188,6 +195,8 @@ interface DefineAuthConfig {
188
195
  * Recovery reissues the signed cookie without rotating refresh tokens.
189
196
  */
190
197
  verifyAlways?: boolean;
198
+ /** Secret-free diagnostic hook for failed server or proxy recovery. */
199
+ onRecoveryFailure?: (failure: SessionRecoveryFailure) => void;
191
200
  }
192
201
  interface AuthKit {
193
202
  /**
@@ -223,4 +232,4 @@ interface AuthKit {
223
232
  }
224
233
  declare function defineAuth(authConfig?: DefineAuthConfig): AuthKit;
225
234
 
226
- export { AuthConfigError, type AuthKit, AuthTransportError, type DefineAuthConfig, type GetSessionConfig, NoSessionError, type ServerSession, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getServerSession, getSession, withAuth };
235
+ export { AuthConfigError, type AuthKit, AuthTransportError, type DefineAuthConfig, type GetSessionConfig, NoSessionError, type ServerSession, SessionRecoveryFailure, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getServerSession, getSession, withAuth };
@@ -38,20 +38,33 @@ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_
38
38
  }
39
39
  }
40
40
  async function verifySessionCookie(rawCookieValue, options) {
41
- if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) return null;
41
+ const result = await verifySessionCookieDetailed(rawCookieValue, options);
42
+ return result.status === "valid" ? result.claims : null;
43
+ }
44
+ async function verifySessionCookieDetailed(rawCookieValue, options) {
45
+ if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) {
46
+ return { status: "invalid", reason: "format" };
47
+ }
48
+ let sawSignedFormat = false;
49
+ let sawValidHmac = false;
42
50
  for (const signedValue of cookieValueCandidates(rawCookieValue)) {
43
51
  const lastDot = signedValue.lastIndexOf(".");
44
52
  if (lastDot <= 0 || lastDot === signedValue.length - 1) continue;
53
+ sawSignedFormat = true;
45
54
  const payload = signedValue.slice(0, lastDot);
46
55
  const signature = signedValue.slice(lastDot + 1);
47
56
  if (!await verifyHmac(payload, signature, options.secret)) continue;
48
- return parseSessionCookiePayload(
57
+ sawValidHmac = true;
58
+ const claims = parseSessionCookiePayload(
49
59
  payload,
50
60
  options.maxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
51
61
  options.now
52
62
  );
63
+ if (claims) return { status: "valid", claims };
53
64
  }
54
- return null;
65
+ if (sawValidHmac) return { status: "invalid", reason: "payload" };
66
+ if (sawSignedFormat) return { status: "invalid", reason: "hmac" };
67
+ return { status: "invalid", reason: "format" };
55
68
  }
56
69
  function readCookieValue(cookieHeader, name) {
57
70
  for (const part of cookieHeader.split(";")) {
@@ -134,6 +147,7 @@ var init_sessionCookie = __esm({
134
147
  __name(resolveSessionSecret, "resolveSessionSecret");
135
148
  __name(parseSessionCookiePayload, "parseSessionCookiePayload");
136
149
  __name(verifySessionCookie, "verifySessionCookie");
150
+ __name(verifySessionCookieDetailed, "verifySessionCookieDetailed");
137
151
  __name(readCookieValue, "readCookieValue");
138
152
  __name(verifyHmac, "verifyHmac");
139
153
  __name(cookieValueCandidates, "cookieValueCandidates");
@@ -145,18 +159,27 @@ var init_sessionCookie = __esm({
145
159
  });
146
160
 
147
161
  // src/client/sessionRecovery.ts
162
+ function resolveInternalRecoveryURL(explicit) {
163
+ if (explicit !== void 0) return explicit || void 0;
164
+ if (typeof process === "undefined") return void 0;
165
+ return process.env.NAJM_AUTH_INTERNAL_URL || void 0;
166
+ }
148
167
  async function requestSessionRecovery(options) {
149
168
  if (!isCookieName(options.refreshCookieName) || !isCookieName(options.sessionCookieName)) {
169
+ reportFailure(options, { reason: "invalid-cookie-name" });
150
170
  return { status: "unavailable" };
151
171
  }
152
172
  if (!isCookieValue(options.refreshCookieValue)) {
173
+ reportFailure(options, { reason: "invalid-refresh-cookie" });
153
174
  return { status: "invalid" };
154
175
  }
155
176
  const endpoint = sameOriginRecoveryEndpoint(
156
177
  options.endpoint,
157
- options.requestOrigin
178
+ options.requestOrigin,
179
+ options.allowLoopbackEndpoint
158
180
  );
159
181
  if (!endpoint) {
182
+ reportFailure(options, { reason: "invalid-endpoint" });
160
183
  return { status: "unavailable" };
161
184
  }
162
185
  let response;
@@ -171,30 +194,51 @@ async function requestSessionRecovery(options) {
171
194
  cache: "no-store",
172
195
  redirect: "manual"
173
196
  });
174
- } catch {
197
+ } catch (error) {
198
+ reportFailure(options, {
199
+ reason: "fetch-error",
200
+ error: safeErrorDetails(error)
201
+ });
175
202
  return { status: "unavailable" };
176
203
  }
177
204
  if (!response.ok) {
178
205
  const status = response.status;
206
+ reportFailure(options, { reason: "http-status", httpStatus: status });
179
207
  return {
180
208
  status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
181
209
  httpStatus: status
182
210
  };
183
211
  }
184
212
  const setCookie = response.headers.get("set-cookie");
185
- if (!setCookie) return { status: "unavailable", httpStatus: response.status };
213
+ if (!setCookie) {
214
+ reportFailure(options, {
215
+ reason: "missing-set-cookie",
216
+ httpStatus: response.status
217
+ });
218
+ return { status: "unavailable", httpStatus: response.status };
219
+ }
186
220
  const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
187
221
  if (!sessionCookieValue) {
222
+ reportFailure(options, {
223
+ reason: "session-cookie-parse",
224
+ httpStatus: response.status
225
+ });
188
226
  return { status: "unavailable", httpStatus: response.status };
189
227
  }
190
- const claims = await verifySessionCookie(sessionCookieValue, {
228
+ const verification = await verifySessionCookieDetailed(sessionCookieValue, {
191
229
  secret: options.sessionSecret,
192
230
  maxAgeSeconds: options.sessionMaxAge
193
231
  });
194
- if (!claims) return { status: "unavailable", httpStatus: response.status };
232
+ if (verification.status === "invalid") {
233
+ reportFailure(options, {
234
+ reason: verification.reason === "hmac" ? "session-cookie-hmac" : verification.reason === "payload" ? "session-cookie-payload" : "session-cookie-parse",
235
+ httpStatus: response.status
236
+ });
237
+ return { status: "unavailable", httpStatus: response.status };
238
+ }
195
239
  return {
196
240
  status: "recovered",
197
- claims,
241
+ claims: verification.claims,
198
242
  setCookie,
199
243
  sessionCookieValue
200
244
  };
@@ -222,7 +266,7 @@ function isCookieName(value) {
222
266
  function isCookieValue(value) {
223
267
  return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
224
268
  }
225
- function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
269
+ function sameOriginRecoveryEndpoint(endpoint, requestOrigin, allowLoopback = false) {
226
270
  try {
227
271
  const trusted = new URL(requestOrigin);
228
272
  if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
@@ -230,15 +274,57 @@ function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
230
274
  }
231
275
  const resolved = new URL(endpoint, trusted.origin);
232
276
  if (resolved.username || resolved.password) return void 0;
233
- if (resolved.origin !== trusted.origin) return void 0;
277
+ if (resolved.origin !== trusted.origin && (!allowLoopback || !isLoopbackURL(resolved))) {
278
+ return void 0;
279
+ }
234
280
  return resolved.toString();
235
281
  } catch {
236
282
  return void 0;
237
283
  }
238
284
  }
285
+ function isLoopbackURL(url) {
286
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
287
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
288
+ }
289
+ function reportFailure(options, failure) {
290
+ try {
291
+ options.onFailure?.(failure);
292
+ } catch {
293
+ }
294
+ }
295
+ function safeErrorDetails(value) {
296
+ const error = isRecord2(value) ? value : {};
297
+ const details = {
298
+ name: safeText(error.name, "Error"),
299
+ message: safeText(error.message, "Session recovery fetch failed")
300
+ };
301
+ const code = safeOptionalText(error.code);
302
+ if (code) details.code = code;
303
+ if (isRecord2(error.cause)) {
304
+ const causeCode = safeOptionalText(error.cause.code);
305
+ details.cause = {
306
+ name: safeText(error.cause.name, "Error"),
307
+ message: safeText(error.cause.message, "Session recovery fetch failed"),
308
+ ...causeCode ? { code: causeCode } : {}
309
+ };
310
+ }
311
+ return details;
312
+ }
313
+ function safeText(value, fallback) {
314
+ if (typeof value !== "string" || !value) return fallback;
315
+ return value.replace(/[\u0000-\u001F\u007F]/g, " ").slice(0, 300);
316
+ }
317
+ function safeOptionalText(value) {
318
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
319
+ return safeText(String(value), "");
320
+ }
321
+ function isRecord2(value) {
322
+ return typeof value === "object" && value !== null;
323
+ }
239
324
  var init_sessionRecovery = __esm({
240
325
  "src/client/sessionRecovery.ts"() {
241
326
  init_sessionCookie();
327
+ __name(resolveInternalRecoveryURL, "resolveInternalRecoveryURL");
242
328
  __name(requestSessionRecovery, "requestSessionRecovery");
243
329
  __name(authEndpoint, "authEndpoint");
244
330
  __name(replaceCookieValue, "replaceCookieValue");
@@ -246,6 +332,12 @@ var init_sessionRecovery = __esm({
246
332
  __name(isCookieName, "isCookieName");
247
333
  __name(isCookieValue, "isCookieValue");
248
334
  __name(sameOriginRecoveryEndpoint, "sameOriginRecoveryEndpoint");
335
+ __name(isLoopbackURL, "isLoopbackURL");
336
+ __name(reportFailure, "reportFailure");
337
+ __name(safeErrorDetails, "safeErrorDetails");
338
+ __name(safeText, "safeText");
339
+ __name(safeOptionalText, "safeOptionalText");
340
+ __name(isRecord2, "isRecord");
249
341
  }
250
342
  });
251
343
 
@@ -286,6 +378,7 @@ async function getSession(config = {}) {
286
378
  const baseURL = config.baseURL ?? defaultBaseURL();
287
379
  const prefix = config.authPrefix ?? "/auth";
288
380
  const strict = config.mode === "strict";
381
+ const internalRecoveryURL = resolveInternalRecoveryURL(config.internalRecoveryURL);
289
382
  let sessionCookieValue;
290
383
  let refreshCookieValue;
291
384
  let requestOrigin;
@@ -319,7 +412,7 @@ async function getSession(config = {}) {
319
412
  if (strict) throw new AuthConfigError("Session cookie secret is not configured");
320
413
  return null;
321
414
  }
322
- if (!refreshCookieValue || config.recoveryURL === false) {
415
+ if (!refreshCookieValue || config.recoveryURL === false && !internalRecoveryURL) {
323
416
  if (strict) throw new NoSessionError("No recoverable refresh session");
324
417
  return null;
325
418
  }
@@ -327,15 +420,17 @@ async function getSession(config = {}) {
327
420
  if (strict) throw new AuthConfigError("Incoming request origin is unavailable");
328
421
  return null;
329
422
  }
330
- const endpoint = config.recoveryURL ? new URL(config.recoveryURL, requestOrigin).toString() : authEndpoint(baseURL, prefix, "/session/recover", requestOrigin);
423
+ const endpoint = internalRecoveryURL ?? (config.recoveryURL ? new URL(config.recoveryURL, requestOrigin).toString() : authEndpoint(baseURL, prefix, "/session/recover", requestOrigin));
331
424
  const recovery = await requestSessionRecovery({
332
425
  endpoint,
333
426
  requestOrigin,
427
+ allowLoopbackEndpoint: internalRecoveryURL !== void 0,
334
428
  refreshCookieName: cookieName,
335
429
  refreshCookieValue,
336
430
  sessionCookieName,
337
431
  sessionSecret: secret,
338
- sessionMaxAge: config.sessionMaxAge
432
+ sessionMaxAge: config.sessionMaxAge,
433
+ onFailure: config.onRecoveryFailure
339
434
  });
340
435
  if (recovery.status === "recovered") {
341
436
  return {
@@ -603,8 +698,11 @@ function withAuthMiddleware(config) {
603
698
  sessionSecret,
604
699
  sessionMaxAge,
605
700
  verifyAlways = false,
606
- recoveryURL
701
+ recoveryURL,
702
+ internalRecoveryURL,
703
+ onRecoveryFailure
607
704
  } = config;
705
+ const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
608
706
  return /* @__PURE__ */ __name(async function middleware(request) {
609
707
  const { NextResponse } = await import("next/server");
610
708
  const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
@@ -640,18 +738,20 @@ function withAuthMiddleware(config) {
640
738
  let recovery = null;
641
739
  if (!session || verifyAlways) {
642
740
  const refreshCookie = readCookieValue(cookie, cookieName);
643
- if (!refreshCookie || recoveryURL === false) {
741
+ if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
644
742
  return redirectToLogin(returnPath, ["refresh", "session"]);
645
743
  }
646
- const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
744
+ const endpoint = resolvedInternalRecoveryURL ?? (recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url));
647
745
  recovery = await requestSessionRecovery({
648
746
  endpoint,
649
747
  requestOrigin: url.origin,
748
+ allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
650
749
  refreshCookieName: cookieName,
651
750
  refreshCookieValue: refreshCookie,
652
751
  sessionCookieName,
653
752
  sessionSecret: secret,
654
- sessionMaxAge
753
+ sessionMaxAge,
754
+ onFailure: onRecoveryFailure
655
755
  });
656
756
  if (recovery.status !== "recovered") {
657
757
  return redirectToLogin(
@@ -1212,8 +1312,10 @@ function defineAuth(authConfig = {}) {
1212
1312
  sessionSecret,
1213
1313
  sessionMaxAge,
1214
1314
  recoveryURL,
1315
+ internalRecoveryURL,
1215
1316
  matcher = ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
1216
1317
  verifyAlways = false,
1318
+ onRecoveryFailure,
1217
1319
  refreshThreshold,
1218
1320
  tabSync,
1219
1321
  channelName,
@@ -1227,7 +1329,9 @@ function defineAuth(authConfig = {}) {
1227
1329
  sessionCookieName,
1228
1330
  sessionSecret,
1229
1331
  sessionMaxAge,
1230
- recoveryURL
1332
+ recoveryURL,
1333
+ internalRecoveryURL,
1334
+ onRecoveryFailure
1231
1335
  };
1232
1336
  let _client = null;
1233
1337
  const getClient = /* @__PURE__ */ __name(() => {
@@ -1281,7 +1385,9 @@ function defineAuth(authConfig = {}) {
1281
1385
  sessionSecret,
1282
1386
  sessionMaxAge,
1283
1387
  recoveryURL,
1284
- verifyAlways
1388
+ internalRecoveryURL,
1389
+ verifyAlways,
1390
+ onRecoveryFailure
1285
1391
  });
1286
1392
  const protect = /* @__PURE__ */ __name((Page, options) => {
1287
1393
  return /* @__PURE__ */ __name(async function ProtectedPage(props) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "2.0.6",
3
+ "version": "2.0.9",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [
@@ -48,6 +48,7 @@
48
48
  "scripts": {
49
49
  "build": "tsup",
50
50
  "test": "bun test",
51
+ "test:next16": "bun run build && bun integration/next16-proxy/run.ts",
51
52
  "test:watch": "bun test --watch",
52
53
  "start": "bun --watch src/main.ts",
53
54
  "clean": "rimraf dist tsconfig.tsbuildinfo"
@@ -70,6 +71,7 @@
70
71
  "drizzle-kit": "^0.31.10",
71
72
  "drizzle-orm": "^0.45.2",
72
73
  "happy-dom": "^20.11.1",
74
+ "next": "16.2.11",
73
75
  "tsup": "^8.5.1",
74
76
  "typescript": "^5.9.3"
75
77
  },