najm-auth 2.0.5 → 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
@@ -681,6 +681,13 @@ throw new HttpError(403, 'Insufficient permissions for this action');
681
681
  their session version.
682
682
  - Expired signed sessions recover through authoritative, non-rotating refresh
683
683
  validation; middleware verifies the reissued HMAC before using its claims.
684
+ - Server-side recovery sends only the configured refresh cookie and accepts
685
+ relative or exact same-origin endpoints. URL credentials and any
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.
684
691
  - `verifyAlways` forces that authoritative check on every protected request;
685
692
  the default bounds cached role/status staleness to `session.maxAge`.
686
693
 
@@ -247,4 +247,4 @@ declare class NajmAuthClient {
247
247
  */
248
248
  declare function createAuthClient(config: AuthClientConfig): NajmAuthClient;
249
249
 
250
- export { AuthError as A, type DecodedToken as D, FetchClient as F, type HydrateSession as H, NajmAuthClient as N, type OAuthProvider as O, type RetryConfig as R, type SyncPayload as S, type TabSyncMessage as T, type AuthClientConfig as a, type AuthEventMap as b, createAuthClient as c, type AuthState as d, type AuthUser as e, type AuthEvent as f, type AuthEventHandler as g, type ServerResponse as h, type TokenPair as i, type RequestOptions as j, type OAuthLoginOptions as k };
250
+ export { type AuthClientConfig as A, type DecodedToken as D, FetchClient as F, type HydrateSession as H, NajmAuthClient as N, type OAuthLoginOptions as O, type RequestOptions as R, type SyncPayload as S, type TabSyncMessage as T, AuthError as a, type AuthEvent as b, type AuthEventHandler as c, type AuthEventMap as d, type AuthState as e, type AuthUser as f, type OAuthProvider as g, type RetryConfig as h, type ServerResponse as i, type TokenPair as j, createAuthClient as k };
@@ -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,19 +146,33 @@ 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
- if (!isSafeRecoveryEndpoint(options.endpoint)) {
164
+ const endpoint = sameOriginRecoveryEndpoint(
165
+ options.endpoint,
166
+ options.requestOrigin,
167
+ options.allowLoopbackEndpoint
168
+ );
169
+ if (!endpoint) {
170
+ reportFailure(options, { reason: "invalid-endpoint" });
143
171
  return { status: "unavailable" };
144
172
  }
145
173
  let response;
146
174
  try {
147
- response = await fetch(options.endpoint, {
175
+ response = await fetch(endpoint, {
148
176
  method: "POST",
149
177
  headers: {
150
178
  Accept: "application/json",
@@ -154,30 +182,51 @@ async function requestSessionRecovery(options) {
154
182
  cache: "no-store",
155
183
  redirect: "manual"
156
184
  });
157
- } catch {
185
+ } catch (error) {
186
+ reportFailure(options, {
187
+ reason: "fetch-error",
188
+ error: safeErrorDetails(error)
189
+ });
158
190
  return { status: "unavailable" };
159
191
  }
160
192
  if (!response.ok) {
161
193
  const status = response.status;
194
+ reportFailure(options, { reason: "http-status", httpStatus: status });
162
195
  return {
163
196
  status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
164
197
  httpStatus: status
165
198
  };
166
199
  }
167
200
  const setCookie = response.headers.get("set-cookie");
168
- 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
+ }
169
208
  const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
170
209
  if (!sessionCookieValue) {
210
+ reportFailure(options, {
211
+ reason: "session-cookie-parse",
212
+ httpStatus: response.status
213
+ });
171
214
  return { status: "unavailable", httpStatus: response.status };
172
215
  }
173
- const claims = await verifySessionCookie(sessionCookieValue, {
216
+ const verification = await verifySessionCookieDetailed(sessionCookieValue, {
174
217
  secret: options.sessionSecret,
175
218
  maxAgeSeconds: options.sessionMaxAge
176
219
  });
177
- 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
+ }
178
227
  return {
179
228
  status: "recovered",
180
- claims,
229
+ claims: verification.claims,
181
230
  setCookie,
182
231
  sessionCookieValue
183
232
  };
@@ -208,21 +257,71 @@ function isCookieName(value) {
208
257
  }
209
258
  __name(isCookieName, "isCookieName");
210
259
  function isCookieValue(value) {
211
- return value.length > 0 && !/[\r\n;]/.test(value);
260
+ return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
212
261
  }
213
262
  __name(isCookieValue, "isCookieValue");
214
- function isSafeRecoveryEndpoint(value) {
263
+ function sameOriginRecoveryEndpoint(endpoint, requestOrigin, allowLoopback = false) {
215
264
  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";
265
+ const trusted = new URL(requestOrigin);
266
+ if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
267
+ return void 0;
268
+ }
269
+ const resolved = new URL(endpoint, trusted.origin);
270
+ if (resolved.username || resolved.password) return void 0;
271
+ if (resolved.origin !== trusted.origin && (!allowLoopback || !isLoopbackURL(resolved))) {
272
+ return void 0;
273
+ }
274
+ return resolved.toString();
221
275
  } catch {
222
- return false;
276
+ return void 0;
277
+ }
278
+ }
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
+ };
223
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;
224
323
  }
225
- __name(isSafeRecoveryEndpoint, "isSafeRecoveryEndpoint");
324
+ __name(isRecord2, "isRecord");
226
325
 
227
326
  // src/client/server/withAuthMiddleware.ts
228
327
  function withAuthMiddleware(config) {
@@ -238,8 +337,11 @@ function withAuthMiddleware(config) {
238
337
  sessionSecret,
239
338
  sessionMaxAge,
240
339
  verifyAlways = false,
241
- recoveryURL
340
+ recoveryURL,
341
+ internalRecoveryURL,
342
+ onRecoveryFailure
242
343
  } = config;
344
+ const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
243
345
  return /* @__PURE__ */ __name(async function middleware(request) {
244
346
  const { NextResponse } = await import("next/server");
245
347
  const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
@@ -275,17 +377,20 @@ function withAuthMiddleware(config) {
275
377
  let recovery = null;
276
378
  if (!session || verifyAlways) {
277
379
  const refreshCookie = readCookieValue(cookie, cookieName);
278
- if (!refreshCookie || recoveryURL === false) {
380
+ if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
279
381
  return redirectToLogin(returnPath, ["refresh", "session"]);
280
382
  }
281
- 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));
282
384
  recovery = await requestSessionRecovery({
283
385
  endpoint,
386
+ requestOrigin: url.origin,
387
+ allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
284
388
  refreshCookieName: cookieName,
285
389
  refreshCookieValue: refreshCookie,
286
390
  sessionCookieName,
287
391
  sessionSecret: secret,
288
- sessionMaxAge
392
+ sessionMaxAge,
393
+ onFailure: onRecoveryFailure
289
394
  });
290
395
  if (recovery.status !== "recovered") {
291
396
  return redirectToLogin(
@@ -1,5 +1,5 @@
1
- import { D as DecodedToken, T as TabSyncMessage, S as SyncPayload } from '../NajmAuthClient-B9dGk9MH.js';
2
- export { a as AuthClientConfig, A as AuthError, f as AuthEvent, g as AuthEventHandler, b as AuthEventMap, d as AuthState, e as AuthUser, F as FetchClient, H as HydrateSession, N as NajmAuthClient, k as OAuthLoginOptions, O as OAuthProvider, j as RequestOptions, R as RetryConfig, h as ServerResponse, i as TokenPair, c as createAuthClient } from '../NajmAuthClient-B9dGk9MH.js';
1
+ import { D as DecodedToken, T as TabSyncMessage, S as SyncPayload } from '../NajmAuthClient-0yzFb9CR.js';
2
+ export { A as AuthClientConfig, a as AuthError, b as AuthEvent, c as AuthEventHandler, d as AuthEventMap, e as AuthState, f as AuthUser, F as FetchClient, H as HydrateSession, N as NajmAuthClient, O as OAuthLoginOptions, g as OAuthProvider, R as RequestOptions, h as RetryConfig, i as ServerResponse, j as TokenPair, k as createAuthClient } from '../NajmAuthClient-0yzFb9CR.js';
3
3
 
4
4
  /**
5
5
  * Decode a JWT token payload without verification.
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as react from 'react';
3
3
  import { ReactNode, CSSProperties, ReactElement } from 'react';
4
- import { N as NajmAuthClient, H as HydrateSession, d as AuthState, e as AuthUser, A as AuthError, k as OAuthLoginOptions, f as AuthEvent, b as AuthEventMap } from '../../NajmAuthClient-B9dGk9MH.js';
4
+ import { N as NajmAuthClient, H as HydrateSession, e as AuthState, f as AuthUser, a as AuthError, O as OAuthLoginOptions, b as AuthEvent, d as AuthEventMap } from '../../NajmAuthClient-0yzFb9CR.js';
5
5
 
6
6
  interface AuthProviderProps {
7
7
  client: NajmAuthClient;
@@ -7,7 +7,7 @@ import { useEffect, useRef } from "react";
7
7
 
8
8
  // src/client/react/context.ts
9
9
  import { createContext, useContext } from "react";
10
- var KEY = /* @__PURE__ */ Symbol.for("najm:auth:client:context");
10
+ var KEY = Symbol.for("najm:auth:client:context");
11
11
  var contextStore = globalThis;
12
12
  function getAuthClientContext() {
13
13
  const existing = contextStore[KEY];
@@ -1,5 +1,6 @@
1
- import { e as AuthUser, F as FetchClient, R as RetryConfig, N as NajmAuthClient } from '../../NajmAuthClient-B9dGk9MH.js';
2
- export { withAuthMiddleware } from '../edge.js';
1
+ import { f as AuthUser, F as FetchClient, N as NajmAuthClient, h as RetryConfig } from '../../NajmAuthClient-0yzFb9CR.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 {
@@ -72,7 +73,7 @@ interface ServerSession {
72
73
  interface GetSessionConfig {
73
74
  /**
74
75
  * Base URL for auth endpoints.
75
- * Defaults to `${NEXT_PUBLIC_API_URL || http://localhost:${PORT||3000}}/api`.
76
+ * Defaults to `NEXT_PUBLIC_API_URL` or the same-origin `/api` path.
76
77
  */
77
78
  baseURL?: string;
78
79
  /** Auth route prefix appended to baseURL (default: '/auth'). */
@@ -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,19 +159,32 @@ 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
- if (!isSafeRecoveryEndpoint(options.endpoint)) {
176
+ const endpoint = sameOriginRecoveryEndpoint(
177
+ options.endpoint,
178
+ options.requestOrigin,
179
+ options.allowLoopbackEndpoint
180
+ );
181
+ if (!endpoint) {
182
+ reportFailure(options, { reason: "invalid-endpoint" });
156
183
  return { status: "unavailable" };
157
184
  }
158
185
  let response;
159
186
  try {
160
- response = await fetch(options.endpoint, {
187
+ response = await fetch(endpoint, {
161
188
  method: "POST",
162
189
  headers: {
163
190
  Accept: "application/json",
@@ -167,30 +194,51 @@ async function requestSessionRecovery(options) {
167
194
  cache: "no-store",
168
195
  redirect: "manual"
169
196
  });
170
- } catch {
197
+ } catch (error) {
198
+ reportFailure(options, {
199
+ reason: "fetch-error",
200
+ error: safeErrorDetails(error)
201
+ });
171
202
  return { status: "unavailable" };
172
203
  }
173
204
  if (!response.ok) {
174
205
  const status = response.status;
206
+ reportFailure(options, { reason: "http-status", httpStatus: status });
175
207
  return {
176
208
  status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
177
209
  httpStatus: status
178
210
  };
179
211
  }
180
212
  const setCookie = response.headers.get("set-cookie");
181
- 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
+ }
182
220
  const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
183
221
  if (!sessionCookieValue) {
222
+ reportFailure(options, {
223
+ reason: "session-cookie-parse",
224
+ httpStatus: response.status
225
+ });
184
226
  return { status: "unavailable", httpStatus: response.status };
185
227
  }
186
- const claims = await verifySessionCookie(sessionCookieValue, {
228
+ const verification = await verifySessionCookieDetailed(sessionCookieValue, {
187
229
  secret: options.sessionSecret,
188
230
  maxAgeSeconds: options.sessionMaxAge
189
231
  });
190
- 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
+ }
191
239
  return {
192
240
  status: "recovered",
193
- claims,
241
+ claims: verification.claims,
194
242
  setCookie,
195
243
  sessionCookieValue
196
244
  };
@@ -216,29 +264,80 @@ function isCookieName(value) {
216
264
  return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value);
217
265
  }
218
266
  function isCookieValue(value) {
219
- return value.length > 0 && !/[\r\n;]/.test(value);
267
+ return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
220
268
  }
221
- function isSafeRecoveryEndpoint(value) {
269
+ function sameOriginRecoveryEndpoint(endpoint, requestOrigin, allowLoopback = false) {
222
270
  try {
223
- const url = new URL(value);
224
- if (url.username || url.password) return false;
225
- if (url.protocol === "https:") return true;
226
- if (url.protocol !== "http:") return false;
227
- return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
271
+ const trusted = new URL(requestOrigin);
272
+ if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
273
+ return void 0;
274
+ }
275
+ const resolved = new URL(endpoint, trusted.origin);
276
+ if (resolved.username || resolved.password) return void 0;
277
+ if (resolved.origin !== trusted.origin && (!allowLoopback || !isLoopbackURL(resolved))) {
278
+ return void 0;
279
+ }
280
+ return resolved.toString();
228
281
  } catch {
229
- return false;
282
+ return void 0;
283
+ }
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
+ };
230
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;
231
323
  }
232
324
  var init_sessionRecovery = __esm({
233
325
  "src/client/sessionRecovery.ts"() {
234
326
  init_sessionCookie();
327
+ __name(resolveInternalRecoveryURL, "resolveInternalRecoveryURL");
235
328
  __name(requestSessionRecovery, "requestSessionRecovery");
236
329
  __name(authEndpoint, "authEndpoint");
237
330
  __name(replaceCookieValue, "replaceCookieValue");
238
331
  __name(readSetCookieValue, "readSetCookieValue");
239
332
  __name(isCookieName, "isCookieName");
240
333
  __name(isCookieValue, "isCookieValue");
241
- __name(isSafeRecoveryEndpoint, "isSafeRecoveryEndpoint");
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");
242
341
  }
243
342
  });
244
343
 
@@ -253,8 +352,25 @@ __export(getSession_exports, {
253
352
  function defaultBaseURL() {
254
353
  const explicit = typeof process !== "undefined" ? process.env.NAJM_AUTH_BASE_URL : void 0;
255
354
  if (explicit) return explicit;
256
- const origin = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? 3e3}` : "http://localhost:3000";
257
- return `${origin.replace(/\/$/, "")}/api`;
355
+ const publicUrl = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_URL : void 0;
356
+ return publicUrl || "/api";
357
+ }
358
+ function firstForwardedValue(value) {
359
+ const first = value?.split(",")[0]?.trim();
360
+ return first || void 0;
361
+ }
362
+ function requestOriginFromHeaders(headers) {
363
+ const host = firstForwardedValue(headers.get("x-forwarded-host")) ?? firstForwardedValue(headers.get("host"));
364
+ if (!host) return void 0;
365
+ const protocol = firstForwardedValue(headers.get("x-forwarded-proto")) ?? "https";
366
+ if (protocol !== "https" && protocol !== "http") return void 0;
367
+ try {
368
+ const url = new URL(`${protocol}://${host}`);
369
+ if (url.username || url.password) return void 0;
370
+ return url.origin;
371
+ } catch {
372
+ return void 0;
373
+ }
258
374
  }
259
375
  async function getSession(config = {}) {
260
376
  const cookieName = config.cookieName ?? "refreshToken";
@@ -262,13 +378,18 @@ async function getSession(config = {}) {
262
378
  const baseURL = config.baseURL ?? defaultBaseURL();
263
379
  const prefix = config.authPrefix ?? "/auth";
264
380
  const strict = config.mode === "strict";
381
+ const internalRecoveryURL = resolveInternalRecoveryURL(config.internalRecoveryURL);
265
382
  let sessionCookieValue;
266
383
  let refreshCookieValue;
384
+ let requestOrigin;
267
385
  try {
268
386
  const mod = await import("next/headers");
269
387
  const cookieStore = await mod.cookies();
270
388
  sessionCookieValue = cookieStore.get(sessionCookieName)?.value;
271
389
  refreshCookieValue = cookieStore.get(cookieName)?.value;
390
+ if (typeof mod.headers === "function") {
391
+ requestOrigin = requestOriginFromHeaders(await mod.headers());
392
+ }
272
393
  } catch {
273
394
  if (strict) throw new AuthConfigError("Failed to read cookies from Next.js headers()");
274
395
  return null;
@@ -291,18 +412,25 @@ async function getSession(config = {}) {
291
412
  if (strict) throw new AuthConfigError("Session cookie secret is not configured");
292
413
  return null;
293
414
  }
294
- if (!refreshCookieValue || config.recoveryURL === false) {
415
+ if (!refreshCookieValue || config.recoveryURL === false && !internalRecoveryURL) {
295
416
  if (strict) throw new NoSessionError("No recoverable refresh session");
296
417
  return null;
297
418
  }
298
- const endpoint = config.recoveryURL ? new URL(config.recoveryURL, baseURL).toString() : authEndpoint(baseURL, prefix, "/session/recover");
419
+ if (!requestOrigin) {
420
+ if (strict) throw new AuthConfigError("Incoming request origin is unavailable");
421
+ return null;
422
+ }
423
+ const endpoint = internalRecoveryURL ?? (config.recoveryURL ? new URL(config.recoveryURL, requestOrigin).toString() : authEndpoint(baseURL, prefix, "/session/recover", requestOrigin));
299
424
  const recovery = await requestSessionRecovery({
300
425
  endpoint,
426
+ requestOrigin,
427
+ allowLoopbackEndpoint: internalRecoveryURL !== void 0,
301
428
  refreshCookieName: cookieName,
302
429
  refreshCookieValue,
303
430
  sessionCookieName,
304
431
  sessionSecret: secret,
305
- sessionMaxAge: config.sessionMaxAge
432
+ sessionMaxAge: config.sessionMaxAge,
433
+ onFailure: config.onRecoveryFailure
306
434
  });
307
435
  if (recovery.status === "recovered") {
308
436
  return {
@@ -359,6 +487,8 @@ var init_getSession = __esm({
359
487
  code = "AUTH_TRANSPORT_ERROR";
360
488
  };
361
489
  __name(defaultBaseURL, "defaultBaseURL");
490
+ __name(firstForwardedValue, "firstForwardedValue");
491
+ __name(requestOriginFromHeaders, "requestOriginFromHeaders");
362
492
  __name(getSession, "getSession");
363
493
  }
364
494
  });
@@ -568,8 +698,11 @@ function withAuthMiddleware(config) {
568
698
  sessionSecret,
569
699
  sessionMaxAge,
570
700
  verifyAlways = false,
571
- recoveryURL
701
+ recoveryURL,
702
+ internalRecoveryURL,
703
+ onRecoveryFailure
572
704
  } = config;
705
+ const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
573
706
  return /* @__PURE__ */ __name(async function middleware(request) {
574
707
  const { NextResponse } = await import("next/server");
575
708
  const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
@@ -605,17 +738,20 @@ function withAuthMiddleware(config) {
605
738
  let recovery = null;
606
739
  if (!session || verifyAlways) {
607
740
  const refreshCookie = readCookieValue(cookie, cookieName);
608
- if (!refreshCookie || recoveryURL === false) {
741
+ if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
609
742
  return redirectToLogin(returnPath, ["refresh", "session"]);
610
743
  }
611
- 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));
612
745
  recovery = await requestSessionRecovery({
613
746
  endpoint,
747
+ requestOrigin: url.origin,
748
+ allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
614
749
  refreshCookieName: cookieName,
615
750
  refreshCookieValue: refreshCookie,
616
751
  sessionCookieName,
617
752
  sessionSecret: secret,
618
- sessionMaxAge
753
+ sessionMaxAge,
754
+ onFailure: onRecoveryFailure
619
755
  });
620
756
  if (recovery.status !== "recovered") {
621
757
  return redirectToLogin(
@@ -1176,8 +1312,10 @@ function defineAuth(authConfig = {}) {
1176
1312
  sessionSecret,
1177
1313
  sessionMaxAge,
1178
1314
  recoveryURL,
1315
+ internalRecoveryURL,
1179
1316
  matcher = ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
1180
1317
  verifyAlways = false,
1318
+ onRecoveryFailure,
1181
1319
  refreshThreshold,
1182
1320
  tabSync,
1183
1321
  channelName,
@@ -1185,14 +1323,15 @@ function defineAuth(authConfig = {}) {
1185
1323
  retry
1186
1324
  } = authConfig;
1187
1325
  const sessionConfig = {
1188
- baseURL: void 0,
1189
- // resolved at call time from env/defaults
1326
+ baseURL: apiBaseURL,
1190
1327
  authPrefix,
1191
1328
  cookieName,
1192
1329
  sessionCookieName,
1193
1330
  sessionSecret,
1194
1331
  sessionMaxAge,
1195
- recoveryURL
1332
+ recoveryURL,
1333
+ internalRecoveryURL,
1334
+ onRecoveryFailure
1196
1335
  };
1197
1336
  let _client = null;
1198
1337
  const getClient = /* @__PURE__ */ __name(() => {
@@ -1246,7 +1385,9 @@ function defineAuth(authConfig = {}) {
1246
1385
  sessionSecret,
1247
1386
  sessionMaxAge,
1248
1387
  recoveryURL,
1249
- verifyAlways
1388
+ internalRecoveryURL,
1389
+ verifyAlways,
1390
+ onRecoveryFailure
1250
1391
  });
1251
1392
  const protect = /* @__PURE__ */ __name((Page, options) => {
1252
1393
  return /* @__PURE__ */ __name(async function ProtectedPage(props) {
package/dist/index.js CHANGED
@@ -10,12 +10,12 @@ import { Err as Err11, plugin } from "najm-core";
10
10
  import { cache } from "najm-cache";
11
11
 
12
12
  // src/auth.tokens.ts
13
- var AUTH_CONFIG = /* @__PURE__ */ Symbol.for("najm:auth:config");
14
- var AUTH_SCHEMA = /* @__PURE__ */ Symbol.for("najm:auth:schema");
15
- var AUTH_USER = /* @__PURE__ */ Symbol.for("najm:auth:user");
16
- var AUTH_ROLE = /* @__PURE__ */ Symbol.for("najm:auth:role");
17
- var AUTH_PERMISSIONS = /* @__PURE__ */ Symbol.for("najm:auth:permissions");
18
- var AUTH_ENCRYPTION_KEY = /* @__PURE__ */ Symbol.for("najm:auth:encryption-key");
13
+ var AUTH_CONFIG = Symbol.for("najm:auth:config");
14
+ var AUTH_SCHEMA = Symbol.for("najm:auth:schema");
15
+ var AUTH_USER = Symbol.for("najm:auth:user");
16
+ var AUTH_ROLE = Symbol.for("najm:auth:role");
17
+ var AUTH_PERMISSIONS = Symbol.for("najm:auth:permissions");
18
+ var AUTH_ENCRYPTION_KEY = Symbol.for("najm:auth:encryption-key");
19
19
 
20
20
  // src/schema/pg.ts
21
21
  import { pgTable, text, boolean, timestamp, pgEnum, primaryKey, integer, index, uniqueIndex } from "drizzle-orm/pg-core";
@@ -3980,9 +3980,9 @@ var revokeTokenDto = z4.object({
3980
3980
  // src/ownership/scopedOwnership.ts
3981
3981
  import { aliasedTable, eq as eq6, getTableColumns, sql as sql4 } from "drizzle-orm";
3982
3982
  var DEFAULT_ADMIN_ROLES = ["admin"];
3983
- var DRIZZLE_NAME = /* @__PURE__ */ Symbol.for("drizzle:Name");
3984
- var DRIZZLE_BASE_NAME = /* @__PURE__ */ Symbol.for("drizzle:BaseName");
3985
- var DRIZZLE_IS_ALIAS = /* @__PURE__ */ Symbol.for("drizzle:IsAlias");
3983
+ var DRIZZLE_NAME = Symbol.for("drizzle:Name");
3984
+ var DRIZZLE_BASE_NAME = Symbol.for("drizzle:BaseName");
3985
+ var DRIZZLE_IS_ALIAS = Symbol.for("drizzle:IsAlias");
3986
3986
  function join2(left, right) {
3987
3987
  const table = right.table;
3988
3988
  if (!table)
@@ -4430,9 +4430,9 @@ __name(configureOwnership, "configureOwnership");
4430
4430
  // src/ownership/ScopeGuard.ts
4431
4431
  import "reflect-metadata";
4432
4432
  import { composeGuards as composeGuards5 } from "najm-guard";
4433
- var ACTION_KEY = /* @__PURE__ */ Symbol.for("najm:guard:action");
4434
- var TOKEN_KEY = /* @__PURE__ */ Symbol.for("najm:guard:token");
4435
- var POLICY_KEY = /* @__PURE__ */ Symbol.for("najm:policy:token");
4433
+ var ACTION_KEY = Symbol.for("najm:guard:action");
4434
+ var TOKEN_KEY = Symbol.for("najm:guard:token");
4435
+ var POLICY_KEY = Symbol.for("najm:policy:token");
4436
4436
  var PERM = {
4437
4437
  list: /* @__PURE__ */ __name((n) => `read:${n}`, "list"),
4438
4438
  read: /* @__PURE__ */ __name((n) => `read:${n}`, "read"),
@@ -4502,7 +4502,7 @@ var __metadata25 = function(k, v) {
4502
4502
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4503
4503
  };
4504
4504
  var _a16;
4505
- var OWNED_META = /* @__PURE__ */ Symbol.for("najm:owned");
4505
+ var OWNED_META = Symbol.for("najm:owned");
4506
4506
  var ScopeContext = class ScopeContext2 {
4507
4507
  static {
4508
4508
  __name(this, "ScopeContext");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "2.0.5",
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"
@@ -67,9 +68,10 @@
67
68
  "@testing-library/react": "^16.3.2",
68
69
  "@types/jsonwebtoken": "^9.0.10",
69
70
  "@types/node": "^25.0.2",
70
- "drizzle-kit": "^0.31.8",
71
- "drizzle-orm": "^0.45.1",
72
- "happy-dom": "^17.6.3",
71
+ "drizzle-kit": "^0.31.10",
72
+ "drizzle-orm": "^0.45.2",
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
  },
@@ -93,8 +95,8 @@
93
95
  "zod": "^4.2.1"
94
96
  },
95
97
  "peerDependencies": {
96
- "drizzle-orm": "^0.45.1",
97
- "hono": "^4.0.0",
98
+ "drizzle-orm": "^0.45.2",
99
+ "hono": "^4.12.18",
98
100
  "react": ">=18",
99
101
  "next": ">=14"
100
102
  },