najm-auth 2.0.3 → 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.
- package/dist/client/edge.d.ts +9 -6
- package/dist/client/edge.js +145 -26
- package/dist/client/server/index.d.ts +9 -3
- package/dist/client/server/index.js +184 -138
- package/dist/index.d.ts +2 -2
- package/dist/index.js +46 -12
- package/package.json +1 -1
package/dist/client/edge.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
14
|
+
/** Signed session cookie name (default: 'najm.session') */
|
|
15
15
|
sessionCookieName?: string;
|
|
16
|
-
/**
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
34
|
+
* import { withAuthMiddleware } from 'najm-auth/client/edge';
|
|
32
35
|
*
|
|
33
36
|
* export default withAuthMiddleware({
|
|
34
37
|
* protectedRoutes: ['/dashboard/:path*', '/admin/:path*'],
|
package/dist/client/edge.js
CHANGED
|
@@ -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
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
*
|
|
200
|
-
*
|
|
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 =
|
|
92
|
-
if (secret) {
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
389
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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 =
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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.)
|
|
@@ -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
|
|
5383
|
-
//
|
|
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)
|