najm-auth 2.0.6 → 2.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/client/edge.d.ts +26 -1
- package/dist/client/edge.js +113 -16
- package/dist/client/server/index.d.ts +11 -2
- package/dist/client/server/index.js +126 -20
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -684,6 +684,10 @@ throw new HttpError(403, 'Insufficient permissions for this action');
|
|
|
684
684
|
- Server-side recovery sends only the configured refresh cookie and accepts
|
|
685
685
|
relative or exact same-origin endpoints. URL credentials and any
|
|
686
686
|
scheme/hostname/port change are rejected before the network request.
|
|
687
|
+
- Self-hosted apps may explicitly use a loopback-only `internalRecoveryURL`
|
|
688
|
+
when their public reverse-proxy origin is not reachable from the app process.
|
|
689
|
+
- `onRecoveryFailure` exposes structured, secret-free recovery diagnostics
|
|
690
|
+
without logging anything by default.
|
|
687
691
|
- `verifyAlways` forces that authoritative check on every protected request;
|
|
688
692
|
the default bounds cached role/status staleness to `session.maxAge`.
|
|
689
693
|
|
package/dist/client/edge.d.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import * as next_server from 'next/server';
|
|
2
2
|
|
|
3
|
+
type SessionRecoveryFailureReason = 'invalid-cookie-name' | 'invalid-refresh-cookie' | 'invalid-endpoint' | 'fetch-error' | 'http-status' | 'missing-set-cookie' | 'session-cookie-parse' | 'session-cookie-hmac' | 'session-cookie-payload';
|
|
4
|
+
interface SessionRecoveryErrorDetails {
|
|
5
|
+
name: string;
|
|
6
|
+
message: string;
|
|
7
|
+
code?: string;
|
|
8
|
+
cause?: {
|
|
9
|
+
name: string;
|
|
10
|
+
message: string;
|
|
11
|
+
code?: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
interface SessionRecoveryFailure {
|
|
15
|
+
reason: SessionRecoveryFailureReason;
|
|
16
|
+
httpStatus?: number;
|
|
17
|
+
error?: SessionRecoveryErrorDetails;
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
interface AuthMiddlewareConfig {
|
|
4
21
|
/** Routes that require authentication (glob patterns) */
|
|
5
22
|
protectedRoutes?: string[];
|
|
@@ -34,6 +51,14 @@ interface AuthMiddlewareConfig {
|
|
|
34
51
|
* Set to false to disable automatic recovery.
|
|
35
52
|
*/
|
|
36
53
|
recoveryURL?: string | false;
|
|
54
|
+
/**
|
|
55
|
+
* Optional loopback-only endpoint for self-hosted apps whose public origin
|
|
56
|
+
* cannot be reached from the app container. Takes precedence over
|
|
57
|
+
* `recoveryURL` for the server-side recovery request.
|
|
58
|
+
*/
|
|
59
|
+
internalRecoveryURL?: string;
|
|
60
|
+
/** Secret-free diagnostic hook for failed authoritative recovery attempts. */
|
|
61
|
+
onRecoveryFailure?: (failure: SessionRecoveryFailure) => void;
|
|
37
62
|
}
|
|
38
63
|
/**
|
|
39
64
|
* Create a Next.js middleware function that protects routes based on auth state.
|
|
@@ -57,4 +82,4 @@ interface AuthMiddlewareConfig {
|
|
|
57
82
|
*/
|
|
58
83
|
declare function withAuthMiddleware(config: AuthMiddlewareConfig): (request: Request) => Promise<next_server.NextResponse<unknown>>;
|
|
59
84
|
|
|
60
|
-
export { type AuthMiddlewareConfig, withAuthMiddleware };
|
|
85
|
+
export { type AuthMiddlewareConfig, type SessionRecoveryErrorDetails, type SessionRecoveryFailure, type SessionRecoveryFailureReason, withAuthMiddleware };
|
package/dist/client/edge.js
CHANGED
|
@@ -36,22 +36,36 @@ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_
|
|
|
36
36
|
}
|
|
37
37
|
__name(parseSessionCookiePayload, "parseSessionCookiePayload");
|
|
38
38
|
async function verifySessionCookie(rawCookieValue, options) {
|
|
39
|
-
|
|
39
|
+
const result = await verifySessionCookieDetailed(rawCookieValue, options);
|
|
40
|
+
return result.status === "valid" ? result.claims : null;
|
|
41
|
+
}
|
|
42
|
+
__name(verifySessionCookie, "verifySessionCookie");
|
|
43
|
+
async function verifySessionCookieDetailed(rawCookieValue, options) {
|
|
44
|
+
if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) {
|
|
45
|
+
return { status: "invalid", reason: "format" };
|
|
46
|
+
}
|
|
47
|
+
let sawSignedFormat = false;
|
|
48
|
+
let sawValidHmac = false;
|
|
40
49
|
for (const signedValue of cookieValueCandidates(rawCookieValue)) {
|
|
41
50
|
const lastDot = signedValue.lastIndexOf(".");
|
|
42
51
|
if (lastDot <= 0 || lastDot === signedValue.length - 1) continue;
|
|
52
|
+
sawSignedFormat = true;
|
|
43
53
|
const payload = signedValue.slice(0, lastDot);
|
|
44
54
|
const signature = signedValue.slice(lastDot + 1);
|
|
45
55
|
if (!await verifyHmac(payload, signature, options.secret)) continue;
|
|
46
|
-
|
|
56
|
+
sawValidHmac = true;
|
|
57
|
+
const claims = parseSessionCookiePayload(
|
|
47
58
|
payload,
|
|
48
59
|
options.maxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
|
|
49
60
|
options.now
|
|
50
61
|
);
|
|
62
|
+
if (claims) return { status: "valid", claims };
|
|
51
63
|
}
|
|
52
|
-
return
|
|
64
|
+
if (sawValidHmac) return { status: "invalid", reason: "payload" };
|
|
65
|
+
if (sawSignedFormat) return { status: "invalid", reason: "hmac" };
|
|
66
|
+
return { status: "invalid", reason: "format" };
|
|
53
67
|
}
|
|
54
|
-
__name(
|
|
68
|
+
__name(verifySessionCookieDetailed, "verifySessionCookieDetailed");
|
|
55
69
|
function readCookieValue(cookieHeader, name) {
|
|
56
70
|
for (const part of cookieHeader.split(";")) {
|
|
57
71
|
const separator = part.indexOf("=");
|
|
@@ -132,18 +146,28 @@ function isStringArray(value) {
|
|
|
132
146
|
__name(isStringArray, "isStringArray");
|
|
133
147
|
|
|
134
148
|
// src/client/sessionRecovery.ts
|
|
149
|
+
function resolveInternalRecoveryURL(explicit) {
|
|
150
|
+
if (explicit !== void 0) return explicit || void 0;
|
|
151
|
+
if (typeof process === "undefined") return void 0;
|
|
152
|
+
return process.env.NAJM_AUTH_INTERNAL_URL || void 0;
|
|
153
|
+
}
|
|
154
|
+
__name(resolveInternalRecoveryURL, "resolveInternalRecoveryURL");
|
|
135
155
|
async function requestSessionRecovery(options) {
|
|
136
156
|
if (!isCookieName(options.refreshCookieName) || !isCookieName(options.sessionCookieName)) {
|
|
157
|
+
reportFailure(options, { reason: "invalid-cookie-name" });
|
|
137
158
|
return { status: "unavailable" };
|
|
138
159
|
}
|
|
139
160
|
if (!isCookieValue(options.refreshCookieValue)) {
|
|
161
|
+
reportFailure(options, { reason: "invalid-refresh-cookie" });
|
|
140
162
|
return { status: "invalid" };
|
|
141
163
|
}
|
|
142
164
|
const endpoint = sameOriginRecoveryEndpoint(
|
|
143
165
|
options.endpoint,
|
|
144
|
-
options.requestOrigin
|
|
166
|
+
options.requestOrigin,
|
|
167
|
+
options.allowLoopbackEndpoint
|
|
145
168
|
);
|
|
146
169
|
if (!endpoint) {
|
|
170
|
+
reportFailure(options, { reason: "invalid-endpoint" });
|
|
147
171
|
return { status: "unavailable" };
|
|
148
172
|
}
|
|
149
173
|
let response;
|
|
@@ -158,30 +182,51 @@ async function requestSessionRecovery(options) {
|
|
|
158
182
|
cache: "no-store",
|
|
159
183
|
redirect: "manual"
|
|
160
184
|
});
|
|
161
|
-
} catch {
|
|
185
|
+
} catch (error) {
|
|
186
|
+
reportFailure(options, {
|
|
187
|
+
reason: "fetch-error",
|
|
188
|
+
error: safeErrorDetails(error)
|
|
189
|
+
});
|
|
162
190
|
return { status: "unavailable" };
|
|
163
191
|
}
|
|
164
192
|
if (!response.ok) {
|
|
165
193
|
const status = response.status;
|
|
194
|
+
reportFailure(options, { reason: "http-status", httpStatus: status });
|
|
166
195
|
return {
|
|
167
196
|
status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
|
|
168
197
|
httpStatus: status
|
|
169
198
|
};
|
|
170
199
|
}
|
|
171
200
|
const setCookie = response.headers.get("set-cookie");
|
|
172
|
-
if (!setCookie)
|
|
201
|
+
if (!setCookie) {
|
|
202
|
+
reportFailure(options, {
|
|
203
|
+
reason: "missing-set-cookie",
|
|
204
|
+
httpStatus: response.status
|
|
205
|
+
});
|
|
206
|
+
return { status: "unavailable", httpStatus: response.status };
|
|
207
|
+
}
|
|
173
208
|
const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
|
|
174
209
|
if (!sessionCookieValue) {
|
|
210
|
+
reportFailure(options, {
|
|
211
|
+
reason: "session-cookie-parse",
|
|
212
|
+
httpStatus: response.status
|
|
213
|
+
});
|
|
175
214
|
return { status: "unavailable", httpStatus: response.status };
|
|
176
215
|
}
|
|
177
|
-
const
|
|
216
|
+
const verification = await verifySessionCookieDetailed(sessionCookieValue, {
|
|
178
217
|
secret: options.sessionSecret,
|
|
179
218
|
maxAgeSeconds: options.sessionMaxAge
|
|
180
219
|
});
|
|
181
|
-
if (
|
|
220
|
+
if (verification.status === "invalid") {
|
|
221
|
+
reportFailure(options, {
|
|
222
|
+
reason: verification.reason === "hmac" ? "session-cookie-hmac" : verification.reason === "payload" ? "session-cookie-payload" : "session-cookie-parse",
|
|
223
|
+
httpStatus: response.status
|
|
224
|
+
});
|
|
225
|
+
return { status: "unavailable", httpStatus: response.status };
|
|
226
|
+
}
|
|
182
227
|
return {
|
|
183
228
|
status: "recovered",
|
|
184
|
-
claims,
|
|
229
|
+
claims: verification.claims,
|
|
185
230
|
setCookie,
|
|
186
231
|
sessionCookieValue
|
|
187
232
|
};
|
|
@@ -215,7 +260,7 @@ function isCookieValue(value) {
|
|
|
215
260
|
return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
|
|
216
261
|
}
|
|
217
262
|
__name(isCookieValue, "isCookieValue");
|
|
218
|
-
function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
|
|
263
|
+
function sameOriginRecoveryEndpoint(endpoint, requestOrigin, allowLoopback = false) {
|
|
219
264
|
try {
|
|
220
265
|
const trusted = new URL(requestOrigin);
|
|
221
266
|
if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
|
|
@@ -223,13 +268,60 @@ function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
|
|
|
223
268
|
}
|
|
224
269
|
const resolved = new URL(endpoint, trusted.origin);
|
|
225
270
|
if (resolved.username || resolved.password) return void 0;
|
|
226
|
-
if (resolved.origin !== trusted.origin
|
|
271
|
+
if (resolved.origin !== trusted.origin && (!allowLoopback || !isLoopbackURL(resolved))) {
|
|
272
|
+
return void 0;
|
|
273
|
+
}
|
|
227
274
|
return resolved.toString();
|
|
228
275
|
} catch {
|
|
229
276
|
return void 0;
|
|
230
277
|
}
|
|
231
278
|
}
|
|
232
279
|
__name(sameOriginRecoveryEndpoint, "sameOriginRecoveryEndpoint");
|
|
280
|
+
function isLoopbackURL(url) {
|
|
281
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return false;
|
|
282
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
283
|
+
}
|
|
284
|
+
__name(isLoopbackURL, "isLoopbackURL");
|
|
285
|
+
function reportFailure(options, failure) {
|
|
286
|
+
try {
|
|
287
|
+
options.onFailure?.(failure);
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
__name(reportFailure, "reportFailure");
|
|
292
|
+
function safeErrorDetails(value) {
|
|
293
|
+
const error = isRecord2(value) ? value : {};
|
|
294
|
+
const details = {
|
|
295
|
+
name: safeText(error.name, "Error"),
|
|
296
|
+
message: safeText(error.message, "Session recovery fetch failed")
|
|
297
|
+
};
|
|
298
|
+
const code = safeOptionalText(error.code);
|
|
299
|
+
if (code) details.code = code;
|
|
300
|
+
if (isRecord2(error.cause)) {
|
|
301
|
+
const causeCode = safeOptionalText(error.cause.code);
|
|
302
|
+
details.cause = {
|
|
303
|
+
name: safeText(error.cause.name, "Error"),
|
|
304
|
+
message: safeText(error.cause.message, "Session recovery fetch failed"),
|
|
305
|
+
...causeCode ? { code: causeCode } : {}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return details;
|
|
309
|
+
}
|
|
310
|
+
__name(safeErrorDetails, "safeErrorDetails");
|
|
311
|
+
function safeText(value, fallback) {
|
|
312
|
+
if (typeof value !== "string" || !value) return fallback;
|
|
313
|
+
return value.replace(/[\u0000-\u001F\u007F]/g, " ").slice(0, 300);
|
|
314
|
+
}
|
|
315
|
+
__name(safeText, "safeText");
|
|
316
|
+
function safeOptionalText(value) {
|
|
317
|
+
if (typeof value !== "string" && typeof value !== "number") return void 0;
|
|
318
|
+
return safeText(String(value), "");
|
|
319
|
+
}
|
|
320
|
+
__name(safeOptionalText, "safeOptionalText");
|
|
321
|
+
function isRecord2(value) {
|
|
322
|
+
return typeof value === "object" && value !== null;
|
|
323
|
+
}
|
|
324
|
+
__name(isRecord2, "isRecord");
|
|
233
325
|
|
|
234
326
|
// src/client/server/withAuthMiddleware.ts
|
|
235
327
|
function withAuthMiddleware(config) {
|
|
@@ -245,8 +337,11 @@ function withAuthMiddleware(config) {
|
|
|
245
337
|
sessionSecret,
|
|
246
338
|
sessionMaxAge,
|
|
247
339
|
verifyAlways = false,
|
|
248
|
-
recoveryURL
|
|
340
|
+
recoveryURL,
|
|
341
|
+
internalRecoveryURL,
|
|
342
|
+
onRecoveryFailure
|
|
249
343
|
} = config;
|
|
344
|
+
const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
|
|
250
345
|
return /* @__PURE__ */ __name(async function middleware(request) {
|
|
251
346
|
const { NextResponse } = await import("next/server");
|
|
252
347
|
const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
|
|
@@ -282,18 +377,20 @@ function withAuthMiddleware(config) {
|
|
|
282
377
|
let recovery = null;
|
|
283
378
|
if (!session || verifyAlways) {
|
|
284
379
|
const refreshCookie = readCookieValue(cookie, cookieName);
|
|
285
|
-
if (!refreshCookie || recoveryURL === false) {
|
|
380
|
+
if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
|
|
286
381
|
return redirectToLogin(returnPath, ["refresh", "session"]);
|
|
287
382
|
}
|
|
288
|
-
const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
|
|
383
|
+
const endpoint = resolvedInternalRecoveryURL ?? (recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url));
|
|
289
384
|
recovery = await requestSessionRecovery({
|
|
290
385
|
endpoint,
|
|
291
386
|
requestOrigin: url.origin,
|
|
387
|
+
allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
|
|
292
388
|
refreshCookieName: cookieName,
|
|
293
389
|
refreshCookieValue: refreshCookie,
|
|
294
390
|
sessionCookieName,
|
|
295
391
|
sessionSecret: secret,
|
|
296
|
-
sessionMaxAge
|
|
392
|
+
sessionMaxAge,
|
|
393
|
+
onFailure: onRecoveryFailure
|
|
297
394
|
});
|
|
298
395
|
if (recovery.status !== "recovered") {
|
|
299
396
|
return redirectToLogin(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { f as AuthUser, F as FetchClient, N as NajmAuthClient, h as RetryConfig } from '../../NajmAuthClient-0yzFb9CR.js';
|
|
2
|
-
|
|
2
|
+
import { SessionRecoveryFailure } from '../edge.js';
|
|
3
|
+
export { SessionRecoveryErrorDetails, SessionRecoveryFailureReason, withAuthMiddleware } from '../edge.js';
|
|
3
4
|
import 'next/server';
|
|
4
5
|
|
|
5
6
|
interface GetServerSessionOptions {
|
|
@@ -96,12 +97,16 @@ interface GetSessionConfig {
|
|
|
96
97
|
* `${baseURL}${authPrefix}/session/recover`. Set to false to disable fallback.
|
|
97
98
|
*/
|
|
98
99
|
recoveryURL?: string | false;
|
|
100
|
+
/** Loopback-only recovery endpoint for self-hosted reverse-proxy setups. */
|
|
101
|
+
internalRecoveryURL?: string;
|
|
99
102
|
/**
|
|
100
103
|
* Error handling mode:
|
|
101
104
|
* - 'nullable' (default): returns null on any failure
|
|
102
105
|
* - 'strict': throws typed errors for debugging
|
|
103
106
|
*/
|
|
104
107
|
mode?: 'nullable' | 'strict';
|
|
108
|
+
/** Secret-free diagnostic hook for failed recovery attempts. */
|
|
109
|
+
onRecoveryFailure?: (failure: SessionRecoveryFailure) => void;
|
|
105
110
|
}
|
|
106
111
|
declare class NoSessionError extends Error {
|
|
107
112
|
readonly code = "NO_SESSION";
|
|
@@ -181,6 +186,8 @@ interface DefineAuthConfig {
|
|
|
181
186
|
* `${apiBaseURL}${authPrefix}/session/recover`; false disables recovery.
|
|
182
187
|
*/
|
|
183
188
|
recoveryURL?: string | false;
|
|
189
|
+
/** Loopback-only recovery endpoint for self-hosted reverse-proxy setups. */
|
|
190
|
+
internalRecoveryURL?: string;
|
|
184
191
|
/** Next.js middleware matcher (default: exclude _next, favicon, api) */
|
|
185
192
|
matcher?: string[];
|
|
186
193
|
/**
|
|
@@ -188,6 +195,8 @@ interface DefineAuthConfig {
|
|
|
188
195
|
* Recovery reissues the signed cookie without rotating refresh tokens.
|
|
189
196
|
*/
|
|
190
197
|
verifyAlways?: boolean;
|
|
198
|
+
/** Secret-free diagnostic hook for failed server or proxy recovery. */
|
|
199
|
+
onRecoveryFailure?: (failure: SessionRecoveryFailure) => void;
|
|
191
200
|
}
|
|
192
201
|
interface AuthKit {
|
|
193
202
|
/**
|
|
@@ -223,4 +232,4 @@ interface AuthKit {
|
|
|
223
232
|
}
|
|
224
233
|
declare function defineAuth(authConfig?: DefineAuthConfig): AuthKit;
|
|
225
234
|
|
|
226
|
-
export { AuthConfigError, type AuthKit, AuthTransportError, type DefineAuthConfig, type GetSessionConfig, NoSessionError, type ServerSession, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getServerSession, getSession, withAuth };
|
|
235
|
+
export { AuthConfigError, type AuthKit, AuthTransportError, type DefineAuthConfig, type GetSessionConfig, NoSessionError, type ServerSession, SessionRecoveryFailure, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getServerSession, getSession, withAuth };
|
|
@@ -38,20 +38,33 @@ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
async function verifySessionCookie(rawCookieValue, options) {
|
|
41
|
-
|
|
41
|
+
const result = await verifySessionCookieDetailed(rawCookieValue, options);
|
|
42
|
+
return result.status === "valid" ? result.claims : null;
|
|
43
|
+
}
|
|
44
|
+
async function verifySessionCookieDetailed(rawCookieValue, options) {
|
|
45
|
+
if (!rawCookieValue || !options.secret || !globalThis.crypto?.subtle) {
|
|
46
|
+
return { status: "invalid", reason: "format" };
|
|
47
|
+
}
|
|
48
|
+
let sawSignedFormat = false;
|
|
49
|
+
let sawValidHmac = false;
|
|
42
50
|
for (const signedValue of cookieValueCandidates(rawCookieValue)) {
|
|
43
51
|
const lastDot = signedValue.lastIndexOf(".");
|
|
44
52
|
if (lastDot <= 0 || lastDot === signedValue.length - 1) continue;
|
|
53
|
+
sawSignedFormat = true;
|
|
45
54
|
const payload = signedValue.slice(0, lastDot);
|
|
46
55
|
const signature = signedValue.slice(lastDot + 1);
|
|
47
56
|
if (!await verifyHmac(payload, signature, options.secret)) continue;
|
|
48
|
-
|
|
57
|
+
sawValidHmac = true;
|
|
58
|
+
const claims = parseSessionCookiePayload(
|
|
49
59
|
payload,
|
|
50
60
|
options.maxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
|
|
51
61
|
options.now
|
|
52
62
|
);
|
|
63
|
+
if (claims) return { status: "valid", claims };
|
|
53
64
|
}
|
|
54
|
-
return
|
|
65
|
+
if (sawValidHmac) return { status: "invalid", reason: "payload" };
|
|
66
|
+
if (sawSignedFormat) return { status: "invalid", reason: "hmac" };
|
|
67
|
+
return { status: "invalid", reason: "format" };
|
|
55
68
|
}
|
|
56
69
|
function readCookieValue(cookieHeader, name) {
|
|
57
70
|
for (const part of cookieHeader.split(";")) {
|
|
@@ -134,6 +147,7 @@ var init_sessionCookie = __esm({
|
|
|
134
147
|
__name(resolveSessionSecret, "resolveSessionSecret");
|
|
135
148
|
__name(parseSessionCookiePayload, "parseSessionCookiePayload");
|
|
136
149
|
__name(verifySessionCookie, "verifySessionCookie");
|
|
150
|
+
__name(verifySessionCookieDetailed, "verifySessionCookieDetailed");
|
|
137
151
|
__name(readCookieValue, "readCookieValue");
|
|
138
152
|
__name(verifyHmac, "verifyHmac");
|
|
139
153
|
__name(cookieValueCandidates, "cookieValueCandidates");
|
|
@@ -145,18 +159,27 @@ var init_sessionCookie = __esm({
|
|
|
145
159
|
});
|
|
146
160
|
|
|
147
161
|
// src/client/sessionRecovery.ts
|
|
162
|
+
function resolveInternalRecoveryURL(explicit) {
|
|
163
|
+
if (explicit !== void 0) return explicit || void 0;
|
|
164
|
+
if (typeof process === "undefined") return void 0;
|
|
165
|
+
return process.env.NAJM_AUTH_INTERNAL_URL || void 0;
|
|
166
|
+
}
|
|
148
167
|
async function requestSessionRecovery(options) {
|
|
149
168
|
if (!isCookieName(options.refreshCookieName) || !isCookieName(options.sessionCookieName)) {
|
|
169
|
+
reportFailure(options, { reason: "invalid-cookie-name" });
|
|
150
170
|
return { status: "unavailable" };
|
|
151
171
|
}
|
|
152
172
|
if (!isCookieValue(options.refreshCookieValue)) {
|
|
173
|
+
reportFailure(options, { reason: "invalid-refresh-cookie" });
|
|
153
174
|
return { status: "invalid" };
|
|
154
175
|
}
|
|
155
176
|
const endpoint = sameOriginRecoveryEndpoint(
|
|
156
177
|
options.endpoint,
|
|
157
|
-
options.requestOrigin
|
|
178
|
+
options.requestOrigin,
|
|
179
|
+
options.allowLoopbackEndpoint
|
|
158
180
|
);
|
|
159
181
|
if (!endpoint) {
|
|
182
|
+
reportFailure(options, { reason: "invalid-endpoint" });
|
|
160
183
|
return { status: "unavailable" };
|
|
161
184
|
}
|
|
162
185
|
let response;
|
|
@@ -171,30 +194,51 @@ async function requestSessionRecovery(options) {
|
|
|
171
194
|
cache: "no-store",
|
|
172
195
|
redirect: "manual"
|
|
173
196
|
});
|
|
174
|
-
} catch {
|
|
197
|
+
} catch (error) {
|
|
198
|
+
reportFailure(options, {
|
|
199
|
+
reason: "fetch-error",
|
|
200
|
+
error: safeErrorDetails(error)
|
|
201
|
+
});
|
|
175
202
|
return { status: "unavailable" };
|
|
176
203
|
}
|
|
177
204
|
if (!response.ok) {
|
|
178
205
|
const status = response.status;
|
|
206
|
+
reportFailure(options, { reason: "http-status", httpStatus: status });
|
|
179
207
|
return {
|
|
180
208
|
status: status === 400 || status === 401 || status === 403 ? "invalid" : "unavailable",
|
|
181
209
|
httpStatus: status
|
|
182
210
|
};
|
|
183
211
|
}
|
|
184
212
|
const setCookie = response.headers.get("set-cookie");
|
|
185
|
-
if (!setCookie)
|
|
213
|
+
if (!setCookie) {
|
|
214
|
+
reportFailure(options, {
|
|
215
|
+
reason: "missing-set-cookie",
|
|
216
|
+
httpStatus: response.status
|
|
217
|
+
});
|
|
218
|
+
return { status: "unavailable", httpStatus: response.status };
|
|
219
|
+
}
|
|
186
220
|
const sessionCookieValue = readSetCookieValue(setCookie, options.sessionCookieName);
|
|
187
221
|
if (!sessionCookieValue) {
|
|
222
|
+
reportFailure(options, {
|
|
223
|
+
reason: "session-cookie-parse",
|
|
224
|
+
httpStatus: response.status
|
|
225
|
+
});
|
|
188
226
|
return { status: "unavailable", httpStatus: response.status };
|
|
189
227
|
}
|
|
190
|
-
const
|
|
228
|
+
const verification = await verifySessionCookieDetailed(sessionCookieValue, {
|
|
191
229
|
secret: options.sessionSecret,
|
|
192
230
|
maxAgeSeconds: options.sessionMaxAge
|
|
193
231
|
});
|
|
194
|
-
if (
|
|
232
|
+
if (verification.status === "invalid") {
|
|
233
|
+
reportFailure(options, {
|
|
234
|
+
reason: verification.reason === "hmac" ? "session-cookie-hmac" : verification.reason === "payload" ? "session-cookie-payload" : "session-cookie-parse",
|
|
235
|
+
httpStatus: response.status
|
|
236
|
+
});
|
|
237
|
+
return { status: "unavailable", httpStatus: response.status };
|
|
238
|
+
}
|
|
195
239
|
return {
|
|
196
240
|
status: "recovered",
|
|
197
|
-
claims,
|
|
241
|
+
claims: verification.claims,
|
|
198
242
|
setCookie,
|
|
199
243
|
sessionCookieValue
|
|
200
244
|
};
|
|
@@ -222,7 +266,7 @@ function isCookieName(value) {
|
|
|
222
266
|
function isCookieValue(value) {
|
|
223
267
|
return value.length > 0 && /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/.test(value);
|
|
224
268
|
}
|
|
225
|
-
function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
|
|
269
|
+
function sameOriginRecoveryEndpoint(endpoint, requestOrigin, allowLoopback = false) {
|
|
226
270
|
try {
|
|
227
271
|
const trusted = new URL(requestOrigin);
|
|
228
272
|
if (trusted.protocol !== "https:" && trusted.protocol !== "http:" || trusted.username || trusted.password) {
|
|
@@ -230,15 +274,57 @@ function sameOriginRecoveryEndpoint(endpoint, requestOrigin) {
|
|
|
230
274
|
}
|
|
231
275
|
const resolved = new URL(endpoint, trusted.origin);
|
|
232
276
|
if (resolved.username || resolved.password) return void 0;
|
|
233
|
-
if (resolved.origin !== trusted.origin
|
|
277
|
+
if (resolved.origin !== trusted.origin && (!allowLoopback || !isLoopbackURL(resolved))) {
|
|
278
|
+
return void 0;
|
|
279
|
+
}
|
|
234
280
|
return resolved.toString();
|
|
235
281
|
} catch {
|
|
236
282
|
return void 0;
|
|
237
283
|
}
|
|
238
284
|
}
|
|
285
|
+
function isLoopbackURL(url) {
|
|
286
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return false;
|
|
287
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
288
|
+
}
|
|
289
|
+
function reportFailure(options, failure) {
|
|
290
|
+
try {
|
|
291
|
+
options.onFailure?.(failure);
|
|
292
|
+
} catch {
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function safeErrorDetails(value) {
|
|
296
|
+
const error = isRecord2(value) ? value : {};
|
|
297
|
+
const details = {
|
|
298
|
+
name: safeText(error.name, "Error"),
|
|
299
|
+
message: safeText(error.message, "Session recovery fetch failed")
|
|
300
|
+
};
|
|
301
|
+
const code = safeOptionalText(error.code);
|
|
302
|
+
if (code) details.code = code;
|
|
303
|
+
if (isRecord2(error.cause)) {
|
|
304
|
+
const causeCode = safeOptionalText(error.cause.code);
|
|
305
|
+
details.cause = {
|
|
306
|
+
name: safeText(error.cause.name, "Error"),
|
|
307
|
+
message: safeText(error.cause.message, "Session recovery fetch failed"),
|
|
308
|
+
...causeCode ? { code: causeCode } : {}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return details;
|
|
312
|
+
}
|
|
313
|
+
function safeText(value, fallback) {
|
|
314
|
+
if (typeof value !== "string" || !value) return fallback;
|
|
315
|
+
return value.replace(/[\u0000-\u001F\u007F]/g, " ").slice(0, 300);
|
|
316
|
+
}
|
|
317
|
+
function safeOptionalText(value) {
|
|
318
|
+
if (typeof value !== "string" && typeof value !== "number") return void 0;
|
|
319
|
+
return safeText(String(value), "");
|
|
320
|
+
}
|
|
321
|
+
function isRecord2(value) {
|
|
322
|
+
return typeof value === "object" && value !== null;
|
|
323
|
+
}
|
|
239
324
|
var init_sessionRecovery = __esm({
|
|
240
325
|
"src/client/sessionRecovery.ts"() {
|
|
241
326
|
init_sessionCookie();
|
|
327
|
+
__name(resolveInternalRecoveryURL, "resolveInternalRecoveryURL");
|
|
242
328
|
__name(requestSessionRecovery, "requestSessionRecovery");
|
|
243
329
|
__name(authEndpoint, "authEndpoint");
|
|
244
330
|
__name(replaceCookieValue, "replaceCookieValue");
|
|
@@ -246,6 +332,12 @@ var init_sessionRecovery = __esm({
|
|
|
246
332
|
__name(isCookieName, "isCookieName");
|
|
247
333
|
__name(isCookieValue, "isCookieValue");
|
|
248
334
|
__name(sameOriginRecoveryEndpoint, "sameOriginRecoveryEndpoint");
|
|
335
|
+
__name(isLoopbackURL, "isLoopbackURL");
|
|
336
|
+
__name(reportFailure, "reportFailure");
|
|
337
|
+
__name(safeErrorDetails, "safeErrorDetails");
|
|
338
|
+
__name(safeText, "safeText");
|
|
339
|
+
__name(safeOptionalText, "safeOptionalText");
|
|
340
|
+
__name(isRecord2, "isRecord");
|
|
249
341
|
}
|
|
250
342
|
});
|
|
251
343
|
|
|
@@ -286,6 +378,7 @@ async function getSession(config = {}) {
|
|
|
286
378
|
const baseURL = config.baseURL ?? defaultBaseURL();
|
|
287
379
|
const prefix = config.authPrefix ?? "/auth";
|
|
288
380
|
const strict = config.mode === "strict";
|
|
381
|
+
const internalRecoveryURL = resolveInternalRecoveryURL(config.internalRecoveryURL);
|
|
289
382
|
let sessionCookieValue;
|
|
290
383
|
let refreshCookieValue;
|
|
291
384
|
let requestOrigin;
|
|
@@ -319,7 +412,7 @@ async function getSession(config = {}) {
|
|
|
319
412
|
if (strict) throw new AuthConfigError("Session cookie secret is not configured");
|
|
320
413
|
return null;
|
|
321
414
|
}
|
|
322
|
-
if (!refreshCookieValue || config.recoveryURL === false) {
|
|
415
|
+
if (!refreshCookieValue || config.recoveryURL === false && !internalRecoveryURL) {
|
|
323
416
|
if (strict) throw new NoSessionError("No recoverable refresh session");
|
|
324
417
|
return null;
|
|
325
418
|
}
|
|
@@ -327,15 +420,17 @@ async function getSession(config = {}) {
|
|
|
327
420
|
if (strict) throw new AuthConfigError("Incoming request origin is unavailable");
|
|
328
421
|
return null;
|
|
329
422
|
}
|
|
330
|
-
const endpoint = config.recoveryURL ? new URL(config.recoveryURL, requestOrigin).toString() : authEndpoint(baseURL, prefix, "/session/recover", requestOrigin);
|
|
423
|
+
const endpoint = internalRecoveryURL ?? (config.recoveryURL ? new URL(config.recoveryURL, requestOrigin).toString() : authEndpoint(baseURL, prefix, "/session/recover", requestOrigin));
|
|
331
424
|
const recovery = await requestSessionRecovery({
|
|
332
425
|
endpoint,
|
|
333
426
|
requestOrigin,
|
|
427
|
+
allowLoopbackEndpoint: internalRecoveryURL !== void 0,
|
|
334
428
|
refreshCookieName: cookieName,
|
|
335
429
|
refreshCookieValue,
|
|
336
430
|
sessionCookieName,
|
|
337
431
|
sessionSecret: secret,
|
|
338
|
-
sessionMaxAge: config.sessionMaxAge
|
|
432
|
+
sessionMaxAge: config.sessionMaxAge,
|
|
433
|
+
onFailure: config.onRecoveryFailure
|
|
339
434
|
});
|
|
340
435
|
if (recovery.status === "recovered") {
|
|
341
436
|
return {
|
|
@@ -603,8 +698,11 @@ function withAuthMiddleware(config) {
|
|
|
603
698
|
sessionSecret,
|
|
604
699
|
sessionMaxAge,
|
|
605
700
|
verifyAlways = false,
|
|
606
|
-
recoveryURL
|
|
701
|
+
recoveryURL,
|
|
702
|
+
internalRecoveryURL,
|
|
703
|
+
onRecoveryFailure
|
|
607
704
|
} = config;
|
|
705
|
+
const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
|
|
608
706
|
return /* @__PURE__ */ __name(async function middleware(request) {
|
|
609
707
|
const { NextResponse } = await import("next/server");
|
|
610
708
|
const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
|
|
@@ -640,18 +738,20 @@ function withAuthMiddleware(config) {
|
|
|
640
738
|
let recovery = null;
|
|
641
739
|
if (!session || verifyAlways) {
|
|
642
740
|
const refreshCookie = readCookieValue(cookie, cookieName);
|
|
643
|
-
if (!refreshCookie || recoveryURL === false) {
|
|
741
|
+
if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
|
|
644
742
|
return redirectToLogin(returnPath, ["refresh", "session"]);
|
|
645
743
|
}
|
|
646
|
-
const endpoint = recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url);
|
|
744
|
+
const endpoint = resolvedInternalRecoveryURL ?? (recoveryURL ? new URL(recoveryURL, request.url).toString() : authEndpoint(apiBaseURL, authPrefix, "/session/recover", request.url));
|
|
647
745
|
recovery = await requestSessionRecovery({
|
|
648
746
|
endpoint,
|
|
649
747
|
requestOrigin: url.origin,
|
|
748
|
+
allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
|
|
650
749
|
refreshCookieName: cookieName,
|
|
651
750
|
refreshCookieValue: refreshCookie,
|
|
652
751
|
sessionCookieName,
|
|
653
752
|
sessionSecret: secret,
|
|
654
|
-
sessionMaxAge
|
|
753
|
+
sessionMaxAge,
|
|
754
|
+
onFailure: onRecoveryFailure
|
|
655
755
|
});
|
|
656
756
|
if (recovery.status !== "recovered") {
|
|
657
757
|
return redirectToLogin(
|
|
@@ -1212,8 +1312,10 @@ function defineAuth(authConfig = {}) {
|
|
|
1212
1312
|
sessionSecret,
|
|
1213
1313
|
sessionMaxAge,
|
|
1214
1314
|
recoveryURL,
|
|
1315
|
+
internalRecoveryURL,
|
|
1215
1316
|
matcher = ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
|
1216
1317
|
verifyAlways = false,
|
|
1318
|
+
onRecoveryFailure,
|
|
1217
1319
|
refreshThreshold,
|
|
1218
1320
|
tabSync,
|
|
1219
1321
|
channelName,
|
|
@@ -1227,7 +1329,9 @@ function defineAuth(authConfig = {}) {
|
|
|
1227
1329
|
sessionCookieName,
|
|
1228
1330
|
sessionSecret,
|
|
1229
1331
|
sessionMaxAge,
|
|
1230
|
-
recoveryURL
|
|
1332
|
+
recoveryURL,
|
|
1333
|
+
internalRecoveryURL,
|
|
1334
|
+
onRecoveryFailure
|
|
1231
1335
|
};
|
|
1232
1336
|
let _client = null;
|
|
1233
1337
|
const getClient = /* @__PURE__ */ __name(() => {
|
|
@@ -1281,7 +1385,9 @@ function defineAuth(authConfig = {}) {
|
|
|
1281
1385
|
sessionSecret,
|
|
1282
1386
|
sessionMaxAge,
|
|
1283
1387
|
recoveryURL,
|
|
1284
|
-
|
|
1388
|
+
internalRecoveryURL,
|
|
1389
|
+
verifyAlways,
|
|
1390
|
+
onRecoveryFailure
|
|
1285
1391
|
});
|
|
1286
1392
|
const protect = /* @__PURE__ */ __name((Page, options) => {
|
|
1287
1393
|
return /* @__PURE__ */ __name(async function ProtectedPage(props) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "najm-auth",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.9",
|
|
4
4
|
"description": "Authentication and authorization library for najm framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsup",
|
|
50
50
|
"test": "bun test",
|
|
51
|
+
"test:next16": "bun run build && bun integration/next16-proxy/run.ts",
|
|
51
52
|
"test:watch": "bun test --watch",
|
|
52
53
|
"start": "bun --watch src/main.ts",
|
|
53
54
|
"clean": "rimraf dist tsconfig.tsbuildinfo"
|
|
@@ -70,6 +71,7 @@
|
|
|
70
71
|
"drizzle-kit": "^0.31.10",
|
|
71
72
|
"drizzle-orm": "^0.45.2",
|
|
72
73
|
"happy-dom": "^20.11.1",
|
|
74
|
+
"next": "16.2.11",
|
|
73
75
|
"tsup": "^8.5.1",
|
|
74
76
|
"typescript": "^5.9.3"
|
|
75
77
|
},
|