najm-auth 2.0.6 → 2.0.10

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,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,56 @@ 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
+ options.refreshCookieValue,
190
+ options.sessionSecret,
191
+ endpoint,
192
+ options.requestOrigin
193
+ ])
194
+ });
162
195
  return { status: "unavailable" };
163
196
  }
164
197
  if (!response.ok) {
165
198
  const status = response.status;
199
+ reportFailure(options, { reason: "http-status", httpStatus: status });
166
200
  return {
167
201
  status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
168
202
  httpStatus: status
169
203
  };
170
204
  }
171
205
  const setCookie = response.headers.get("set-cookie");
172
- if (!setCookie) return { status: "unavailable", httpStatus: response.status };
206
+ if (!setCookie) {
207
+ reportFailure(options, {
208
+ reason: "missing-set-cookie",
209
+ httpStatus: response.status
210
+ });
211
+ return { status: "unavailable", httpStatus: response.status };
212
+ }
173
213
  const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
174
214
  if (!sessionCookieValue) {
215
+ reportFailure(options, {
216
+ reason: "session-cookie-parse",
217
+ httpStatus: response.status
218
+ });
175
219
  return { status: "unavailable", httpStatus: response.status };
176
220
  }
177
- const claims = await verifySessionCookie(sessionCookieValue, {
221
+ const verification = await verifySessionCookieDetailed(sessionCookieValue, {
178
222
  secret: options.sessionSecret,
179
223
  maxAgeSeconds: options.sessionMaxAge
180
224
  });
181
- if (!claims) return { status: "unavailable", httpStatus: response.status };
225
+ if (verification.status === "invalid") {
226
+ reportFailure(options, {
227
+ reason: verification.reason === "hmac" ? "session-cookie-hmac" : verification.reason === "payload" ? "session-cookie-payload" : "session-cookie-parse",
228
+ httpStatus: response.status
229
+ });
230
+ return { status: "unavailable", httpStatus: response.status };
231
+ }
182
232
  return {
183
233
  status: "recovered",
184
- claims,
234
+ claims: verification.claims,
185
235
  setCookie,
186
236
  sessionCookieValue
187
237
  };
@@ -215,7 +265,7 @@ function isCookieValue(value) {
215
265
  return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
216
266
  }
217
267
  __name(isCookieValue, "isCookieValue");
218
- function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
268
+ function sameOriginRecoveryEndpoint(endpoint, requestOrigin, allowLoopback = false) {
219
269
  try {
220
270
  const trusted = new URL(requestOrigin);
221
271
  if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
@@ -223,13 +273,64 @@ function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
223
273
  }
224
274
  const resolved = new URL(endpoint, trusted.origin);
225
275
  if (resolved.username || resolved.password) return void 0;
226
- if (resolved.origin !== trusted.origin) return void 0;
276
+ if (resolved.origin !== trusted.origin && (!allowLoopback || !isLoopbackURL(resolved))) {
277
+ return void 0;
278
+ }
227
279
  return resolved.toString();
228
280
  } catch {
229
281
  return void 0;
230
282
  }
231
283
  }
232
284
  __name(sameOriginRecoveryEndpoint, "sameOriginRecoveryEndpoint");
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
+ __name(isLoopbackURL, "isLoopbackURL");
290
+ function reportFailure(options, failure) {
291
+ try {
292
+ options.onFailure?.(failure);
293
+ } catch {
294
+ }
295
+ }
296
+ __name(reportFailure, "reportFailure");
297
+ function safeErrorDetails(value, sensitiveValues) {
298
+ const error = isRecord2(value) ? value : {};
299
+ const details = {
300
+ name: safeText(error.name, "Error", sensitiveValues),
301
+ message: safeText(error.message, "Session recovery fetch failed", sensitiveValues)
302
+ };
303
+ const code = safeOptionalText(error.code, sensitiveValues);
304
+ if (code) details.code = code;
305
+ if (isRecord2(error.cause)) {
306
+ const causeCode = safeOptionalText(error.cause.code, sensitiveValues);
307
+ details.cause = {
308
+ name: safeText(error.cause.name, "Error", sensitiveValues),
309
+ message: safeText(error.cause.message, "Session recovery fetch failed", sensitiveValues),
310
+ ...causeCode ? { code: causeCode } : {}
311
+ };
312
+ }
313
+ return details;
314
+ }
315
+ __name(safeErrorDetails, "safeErrorDetails");
316
+ function safeText(value, fallback, sensitiveValues) {
317
+ if (typeof value !== "string" || !value) return fallback;
318
+ let safe = value.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\b(authorization|cookie)\s*[:=]\s*[^\s,;]+/gi, "$1=[redacted]").replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]").replace(/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted]");
319
+ for (const sensitive of sensitiveValues) {
320
+ if (sensitive) safe = safe.split(sensitive).join("[redacted]");
321
+ }
322
+ return safe.slice(0, 300);
323
+ }
324
+ __name(safeText, "safeText");
325
+ function safeOptionalText(value, sensitiveValues) {
326
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
327
+ return safeText(String(value), "", sensitiveValues);
328
+ }
329
+ __name(safeOptionalText, "safeOptionalText");
330
+ function isRecord2(value) {
331
+ return typeof value === "object" && value !== null;
332
+ }
333
+ __name(isRecord2, "isRecord");
233
334
 
