najm-auth 2.0.2 → 2.0.4

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.
@@ -11,14 +11,17 @@ interface AuthMiddlewareConfig {
11
11
  roleRoutes?: Record<string, string[]>;
12
12
  /** Refresh token cookie name (default: 'refreshToken') */
13
13
  cookieName?: string;
14
- /** Session cookie name to clear on redirect (default: 'najm.session') */
14
+ /** Signed session cookie name (default: 'najm.session') */
15
15
  sessionCookieName?: string;
16
- /** URL of the verify endpoint (default: derived from request) */
16
+ /** @deprecated Session cookies are verified locally at the Edge. */
17
17
  verifyURL?: string;
18
+ /** Secret for verifying the session cookie HMAC. Falls back to env vars. */
19
+ sessionSecret?: string;
20
+ /** Must match the auth plugin's session.maxAge. Default: 300 seconds. */
21
+ sessionMaxAge?: number;
18
22
  /**
19
- * When true, call the verify endpoint on EVERY protected route (not just
20
- * roleRoutes). Redirects to loginRoute if the session is invalid. Adds one
21
- * fetch per navigation — trade latency for stronger guarantees.
23
+ * Retained for compatibility. Every protected route now verifies the signed
24
+ * session cookie locally, so enabling this never calls `/auth/me`.
22
25
  */
23
26
  verifyAlways?: boolean;
24
27
  }
