najm-auth 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -167,6 +167,7 @@ All routes are prefixed with `/auth` and auto-registered by the plugin.
167
167
  | `POST` | `/auth/register` | Register new user | None |
168
168
  | `POST` | `/auth/login` | Login with email/password | None |
169
169
  | `POST` | `/auth/refresh` | Refresh access token (cookie) | None (uses refresh cookie) |
170
+ | `POST` | `/auth/session/recover` | Reissue signed session without token rotation | Refresh cookie + recovery header |
170
171
  | `POST` | `/auth/logout` | Logout and revoke tokens | ✅ Required |
171
172
  | `GET` | `/auth/me` | Get current user profile | ✅ Required |
172
173
  | `POST` | `/auth/forgot-password` | Request password reset | None |
@@ -600,6 +601,7 @@ limits are active when `auth()` is registered.
600
601
  | `POST /auth/register` | 5 | 15 minutes | IP |
601
602
  | `POST /auth/login` | 5 | 15 minutes | IP |
602
603
  | `POST /auth/refresh` | 15 | 15 minutes | Cookie fingerprint |
604
+ | `POST /auth/session/recover` | 120 | 1 minute | Cookie fingerprint |
603
605
  | `POST /auth/logout` | 10 | 15 minutes | User ID |
604
606
  | `GET /auth/me` | 30 | 1 minute | User ID |
605
607
  | `POST /auth/forgot-password` | 3 | 15 minutes | IP |
@@ -675,8 +677,15 @@ throw new HttpError(403, 'Insufficient permissions for this action');
675
677
  - Login uses a dummy password hash for missing users to reduce timing leaks.
676
678
  - Forgot-password responses avoid email enumeration.
677
679
  - Auth routes register `najm-rate` and ship route-level brute-force limits.
678
- - Session cookies are signed, short-lived, and checked against session version
679
- invalidation.
680
+ - Session cookies are signed and short-lived; server auth resolution checks
681
+ their session version.
682
+ - Expired signed sessions recover through authoritative, non-rotating refresh
683
+ validation; middleware verifies the reissued HMAC before using its claims.
684
+ - 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
+ - `verifyAlways` forces that authoritative check on every protected request;
688
+ the default bounds cached role/status staleness to `session.maxAge`.
680
689
 
681
690
  ### Password Reset Tokens
682
691
 
