najm-auth 2.0.3 → 2.0.5
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 +8 -2
- package/dist/client/edge.d.ts +19 -6
- package/dist/client/edge.js +281 -30
- package/dist/client/server/index.d.ts +27 -40
- package/dist/client/server/index.js +359 -182
- package/dist/index.d.ts +31 -2
- package/dist/index.js +134 -30
- package/package.json +1 -1
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,12 @@ 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
|
|
679
|
-
|
|
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
|
+
- `verifyAlways` forces that authoritative check on every protected request;
|
|
685
|
+
the default bounds cached role/status staleness to `session.maxAge`.
|
|
680
686
|
|
|
681
687
|
### Password Reset Tokens
|
|
682
688
|
|
package/dist/client/edge.d.ts
CHANGED
|
@@ -11,16 +11,29 @@ interface AuthMiddlewareConfig {
|
|
|
11
11
|
roleRoutes?: Record<string, string[]>;
|
|
12
12
|
/** Refresh token cookie name (default: 'refreshToken') */
|
|
13
13
|
cookieName?: string;
|
|
14
|
-
/**
|
|
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;
|
|
18
|
+
/** Signed session cookie name (default: 'najm.session') */
|
|
15
19
|
sessionCookieName?: string;
|
|
16
|
-
/**
|
|
20
|
+
/** @deprecated Session cookies are verified locally at the Edge. */
|
|
17
21
|
verifyURL?: string;
|
|
22
|
+
/** Secret for verifying the session cookie HMAC. Falls back to env vars. */
|
|
23
|
+
sessionSecret?: string;
|
|
24
|
+
/** Must match the auth plugin's session.maxAge. Default: 300 seconds. */
|
|
25
|
+
sessionMaxAge?: number;
|
|
18
26
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* fetch per navigation — trade latency for stronger guarantees.
|
|
27
|
+
* Force authoritative refresh-session validation on every protected request.
|
|
28
|
+
* This reissues the signed session cookie without rotating refresh tokens.
|
|
22
29
|
*/
|
|
23
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;
|
|
24
37
|
}
|
|
25
38
|
/**
|
|
26
39
|
* Create a Next.js middleware function that protects routes based on auth state.
|
|
@@ -28,7 +41,7 @@ interface AuthMiddlewareConfig {
|
|
|
28
41
|
* @example
|
|
29
42
|
* ```ts
|
|
30
43
|
* // middleware.ts
|
|
31
|
-
|
|
44
|
+
* import { withAuthMiddleware } from 'najm-auth/client/edge';
|
|
32
45
|
*
|
|
33
46
|
* export default withAuthMiddleware({
|
|
34
47
|
* protectedRoutes: ['/dashboard/:path*', '/admin/:path*'],
|
package/dist/client/edge.js
CHANGED
|
@@ -1,6 +1,229 @@
|
|
|
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
|
+
|
|
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
|
+
if (!isSafeRecoveryEndpoint(options.endpoint)) {
|
|
143
|
+
return { status: "unavailable" };
|
|
144
|
+
}
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
response = await fetch(options.endpoint, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: {
|
|
150
|
+
Accept: "application/json",
|
|
151
|
+
Cookie: `${options.refreshCookieName}=${options.refreshCookieValue}`,
|
|
152
|
+
"X-Najm-Session-Recovery": "1"
|
|
153
|
+
},
|
|
154
|
+
cache: "no-store",
|
|
155
|
+
redirect: "manual"
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
return { status: "unavailable" };
|
|
159
|
+
}
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
const status = response.status;
|
|
162
|
+
return {
|
|
163
|
+
status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
|
|
164
|
+
httpStatus: status
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const setCookie = response.headers.get("set-cookie");
|
|
168
|
+
if (!setCookie) return { status: "unavailable", httpStatus: response.status };
|
|
169
|
+
const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
|
|
170
|
+
if (!sessionCookieValue) {
|
|
171
|
+
return { status: "unavailable", httpStatus: response.status };
|
|
172
|
+
}
|
|
173
|
+
const claims = await verifySessionCookie(sessionCookieValue, {
|
|
174
|
+
secret: options.sessionSecret,
|
|
175
|
+
maxAgeSeconds: options.sessionMaxAge
|
|
176
|
+
});
|
|
177
|
+
if (!claims) return { status: "unavailable", httpStatus: response.status };
|
|
178
|
+
return {
|
|
179
|
+
status: "recovered",
|
|
180
|
+
claims,
|
|
181
|
+
setCookie,
|
|
182
|
+
sessionCookieValue
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
__name(requestSessionRecovery, "requestSessionRecovery");
|
|
186
|
+
function authEndpoint(baseURL, authPrefix, suffix, requestURL) {
|
|
187
|
+
const normalizedBase = baseURL.replace(/\/+$/, "");
|
|
188
|
+
const normalizedPrefix = `/${authPrefix.replace(/^\/+|\/+$/g, "")}`;
|
|
189
|
+
const normalizedSuffix = `/${suffix.replace(/^\/+/, "")}`;
|
|
190
|
+
const value = `${normalizedBase}${normalizedPrefix}${normalizedSuffix}`;
|
|
191
|
+
return requestURL ? new URL(value, requestURL).toString() : value;
|
|
192
|
+
}
|
|
193
|
+
__name(authEndpoint, "authEndpoint");
|
|
194
|
+
function replaceCookieValue(cookieHeader, name, value) {
|
|
195
|
+
const parts = cookieHeader.split(";").map((part) => part.trim()).filter(Boolean).filter((part) => part.slice(0, part.indexOf("=")).trim() !== name);
|
|
196
|
+
parts.push(`${name}=${value}`);
|
|
197
|
+
return parts.join("; ");
|
|
198
|
+
}
|
|
199
|
+
__name(replaceCookieValue, "replaceCookieValue");
|
|
200
|
+
function readSetCookieValue(setCookie, name) {
|
|
201
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
202
|
+
const match = setCookie.match(new RegExp(`(?:^|,\\s*)${escaped}=([^;]*)`));
|
|
203
|
+
return match?.[1] || void 0;
|
|
204
|
+
}
|
|
205
|
+
__name(readSetCookieValue, "readSetCookieValue");
|
|
206
|
+
function isCookieName(value) {
|
|
207
|
+
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value);
|
|
208
|
+
}
|
|
209
|
+
__name(isCookieName, "isCookieName");
|
|
210
|
+
function isCookieValue(value) {
|
|
211
|
+
return value.length > 0 && !/[\r\n;]/.test(value);
|
|
212
|
+
}
|
|
213
|
+
__name(isCookieValue, "isCookieValue");
|
|
214
|
+
function isSafeRecoveryEndpoint(value) {
|
|
215
|
+
try {
|
|
216
|
+
const url = new URL(value);
|
|
217
|
+
if (url.username || url.password) return false;
|
|
218
|
+
if (url.protocol === "https:") return true;
|
|
219
|
+
if (url.protocol !== "http:") return false;
|
|
220
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
__name(isSafeRecoveryEndpoint, "isSafeRecoveryEndpoint");
|
|
226
|
+
|
|
4
227
|
// src/client/server/withAuthMiddleware.ts
|
|
5
228
|
function withAuthMiddleware(config) {
|
|
6
229
|
const {
|
|
@@ -9,54 +232,86 @@ function withAuthMiddleware(config) {
|
|
|
9
232
|
loginRoute = "/login",
|
|
10
233
|
roleRoutes = {},
|
|
11
234
|
cookieName = "refreshToken",
|
|
235
|
+
apiBaseURL = "/api",
|
|
236
|
+
authPrefix = "/auth",
|
|
12
237
|
sessionCookieName = "najm.session",
|
|
13
|
-
|
|
238
|
+
sessionSecret,
|
|
239
|
+
sessionMaxAge,
|
|
240
|
+
verifyAlways = false,
|
|
241
|
+
recoveryURL
|
|
14
242
|
} = config;
|
|
15
243
|
return /* @__PURE__ */ __name(async function middleware(request) {
|
|
16
244
|
const { NextResponse } = await import("next/server");
|
|
17
|
-
const redirectToLogin = /* @__PURE__ */ __name((
|
|
245
|
+
const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
|
|
18
246
|
const loginUrl = new URL(loginRoute, request.url);
|
|
19
|
-
loginUrl.searchParams.set("from",
|
|
247
|
+
loginUrl.searchParams.set("from", returnPath2);
|
|
20
248
|
const res = NextResponse.redirect(loginUrl);
|
|
21
|
-
if (clearCookies) {
|
|
249
|
+
if (clearCookies.includes("refresh")) {
|
|
22
250
|
res.cookies.delete(cookieName);
|
|
251
|
+
}
|
|
252
|
+
if (clearCookies.includes("session")) {
|
|
23
253
|
res.cookies.delete(sessionCookieName);
|
|
24
254
|
}
|
|
25
255
|
return res;
|
|
26
256
|
}, "redirectToLogin");
|
|
27
257
|
const url = new URL(request.url);
|
|
28
258
|
const pathname = url.pathname;
|
|
259
|
+
const returnPath = `${url.pathname}${url.search}`;
|
|
29
260
|
if (matchesAny(pathname, publicRoutes)) {
|
|
30
261
|
return NextResponse.next();
|
|
31
262
|
}
|
|
32
263
|
const isProtected = protectedRoutes.length === 0 || matchesAny(pathname, protectedRoutes);
|
|
33
264
|
if (!isProtected) return NextResponse.next();
|
|
34
265
|
const cookie = request.headers.get("cookie") ?? "";
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
266
|
+
const sessionCookie = readCookieValue(cookie, sessionCookieName);
|
|
267
|
+
const secret = resolveSessionSecret(sessionSecret);
|
|
268
|
+
if (!secret) {
|
|
269
|
+
return redirectToLogin(returnPath, ["session"]);
|
|
270
|
+
}
|
|
271
|
+
let session = sessionCookie ? await verifySessionCookie(sessionCookie, {
|
|
272
|
+
secret,
|
|
273
|
+
maxAgeSeconds: sessionMaxAge
|
|
274
|
+
}) : null;
|
|
275
|
+
let recovery = null;
|
|
276
|
+
if (!session || verifyAlways) {
|
|
277
|
+
const refreshCookie = readCookieValue(cookie, cookieName);
|
|
278
|
+
if (!refreshCookie || recoveryURL === false) {
|
|
279
|
+
return redirectToLogin(returnPath, ["refresh", "session"]);
|
|
280
|
+
}
|
|
281
|
+
const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
|
|
282
|
+
recovery = await requestSessionRecovery({
|
|
283
|
+
endpoint,
|
|
284
|
+
refreshCookieName: cookieName,
|
|
285
|
+
refreshCookieValue: refreshCookie,
|
|
286
|
+
sessionCookieName,
|
|
287
|
+
sessionSecret: secret,
|
|
288
|
+
sessionMaxAge
|
|
289
|
+
});
|
|
290
|
+
if (recovery.status !== "recovered") {
|
|
291
|
+
return redirectToLogin(
|
|
292
|
+
returnPath,
|
|
293
|
+
recovery.status === "invalid" ? ["refresh", "session"] : ["session"]
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
session = recovery.claims;
|
|
38
297
|
}
|
|
39
298
|
const requiredRoles = findMatchingRoles(pathname, roleRoutes);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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);
|
|
299
|
+
if (requiredRoles && !session.roles.some((role) => requiredRoles.includes(role))) {
|
|
300
|
+
const forbidden = new NextResponse(null, { status: 403 });
|
|
301
|
+
if (recovery?.status === "recovered") {
|
|
302
|
+
forbidden.headers.append("Set-Cookie", recovery.setCookie);
|
|
59
303
|
}
|
|
304
|
+
return forbidden;
|
|
305
|
+
}
|
|
306
|
+
if (recovery?.status === "recovered") {
|
|
307
|
+
const requestHeaders = new Headers(request.headers);
|
|
308
|
+
requestHeaders.set(
|
|
309
|
+
"cookie",
|
|
310
|
+
replaceCookieValue(cookie, sessionCookieName, recovery.sessionCookieValue)
|
|
311
|
+
);
|
|
312
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
313
|
+
response.headers.append("Set-Cookie", recovery.setCookie);
|
|
314
|
+
return response;
|
|
60
315
|
}
|
|
61
316
|
return NextResponse.next();
|
|
62
317
|
}, "middleware");
|
|
@@ -72,10 +327,6 @@ function matchPattern(pathname, pattern) {
|
|
|
72
327
|
return new RegExp(`^${regex}$`).test(pathname);
|
|
73
328
|
}
|
|
74
329
|
__name(matchPattern, "matchPattern");
|
|
75
|
-
function cookieRegex(name) {
|
|
76
|
-
return new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=[^;]`);
|
|
77
|
-
}
|
|
78
|
-
__name(cookieRegex, "cookieRegex");
|
|
79
330
|
function findMatchingRoles(pathname, roleRoutes) {
|
|
80
331
|
for (const [pattern, roles] of Object.entries(roleRoutes)) {
|
|
81
332
|
if (matchPattern(pathname, pattern)) return roles;
|
|
@@ -75,27 +75,27 @@ interface GetSessionConfig {
|
|
|
75
75
|
* Defaults to `${NEXT_PUBLIC_API_URL || http://localhost:${PORT||3000}}/api`.
|
|
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;
|
|
89
|
+
/**
|
|
90
|
+
* Maximum accepted session-cookie age in seconds.
|
|
91
|
+
* Must match the auth plugin's `session.maxAge`. Default: 300.
|
|
92
|
+
*/
|
|
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;
|
|
99
99
|
/**
|
|
100
100
|
* Error handling mode:
|
|
101
101
|
* - 'nullable' (default): returns null on any failure
|
|
@@ -117,29 +117,10 @@ declare class AuthTransportError extends Error {
|
|
|
117
117
|
constructor(message: string, status?: number);
|
|
118
118
|
}
|
|
119
119
|
/**
|
|
120
|
-
* Resolve
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* returns the session instantly with zero network calls.
|
|
125
|
-
*
|
|
126
|
-
* **Fallback**: Checks the refresh token cookie and calls `/auth/me`.
|
|
127
|
-
*
|
|
128
|
-
* @example
|
|
129
|
-
* ```tsx
|
|
130
|
-
* import { getSession } from 'najm-auth/client/server';
|
|
131
|
-
*
|
|
132
|
-
* export default async function RootLayout({ children }) {
|
|
133
|
-
* const session = await getSession();
|
|
134
|
-
* return (
|
|
135
|
-
* <html><body>
|
|
136
|
-
* <AuthProvider client={authClient} initialSession={session}>
|
|
137
|
-
* {children}
|
|
138
|
-
* </AuthProvider>
|
|
139
|
-
* </body></html>
|
|
140
|
-
* );
|
|
141
|
-
* }
|
|
142
|
-
* ```
|
|
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.
|
|
143
124
|
*/
|
|
144
125
|
declare function getSession(config?: GetSessionConfig): Promise<ServerSession | null>;
|
|
145
126
|
|
|
@@ -193,12 +174,18 @@ interface DefineAuthConfig {
|
|
|
193
174
|
sessionCookieName?: string;
|
|
194
175
|
/** Secret for verifying session cookie HMAC. Falls back to env vars. */
|
|
195
176
|
sessionSecret?: string;
|
|
177
|
+
/** Must match the auth plugin's session.maxAge. Default: 300 seconds. */
|
|
178
|
+
sessionMaxAge?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Session-recovery endpoint. Defaults to
|
|
181
|
+
* `${apiBaseURL}${authPrefix}/session/recover`; false disables recovery.
|
|
182
|
+
*/
|
|
183
|
+
recoveryURL?: string | false;
|
|
196
184
|
/** Next.js middleware matcher (default: exclude _next, favicon, api) */
|
|
197
185
|
matcher?: string[];
|
|
198
186
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* fetch per navigation in exchange for guaranteed fresh auth state.
|
|
187
|
+
* Force authoritative refresh-session validation on every protected request.
|
|
188
|
+
* Recovery reissues the signed cookie without rotating refresh tokens.
|
|
202
189
|
*/
|
|
203
190
|
verifyAlways?: boolean;
|
|
204
191
|
}
|
|
@@ -212,7 +199,7 @@ interface AuthKit {
|
|
|
212
199
|
readonly client: NajmAuthClient;
|
|
213
200
|
/** Shortcut for `client.api` — the underlying FetchClient with auth attached. */
|
|
214
201
|
readonly api: FetchClient;
|
|
215
|
-
/** Resolve session —
|
|
202
|
+
/** Resolve session — signed-cookie first, then non-rotating recovery. */
|
|
216
203
|
getSession: (opts?: Pick<GetSessionConfig, 'mode'>) => Promise<ServerSession | null>;
|
|
217
204
|
/** Require session — throws if unauthenticated */
|
|
218
205
|
requireSession: () => Promise<ServerSession>;
|