234
335
  // src/client/server/withAuthMiddleware.ts
235
336
  function withAuthMiddleware(config) {
@@ -245,8 +346,11 @@ function withAuthMiddleware(config) {
245
346
  sessionSecret,
246
347
  sessionMaxAge,
247
348
  verifyAlways = false,
248
- recoveryURL
349
+ recoveryURL,
350
+ internalRecoveryURL,
351
+ onRecoveryFailure
249
352
  } = config;
353
+ const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
250
354
  return /* @__PURE__ */ __name(async function middleware(request) {
251
355
  const { NextResponse } = await import("next/server");
252
356
  const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
@@ -282,18 +386,20 @@ function withAuthMiddleware(config) {
282
386
  let recovery = null;
283
387
  if (!session || verifyAlways) {
284
388
  const refreshCookie = readCookieValue(cookie, cookieName);
285
- if (!refreshCookie || recoveryURL === false) {
389
+ if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
286
390
  return redirectToLogin(returnPath, ["refresh", "session"]);
287
391
  }
288
- const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
392
+ const endpoint = resolvedInternalRecoveryURL ?? (recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url));
289
393
  recovery = await requestSessionRecovery({
290
394
  endpoint,
291
395
  requestOrigin: url.origin,
396
+ allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
292
397
  refreshCookieName: cookieName,
293
398
  refreshCookieValue: refreshCookie,
294
399
  sessionCookieName,
295
400
  sessionSecret: secret,
296
- sessionMaxAge
401
+ sessionMaxAge,
402
+ onFailure: onRecoveryFailure
297
403
  });
298
404
  if (recovery.status !== "recovered") {
299
405
  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,56 @@ 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
+ options.refreshCookieValue,
202
+ options.sessionSecret,
203
+ endpoint,
204
+ options.requestOrigin
205
+ ])
206
+ });
175
207
  return { status: "unavailable" };
176
208
  }
177
209
  if (!response.ok) {
178
210
  const status = response.status;
211
+ reportFailure(options, { reason: "http-status", httpStatus: status });
179
212
  return {
180
213
  status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
181
214
  httpStatus: status
182
215
  };
183
216
  }
184
217
  const setCookie = response.headers.get("set-cookie");
185
- if (!setCookie) return { status: "unavailable", httpStatus: response.status };
218
+ if (!setCookie) {
219
+ reportFailure(options, {
220
+ reason: "missing-set-cookie",
221
+ httpStatus: response.status
222
+ });
223
+ return { status: "unavailable", httpStatus: response.status };
224
+ }
186
225
  const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
187
226
  if (!sessionCookieValue) {
227
+ reportFailure(options, {
228
+ reason: "session-cookie-parse",
229
+ httpStatus: response.status
230
+ });
188
231
  return { status: "unavailable", httpStatus: response.status };
189
232
  }