@@ -28,7 +31,7 @@ interface AuthMiddlewareConfig {
28
31
  * @example
29
32
  * ```ts
30
33
  * // middleware.ts
31
- * import { withAuthMiddleware } from 'najm-auth/client/server';
34
+ * import { withAuthMiddleware } from 'najm-auth/client/edge';
32
35
  *
33
36
  * export default withAuthMiddleware({
34
37
  * protectedRoutes: ['/dashboard/:path*', '/admin/:path*'],
@@ -1,6 +1,136 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
+ // src/client/sessionCookie.ts
5
+ var DEFAULT_SESSION_MAX_AGE_SECONDS = 300;
6
+ var MAX_CLOCK_SKEW_MS = 3e4;
7
+ var HMAC_SHA256_BASE64URL_LENGTH = 43;
8
+ var BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
9
+ function resolveSessionSecret(explicit) {
10
+ if (explicit !== void 0) return explicit || void 0;
11
+ if (typeof process === "undefined") return void 0;
12
+ return process.env.NAJM_SESSION_SECRET || process.env.JWT_ACCESS_SECRET || void 0;
13
+ }
14
+ __name(resolveSessionSecret, "resolveSessionSecret");
15
+ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_AGE_SECONDS, now = Date.now()) {
16
+ if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) return null;
17
+ try {
18
+ const data = JSON.parse(payload);
19
+ if (!isRecord(data) || !isValidUser(data.user)) return null;
20
+ if (!isStringArray(data.roles) || !isStringArray(data.permissions)) return null;
21
+ if (!Number.isInteger(data.sessionVersion) || data.sessionVersion < 0) return null;
22
+ if (!Number.isFinite(data.iat) || !Number.isInteger(data.iat) || data.iat <= 0) return null;
23
+ const issuedAt = data.iat;
24
+ if (issuedAt > now + MAX_CLOCK_SKEW_MS) return null;
25
+ if (now - issuedAt >= maxAgeSeconds * 1e3) return null;
26
+ return {
27
+ user: data.user,
28
+ roles: [...data.roles],
29
+ permissions: [...data.permissions],
30
+ sessionVersion: data.sessionVersion,
31
+ iat: issuedAt
32
+ };
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ __name(parseSessionCookiePayload, "parseSessionCookiePayload");
38
+ async function verifySessionCookie(rawCookieValue, options) {
39
+ if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) return null;
40
+ for (const signedValue of cookieValueCandidates(rawCookieValue)) {
41
+ const lastDot = signedValue.lastIndexOf(".");
42
+ if (lastDot <= 0 || lastDot === signedValue.length - 1) continue;
43
+ const payload = signedValue.slice(0, lastDot);
44
+ const signature = signedValue.slice(lastDot + 1);
45
+ if (!await verifyHmac(payload, signature, options.secret)) continue;
46
+ return parseSessionCookiePayload(
47
+ payload,
48
+ options.maxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
49
+ options.now
50
+ );
51
+ }
52
+ return null;
53
+ }
54
+ __name(verifySessionCookie, "verifySessionCookie");
55
+ function readCookieValue(cookieHeader, name) {
56
+ for (const part of cookieHeader.split(";")) {
57
+ const separator = part.indexOf("=");
58
+ if (separator === -1) continue;
59
+ if (part.slice(0, separator).trim() === name) {
60
+ const value = part.slice(separator + 1).trim();
61
+ return value || void 0;
62
+ }
63
+ }
64
+ return void 0;
65
+ }
66
+ __name(readCookieValue, "readCookieValue");
67
+ async function verifyHmac(payload, signature, secret) {
68
+ if (signature.length !== HMAC_SHA256_BASE64URL_LENGTH) return false;
69
+ const signatureBytes = decodeBase64Url(signature);
70
+ if (!signatureBytes || signatureBytes.length !== 32) return false;
71
+ try {
72
+ const encoder = new TextEncoder();
73
+ const key = await globalThis.crypto.subtle.importKey(
74
+ "raw",
75
+ encoder.encode(secret),
76
+ { name: "HMAC", hash: "SHA-256" },
77
+ false,
78
+ ["verify"]
79
+ );
80
+ return await globalThis.crypto.subtle.verify(
81
+ "HMAC",
82
+ key,
83
+ signatureBytes,
84
+ encoder.encode(payload)
85
+ );
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+ __name(verifyHmac, "verifyHmac");
91
+ function cookieValueCandidates(raw) {
92
+ const candidates = [raw];
93
+ try {
94
+ const decoded = decodeURIComponent(raw);
95
+ if (decoded !== raw) candidates.push(decoded);
96
+ } catch {
97
+ }
98
+ return candidates;
99
+ }
100
+ __name(cookieValueCandidates, "cookieValueCandidates");
101
+ function decodeBase64Url(value) {
102
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
103
+ const bytes = [];
104
+ let accumulator = 0;
105
+ let bits = 0;
106
+ for (const char of value) {
107
+ const digit = BASE64URL_ALPHABET.indexOf(char);
108
+ if (digit === -1) return null;
109
+ accumulator = accumulator << 6 | digit;
110
+ bits += 6;
111
+ if (bits >= 8) {
112
+ bits -= 8;
113
+ bytes.push(accumulator >> bits & 255);
114
+ accumulator &= (1 << bits) - 1;
115
+ }
116
+ }
117
+ if (bits > 0 && accumulator !== 0) return null;
118
+ return new Uint8Array(bytes);
119
+ }
120
+ __name(decodeBase64Url, "decodeBase64Url");
121
+ function isRecord(value) {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value);
123
+ }
124
+ __name(isRecord, "isRecord");
125
+ function isValidUser(value) {
126
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.email === "string" && value.email.length > 0 && (value.role === void 0 || value.role === null || typeof value.role === "string");
127
+ }
128
+ __name(isValidUser, "isValidUser");
129
+ function isStringArray(value) {
130
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
131
+ }
132
+ __name(isStringArray, "isStringArray");
133
+
4
134
  // src/client/server/withAuthMiddleware.ts
5
135
  function withAuthMiddleware(config) {
6
136
  const {
@@ -10,8 +140,11 @@ function withAuthMiddleware(config) {
10
140
  roleRoutes = {},
11
141
  cookieName = "refreshToken",
12
142
  sessionCookieName = "najm.session",
143
+ sessionSecret,
144
+ sessionMaxAge,
13
145
  verifyAlways = false
14
146
  } = config;
147
+ void verifyAlways;
15
148
  return /* @__PURE__ */ __name(async function middleware(request) {
16
149
  const { NextResponse } = await import("next/server");
17
150
  const redirectToLogin = /* @__PURE__ */ __name((pathname2, clearCookies) => {
@@ -32,31 +165,21 @@ function withAuthMiddleware(config) {
32
165
  const isProtected = protectedRoutes.length === 0 || matchesAny(pathname, protectedRoutes);
33
166
  if (!isProtected) return NextResponse.next();
34
167
  const cookie = request.headers.get("cookie") ?? "";
35
- const hasToken = cookieRegex(cookieName).test(cookie);
36
- if (!hasToken) {
168
+ const sessionCookie = readCookieValue(cookie, sessionCookieName);
169
+ const secret = resolveSessionSecret(sessionSecret);
170
+ if (!sessionCookie || !secret) {
171
+ return redirectToLogin(pathname, true);
172
+ }
173
+ const session = await verifySessionCookie(sessionCookie, {
174
+ secret,
175
+ maxAgeSeconds: sessionMaxAge
176
+ });
177
+ if (!session) {
37
178
  return redirectToLogin(pathname, true);
38
179
  }
39
180
  const requiredRoles = findMatchingRoles(pathname, roleRoutes);
40
- const needsVerify = verifyAlways || !!requiredRoles;
41
- if (needsVerify) {
42
- const verifyURL = config.verifyURL ?? `${url.origin}/api/auth/me`;
43
- try {
44
- const res = await fetch(verifyURL, {
45
- headers: { "Cookie": cookie, "Accept": "application/json" }
46
- });
47
- if (!res.ok) {
48
- return redirectToLogin(pathname, true);
49
- }
50
- if (requiredRoles) {
51
- const body = await res.json();
52
- const userRole = body?.data?.role;
53
- if (!userRole || !requiredRoles.includes(userRole)) {
54
- return new NextResponse(null, { status: 403 });
55
- }
56
- }
57
- } catch {
58
- return redirectToLogin(pathname, true);
59
- }
181
+ if (requiredRoles && !session.roles.some((role) => requiredRoles.includes(role))) {
182
+ return new NextResponse(null, { status: 403 });
60
183
  }
61
184
  return NextResponse.next();
62
185
  }, "middleware");
@@ -72,10 +195,6 @@ function matchPattern(pathname, pattern) {
72
195
  return new RegExp(`^${regex}$`).test(pathname);
73
196
  }
74
197
  __name(matchPattern, "matchPattern");
75
- function cookieRegex(name) {
76
- return new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=[^;]`);
77
- }
78
- __name(cookieRegex, "cookieRegex");
79
198
  function findMatchingRoles(pathname, roleRoutes) {
80
199
  for (const [pattern, roles] of Object.entries(roleRoutes)) {
81
200
  if (matchPattern(pathname, pattern)) return roles;
@@ -96,6 +96,11 @@ interface GetSessionConfig {
96
96
  * Falls back to NAJM_SESSION_SECRET or JWT_ACCESS_SECRET env vars.
97
97
  */
98
98
  sessionSecret?: string;
99
+ /**
100
+ * Maximum accepted session-cookie age in seconds.
101
+ * Must match the auth plugin's `session.maxAge`. Default: 300.
102
+ */
103
+ sessionMaxAge?: number;
99
104
  /**
100
105
  * Error handling mode:
101
106
  * - 'nullable' (default): returns null on any failure
@@ -193,12 +198,13 @@ interface DefineAuthConfig {
193
198
  sessionCookieName?: string;
194
199
  /** Secret for verifying session cookie HMAC. Falls back to env vars. */
195
200
  sessionSecret?: string;
201
+ /** Must match the auth plugin's session.maxAge. Default: 300 seconds. */
202
+ sessionMaxAge?: number;
196
203
  /** Next.js middleware matcher (default: exclude _next, favicon, api) */
197
204
  matcher?: string[];
198
205
  /**
199
- * When true, call the verify endpoint on every protected route (not just
200
- * roleRoutes). Redirects to loginRoute if the session is invalid. One extra
201
- * fetch per navigation in exchange for guaranteed fresh auth state.
206
+ * Retained for compatibility. Every protected route verifies the signed
207
+ * session cookie locally without an `/auth/me` request.
202
208
  */
203
209
  verifyAlways?: boolean;
204
210
  }
@@ -9,6 +9,141 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
11
 
12
+ // src/client/sessionCookie.ts
13
+ function resolveSessionSecret(explicit) {
14
+ if (explicit !== void 0) return explicit || void 0;
15
+ if (typeof process === "undefined") return void 0;
16
+ return process.env.NAJM_SESSION_SECRET || process.env.JWT_ACCESS_SECRET || void 0;
17
+ }
18
+ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_AGE_SECONDS, now = Date.now()) {
19
+ if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) return null;
20
+ try {
21
+ const data = JSON.parse(payload);
22
+ if (!isRecord(data) || !isValidUser(data.user)) return null;
23
+ if (!isStringArray(data.roles) || !isStringArray(data.permissions)) return null;
24
+ if (!Number.isInteger(data.sessionVersion) || data.sessionVersion < 0) return null;
25
+ if (!Number.isFinite(data.iat) || !Number.isInteger(data.iat) || data.iat <= 0) return null;
26
+ const issuedAt = data.iat;
27
+ if (issuedAt > now + MAX_CLOCK_SKEW_MS) return null;
28
+ if (now - issuedAt >= maxAgeSeconds * 1e3) return null;
29
+ return {
30
+ user: data.user,
31
+ roles: [...data.roles],
32
+ permissions: [...data.permissions],
33
+ sessionVersion: data.sessionVersion,
34
+ iat: issuedAt
35
+ };
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ async function verifySessionCookie(rawCookieValue, options) {
41
+ if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) return null;
42
+ for (const signedValue of cookieValueCandidates(rawCookieValue)) {
43
+ const lastDot = signedValue.lastIndexOf(".");
44
+ if (lastDot <= 0 || lastDot === signedValue.length - 1) continue;
45
+ const payload = signedValue.slice(0, lastDot);
46
+ const signature = signedValue.slice(lastDot + 1);
47
+ if (!await verifyHmac(payload, signature, options.secret)) continue;
48
+ return parseSessionCookiePayload(
49
+ payload,
50
+ options.maxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
51
+ options.now
52
+ );
53
+ }
54
+ return null;
55
+ }
56
+ function readCookieValue(cookieHeader, name) {
57
+ for (const part of cookieHeader.split(";")) {
58
+ const separator = part.indexOf("=");
59
+ if (separator === -1) continue;
60
+ if (part.slice(0, separator).trim() === name) {
61
+ const value = part.slice(separator + 1).trim();
62
+ return value || void 0;
63
+ }
64
+ }
65
+ return void 0;
66
+ }
67
+ async function verifyHmac(payload, signature, secret) {
68
+ if (signature.length !== HMAC_SHA256_BASE64URL_LENGTH) return false;
69
+ const signatureBytes = decodeBase64Url(signature);
70
+ if (!signatureBytes || signatureBytes.length !== 32) return false;
71
+ try {
72
+ const encoder = new TextEncoder();
73
+ const key = await globalThis.crypto.subtle.importKey(
74
+ "raw",
75
+ encoder.encode(secret),
76
+ { name: "HMAC", hash: "SHA-256" },
77
+ false,
78
+ ["verify"]
79
+ );
80
+ return await globalThis.crypto.subtle.verify(
81
+ "HMAC",
82
+ key,
83
+ signatureBytes,
84
+ encoder.encode(payload)
85
+ );
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+ function cookieValueCandidates(raw) {
91
+ const candidates = [raw];
92
+ try {
93
+ const decoded = decodeURIComponent(raw);
94
+ if (decoded !== raw) candidates.push(decoded);
95
+ } catch {
96
+ }
97
+ return candidates;
98
+ }
99
+ function decodeBase64Url(value) {
100
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
101
+ const bytes = [];
102
+ let accumulator = 0;
103
+ let bits = 0;
104
+ for (const char of value) {
105
+ const digit = BASE64URL_ALPHABET.indexOf(char);
106
+ if (digit === -1) return null;
107
+ accumulator = accumulator << 6 | digit;
108
+ bits += 6;
109
+ if (bits >= 8) {
110
+ bits -= 8;
111
+ bytes.push(accumulator >> bits & 255);
112
+ accumulator &= (1 << bits) - 1;
113
+ }
114
+ }
115
+ if (bits > 0 && accumulator !== 0) return null;
116
+ return new Uint8Array(bytes);
117
+ }
118
+ function isRecord(value) {
119
+ return typeof value === "object" && value !== null && !Array.isArray(value);
120
+ }
121
+ function isValidUser(value) {
122
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.email === "string" && value.email.length > 0 && (value.role === void 0 || value.role === null || typeof value.role === "string");
123
+ }
124
+ function isStringArray(value) {
125
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
126
+ }
127
+ var DEFAULT_SESSION_MAX_AGE_SECONDS, MAX_CLOCK_SKEW_MS, HMAC_SHA256_BASE64URL_LENGTH, BASE64URL_ALPHABET;
128
+ var init_sessionCookie = __esm({
129
+ "src/client/sessionCookie.ts"() {
130
+ DEFAULT_SESSION_MAX_AGE_SECONDS = 300;
131
+ MAX_CLOCK_SKEW_MS = 3e4;
132
+ HMAC_SHA256_BASE64URL_LENGTH = 43;
133
+ BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
134
+ __name(resolveSessionSecret, "resolveSessionSecret");
135
+ __name(parseSessionCookiePayload, "parseSessionCookiePayload");
136
+ __name(verifySessionCookie, "verifySessionCookie");
137
+ __name(readCookieValue, "readCookieValue");
138
+ __name(verifyHmac, "verifyHmac");
139
+ __name(cookieValueCandidates, "cookieValueCandidates");
140
+ __name(decodeBase64Url, "decodeBase64Url");
141
+ __name(isRecord, "isRecord");
142
+ __name(isValidUser, "isValidUser");
143
+ __name(isStringArray, "isStringArray");
144
+ }
145
+ });
146
+
12
147
  // src/client/server/getSession.ts
13
148
  var getSession_exports = {};
14
149
  __export(getSession_exports, {
@@ -17,45 +152,12 @@ __export(getSession_exports, {
17
152
  NoSessionError: () => NoSessionError,
18
153
  getSession: () => getSession
19
154
  });
20
- import { createHmac, timingSafeEqual } from "crypto";
21
155
  function defaultBaseURL() {
22
156
  const explicit = typeof process !== "undefined" ? process.env.NAJM_AUTH_BASE_URL : void 0;
23
157
  if (explicit) return explicit;
24
158
  const origin = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? 3e3}` : "http://localhost:3000";
25
159
  return `${origin.replace(/\/$/, "")}/api`;
26
160
  }
27
- function getSessionSecret(config) {
28
- return config.sessionSecret ?? (typeof process !== "undefined" ? process.env.NAJM_SESSION_SECRET : void 0) ?? (typeof process !== "undefined" ? process.env.JWT_ACCESS_SECRET : void 0);
29
- }
30
- function verifyHmac(payload, signature, secret) {
31
- const expected = createHmac("sha256", secret).update(payload).digest("base64url");
32
- if (expected.length !== signature.length) return false;
33
- try {
34
- return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
35
- } catch {
36
- return false;
37
- }
38
- }
39
- function parseSessionCookie(raw, secret) {
40
- const lastDot = raw.lastIndexOf(".");
41
- if (lastDot === -1) return null;
42
- const payload = raw.substring(0, lastDot);
43
- const signature = raw.substring(lastDot + 1);
44
- if (!verifyHmac(payload, signature, secret)) return null;
45
- try {
46
- const decoded = decodeURIComponent(payload);
47
- const data = JSON.parse(decoded);
48
- if (Date.now() - data.iat > SESSION_COOKIE_MAX_AGE_MS) return null;
49
- const { user, roles, permissions } = data;
50
- return {
51
- user,
52
- roles: roles ?? (user.role ? [user.role] : void 0),
53
- permissions: permissions ?? void 0
54
- };
55
- } catch {
56
- return null;
57
- }
58
- }
59
161
  function buildSession(body) {
60
162
  if (!body?.data) return null;
61
163
  const { roles, permissions, ...user } = body.data;
@@ -88,11 +190,24 @@ async function getSession(config = {}) {
88
190
  return null;
89
191
  }
90
192
  if (sessionCookieValue) {
91
- const secret = getSessionSecret(config);
92
- if (secret) {
93
- const session = parseSessionCookie(sessionCookieValue, secret);
94
- if (session) return session;
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
+ }
198
+ const claims = await verifySessionCookie(sessionCookieValue, {
199
+ secret,
200
+ maxAgeSeconds: config.sessionMaxAge
201
+ });
202
+ if (!claims) {
203
+ if (strict) throw new NoSessionError("Invalid or expired session cookie");
204
+ return null;
95
205
  }
206
+ return {
207
+ user: claims.user,
208
+ roles: claims.roles,
209
+ permissions: claims.permissions
210
+ };
96
211
  }
97
212
  if (!hasRefreshCookie) {
98
213
  if (strict) throw new NoSessionError("No refresh token cookie");
@@ -121,9 +236,10 @@ async function getSession(config = {}) {
121
236
  return null;
122
237
  }
123
238
  }
124
- var NoSessionError, AuthConfigError, AuthTransportError, SESSION_COOKIE_MAX_AGE_MS;
239
+ var NoSessionError, AuthConfigError, AuthTransportError;
125
240
  var init_getSession = __esm({
126
241
  "src/client/server/getSession.ts"() {
242
+ init_sessionCookie();
127
243
  NoSessionError = class extends Error {
128
244
  static {
129
245
  __name(this, "NoSessionError");
@@ -156,10 +272,6 @@ var init_getSession = __esm({
156
272
  code = "AUTH_TRANSPORT_ERROR";
157
273
  };
158
274
  __name(defaultBaseURL, "defaultBaseURL");
159
- __name(getSessionSecret, "getSessionSecret");
160
- __name(verifyHmac, "verifyHmac");
161
- SESSION_COOKIE_MAX_AGE_MS = 3e5;
162
- __name(parseSessionCookie, "parseSessionCookie");
163
275
  __name(buildSession, "buildSession");
164
276
  __name(getSession, "getSession");
165
277
  }
@@ -355,6 +467,7 @@ function createServerClient(config) {
355
467
  __name(createServerClient, "createServerClient");
356
468
 
357
469
  // src/client/server/withAuthMiddleware.ts
470
+ init_sessionCookie();
358
471
  function withAuthMiddleware(config) {
359
472
  const {
360
473
  protectedRoutes = [],
@@ -363,8 +476,11 @@ function withAuthMiddleware(config) {
363
476
  roleRoutes = {},
364
477
  cookieName = "refreshToken",
365
478
  sessionCookieName = "najm.session",
479
+ sessionSecret,
480
+ sessionMaxAge,
366
481
  verifyAlways = false
367
482
  } = config;
483
+ void verifyAlways;
368
484
  return /* @__PURE__ */ __name(async function middleware(request) {
369
485
  const { NextResponse } = await import("next/server");
370
486
  const redirectToLogin = /* @__PURE__ */ __name((pathname2, clearCookies) => {
@@ -385,31 +501,21 @@ function withAuthMiddleware(config) {
385
501
  const isProtected = protectedRoutes.length === 0 || matchesAny(pathname, protectedRoutes);
386
502
  if (!isProtected) return NextResponse.next();
387
503
  const cookie = request.headers.get("cookie") ?? "";
388
- const hasToken = cookieRegex(cookieName).test(cookie);
389
- if (!hasToken) {
504
+ const sessionCookie = readCookieValue(cookie, sessionCookieName);
505
+ const secret = resolveSessionSecret(sessionSecret);
506
+ if (!sessionCookie || !secret) {
507
+ return redirectToLogin(pathname, true);
508
+ }
509
+ const session = await verifySessionCookie(sessionCookie, {
510
+ secret,
511
+ maxAgeSeconds: sessionMaxAge
512
+ });
513
+ if (!session) {
390
514
  return redirectToLogin(pathname, true);
391
515
  }
392
516
  const requiredRoles = findMatchingRoles(pathname, roleRoutes);
393
- const needsVerify = verifyAlways || !!requiredRoles;
394
- if (needsVerify) {
395
- const verifyURL = config.verifyURL ?? `${url.origin}/api/auth/me`;
396
- try {
397
- const res = await fetch(verifyURL, {
398
- headers: { "Cookie": cookie, "Accept": "application/json" }
399
- });
400
- if (!res.ok) {
401
- return redirectToLogin(pathname, true);
402
- }
403
- if (requiredRoles) {
404
- const body = await res.json();
405
- const userRole = body?.data?.role;
406
- if (!userRole || !requiredRoles.includes(userRole)) {
407
- return new NextResponse(null, { status: 403 });
408
- }
409
- }
410
- } catch {
411
- return redirectToLogin(pathname, true);
412
- }
517
+ if (requiredRoles && !session.roles.some((role) => requiredRoles.includes(role))) {
518
+ return new NextResponse(null, { status: 403 });
413
519
  }
414
520
  return NextResponse.next();
415
521
  }, "middleware");
@@ -425,10 +531,6 @@ function matchPattern(pathname, pattern) {
425
531
  return new RegExp(`^${regex}$`).test(pathname);
426
532
  }
427
533
  __name(matchPattern, "matchPattern");
428
- function cookieRegex(name) {
429
- return new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=[^;]`);
430
- }
431
- __name(cookieRegex, "cookieRegex");
432
534
  function findMatchingRoles(pathname, roleRoutes) {
433
535
  for (const [pattern, roles] of Object.entries(roleRoutes)) {
434
536
  if (matchPattern(pathname, pattern)) return roles;
@@ -935,27 +1037,6 @@ function createAuthClient(config) {
935
1037
  __name(createAuthClient, "createAuthClient");
936
1038
 
937
1039
  // src/client/server/defineAuth.ts
938
- function matchesAny2(pathname, patterns) {
939
- return patterns.some((p) => matchPattern2(pathname, p));
940
- }
941
- __name(matchesAny2, "matchesAny");
942
- function matchPattern2(pathname, pattern) {
943
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
944
- const regex = escaped.replace(/\/:[^/]+\*/g, "(?:/.*)?").replace(/\/\\\*$/g, "(?:/.*)?").replace(/\\\*/g, "(?:/.*)?").replace(/\//g, "\\/");
945
- return new RegExp(`^${regex}$`).test(pathname);
946
- }
947
- __name(matchPattern2, "matchPattern");
948
- function cookieRegex2(name) {
949
- return new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=[^;]`);
950
- }
951
- __name(cookieRegex2, "cookieRegex");
952
- function findMatchingRoles2(pathname, roleRoutes) {
953
- for (const [pattern, roles] of Object.entries(roleRoutes)) {
954
- if (matchPattern2(pathname, pattern)) return roles;
955
- }
956
- return null;
957
- }
958
- __name(findMatchingRoles2, "findMatchingRoles");
959
1040
  function defineAuth(authConfig = {}) {
960
1041
  const {
961
1042
  apiBaseURL = "/api",
@@ -967,6 +1048,7 @@ function defineAuth(authConfig = {}) {
967
1048
  cookieName = "refreshToken",
968
1049
  sessionCookieName = "najm.session",
969
1050
  sessionSecret,
1051
+ sessionMaxAge,
970
1052
  matcher = ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
971
1053
  verifyAlways = false,
972
1054
  refreshThreshold,
@@ -981,7 +1063,8 @@ function defineAuth(authConfig = {}) {
981
1063
  authPrefix,
982
1064
  cookieName,
983
1065
  sessionCookieName,
984
- sessionSecret
1066
+ sessionSecret,
1067
+ sessionMaxAge
985
1068
  };
986
1069
  let _client = null;
987
1070
  const getClient = /* @__PURE__ */ __name(() => {
@@ -1023,54 +1106,17 @@ function defineAuth(authConfig = {}) {
1023
1106
  throw err;
1024
1107
  }
1025
1108
  }, "requireSession");
1026
- const middleware = /* @__PURE__ */ __name(async (request) => {
1027
- const { NextResponse } = await import("next/server");
1028
- const url = new URL(request.url);
1029
- const pathname = url.pathname;
1030
- const redirectToLogin = /* @__PURE__ */ __name((clearCookies) => {
1031
- const loginUrl = new URL(loginRoute, request.url);
1032
- loginUrl.searchParams.set("from", pathname);
1033
- const res = NextResponse.redirect(loginUrl);
1034
- if (clearCookies) {
1035
- res.cookies.delete(cookieName);
1036
- res.cookies.delete(sessionCookieName);
1037
- }
1038
- return res;
1039
- }, "redirectToLogin");
1040
- if (matchesAny2(pathname, publicRoutes)) {
1041
- return NextResponse.next();
1042
- }
1043
- const isProtected = protectedRoutes.length === 0 || matchesAny2(pathname, protectedRoutes);
1044
- if (!isProtected) return NextResponse.next();
1045
- const cookie = request.headers.get("cookie") ?? "";
1046
- const hasToken = cookieRegex2(cookieName).test(cookie);
1047
- if (!hasToken) {
1048
- return redirectToLogin(true);
1049
- }
1050
- const requiredRoles = findMatchingRoles2(pathname, roleRoutes);
1051
- const needsVerify = verifyAlways || !!requiredRoles;
1052
- if (needsVerify) {
1053
- const verifyURL = `${url.origin}${apiBaseURL}${authPrefix}/me`;
1054
- try {
1055
- const res = await fetch(verifyURL, {
1056
- headers: { Cookie: cookie, Accept: "application/json" }
1057
- });
1058
- if (!res.ok) {
1059
- return redirectToLogin(true);
1060
- }
1061
- if (requiredRoles) {
1062
- const body = await res.json();
1063
- const userRole = body?.data?.role;
1064
- if (!userRole || !requiredRoles.includes(userRole)) {
1065
- return new NextResponse(null, { status: 403 });
1066
- }
1067
- }
1068
- } catch {
1069
- return redirectToLogin(true);
1070
- }
1071
- }
1072
- return NextResponse.next();
1073
- }, "middleware");
1109
+ const middleware = withAuthMiddleware({
1110
+ protectedRoutes,
1111
+ publicRoutes,
1112
+ loginRoute,
1113
+ roleRoutes,
1114
+ cookieName,
1115
+ sessionCookieName,
1116
+ sessionSecret,
1117
+ sessionMaxAge,
1118
+ verifyAlways
1119
+ });
1074
1120
  const protect = /* @__PURE__ */ __name((Page, options) => {
1075
1121
  return /* @__PURE__ */ __name(async function ProtectedPage(props) {
1076
1122
  const session = await getSession2();
package/dist/index.d.ts CHANGED
@@ -42,7 +42,7 @@ interface SessionCookieConfig {
42
42
  name: string;
43
43
  /** Max age in seconds (default: 300 = 5 min) */
44
44
  maxAge: number;
45
- /** Secret used to HMAC-sign the cookie. Defaults to jwt.accessSecret. */
45
+ /** HMAC secret. Falls back to NAJM_SESSION_SECRET, then jwt.accessSecret. */
46
46
  secret?: string;
47
47
  }
48
48
  type OAuthProvider = 'google';
@@ -518,7 +518,7 @@ declare class CookieManager {
518
518
  getCookieName(): string;
519
519
  /**
520
520
  * Write a signed session cookie containing user data, roles, and permissions.
521
- * The cookie is HMAC-signed with the access secret so it is tamper-proof
521
+ * The cookie is HMAC-signed with the configured session secret so it is tamper-proof
522
522
  * but readable without a database query. Short TTL (5 min) ensures freshness.
523
523
  */
524
524
  setSessionCookie(data: Omit<SessionCookieData, 'iat'>): void;
package/dist/index.js CHANGED
@@ -293,6 +293,47 @@ EncryptionService = __decorate([
293
293
  import { Service, Inject as Inject2 } from "najm-core";
294
294
  import { CookieService } from "najm-cookies";
295
295
  import timestring from "timestring";
296
+
297
+ // src/client/sessionCookie.ts
298
+ var DEFAULT_SESSION_MAX_AGE_SECONDS = 300;
299
+ var MAX_CLOCK_SKEW_MS = 3e4;
300
+ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_AGE_SECONDS, now = Date.now()) {
301
+ if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) return null;
302
+ try {
303
+ const data = JSON.parse(payload);
304
+ if (!isRecord(data) || !isValidUser(data.user)) return null;
305
+ if (!isStringArray(data.roles) || !isStringArray(data.permissions)) return null;
306
+ if (!Number.isInteger(data.sessionVersion) || data.sessionVersion < 0) return null;
307
+ if (!Number.isFinite(data.iat) || !Number.isInteger(data.iat) || data.iat <= 0) return null;
308
+ const issuedAt = data.iat;
309
+ if (issuedAt > now + MAX_CLOCK_SKEW_MS) return null;
310
+ if (now - issuedAt >= maxAgeSeconds * 1e3) return null;
311
+ return {
312
+ user: data.user,
313
+ roles: [...data.roles],
314
+ permissions: [...data.permissions],
315
+ sessionVersion: data.sessionVersion,
316
+ iat: issuedAt
317
+ };
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+ __name(parseSessionCookiePayload, "parseSessionCookiePayload");
323
+ function isRecord(value) {
324
+ return typeof value === "object" && value !== null && !Array.isArray(value);
325
+ }
326
+ __name(isRecord, "isRecord");
327
+ function isValidUser(value) {
328
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.email === "string" && value.email.length > 0 && (value.role === void 0 || value.role === null || typeof value.role === "string");
329
+ }
330
+ __name(isValidUser, "isValidUser");
331
+ function isStringArray(value) {
332
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
333
+ }
334
+ __name(isStringArray, "isStringArray");
335
+
336
+ // src/auth/CookieManager.ts
296
337
  var __decorate2 = function(decorators, target, key, desc) {
297
338
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
298
339
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -348,7 +389,7 @@ var CookieManager = class CookieManager2 {
348
389
  // =========================================================================
349
390
  /**
350
391
  * Write a signed session cookie containing user data, roles, and permissions.
351
- * The cookie is HMAC-signed with the access secret so it is tamper-proof
392
+ * The cookie is HMAC-signed with the configured session secret so it is tamper-proof
352
393
  * but readable without a database query. Short TTL (5 min) ensures freshness.
353
394
  */
354
395
  setSessionCookie(data) {
@@ -368,15 +409,7 @@ var CookieManager = class CookieManager2 {
368
409
  const raw = this.cookieService.getSigned(this.sessionCookieName, this.sessionSecret);
369
410
  if (!raw)
370
411
  return null;
371
- try {
372
- const data = JSON.parse(raw);
373
- const age = Date.now() - data.iat;
374
- if (age > this.sessionMaxAge * 1e3)
375
- return null;
376
- return data;
377
- } catch {
378
- return null;
379
- }
412
+ return parseSessionCookiePayload(raw, this.sessionMaxAge);
380
413
  }
381
414
  /**
382
415
  * Clear the session cookie (on logout, password change, etc.)
@@ -400,7 +433,7 @@ CookieManager = __decorate2([
400
433
  // src/auth/AuthController.ts
401
434
  import { Controller } from "najm-core";
402
435
  import { Get, Post, ResMsg } from "najm-core";
403
- import { Body, User as User3, Headers } from "najm-core";
436
+ import { Body, User as User2, Headers } from "najm-core";
404
437
 
405
438
  // src/auth/AuthService.ts
406
439
  import { Injectable as Injectable8, Inject as Inject8 } from "najm-core";
@@ -2402,7 +2435,7 @@ var isAuth = createGuard(AuthGuard);
2402
2435
 
2403
2436
  // src/roles/RoleGuards.ts
2404
2437
  import { Service as Service3 } from "najm-core";
2405
- import { GuardParams, User as User2 } from "najm-core";
2438
+ import { GuardParams, Role as RequestRole } from "najm-core";
2406
2439
  import { composeGuards, createGuard as createGuard2 } from "najm-guard";
2407
2440
  var __decorate14 = function(decorators, target, key, desc) {
2408
2441
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -2435,7 +2468,7 @@ var RoleGuard = class RoleGuard2 {
2435
2468
  };
2436
2469
  __decorate14([
2437
2470
  __param4(0, GuardParams()),
2438
- __param4(1, User2("role")),
2471
+ __param4(1, RequestRole()),
2439
2472
  __metadata14("design:type", Function),
2440
2473
  __metadata14("design:paramtypes", [Object, String]),
2441
2474
  __metadata14("design:returntype", void 0)
@@ -2630,7 +2663,7 @@ __decorate15([
2630
2663
  Post("/logout"),
2631
2664
  isAuth(),
2632
2665
  RateLimit({ limit: 10, window: "15m", key: "user" }),
2633
- __param5(0, User3("id")),
2666
+ __param5(0, User2("id")),
2634
2667
  __param5(1, Headers("authorization")),
2635
2668
  __metadata15("design:type", Function),
2636
2669
  __metadata15("design:paramtypes", [String, String]),
@@ -2641,7 +2674,7 @@ __decorate15([
2641
2674
  isAuth(),
2642
2675
  Validate(changePasswordDto),
2643
2676
  ResMsg("auth.success.passwordChanged"),
2644
- __param5(0, User3("id")),
2677
+ __param5(0, User2("id")),
2645
2678
  __param5(1, Body()),
2646
2679
  __metadata15("design:type", Function),
2647
2680
  __metadata15("design:paramtypes", [String, Object]),
@@ -3370,7 +3403,7 @@ PermissionRepository = __decorate19([
3370
3403
 
3371
3404
  // src/permissions/PermissionGuards.ts
3372
3405
  import { Injectable as Injectable9 } from "najm-core";
3373
- import { GuardParams as GuardParams2, User as User4 } from "najm-core";
3406
+ import { GuardParams as GuardParams2, User as User3 } from "najm-core";
3374
3407
  import { createGuard as createGuard4, composeGuards as composeGuards3 } from "najm-guard";
3375
3408
  var __decorate20 = function(decorators, target, key, desc) {
3376
3409
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -3416,7 +3449,7 @@ var PermissionGuard = class PermissionGuard2 {
3416
3449
  };
3417
3450
  __decorate20([
3418
3451
  __param8(0, GuardParams2()),
3419
- __param8(1, User4("permissions")),
3452
+ __param8(1, User3("permissions")),
3420
3453
  __metadata20("design:type", Function),
3421
3454
  __metadata20("design:paramtypes", [String, Array]),
3422
3455
  __metadata20("design:returntype", Object)
@@ -4038,7 +4071,7 @@ function own(table, opts) {
4038
4071
  __name(own, "own");
4039
4072
 
4040
4073
  // src/ownership/configureOwnership.ts
4041
- import { Injectable as Injectable12, Inject as Inject11, User as User5, Body as Body5, Params as Params4 } from "najm-core";
4074
+ import { Injectable as Injectable12, Inject as Inject11, User as User4, Body as Body5, Params as Params4 } from "najm-core";
4042
4075
  import { createGuard as createGuard5, composeGuards as composeGuards4 } from "najm-guard";
4043
4076
  var __decorate24 = function(decorators, target, key, desc) {
4044
4077
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -4083,7 +4116,7 @@ function createResourceGuards(ownershipClass, resourceType, resource, options) {
4083
4116
  __metadata24("design:type", Object)
4084
4117
  ], AccessGuard.prototype, "ownership", void 0);
4085
4118
  __decorate24([
4086
- __param10(0, User5()),
4119
+ __param10(0, User4()),
4087
4120
  __param10(1, Params4("id")),
4088
4121
  __metadata24("design:type", Function),
4089
4122
  __metadata24("design:paramtypes", [Object, String]),
@@ -4107,7 +4140,7 @@ function createResourceGuards(ownershipClass, resourceType, resource, options) {
4107
4140
  __metadata24("design:type", Object)
4108
4141
  ], ListGuard.prototype, "ownership", void 0);
4109
4142
  __decorate24([
4110
- __param10(0, User5()),
4143
+ __param10(0, User4()),
4111
4144
  __metadata24("design:type", Function),
4112
4145
  __metadata24("design:paramtypes", [Object]),
4113
4146
  __metadata24("design:returntype", typeof (_b12 = typeof Promise !== "undefined" && Promise) === "function" ? _b12 : Object)
@@ -4266,7 +4299,7 @@ function configureOwnership(config) {
4266
4299
  __metadata24("design:type", GeneratedOwnershipService)
4267
4300
  ], BodyAccessGuard.prototype, "ownership", void 0);
4268
4301
  __decorate24([
4269
- __param10(0, User5()),
4302
+ __param10(0, User4()),
4270
4303
  __param10(1, Body5()),
4271
4304
  __metadata24("design:type", Function),
4272
4305
  __metadata24("design:paramtypes", [Object, Object]),
@@ -4977,7 +5010,7 @@ OAuthAccountService = __decorate29([
4977
5010
 
4978
5011
  // src/oauth/OAuthController.ts
4979
5012
  import { createHash as createHash4 } from "crypto";
4980
- import { Controller as Controller5, Ctx, Get as Get5, Post as Post5, Query as Query2, User as User6 } from "najm-core";
5013
+ import { Controller as Controller5, Ctx, Get as Get5, Post as Post5, Query as Query2, User as User5 } from "najm-core";
4981
5014
  import { RateLimit as RateLimit2 } from "najm-rate";
4982
5015
 
4983
5016
  // src/oauth/OAuthService.ts
@@ -5284,7 +5317,7 @@ __decorate32([
5284
5317
  Post5("/link"),
5285
5318
  isAuth(),
5286
5319
  RateLimit2({ limit: 10, window: "15m", key: "user" }),
5287
- __param11(0, User6("id")),
5320
+ __param11(0, User5("id")),
5288
5321
  __param11(1, Query2("returnTo")),
5289
5322
  __metadata32("design:type", Function),
5290
5323
  __metadata32("design:paramtypes", [String, String]),
@@ -5379,8 +5412,9 @@ var resolveAuthConfig = /* @__PURE__ */ __name((config) => {
5379
5412
  session: {
5380
5413
  name: config?.session?.name ?? "najm.session",
5381
5414
  maxAge: config?.session?.maxAge ?? 300,
5382
- secret: config?.session?.secret
5383
- // fallback to jwt.accessSecret at use site
5415
+ // Keep Edge/server readers aligned with the documented secret order.
5416
+ // CookieManager falls back to jwt.accessSecret when this is undefined.
5417
+ secret: config?.session?.secret ?? process.env.NAJM_SESSION_SECRET
5384
5418
  },
5385
5419
  oauth: {
5386
5420
  google: resolveGoogleConfig(config)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [
@@ -76,8 +76,8 @@
76
76
  "dependencies": {
77
77
  "bcryptjs": "^3.0.2",
78
78
  "najm-cookies": "^2.0.2",
79
- "najm-core": "^2.0.3",
80
- "najm-database": "^2.0.2",
79
+ "najm-core": "^2.0.4",
80
+ "najm-database": "^2.0.3",
81
81
  "najm-guard": "^2.0.2",
82
82
  "najm-i18n": "^2.0.2",
83
83
  "najm-cache": "^2.0.2",