@@ -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 };
@@ -11,6 +11,10 @@ interface AuthMiddlewareConfig {
11
11
  roleRoutes?: Record<string, string[]>;
12
12
  /** Refresh token cookie name (default: 'refreshToken') */
13
13
  cookieName?: string;
14
+ /** API base URL used by session recovery (default: '/api'). */
15
+ apiBaseURL?: string;
16
+ /** Auth endpoint prefix used by session recovery (default: '/auth'). */
17
+ authPrefix?: string;
14
18
  /** Signed session cookie name (default: 'najm.session') */
15
19
  sessionCookieName?: string;
16
20
  /** @deprecated Session cookies are verified locally at the Edge. */
@@ -20,10 +24,16 @@ interface AuthMiddlewareConfig {
20
24
  /** Must match the auth plugin's session.maxAge. Default: 300 seconds. */
21
25
  sessionMaxAge?: number;
22
26
  /**
23
- * Retained for compatibility. Every protected route now verifies the signed
24
- * session cookie locally, so enabling this never calls `/auth/me`.
27
+ * Force authoritative refresh-session validation on every protected request.
28
+ * This reissues the signed session cookie without rotating refresh tokens.
25
29
  */
26
30
  verifyAlways?: boolean;
31
+ /**
32
+ * Session-recovery endpoint. Relative values resolve against the request
33
+ * origin. Defaults to `${apiBaseURL}${authPrefix}/session/recover`.
34
+ * Set to false to disable automatic recovery.
35
+ */
36
+ recoveryURL?: string | false;
27
37
  }
28
38
  /**
29
39
  * Create a Next.js middleware function that protects routes based on auth state.
@@ -131,6 +131,106 @@ function isStringArray(value) {
131
131
  }
132
132
  __name(isStringArray, "isStringArray");
133
133
 
134
+ // src/client/sessionRecovery.ts
135
+ async function requestSessionRecovery(options) {
136
+ if (!isCookieName(options.refreshCookieName) || !isCookieName(options.sessionCookieName)) {
137
+ return { status: "unavailable" };
138
+ }
139
+ if (!isCookieValue(options.refreshCookieValue)) {
140
+ return { status: "invalid" };
141
+ }
142
+ const endpoint = sameOriginRecoveryEndpoint(
143
+ options.endpoint,
144
+ options.requestOrigin
145
+ );
146
+ if (!endpoint) {
147
+ return { status: "unavailable" };
148
+ }
149
+ let response;
150
+ try {
151
+ response = await fetch(endpoint, {
152
+ method: "POST",
153
+ headers: {
154
+ Accept: "application/json",
155
+ Cookie: `${options.refreshCookieName}=${options.refreshCookieValue}`,
156
+ "X-Najm-Session-Recovery": "1"
157
+ },
158
+ cache: "no-store",
159
+ redirect: "manual"
160
+ });
161
+ } catch {
162
+ return { status: "unavailable" };
163
+ }
164
+ if (!response.ok) {
165
+ const status = response.status;
166
+ return {
167
+ status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
168
+ httpStatus: status
169
+ };
170
+ }
171
+ const setCookie = response.headers.get("set-cookie");
172
+ if (!setCookie) return { status: "unavailable", httpStatus: response.status };
173
+ const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
174
+ if (!sessionCookieValue) {
175
+ return { status: "unavailable", httpStatus: response.status };
176
+ }
177
+ const claims = await verifySessionCookie(sessionCookieValue, {
178
+ secret: options.sessionSecret,
179
+ maxAgeSeconds: options.sessionMaxAge
180
+ });
181
+ if (!claims) return { status: "unavailable", httpStatus: response.status };
182
+ return {
183
+ status: "recovered",
184
+ claims,
185
+ setCookie,
186
+ sessionCookieValue
187
+ };
188
+ }
189
+ __name(requestSessionRecovery, "requestSessionRecovery");
190
+ function authEndpoint(baseURL, authPrefix, suffix, requestURL) {
191
+ const normalizedBase = baseURL.replace(/\/+$/, "");
192
+ const normalizedPrefix = `/${authPrefix.replace(/^\/+|\/+$/g, "")}`;
193
+ const normalizedSuffix = `/${suffix.replace(/^\/+/, "")}`;
194
+ const value = `${normalizedBase}${normalizedPrefix}${normalizedSuffix}`;
195
+ return requestURL ? new URL(value, requestURL).toString() : value;
196
+ }
197
+ __name(authEndpoint, "authEndpoint");
198
+ function replaceCookieValue(cookieHeader, name, value) {
199
+ const parts = cookieHeader.split(";").map((part) => part.trim()).filter(Boolean).filter((part) => part.slice(0, part.indexOf("=")).trim() !== name);
200
+ parts.push(`${name}=${value}`);
201
+ return parts.join("; ");
202
+ }
203
+ __name(replaceCookieValue, "replaceCookieValue");
204
+ function readSetCookieValue(setCookie, name) {
205
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
206
+ const match = setCookie.match(new RegExp(`(?:^|,\\s*)${escaped}=([^;]*)`));
207
+ return match?.[1] || void 0;
208
+ }
209
+ __name(readSetCookieValue, "readSetCookieValue");
210
+ function isCookieName(value) {
211
+ return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value);
212
+ }
213
+ __name(isCookieName, "isCookieName");
214
+ function isCookieValue(value) {
215
+ return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
216
+ }
217
+ __name(isCookieValue, "isCookieValue");
218
+ function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
219
+ try {
220
+ const trusted = new URL(requestOrigin);
221
+ if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
222
+ return void 0;
223
+ }
224
+ const resolved = new URL(endpoint, trusted.origin);
225
+ if (resolved.username || resolved.password) return void 0;
226
+ if (resolved.origin !== trusted.origin) return void 0;
227
+ return resolved.toString();
228
+ } catch {
229
+ return void 0;
230
+ }
231
+ }
232
+ __name(sameOriginRecoveryEndpoint, "sameOriginRecoveryEndpoint");
233
+
134
234
  // src/client/server/withAuthMiddleware.ts
135
235
  function withAuthMiddleware(config) {
136
236
  const {
@@ -139,26 +239,31 @@ function withAuthMiddleware(config) {
139
239
  loginRoute = "/login",
140
240
  roleRoutes = {},
141
241
  cookieName = "refreshToken",
242
+ apiBaseURL = "/api",
243
+ authPrefix = "/auth",
142
244
  sessionCookieName = "najm.session",
143
245
  sessionSecret,
144
246
  sessionMaxAge,
145
- verifyAlways = false
247
+ verifyAlways = false,
248
+ recoveryURL
146
249
  } = config;
147
- void verifyAlways;
148
250
  return /* @__PURE__ */ __name(async function middleware(request) {
149
251
  const { NextResponse } = await import("next/server");
150
- const redirectToLogin = /* @__PURE__ */ __name((pathname2, clearCookies) => {
252
+ const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
151
253
  const loginUrl = new URL(loginRoute, request.url);
152
- loginUrl.searchParams.set("from", pathname2);
254
+ loginUrl.searchParams.set("from", returnPath2);
153
255
  const res = NextResponse.redirect(loginUrl);
154
- if (clearCookies) {
256
+ if (clearCookies.includes("refresh")) {
155
257
  res.cookies.delete(cookieName);
258
+ }
259
+ if (clearCookies.includes("session")) {
156
260
  res.cookies.delete(sessionCookieName);
157
261
  }
158
262
  return res;
159
263
  }, "redirectToLogin");
160
264
  const url = new URL(request.url);
161
265
  const pathname = url.pathname;
266
+ const returnPath = `${url.pathname}${url.search}`;
162
267
  if (matchesAny(pathname, publicRoutes)) {
163
268
  return NextResponse.next();
164
269
  }
@@ -167,19 +272,54 @@ function withAuthMiddleware(config) {
167
272
  const cookie = request.headers.get("cookie") ?? "";
168
273
  const sessionCookie = readCookieValue(cookie, sessionCookieName);
169
274
  const secret = resolveSessionSecret(sessionSecret);
170
- if (!sessionCookie || !secret) {
171
- return redirectToLogin(pathname, true);
275
+ if (!secret) {
276
+ return redirectToLogin(returnPath, ["session"]);
172
277
  }
173
- const session = await verifySessionCookie(sessionCookie, {
278
+ let session = sessionCookie ? await verifySessionCookie(sessionCookie, {
174
279
  secret,
175
280
  maxAgeSeconds: sessionMaxAge
176
- });
177
- if (!session) {
178
- return redirectToLogin(pathname, true);
281
+ }) : null;
282
+ let recovery = null;
283
+ if (!session || verifyAlways) {
284
+ const refreshCookie = readCookieValue(cookie, cookieName);
285
+ if (!refreshCookie || recoveryURL === false) {
286
+ return redirectToLogin(returnPath, ["refresh", "session"]);
287
+ }
288
+ const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
289
+ recovery = await requestSessionRecovery({
290
+ endpoint,
291
+ requestOrigin: url.origin,
292
+ refreshCookieName: cookieName,
293
+ refreshCookieValue: refreshCookie,
294
+ sessionCookieName,
295
+ sessionSecret: secret,
296
+ sessionMaxAge
297
+ });
298
+ if (recovery.status !== "recovered") {
299
+ return redirectToLogin(
300
+ returnPath,
301
+ recovery.status === "invalid" ? ["refresh", "session"] : ["session"]
302
+ );
303
+ }
304
+ session = recovery.claims;
179
305
  }
180
306
  const requiredRoles = findMatchingRoles(pathname, roleRoutes);
181
307
  if (requiredRoles && !session.roles.some((role) => requiredRoles.includes(role))) {
182
- return new NextResponse(null, { status: 403 });
308
+ const forbidden = new NextResponse(null, { status: 403 });
309
+ if (recovery?.status === "recovered") {
310
+ forbidden.headers.append("Set-Cookie", recovery.setCookie);
311
+ }
312
+ return forbidden;
313
+ }
314
+ if (recovery?.status === "recovered") {
315
+ const requestHeaders = new Headers(request.headers);
316
+ requestHeaders.set(
317
+ "cookie",
318
+ replaceCookieValue(cookie, sessionCookieName, recovery.sessionCookieValue)
319
+ );
320
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
321
+ response.headers.append("Set-Cookie", recovery.setCookie);
322
+ return response;
183
323
  }
184
324
  return NextResponse.next();
185
325
  }, "middleware");
@@ -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,4 +1,4 @@
1
- import { e as AuthUser, F as FetchClient, R as RetryConfig, N as NajmAuthClient } from '../../NajmAuthClient-B9dGk9MH.js';
1
+ import { f as AuthUser, F as FetchClient, N as NajmAuthClient, h as RetryConfig } from '../../NajmAuthClient-0yzFb9CR.js';
2
2
  export { withAuthMiddleware } from '../edge.js';
3
3
  import 'next/server';
4
4
 
@@ -72,27 +72,17 @@ interface ServerSession {
72
72
  interface GetSessionConfig {
73
73
  /**
74
74
  * Base URL for auth endpoints.
75
- * Defaults to `${NEXT_PUBLIC_API_URL || http://localhost:${PORT||3000}}/api`.
75
+ * Defaults to `NEXT_PUBLIC_API_URL` or the same-origin `/api` path.
76
76
  */
77
77
  baseURL?: string;
78
- /**
79
- * Auth route prefix appended to baseURL (default: '/auth').
80
- */
78
+ /** Auth route prefix appended to baseURL (default: '/auth'). */
81
79
  authPrefix?: string;
82
- /**
83
- * Refresh token cookie name to check before making the network call.
84
- * If absent we skip and return null immediately.
85
- * Default: 'refreshToken'
86
- */
80
+ /** Refresh token cookie name (default: 'refreshToken'). */
87
81
  cookieName?: string;
88
- /**
89
- * Session cookie name (default: 'najm.session').
90
- * When present and valid, skips the /auth/me fetch entirely.
91
- */
82
+ /** Signed session cookie name (default: 'najm.session'). */
92
83
  sessionCookieName?: string;
93
84
  /**
94
85
  * Secret used to verify the session cookie HMAC signature.
95
- * Must match the `jwt.accessSecret` used by the auth plugin.
96
86
  * Falls back to NAJM_SESSION_SECRET or JWT_ACCESS_SECRET env vars.
97
87
  */
98
88
  sessionSecret?: string;
@@ -101,6 +91,11 @@ interface GetSessionConfig {
101
91
  * Must match the auth plugin's `session.maxAge`. Default: 300.
102
92
  */
103
93
  sessionMaxAge?: number;
94
+ /**
95
+ * Session-recovery endpoint. Defaults to
96
+ * `${baseURL}${authPrefix}/session/recover`. Set to false to disable fallback.
97
+ */
98
+ recoveryURL?: string | false;
104
99
  /**
105
100
  * Error handling mode:
106
101
  * - 'nullable' (default): returns null on any failure
@@ -122,29 +117,10 @@ declare class AuthTransportError extends Error {
122
117
  constructor(message: string, status?: number);
123
118
  }
124
119
  /**
125
- * Resolve the current session inside a Next.js Server Component, Route Handler,
126
- * or Server Action.
127
- *
128
- * **Fast path**: If a signed `najm.session` cookie exists and is valid,
129
- * returns the session instantly with zero network calls.
130
- *
131
- * **Fallback**: Checks the refresh token cookie and calls `/auth/me`.
132
- *
133
- * @example
134
- * ```tsx
135
- * import { getSession } from 'najm-auth/client/server';
136
- *
137
- * export default async function RootLayout({ children }) {
138
- * const session = await getSession();
139
- * return (
140
- * <html><body>
141
- * <AuthProvider client={authClient} initialSession={session}>
142
- * {children}
143
- * </AuthProvider>
144
- * </body></html>
145
- * );
146
- * }
147
- * ```
120
+ * Resolve a session in a Next.js Server Component, Route Handler, or Server
121
+ * Action. Recovery returns claims for the current render but cannot persist
122
+ * response cookies during Server Component rendering; middleware performs that
123
+ * persistence for protected navigation.
148
124
  */
149
125
  declare function getSession(config?: GetSessionConfig): Promise<ServerSession | null>;
150
126
 
@@ -200,11 +176,16 @@ interface DefineAuthConfig {
200
176
  sessionSecret?: string;
201
177
  /** Must match the auth plugin's session.maxAge. Default: 300 seconds. */
202
178
  sessionMaxAge?: number;
179
+ /**
180
+ * Session-recovery endpoint. Defaults to
181
+ * `${apiBaseURL}${authPrefix}/session/recover`; false disables recovery.
182
+ */
183
+ recoveryURL?: string | false;
203
184
  /** Next.js middleware matcher (default: exclude _next, favicon, api) */
204
185
  matcher?: string[];
205
186
  /**
206
- * Retained for compatibility. Every protected route verifies the signed
207
- * session cookie locally without an `/auth/me` request.
187
+ * Force authoritative refresh-session validation on every protected request.
188
+ * Recovery reissues the signed cookie without rotating refresh tokens.
208
189
  */
209
190
  verifyAlways?: boolean;
210
191
  }
@@ -218,7 +199,7 @@ interface AuthKit {
218
199
  readonly client: NajmAuthClient;
219
200
  /** Shortcut for `client.api` — the underlying FetchClient with auth attached. */
220
201
  readonly api: FetchClient;
221
- /** Resolve session — reads signed cookie first (instant), falls back to /auth/me */
202
+ /** Resolve session — signed-cookie first, then non-rotating recovery. */
222
203
  getSession: (opts?: Pick<GetSessionConfig, 'mode'>) => Promise<ServerSession | null>;
223
204
  /** Require session — throws if unauthenticated */
224
205
  requireSession: () => Promise<ServerSession>;
@@ -144,6 +144,111 @@ var init_sessionCookie = __esm({
144
144
  }
145
145
  });
146
146
 
147
+ // src/client/sessionRecovery.ts
148
+ async function requestSessionRecovery(options) {
149
+ if (!isCookieName(options.refreshCookieName) || !isCookieName(options.sessionCookieName)) {
150
+ return { status: "unavailable" };
151
+ }
152
+ if (!isCookieValue(options.refreshCookieValue)) {
153
+ return { status: "invalid" };
154
+ }
155
+ const endpoint = sameOriginRecoveryEndpoint(
156
+ options.endpoint,
157
+ options.requestOrigin
158
+ );
159
+ if (!endpoint) {
160
+ return { status: "unavailable" };
161
+ }
162
+ let response;
163
+ try {
164
+ response = await fetch(endpoint, {
165
+ method: "POST",
166
+ headers: {
167
+ Accept: "application/json",
168
+ Cookie: `${options.refreshCookieName}=${options.refreshCookieValue}`,
169
+ "X-Najm-Session-Recovery": "1"
170
+ },
171
+ cache: "no-store",
172
+ redirect: "manual"
173
+ });
174
+ } catch {
175
+ return { status: "unavailable" };
176
+ }
177
+ if (!response.ok) {
178
+ const status = response.status;
179
+ return {
180
+ status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
181
+ httpStatus: status
182
+ };
183
+ }
184
+ const setCookie = response.headers.get("set-cookie");
185
+ if (!setCookie) return { status: "unavailable", httpStatus: response.status };
186
+ const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
187
+ if (!sessionCookieValue) {
188
+ return { status: "unavailable", httpStatus: response.status };
189
+ }
190
+ const claims = await verifySessionCookie(sessionCookieValue, {
191
+ secret: options.sessionSecret,
192
+ maxAgeSeconds: options.sessionMaxAge
193
+ });
194
+ if (!claims) return { status: "unavailable", httpStatus: response.status };
195
+ return {
196
+ status: "recovered",
197
+ claims,
198
+ setCookie,
199
+ sessionCookieValue
200
+ };
201
+ }
202
+ function authEndpoint(baseURL, authPrefix, suffix, requestURL) {
203
+ const normalizedBase = baseURL.replace(/\/+$/, "");
204
+ const normalizedPrefix = `/${authPrefix.replace(/^\/+|\/+$/g, "")}`;
205
+ const normalizedSuffix = `/${suffix.replace(/^\/+/, "")}`;
206
+ const value = `${normalizedBase}${normalizedPrefix}${normalizedSuffix}`;
207
+ return requestURL ? new URL(value, requestURL).toString() : value;
208
+ }
209
+ function replaceCookieValue(cookieHeader, name, value) {
210
+ const parts = cookieHeader.split(";").map((part) => part.trim()).filter(Boolean).filter((part) => part.slice(0, part.indexOf("=")).trim() !== name);
211
+ parts.push(`${name}=${value}`);
212
+ return parts.join("; ");
213
+ }
214
+ function readSetCookieValue(setCookie, name) {
215
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
216
+ const match = setCookie.match(new RegExp(`(?:^|,\\s*)${escaped}=([^;]*)`));
217
+ return match?.[1] || void 0;
218
+ }
219
+ function isCookieName(value) {
220
+ return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value);
221
+ }
222
+ function isCookieValue(value) {
223
+ return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
224
+ }
225
+ function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
226
+ try {
227
+ const trusted = new URL(requestOrigin);
228
+ if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
229
+ return void 0;
230
+ }
231
+ const resolved = new URL(endpoint, trusted.origin);
232
+ if (resolved.username || resolved.password) return void 0;
233
+ if (resolved.origin !== trusted.origin) return void 0;
234
+ return resolved.toString();
235
+ } catch {
236
+ return void 0;
237
+ }
238
+ }
239
+ var init_sessionRecovery = __esm({
240
+ "src/client/sessionRecovery.ts"() {
241
+ init_sessionCookie();
242
+ __name(requestSessionRecovery, "requestSessionRecovery");
243
+ __name(authEndpoint, "authEndpoint");
244
+ __name(replaceCookieValue, "replaceCookieValue");
245
+ __name(readSetCookieValue, "readSetCookieValue");
246
+ __name(isCookieName, "isCookieName");
247
+ __name(isCookieValue, "isCookieValue");
248
+ __name(sameOriginRecoveryEndpoint, "sameOriginRecoveryEndpoint");
249
+ }
250
+ });
251
+
147
252
  // src/client/server/getSession.ts
148
253
  var getSession_exports = {};
149
254
  __export(getSession_exports, {
@@ -155,17 +260,25 @@ __export(getSession_exports, {
155
260
  function defaultBaseURL() {
156
261
  const explicit = typeof process !== "undefined" ? process.env.NAJM_AUTH_BASE_URL : void 0;
157
262
  if (explicit) return explicit;
158
- const origin = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? 3e3}` : "http://localhost:3000";
159
- return `${origin.replace(/\/$/, "")}/api`;
263
+ const publicUrl = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_URL : void 0;
264
+ return publicUrl || "/api";
160
265
  }
161
- function buildSession(body) {
162
- if (!body?.data) return null;
163
- const { roles, permissions, ...user } = body.data;
164
- return {
165
- user,
166
- roles: roles ?? (user.role ? [user.role] : void 0),
167
- permissions: permissions ?? user.permissions
168
- };
266
+ function firstForwardedValue(value) {
267
+ const first = value?.split(",")[0]?.trim();
268
+ return first || void 0;
269
+ }
270
+ function requestOriginFromHeaders(headers) {
271
+ const host = firstForwardedValue(headers.get("x-forwarded-host")) ?? firstForwardedValue(headers.get("host"));
272
+ if (!host) return void 0;
273
+ const protocol = firstForwardedValue(headers.get("x-forwarded-proto")) ?? "https";
274
+ if (protocol !== "https" && protocol !== "http") return void 0;
275
+ try {
276
+ const url = new URL(`${protocol}://${host}`);
277
+ if (url.username || url.password) return void 0;
278
+ return url.origin;
279
+ } catch {
280
+ return void 0;
281
+ }
169
282
  }
170
283
  async function getSession(config = {}) {
171
284
  const cookieName = config.cookieName ?? "refreshToken";
@@ -173,73 +286,80 @@ async function getSession(config = {}) {
173
286
  const baseURL = config.baseURL ?? defaultBaseURL();
174
287
  const prefix = config.authPrefix ?? "/auth";
175
288
  const strict = config.mode === "strict";
176
- let cookieHeader = "";
177
289
  let sessionCookieValue;
178
- let hasRefreshCookie = false;
290
+ let refreshCookieValue;
291
+ let requestOrigin;
179
292
  try {
180
293
  const mod = await import("next/headers");
181
294
  const cookieStore = await mod.cookies();
182
- const sessionCookie = cookieStore.get(sessionCookieName);
183
- if (sessionCookie) sessionCookieValue = sessionCookie.value;
184
- if (typeof cookieStore.get === "function") {
185
- hasRefreshCookie = !!cookieStore.get(cookieName);
295
+ sessionCookieValue = cookieStore.get(sessionCookieName)?.value;
296
+ refreshCookieValue = cookieStore.get(cookieName)?.value;
297
+ if (typeof mod.headers === "function") {
298
+ requestOrigin = requestOriginFromHeaders(await mod.headers());
186
299
  }
187
- cookieHeader = cookieStore.getAll().map((c) => `${c.name}=${c.value}`).join("; ");
188
- } catch (err) {
300
+ } catch {
189
301
  if (strict) throw new AuthConfigError("Failed to read cookies from Next.js headers()");
190
302
  return null;
191
303
  }
192
- if (sessionCookieValue) {
193
- const secret = resolveSessionSecret(config.sessionSecret);
194
- if (!secret) {
195
- if (strict) throw new AuthConfigError("Session cookie secret is not configured");
196
- return null;
197
- }
304
+ const secret = resolveSessionSecret(config.sessionSecret);
305
+ if (sessionCookieValue && secret) {
198
306
  const claims = await verifySessionCookie(sessionCookieValue, {
199
307
  secret,
200
308
  maxAgeSeconds: config.sessionMaxAge
201
309
  });
202
- if (!claims) {
203
- if (strict) throw new NoSessionError("Invalid or expired session cookie");
204
- return null;
310
+ if (claims) {
311
+ return {
312
+ user: claims.user,
313
+ roles: claims.roles,
314
+ permissions: claims.permissions
315
+ };
205
316
  }
206
- return {
207
- user: claims.user,
208
- roles: claims.roles,
209
- permissions: claims.permissions
210
- };
211
317
  }
212
- if (!hasRefreshCookie) {
213
- if (strict) throw new NoSessionError("No refresh token cookie");
318
+ if (!secret) {
319
+ if (strict) throw new AuthConfigError("Session cookie secret is not configured");
214
320
  return null;
215
321
  }
216
- if (!cookieHeader) {
217
- if (strict) throw new NoSessionError("Empty cookie header");
322
+ if (!refreshCookieValue || config.recoveryURL === false) {
323
+ if (strict) throw new NoSessionError("No recoverable refresh session");
218
324
  return null;
219
325
  }
220
- try {
221
- const res = await fetch(`${baseURL}${prefix}/me`, {
222
- headers: { Cookie: cookieHeader, Accept: "application/json" },
223
- cache: "no-store"
224
- });
225
- if (!res.ok) {
226
- if (strict) throw new AuthTransportError(`/auth/me returned ${res.status}`, res.status);
227
- return null;
228
- }
229
- const body = await res.json();
230
- const session = buildSession(body);
231
- if (!session && strict) throw new NoSessionError("No user data in /auth/me response");
232
- return session;
233
- } catch (err) {
234
- if (err instanceof NoSessionError || err instanceof AuthTransportError) throw err;
235
- if (strict) throw new AuthTransportError(`Failed to fetch /auth/me: ${err.message}`);
326
+ if (!requestOrigin) {
327
+ if (strict) throw new AuthConfigError("Incoming request origin is unavailable");
236
328
  return null;
237
329
  }
330
+ const endpoint = config.recoveryURL ? new URL(config.recoveryURL, requestOrigin).toString() : authEndpoint(baseURL, prefix, "/session/recover", requestOrigin);
331
+ const recovery = await requestSessionRecovery({
332
+ endpoint,
333
+ requestOrigin,
334
+ refreshCookieName: cookieName,
335
+ refreshCookieValue,
336
+ sessionCookieName,
337
+ sessionSecret: secret,
338
+ sessionMaxAge: config.sessionMaxAge
339
+ });
340
+ if (recovery.status === "recovered") {
341
+ return {
342
+ user: recovery.claims.user,
343
+ roles: recovery.claims.roles,
344
+ permissions: recovery.claims.permissions
345
+ };
346
+ }
347
+ if (strict) {
348
+ if (recovery.status === "invalid") {
349
+ throw new NoSessionError("Refresh session is invalid or revoked");
350
+ }
351
+ throw new AuthTransportError(
352
+ "Session recovery endpoint was unavailable or returned an invalid session",
353
+ recovery.httpStatus
354
+ );
355
+ }
356
+ return null;
238
357
  }
239
358
  var NoSessionError, AuthConfigError, AuthTransportError;
240
359
  var init_getSession = __esm({
241
360
  "src/client/server/getSession.ts"() {
242
361
  init_sessionCookie();
362
+ init_sessionRecovery();
243
363
  NoSessionError = class extends Error {
244
364
  static {
245
365
  __name(this, "NoSessionError");
@@ -272,7 +392,8 @@ var init_getSession = __esm({
272
392
  code = "AUTH_TRANSPORT_ERROR";
273
393
  };
274
394
  __name(defaultBaseURL, "defaultBaseURL");
275
- __name(buildSession, "buildSession");
395
+ __name(firstForwardedValue, "firstForwardedValue");
396
+ __name(requestOriginFromHeaders, "requestOriginFromHeaders");
276
397
  __name(getSession, "getSession");
277
398
  }
278
399
  });
@@ -468,6 +589,7 @@ __name(createServerClient, "createServerClient");
468
589
 
469
590
  // src/client/server/withAuthMiddleware.ts
470
591
  init_sessionCookie();
592
+ init_sessionRecovery();
471
593
  function withAuthMiddleware(config) {
472
594
  const {
473
595
  protectedRoutes = [],
@@ -475,26 +597,31 @@ function withAuthMiddleware(config) {
475
597
  loginRoute = "/login",
476
598
  roleRoutes = {},
477
599
  cookieName = "refreshToken",
600
+ apiBaseURL = "/api",
601
+ authPrefix = "/auth",
478
602
  sessionCookieName = "najm.session",
479
603
  sessionSecret,
480
604
  sessionMaxAge,
481
- verifyAlways = false
605
+ verifyAlways = false,
606
+ recoveryURL
482
607
  } = config;
483
- void verifyAlways;
484
608
  return /* @__PURE__ */ __name(async function middleware(request) {
485
609
  const { NextResponse } = await import("next/server");
486
- const redirectToLogin = /* @__PURE__ */ __name((pathname2, clearCookies) => {
610
+ const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
487
611
  const loginUrl = new URL(loginRoute, request.url);
488
- loginUrl.searchParams.set("from", pathname2);
612
+ loginUrl.searchParams.set("from", returnPath2);
489
613
  const res = NextResponse.redirect(loginUrl);
490
- if (clearCookies) {
614
+ if (clearCookies.includes("refresh")) {
491
615
  res.cookies.delete(cookieName);
616
+ }
617
+ if (clearCookies.includes("session")) {
492
618
  res.cookies.delete(sessionCookieName);
493
619
  }
494
620
  return res;
495
621
  }, "redirectToLogin");
496
622
  const url = new URL(request.url);
497
623
  const pathname = url.pathname;
624
+ const returnPath = `${url.pathname}${url.search}`;
498
625
  if (matchesAny(pathname, publicRoutes)) {
499
626
  return NextResponse.next();
500
627
  }
@@ -503,19 +630,54 @@ function withAuthMiddleware(config) {
503
630
  const cookie = request.headers.get("cookie") ?? "";
504
631
  const sessionCookie = readCookieValue(cookie, sessionCookieName);
505
632
  const secret = resolveSessionSecret(sessionSecret);
506
- if (!sessionCookie || !secret) {
507
- return redirectToLogin(pathname, true);
633
+ if (!secret) {
634
+ return redirectToLogin(returnPath, ["session"]);
508
635
  }
509
- const session = await verifySessionCookie(sessionCookie, {
636
+ let session = sessionCookie ? await verifySessionCookie(sessionCookie, {
510
637
  secret,
511
638
  maxAgeSeconds: sessionMaxAge
512
- });
513
- if (!session) {
514
- return redirectToLogin(pathname, true);
639
+ }) : null;
640
+ let recovery = null;
641
+ if (!session || verifyAlways) {
642
+ const refreshCookie = readCookieValue(cookie, cookieName);
643
+ if (!refreshCookie || recoveryURL === false) {
644
+ return redirectToLogin(returnPath, ["refresh", "session"]);
645
+ }
646
+ const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
647
+ recovery = await requestSessionRecovery({
648
+ endpoint,
649
+ requestOrigin: url.origin,
650
+ refreshCookieName: cookieName,
651
+ refreshCookieValue: refreshCookie,
652
+ sessionCookieName,
653
+ sessionSecret: secret,
654
+ sessionMaxAge
655
+ });
656
+ if (recovery.status !== "recovered") {
657
+ return redirectToLogin(
658
+ returnPath,
659
+ recovery.status === "invalid" ? ["refresh", "session"] : ["session"]
660
+ );
661
+ }
662
+ session = recovery.claims;
515
663
  }
516
664
  const requiredRoles = findMatchingRoles(pathname, roleRoutes);
517
665
  if (requiredRoles && !session.roles.some((role) => requiredRoles.includes(role))) {
518
- return new NextResponse(null, { status: 403 });
666
+ const forbidden = new NextResponse(null, { status: 403 });
667
+ if (recovery?.status === "recovered") {
668
+ forbidden.headers.append("Set-Cookie", recovery.setCookie);
669
+ }
670
+ return forbidden;
671
+ }
672
+ if (recovery?.status === "recovered") {
673
+ const requestHeaders = new Headers(request.headers);
674
+ requestHeaders.set(
675
+ "cookie",
676
+ replaceCookieValue(cookie, sessionCookieName, recovery.sessionCookieValue)
677
+ );
678
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
679
+ response.headers.append("Set-Cookie", recovery.setCookie);
680
+ return response;
519
681
  }
520
682
  return NextResponse.next();
521
683
  }, "middleware");
@@ -1049,6 +1211,7 @@ function defineAuth(authConfig = {}) {
1049
1211
  sessionCookieName = "najm.session",
1050
1212
  sessionSecret,
1051
1213
  sessionMaxAge,
1214
+ recoveryURL,
1052
1215
  matcher = ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
1053
1216
  verifyAlways = false,
1054
1217
  refreshThreshold,
@@ -1058,13 +1221,13 @@ function defineAuth(authConfig = {}) {
1058
1221
  retry
1059
1222
  } = authConfig;
1060
1223
  const sessionConfig = {
1061
- baseURL: void 0,
1062
- // resolved at call time from env/defaults
1224
+ baseURL: apiBaseURL,
1063
1225
  authPrefix,
1064
1226
  cookieName,
1065
1227
  sessionCookieName,
1066
1228
  sessionSecret,
1067
- sessionMaxAge
1229
+ sessionMaxAge,
1230
+ recoveryURL
1068
1231
  };
1069
1232
  let _client = null;
1070
1233
  const getClient = /* @__PURE__ */ __name(() => {
@@ -1112,9 +1275,12 @@ function defineAuth(authConfig = {}) {
1112
1275
  loginRoute,
1113
1276
  roleRoutes,
1114
1277
  cookieName,
1278
+ apiBaseURL,
1279
+ authPrefix,
1115
1280
  sessionCookieName,
1116
1281
  sessionSecret,
1117
1282
  sessionMaxAge,
1283
+ recoveryURL,
1118
1284
  verifyAlways
1119
1285
  });
1120
1286
  const protect = /* @__PURE__ */ __name((Page, options) => {
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import { User, NewUser, RoleEntity, NewRoleEntity, Permission, NewPermission, Ro
9
9
  export { NewOAuthAccount, NewRolePermission, NewToken, OAuthAccount, Token, authSchema, baseFields, oauthAccountsTable, permissionsTable, rolePermissionsTable, rolesTable, tokenStatusEnum, tokenTypeEnum, tokensTable, userStatusEnum, usersTable } from './schema/pg.js';
10
10
  import { CacheService } from 'najm-cache';
11
11
  import { z } from 'zod';
12
+ import { Context } from 'hono';
12
13
  import { GuardResult } from 'najm-guard';
13
14
  import 'drizzle-orm';
14
15
  import 'drizzle-orm/pg-core';
@@ -279,6 +280,7 @@ var auth = {
279
280
  passwordReset: "Password has been reset successfully",
280
281
  accountInviteSent: "Invitation sent successfully",
281
282
  tokenRefreshed: "Token refreshed successfully",
283
+ sessionRecovered: "Session recovered successfully",
282
284
  oauthLogin: "Google sign-in successful",
283
285
  oauthLinked: "Google account linked successfully"
284
286
  },
@@ -396,6 +398,7 @@ declare const AUTH_LOCALES: {
396
398
  passwordReset: string;
397
399
  accountInviteSent: string;
398
400
  tokenRefreshed: string;
401
+ sessionRecovered: string;
399
402
  oauthLogin: string;
400
403
  oauthLinked: string;
401
404
  };
@@ -932,7 +935,21 @@ declare class TokenService {
932
935
  * the active session. Reuse detection and revocation belong to the
933
936
  * rotation path only.
934
937
  */
938
+ private resolveRefreshSessionFromCookie;
935
939
  resolveUserFromCookie(): Promise<string>;
940
+ /**
941
+ * Resolve authoritative claims for signed-session recovery.
942
+ *
943
+ * This deliberately bypasses the 30-second user cache so status, role, and
944
+ * permission changes are reflected when the short session snapshot expires.
945
+ * It validates but never rotates or consumes the refresh token.
946
+ */
947
+ recoverSessionFromCookie(): Promise<{
948
+ user: any;
949
+ roles: any[];
950
+ permissions: any;
951
+ sessionVersion: number;
952
+ }>;
936
953
  getUser(auth: string): Promise<any>;
937
954
  getUserById(userId: string): Promise<any>;
938
955
  private hashToken;
@@ -1009,6 +1026,7 @@ declare class TokenService {
1009
1026
  accessTokenExpiresAt: number;
1010
1027
  refreshTokenExpiresAt: number;
1011
1028
  }>;
1029
+ private requireActiveRefreshUser;
1012
1030
  /** Revoke every refresh session for a user (password change/reset, logout-all). */
1013
1031
  revokeAllForUser(userId: string): Promise<any>;
1014
1032
  /** Revoke a single refresh session (one family). */
@@ -1242,6 +1260,14 @@ declare class AuthService {
1242
1260
  user: SanitizedUser;
1243
1261
  }>;
1244
1262
  refreshTokens(): Promise<TokenPair>;
1263
+ /**
1264
+ * Reissue the short-lived signed session snapshot from a fully validated
1265
+ * refresh session. This path never creates or returns access/refresh tokens
1266
+ * and never rotates the refresh family.
1267
+ */
1268
+ recoverSession(): Promise<{
1269
+ recovered: true;
1270
+ }>;
1245
1271
  logoutUser(userId: string, authorization?: string): Promise<{
1246
1272
  data: any;
1247
1273
  message: string;
@@ -1310,6 +1336,9 @@ declare class AuthController {
1310
1336
  emailSent: boolean;
1311
1337
  }>;
1312
1338
  refreshTokens(): Promise<TokenPair>;
1339
+ recoverSession(recoveryRequest: string | undefined, ctx: Context): Promise<{
1340
+ recovered: true;
1341
+ }>;
1313
1342
  logoutUser(userId: string, authorization?: string): Promise<{
1314
1343
  data: any;
1315
1344
  message: string;
package/dist/index.js CHANGED
@@ -6,16 +6,16 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/AuthPlugin.ts
9
- import { Err as Err10, plugin } from "najm-core";
9
+ 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";
@@ -431,9 +431,9 @@ CookieManager = __decorate2([
431
431
  ], CookieManager);
432
432
 
433
433
  // src/auth/AuthController.ts
434
- import { Controller } from "najm-core";
434
+ import { Controller, Err as Err9 } from "najm-core";
435
435
  import { Get, Post, ResMsg } from "najm-core";
436
- import { Body, User as User2, Headers } from "najm-core";
436
+ import { Body, User as User2, Headers, Ctx } from "najm-core";
437
437
 
438
438
  // src/auth/AuthService.ts
439
439
  import { Injectable as Injectable8, Inject as Inject8 } from "najm-core";
@@ -1638,7 +1638,7 @@ var TokenService = class TokenService2 {
1638
1638
  * the active session. Reuse detection and revocation belong to the
1639
1639
  * rotation path only.
1640
1640
  */
1641
- async resolveUserFromCookie() {
1641
+ async resolveRefreshSessionFromCookie() {
1642
1642
  const refreshToken = this.cookieManager.getRefreshToken();
1643
1643
  if (!refreshToken) {
1644
1644
  Err6(this.t("errors.refreshTokenMissing"));
@@ -1650,14 +1650,35 @@ var TokenService = class TokenService2 {
1650
1650
  }
1651
1651
  const presentedHash = this.hashToken(refreshToken);
1652
1652
  if (presentedHash === stored.token) {
1653
- return userId;
1653
+ return { userId, tokenFamily };
1654
1654
  }
1655
1655
  const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && new Date(stored.previousValidUntil).getTime() > Date.now() && !stored.previousUsedAt;
1656
1656
  if (canRecover) {
1657
- return userId;
1657
+ return { userId, tokenFamily };
1658
1658
  }
1659
1659
  Err6(this.t("errors.refreshTokenInvalid"));
1660
1660
  }
1661
+ async resolveUserFromCookie() {
1662
+ return (await this.resolveRefreshSessionFromCookie()).userId;
1663
+ }
1664
+ /**
1665
+ * Resolve authoritative claims for signed-session recovery.
1666
+ *
1667
+ * This deliberately bypasses the 30-second user cache so status, role, and
1668
+ * permission changes are reflected when the short session snapshot expires.
1669
+ * It validates but never rotates or consumes the refresh token.
1670
+ */
1671
+ async recoverSessionFromCookie() {
1672
+ const { userId, tokenFamily } = await this.resolveRefreshSessionFromCookie();
1673
+ const user = await this.requireActiveRefreshUser(userId, tokenFamily);
1674
+ const sessionVersion = await this.getUserSessionVersion(userId);
1675
+ return {
1676
+ user,
1677
+ roles: user.role ? [user.role] : [],
1678
+ permissions: Array.isArray(user.permissions) ? user.permissions : [],
1679
+ sessionVersion
1680
+ };
1681
+ }
1661
1682
  // ============ USER RETRIEVAL (MAIN METHOD) ============
1662
1683
  async getUser(auth2) {
1663
1684
  if (!auth2)
@@ -1820,6 +1841,7 @@ var TokenService = class TokenService2 {
1820
1841
  Err6(this.t("errors.refreshTokenInvalid"));
1821
1842
  }
1822
1843
  const presentedHash = this.hashToken(refreshToken);
1844
+ await this.requireActiveRefreshUser(userId, tokenFamily);
1823
1845
  if (presentedHash === stored.token) {
1824
1846
  return this.generateTokens(userId, tokenFamily);
1825
1847
  }
@@ -1834,6 +1856,14 @@ var TokenService = class TokenService2 {
1834
1856
  await this.revokeSuspectRefreshFamily(userId, tokenFamily);
1835
1857
  Err6(this.t("errors.refreshTokenInvalid"));
1836
1858
  }
1859
+ async requireActiveRefreshUser(userId, tokenFamily) {
1860
+ const user = await this.tokenRepository.getUser(userId);
1861
+ if (!user || user.status !== "active") {
1862
+ await this.revokeFamily(tokenFamily);
1863
+ Err6(this.t("errors.refreshTokenInvalid"));
1864
+ }
1865
+ return user;
1866
+ }
1837
1867
  /** Revoke every refresh session for a user (password change/reset, logout-all). */
1838
1868
  async revokeAllForUser(userId) {
1839
1869
  return this.tokenRepository.revokeAllForUser(userId);
@@ -2276,6 +2306,27 @@ var AuthService = class AuthService2 {
2276
2306
  const { userId: _userId, tokenFamily: _tokenFamily, roles: _roles, permissions: _permissions, sessionVersion: _sv, ...tokens } = generated;
2277
2307
  return tokens;
2278
2308
  }
2309
+ /**
2310
+ * Reissue the short-lived signed session snapshot from a fully validated
2311
+ * refresh session. This path never creates or returns access/refresh tokens
2312
+ * and never rotates the refresh family.
2313
+ */
2314
+ async recoverSession() {
2315
+ const recovered = await this.tokenService.recoverSessionFromCookie();
2316
+ this.cookieManager.setSessionCookie({
2317
+ user: {
2318
+ id: recovered.user.id,
2319
+ email: recovered.user.email,
2320
+ name: recovered.user.name,
2321
+ role: recovered.user.role ?? void 0,
2322
+ status: recovered.user.status ?? void 0
2323
+ },
2324
+ roles: recovered.roles,
2325
+ permissions: recovered.permissions,
2326
+ sessionVersion: recovered.sessionVersion
2327
+ });
2328
+ return { recovered: true };
2329
+ }
2279
2330
  async logoutUser(userId, authorization) {
2280
2331
  await this.tokenService.logout(userId, authorization);
2281
2332
  this.cookieManager.clearRefreshToken();
@@ -2604,6 +2655,14 @@ var AuthController = class AuthController2 {
2604
2655
  async refreshTokens() {
2605
2656
  return this.authService.refreshTokens();
2606
2657
  }
2658
+ async recoverSession(recoveryRequest, ctx) {
2659
+ if (recoveryRequest !== "1") {
2660
+ Err9("Invalid session recovery request", 400);
2661
+ }
2662
+ ctx.header("Cache-Control", "private, no-store");
2663
+ ctx.header("Vary", "Cookie");
2664
+ return this.authService.recoverSession();
2665
+ }
2607
2666
  async logoutUser(userId, authorization) {
2608
2667
  return this.authService.logoutUser(userId, authorization);
2609
2668
  }
@@ -2659,6 +2718,16 @@ __decorate15([
2659
2718
  __metadata15("design:paramtypes", []),
2660
2719
  __metadata15("design:returntype", Promise)
2661
2720
  ], AuthController.prototype, "refreshTokens", null);
2721
+ __decorate15([
2722
+ Post("/session/recover"),
2723
+ RateLimit({ limit: 120, window: "1m", key: cookieFingerprint() }),
2724
+ ResMsg("auth.success.sessionRecovered"),
2725
+ __param5(0, Headers("x-najm-session-recovery")),
2726
+ __param5(1, Ctx()),
2727
+ __metadata15("design:type", Function),
2728
+ __metadata15("design:paramtypes", [String, Object]),
2729
+ __metadata15("design:returntype", Promise)
2730
+ ], AuthController.prototype, "recoverSession", null);
2662
2731
  __decorate15([
2663
2732
  Post("/logout"),
2664
2733
  isAuth(),
@@ -3471,7 +3540,7 @@ import { Injectable as Injectable11 } from "najm-core";
3471
3540
  // src/permissions/PermissionValidator.ts
3472
3541
  import { Injectable as Injectable10 } from "najm-core";
3473
3542
  import { I18n as I18n7 } from "najm-i18n";
3474
- import { Err as Err9 } from "najm-core";
3543
+ import { Err as Err10 } from "najm-core";
3475
3544
  var __decorate21 = function(decorators, target, key, desc) {
3476
3545
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3477
3546
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -3500,7 +3569,7 @@ var PermissionValidator = class PermissionValidator2 {
3500
3569
  async checkPermissionExists(id) {
3501
3570
  const permission = await this.permissionRepository.getById(id);
3502
3571
  if (!permission) {
3503
- Err9(this.t("errors.notFound"), 404);
3572
+ Err10(this.t("errors.notFound"), 404);
3504
3573
  }
3505
3574
  return permission;
3506
3575
  }
@@ -3510,7 +3579,7 @@ var PermissionValidator = class PermissionValidator2 {
3510
3579
  async checkPermissionExistsByName(name) {
3511
3580
  const permission = await this.permissionRepository.getByName(name);
3512
3581
  if (!permission) {
3513
- Err9(this.t("errors.notFound"), 404);
3582
+ Err10(this.t("errors.notFound"), 404);
3514
3583
  }
3515
3584
  return permission;
3516
3585
  }
@@ -3522,7 +3591,7 @@ var PermissionValidator = class PermissionValidator2 {
3522
3591
  return;
3523
3592
  const existingPermission = await this.permissionRepository.getByName(name);
3524
3593
  if (existingPermission && existingPermission.id !== excludeId) {
3525
- Err9(this.t("errors.nameExists"), 409);
3594
+ Err10(this.t("errors.nameExists"), 409);
3526
3595
  }
3527
3596
  }
3528
3597
  /**
@@ -3545,7 +3614,7 @@ var PermissionValidator = class PermissionValidator2 {
3545
3614
  await this.checkPermissionExists(permissionId);
3546
3615
  const hasPermission = await this.permissionRepository.checkRoleHasPermission(roleId, permissionId);
3547
3616
  if (hasPermission) {
3548
- Err9(this.t("errors.roleAlreadyHasPermission"), 409);
3617
+ Err10(this.t("errors.roleAlreadyHasPermission"), 409);
3549
3618
  }
3550
3619
  }
3551
3620
  };
@@ -3911,9 +3980,9 @@ var revokeTokenDto = z4.object({
3911
3980
  // src/ownership/scopedOwnership.ts
3912
3981
  import { aliasedTable, eq as eq6, getTableColumns, sql as sql4 } from "drizzle-orm";
3913
3982
  var DEFAULT_ADMIN_ROLES = ["admin"];
3914
- var DRIZZLE_NAME = /* @__PURE__ */ Symbol.for("drizzle:Name");
3915
- var DRIZZLE_BASE_NAME = /* @__PURE__ */ Symbol.for("drizzle:BaseName");
3916
- 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");
3917
3986
  function join2(left, right) {
3918
3987
  const table = right.table;
3919
3988
  if (!table)
@@ -4361,9 +4430,9 @@ __name(configureOwnership, "configureOwnership");
4361
4430
  // src/ownership/ScopeGuard.ts
4362
4431
  import "reflect-metadata";
4363
4432
  import { composeGuards as composeGuards5 } from "najm-guard";
4364
- var ACTION_KEY = /* @__PURE__ */ Symbol.for("najm:guard:action");
4365
- var TOKEN_KEY = /* @__PURE__ */ Symbol.for("najm:guard:token");
4366
- 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");
4367
4436
  var PERM = {
4368
4437
  list: /* @__PURE__ */ __name((n) => `read:${n}`, "list"),
4369
4438
  read: /* @__PURE__ */ __name((n) => `read:${n}`, "read"),
@@ -4433,7 +4502,7 @@ var __metadata25 = function(k, v) {
4433
4502
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4434
4503
  };
4435
4504
  var _a16;
4436
- var OWNED_META = /* @__PURE__ */ Symbol.for("najm:owned");
4505
+ var OWNED_META = Symbol.for("najm:owned");
4437
4506
  var ScopeContext = class ScopeContext2 {
4438
4507
  static {
4439
4508
  __name(this, "ScopeContext");
@@ -4594,6 +4663,7 @@ var en_default = {
4594
4663
  passwordReset: "Password has been reset successfully",
4595
4664
  accountInviteSent: "Invitation sent successfully",
4596
4665
  tokenRefreshed: "Token refreshed successfully",
4666
+ sessionRecovered: "Session recovered successfully",
4597
4667
  oauthLogin: "Google sign-in successful",
4598
4668
  oauthLinked: "Google account linked successfully"
4599
4669
  },
@@ -5010,7 +5080,7 @@ OAuthAccountService = __decorate29([
5010
5080
 
5011
5081
  // src/oauth/OAuthController.ts
5012
5082
  import { createHash as createHash4 } from "crypto";
5013
- import { Controller as Controller5, Ctx, Get as Get5, Post as Post5, Query as Query2, User as User5 } from "najm-core";
5083
+ import { Controller as Controller5, Ctx as Ctx2, Get as Get5, Post as Post5, Query as Query2, User as User5 } from "najm-core";
5014
5084
  import { RateLimit as RateLimit2 } from "najm-rate";
5015
5085
 
5016
5086
  // src/oauth/OAuthService.ts
@@ -5296,7 +5366,7 @@ var OAuthController = class OAuthController2 {
5296
5366
  __decorate32([
5297
5367
  Get5("/start"),
5298
5368
  RateLimit2({ limit: 20, window: "15m", key: "ip" }),
5299
- __param11(0, Ctx()),
5369
+ __param11(0, Ctx2()),
5300
5370
  __param11(1, Query2("returnTo")),
5301
5371
  __metadata32("design:type", Function),
5302
5372
  __metadata32("design:paramtypes", [Object, String]),
@@ -5305,7 +5375,7 @@ __decorate32([
5305
5375
  __decorate32([
5306
5376
  Get5("/callback"),
5307
5377
  RateLimit2({ limit: 20, window: "15m", key: callbackKey }),
5308
- __param11(0, Ctx()),
5378
+ __param11(0, Ctx2()),
5309
5379
  __param11(1, Query2("code")),
5310
5380
  __param11(2, Query2("state")),
5311
5381
  __param11(3, Query2("error")),
@@ -5360,9 +5430,9 @@ var resolveGoogleConfig = /* @__PURE__ */ __name((config) => {
5360
5430
  const clientId = google.clientId ?? process.env.GOOGLE_CLIENT_ID ?? "";
5361
5431
  const clientSecret = google.clientSecret ?? process.env.GOOGLE_CLIENT_SECRET ?? "";
5362
5432
  if (!clientId)
5363
- throw Err10.configRequired("auth.oauth.google", "GOOGLE_CLIENT_ID");
5433
+ throw Err11.configRequired("auth.oauth.google", "GOOGLE_CLIENT_ID");
5364
5434
  if (!clientSecret)
5365
- throw Err10.configRequired("auth.oauth.google", "GOOGLE_CLIENT_SECRET");
5435
+ throw Err11.configRequired("auth.oauth.google", "GOOGLE_CLIENT_SECRET");
5366
5436
  const frontendUrl = config?.frontendUrl ?? process.env.FRONTEND_URL ?? "http://localhost:3000";
5367
5437
  const callbackUrl = google.callbackUrl ?? process.env.GOOGLE_CALLBACK_URL ?? `${frontendUrl.replace(/\/$/, "")}/api/auth/oauth/google/callback`;
5368
5438
  let callback;
@@ -5421,10 +5491,10 @@ var resolveAuthConfig = /* @__PURE__ */ __name((config) => {
5421
5491
  }
5422
5492
  };
5423
5493
  if (!finalConfig.jwt.accessSecret) {
5424
- throw Err10.configRequired("auth", "JWT_ACCESS_SECRET");
5494
+ throw Err11.configRequired("auth", "JWT_ACCESS_SECRET");
5425
5495
  }
5426
5496
  if (!finalConfig.jwt.refreshSecret) {
5427
- throw Err10.configRequired("auth", "JWT_REFRESH_SECRET");
5497
+ throw Err11.configRequired("auth", "JWT_REFRESH_SECRET");
5428
5498
  }
5429
5499
  return finalConfig;
5430
5500
  }, "resolveAuthConfig");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [
@@ -67,9 +67,9 @@
67
67
  "@testing-library/react": "^16.3.2",
68
68
  "@types/jsonwebtoken": "^9.0.10",
69
69
  "@types/node": "^25.0.2",
70
- "drizzle-kit": "^0.31.8",
71
- "drizzle-orm": "^0.45.1",
72
- "happy-dom": "^17.6.3",
70
+ "drizzle-kit": "^0.31.10",
71
+ "drizzle-orm": "^0.45.2",
72
+ "happy-dom": "^20.11.1",
73
73
  "tsup": "^8.5.1",
74
74
  "typescript": "^5.9.3"
75
75
  },
@@ -93,8 +93,8 @@
93
93
  "zod": "^4.2.1"
94
94
  },
95
95
  "peerDependencies": {
96
- "drizzle-orm": "^0.45.1",
97
- "hono": "^4.0.0",
96
+ "drizzle-orm": "^0.45.2",
97
+ "hono": "^4.12.18",
98
98
  "react": ">=18",
99
99
  "next": ">=14"
100
100
  },