190
- const claims = await verifySessionCookie(sessionCookieValue, {
233
+ const verification = await verifySessionCookieDetailed(sessionCookieValue, {
191
234
  secret: options.sessionSecret,
192
235
  maxAgeSeconds: options.sessionMaxAge
193
236
  });
194
- if (!claims) return { status: "unavailable", httpStatus: response.status };
237
+ if (verification.status === "invalid") {
238
+ reportFailure(options, {
239
+ reason: verification.reason === "hmac" ? "session-cookie-hmac" : verification.reason === "payload" ? "session-cookie-payload" : "session-cookie-parse",
240
+ httpStatus: response.status
241
+ });
242
+ return { status: "unavailable", httpStatus: response.status };
243
+ }
195
244
  return {
196
245
  status: "recovered",
197
- claims,
246
+ claims: verification.claims,
198
247
  setCookie,
199
248
  sessionCookieValue
200
249
  };
@@ -222,7 +271,7 @@ function isCookieName(value) {
222
271
  function isCookieValue(value) {
223
272
  return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
224
273
  }
225
- function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
274
+ function sameOriginRecoveryEndpoint(endpoint, requestOrigin, allowLoopback = false) {
226
275
  try {
227
276
  const trusted = new URL(requestOrigin);
228
277
  if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
@@ -230,15 +279,61 @@ function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
230
279
  }
231
280
  const resolved = new URL(endpoint, trusted.origin);
232
281
  if (resolved.username || resolved.password) return void 0;
233
- if (resolved.origin !== trusted.origin) return void 0;
282
+ if (resolved.origin !== trusted.origin && (!allowLoopback || !isLoopbackURL(resolved))) {
283
+ return void 0;
284
+ }
234
285
  return resolved.toString();
235
286
  } catch {
236
287
  return void 0;
237
288
  }
238
289
  }
290
+ function isLoopbackURL(url) {
291
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
292
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
293
+ }
294
+ function reportFailure(options, failure) {
295
+ try {
296
+ options.onFailure?.(failure);
297
+ } catch {
298
+ }
299
+ }
300
+ function safeErrorDetails(value, sensitiveValues) {
301
+ const error = isRecord2(value) ? value : {};
302
+ const details = {
303
+ name: safeText(error.name, "Error", sensitiveValues),
304
+ message: safeText(error.message, "Session recovery fetch failed", sensitiveValues)
305
+ };
306
+ const code = safeOptionalText(error.code, sensitiveValues);
307
+ if (code) details.code = code;
308
+ if (isRecord2(error.cause)) {
309
+ const causeCode = safeOptionalText(error.cause.code, sensitiveValues);
310
+ details.cause = {
311
+ name: safeText(error.cause.name, "Error", sensitiveValues),
312
+ message: safeText(error.cause.message, "Session recovery fetch failed", sensitiveValues),
313
+ ...causeCode ? { code: causeCode } : {}
314
+ };
315
+ }
316
+ return details;
317
+ }
318
+ function safeText(value, fallback, sensitiveValues) {
319
+ if (typeof value !== "string" || !value) return fallback;
320
+ let safe = value.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\b(authorization|cookie)\s*[:=]\s*[^\s,;]+/gi, "$1=[redacted]").replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]").replace(/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted]");
321
+ for (const sensitive of sensitiveValues) {
322
+ if (sensitive) safe = safe.split(sensitive).join("[redacted]");
323
+ }
324
+ return safe.slice(0, 300);
325
+ }
326
+ function safeOptionalText(value, sensitiveValues) {
327
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
328
+ return safeText(String(value), "", sensitiveValues);
329
+ }
330
+ function isRecord2(value) {
331
+ return typeof value === "object" && value !== null;
332
+ }
239
333
  var init_sessionRecovery = __esm({
240
334
  "src/client/sessionRecovery.ts"() {
241
335
  init_sessionCookie();
336
+ __name(resolveInternalRecoveryURL, "resolveInternalRecoveryURL");
242
337
  __name(requestSessionRecovery, "requestSessionRecovery");
243
338
  __name(authEndpoint, "authEndpoint");
244
339
  __name(replaceCookieValue, "replaceCookieValue");
@@ -246,6 +341,12 @@ var init_sessionRecovery = __esm({
246
341
  __name(isCookieName, "isCookieName");
247
342
  __name(isCookieValue, "isCookieValue");
248
343
  __name(sameOriginRecoveryEndpoint, "sameOriginRecoveryEndpoint");
344
+ __name(isLoopbackURL, "isLoopbackURL");
345
+ __name(reportFailure, "reportFailure");
346
+ __name(safeErrorDetails, "safeErrorDetails");
347
+ __name(safeText, "safeText");
348
+ __name(safeOptionalText, "safeOptionalText");
349
+ __name(isRecord2, "isRecord");
249
350
  }
250
351
  });
251
352
 
@@ -286,6 +387,7 @@ async function getSession(config = {}) {
286
387
  const baseURL = config.baseURL ?? defaultBaseURL();
287
388
  const prefix = config.authPrefix ?? "/auth";
288
389
  const strict = config.mode === "strict";
390
+ const internalRecoveryURL = resolveInternalRecoveryURL(config.internalRecoveryURL);
289
391
  let sessionCookieValue;
290
392
  let refreshCookieValue;
291
393
  let requestOrigin;
@@ -319,7 +421,7 @@ async function getSession(config = {}) {
319
421
  if (strict) throw new AuthConfigError("Session cookie secret is not configured");
320
422
  return null;
321
423
  }
322
- if (!refreshCookieValue || config.recoveryURL === false) {
424
+ if (!refreshCookieValue || config.recoveryURL === false && !internalRecoveryURL) {
323
425
  if (strict) throw new NoSessionError("No recoverable refresh session");
324
426
  return null;
325
427
  }
@@ -327,15 +429,17 @@ async function getSession(config = {}) {
327
429
  if (strict) throw new AuthConfigError("Incoming request origin is unavailable");
328
430
  return null;
329
431
  }
330
- const endpoint = config.recoveryURL ? new URL(config.recoveryURL, requestOrigin).toString() : authEndpoint(baseURL, prefix, "/session/recover", requestOrigin);
432
+ const endpoint = internalRecoveryURL ?? (config.recoveryURL ? new URL(config.recoveryURL, requestOrigin).toString() : authEndpoint(baseURL, prefix, "/session/recover", requestOrigin));
331
433
  const recovery = await requestSessionRecovery({
332
434
  endpoint,
333
435
  requestOrigin,
436
+ allowLoopbackEndpoint: internalRecoveryURL !== void 0,
334
437
  refreshCookieName: cookieName,
335
438
  refreshCookieValue,
336
439
  sessionCookieName,
337
440
  sessionSecret: secret,
338
- sessionMaxAge: config.sessionMaxAge
441
+ sessionMaxAge: config.sessionMaxAge,
442
+ onFailure: config.onRecoveryFailure
339
443
  });
340
444
  if (recovery.status === "recovered") {
341
445
  return {
@@ -603,8 +707,11 @@ function withAuthMiddleware(config) {
603
707
  sessionSecret,
604
708
  sessionMaxAge,
605
709
  verifyAlways = false,
606
- recoveryURL
710
+ recoveryURL,
711
+ internalRecoveryURL,
712
+ onRecoveryFailure
607
713
  } = config;
714
+ const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
608
715
  return /* @__PURE__ */ __name(async function middleware(request) {
609
716
  const { NextResponse } = await import("next/server");
610
717
  const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
@@ -640,18 +747,20 @@ function withAuthMiddleware(config) {
640
747
  let recovery = null;
641
748
  if (!session || verifyAlways) {
642
749
  const refreshCookie = readCookieValue(cookie, cookieName);
643
- if (!refreshCookie || recoveryURL === false) {
750
+ if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
644
751
  return redirectToLogin(returnPath, ["refresh", "session"]);
645
752
  }
646
- const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
753
+ const endpoint = resolvedInternalRecoveryURL ?? (recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url));
647
754
  recovery = await requestSessionRecovery({
648
755
  endpoint,
649
756
  requestOrigin: url.origin,
757
+ allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
650
758
  refreshCookieName: cookieName,
651
759
  refreshCookieValue: refreshCookie,
652
760
  sessionCookieName,
653
761
  sessionSecret: secret,
654
- sessionMaxAge
762
+ sessionMaxAge,
763
+ onFailure: onRecoveryFailure
655
764
  });
656
765
  if (recovery.status !== "recovered") {
657
766
  return redirectToLogin(
@@ -1212,8 +1321,10 @@ function defineAuth(authConfig = {}) {
1212
1321
  sessionSecret,
1213
1322
  sessionMaxAge,
1214
1323
  recoveryURL,
1324
+ internalRecoveryURL,
1215
1325
  matcher = ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
1216
1326
  verifyAlways = false,
1327
+ onRecoveryFailure,
1217
1328
  refreshThreshold,
1218
1329
  tabSync,
1219
1330
  channelName,
@@ -1227,7 +1338,9 @@ function defineAuth(authConfig = {}) {
1227
1338
  sessionCookieName,
1228
1339
  sessionSecret,
1229
1340
  sessionMaxAge,
1230
- recoveryURL
1341
+ recoveryURL,
1342
+ internalRecoveryURL,
1343
+ onRecoveryFailure
1231
1344
  };
1232
1345
  let _client = null;
1233
1346
  const getClient = /* @__PURE__ */ __name(() => {
@@ -1281,7 +1394,9 @@ function defineAuth(authConfig = {}) {
1281
1394
  sessionSecret,
1282
1395
  sessionMaxAge,
1283
1396
  recoveryURL,
1284
- verifyAlways
1397
+ internalRecoveryURL,
1398
+ verifyAlways,
1399
+ onRecoveryFailure
1285
1400
  });
1286
1401
  const protect = /* @__PURE__ */ __name((Page, options) => {
1287
1402
  return /* @__PURE__ */ __name(async function ProtectedPage(props) {