najm-auth 2.0.14 → 2.0.15

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.
@@ -169,6 +169,16 @@ interface DefineAuthConfig {
169
169
  loginRoute?: string;
170
170
  /** Route to redirect after login (default: '/dashboard') */
171
171
  afterLoginRoute?: string;
172
+ /**
173
+ * Where an *authenticated* user goes when their role is not allowed
174
+ * (default: '/forbidden').
175
+ *
176
+ * Distinct from `loginRoute` on purpose. Sending them to the login form says
177
+ * "prove who you are" to someone who already has; they log in again, land
178
+ * back on the same page, and get bounced again. A forbidden page is the only
179
+ * response that terminates.
180
+ */
181
+ forbiddenRoute?: string;
172
182
  /** Routes that are always public (glob patterns) */
173
183
  publicRoutes?: string[];
174
184
  /** Routes that require authentication (glob patterns) */
@@ -212,6 +222,15 @@ interface AuthKit {
212
222
  getSession: (opts?: Pick<GetSessionConfig, 'mode'>) => Promise<ServerSession | null>;
213
223
  /** Require session — throws if unauthenticated */
214
224
  requireSession: () => Promise<ServerSession>;
225
+ /**
226
+ * Require one of `roles` — redirects to `loginRoute` when unauthenticated and
227
+ * to `forbiddenRoute` when authenticated as the wrong role.
228
+ *
229
+ * ```ts
230
+ * const session = await auth.requireRole(['admin', 'operator']);
231
+ * ```
232
+ */
233
+ requireRole: (roles: string[]) => Promise<ServerSession>;
215
234
  /** Generated Next.js middleware function */
216
235
  middleware: (request: Request) => Promise<Response>;
217
236
  /** Next.js middleware config with matcher */
@@ -232,4 +251,78 @@ interface AuthKit {
232
251
  }
233
252
  declare function defineAuth(authConfig?: DefineAuthConfig): AuthKit;
234
253
 
235
- export { AuthConfigError, type AuthKit, AuthTransportError, type DefineAuthConfig, type GetSessionConfig, NoSessionError, type ServerSession, SessionRecoveryFailure, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getServerSession, getSession, withAuth };
254
+ interface SafeRedirectOptions {
255
+ /** Where to send anything rejected. Defaults to `/dashboard`. */
256
+ fallback?: string;
257
+ /**
258
+ * Path prefixes that are never a valid destination. Defaults to `/api`,
259
+ * `/login` and `/_next`.
260
+ *
261
+ * `/login` is on the list because bouncing back to it is the redirect loop
262
+ * this parameter causes most often: a user who just authenticated is sent
263
+ * straight back to the form they came from.
264
+ */
265
+ blockedPrefixes?: string[];
266
+ }
267
+ /**
268
+ * Reduces an untrusted `?next=` value to a path that is safe to redirect to.
269
+ *
270
+ * Only same-origin *paths* survive. An absolute URL is rejected outright rather
271
+ * than parsed and compared, because the comparison is where this goes wrong:
272
+ * `//evil.test` is a protocol-relative URL that browsers resolve off-site while
273
+ * a naive `startsWith('/')` check reads it as local. Anything that is not a
274
+ * single leading slash followed by a path is refused.
275
+ *
276
+ * ```ts
277
+ * redirect(getSafeRedirectPath(searchParams.next, { fallback: '/home' }));
278
+ * ```
279
+ */
280
+ declare function getSafeRedirectPath(value: string | string[] | undefined | null, options?: SafeRedirectOptions | string): string;
281
+
282
+ type RequestHandler = (request: Request) => Promise<Response>;
283
+ interface AuthCookiePersistenceOptions {
284
+ /**
285
+ * Cookies whose lifetime this rewrites. Defaults to the two najm-auth issues.
286
+ * Anything not named here is passed through untouched.
287
+ */
288
+ authCookieNames?: string[];
289
+ /** Where the one-bit choice is stored. Defaults to `najm.remember`. */
290
+ rememberCookieName?: string;
291
+ /** How long a remembered choice lasts. Defaults to 7 days. */
292
+ maxAgeSeconds?: number;
293
+ /** Paths whose JSON body carries `rememberMe`. Defaults to `/api/auth/login`. */
294
+ loginPaths?: string[];
295
+ /** Paths that end a session and clear the choice. Defaults to `/api/auth/logout`. */
296
+ logoutPaths?: string[];
297
+ /** Paths that reissue cookies and must reapply the stored choice. */
298
+ refreshPaths?: string[];
299
+ /**
300
+ * Recognizes a response that has *not* issued a usable session because the
301
+ * user must still set up credentials.
302
+ *
303
+ * Such a response may carry auth cookies anyway, and persisting them would
304
+ * leave a half-authenticated browser that skips the setup step on reload.
305
+ * Returning `true` strips them and clears the stored choice.
306
+ */
307
+ isSetupResponse?: (payload: unknown) => boolean;
308
+ }
309
+ /**
310
+ * Strips the lifetime attributes so the browser drops the cookie when it closes.
311
+ *
312
+ * Only the named auth cookies are touched — rewriting an unrelated `Set-Cookie`
313
+ * from the same response would be a silent side effect on someone else's state.
314
+ */
315
+ declare function makeSessionCookie(setCookie: string, authCookieNames?: string[]): string;
316
+ /**
317
+ * Wraps a request handler so the auth cookies it issues match the user's
318
+ * "remember me" choice.
319
+ *
320
+ * ```ts
321
+ * // app/api/[...route]/route.ts
322
+ * const handler = withAuthCookiePersistence((req) => server.fetch(req));
323
+ * export { handler as GET, handler as POST };
324
+ * ```
325
+ */
326
+ declare function withAuthCookiePersistence(handler: RequestHandler, options?: AuthCookiePersistenceOptions): RequestHandler;
327
+
328
+ export { AuthConfigError, type AuthCookiePersistenceOptions, type AuthKit, AuthTransportError, type DefineAuthConfig, type GetSessionConfig, NoSessionError, type SafeRedirectOptions, type ServerSession, SessionRecoveryFailure, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getSafeRedirectPath, getServerSession, getSession, makeSessionCookie, withAuth, withAuthCookiePersistence };
@@ -382,7 +382,7 @@ function requestOriginFromHeaders(headers) {
382
382
  }
383
383
  }
384
384
  async function getSession(config = {}) {
385
- const cookieName = config.cookieName ?? "refreshToken";
385
+ const cookieName2 = config.cookieName ?? "refreshToken";
386
386
  const sessionCookieName = config.sessionCookieName ?? "najm.session";
387
387
  const baseURL = config.baseURL ?? defaultBaseURL();
388
388
  const prefix = config.authPrefix ?? "/auth";
@@ -395,7 +395,7 @@ async function getSession(config = {}) {
395
395
  const mod = await import("next/headers");
396
396
  const cookieStore = await mod.cookies();
397
397
  sessionCookieValue = cookieStore.get(sessionCookieName)?.value;
398
- refreshCookieValue = cookieStore.get(cookieName)?.value;
398
+ refreshCookieValue = cookieStore.get(cookieName2)?.value;
399
399
  if (typeof mod.headers === "function") {
400
400
  requestOrigin = requestOriginFromHeaders(await mod.headers());
401
401
  }
@@ -434,7 +434,7 @@ async function getSession(config = {}) {
434
434
  endpoint,
435
435
  requestOrigin,
436
436
  allowLoopbackEndpoint: internalRecoveryURL !== void 0,
437
- refreshCookieName: cookieName,
437
+ refreshCookieName: cookieName2,
438
438
  refreshCookieValue,
439
439
  sessionCookieName,
440
440
  sessionSecret: secret,
@@ -700,7 +700,7 @@ function withAuthMiddleware(config) {
700
700
  publicRoutes = [],
701
701
  loginRoute = "/login",
702
702
  roleRoutes = {},
703
- cookieName = "refreshToken",
703
+ cookieName: cookieName2 = "refreshToken",
704
704
  apiBaseURL = "/api",
705
705
  authPrefix = "/auth",
706
706
  sessionCookieName = "najm.session",
@@ -719,7 +719,7 @@ function withAuthMiddleware(config) {
719
719
  loginUrl.searchParams.set("from", returnPath2);
720
720
  const res = NextResponse.redirect(loginUrl);
721
721
  if (clearCookies.includes("refresh")) {
722
- res.cookies.delete(cookieName);
722
+ res.cookies.delete(cookieName2);
723
723
  }
724
724
  if (clearCookies.includes("session")) {
725
725
  res.cookies.delete(sessionCookieName);
@@ -746,7 +746,7 @@ function withAuthMiddleware(config) {
746
746
  }) : null;
747
747
  let recovery = null;
748
748
  if (!session || verifyAlways) {
749
- const refreshCookie = readCookieValue(cookie, cookieName);
749
+ const refreshCookie = readCookieValue(cookie, cookieName2);
750
750
  if (!refreshCookie || recoveryURL === false && !resolvedInternalRecoveryURL) {
751
751
  return redirectToLogin(returnPath, ["refresh", "session"]);
752
752
  }
@@ -755,7 +755,7 @@ function withAuthMiddleware(config) {
755
755
  endpoint,
756
756
  requestOrigin: url.origin,
757
757
  allowLoopbackEndpoint: resolvedInternalRecoveryURL !== void 0,
758
- refreshCookieName: cookieName,
758
+ refreshCookieName: cookieName2,
759
759
  refreshCookieValue: refreshCookie,
760
760
  sessionCookieName,
761
761
  sessionSecret: secret,
@@ -1324,10 +1324,11 @@ function defineAuth(authConfig = {}) {
1324
1324
  apiBaseURL = "/api",
1325
1325
  authPrefix = "/auth",
1326
1326
  loginRoute = "/login",
1327
+ forbiddenRoute = "/forbidden",
1327
1328
  publicRoutes = [],
1328
1329
  protectedRoutes = [],
1329
1330
  roleRoutes = {},
1330
- cookieName = "refreshToken",
1331
+ cookieName: cookieName2 = "refreshToken",
1331
1332
  sessionCookieName = "najm.session",
1332
1333
  sessionSecret,
1333
1334
  sessionMaxAge,
@@ -1345,7 +1346,7 @@ function defineAuth(authConfig = {}) {
1345
1346
  const sessionConfig = {
1346
1347
  baseURL: apiBaseURL,
1347
1348
  authPrefix,
1348
- cookieName,
1349
+ cookieName: cookieName2,
1349
1350
  sessionCookieName,
1350
1351
  sessionSecret,
1351
1352
  sessionMaxAge,
@@ -1393,12 +1394,21 @@ function defineAuth(authConfig = {}) {
1393
1394
  throw err;
1394
1395
  }
1395
1396
  }, "requireSession");
1397
+ const requireRole = /* @__PURE__ */ __name(async (roles) => {
1398
+ const session = await requireSession();
1399
+ const held = session.roles ?? (session.user.role ? [session.user.role] : []);
1400
+ if (!held.some((role) => roles.includes(role))) {
1401
+ const { redirect } = await import("next/navigation");
1402
+ redirect(forbiddenRoute);
1403
+ }
1404
+ return session;
1405
+ }, "requireRole");
1396
1406
  const middleware = withAuthMiddleware({
1397
1407
  protectedRoutes,
1398
1408
  publicRoutes,
1399
1409
  loginRoute,
1400
1410
  roleRoutes,
1401
- cookieName,
1411
+ cookieName: cookieName2,
1402
1412
  apiBaseURL,
1403
1413
  authPrefix,
1404
1414
  sessionCookieName,
@@ -1420,7 +1430,7 @@ function defineAuth(authConfig = {}) {
1420
1430
  const userRoles = session.roles ?? (session.user.role ? [session.user.role] : []);
1421
1431
  if (!userRoles.includes(options.role)) {
1422
1432
  const { redirect } = await import("next/navigation");
1423
- redirect(loginRoute);
1433
+ redirect(forbiddenRoute);
1424
1434
  }
1425
1435
  }
1426
1436
  if (options?.permission) {
@@ -1428,7 +1438,7 @@ function defineAuth(authConfig = {}) {
1428
1438
  const perms = session.permissions ?? session.user.permissions ?? [];
1429
1439
  if (!matchPermission2(perms, options.permission)) {
1430
1440
  const { redirect } = await import("next/navigation");
1431
- redirect(loginRoute);
1441
+ redirect(forbiddenRoute);
1432
1442
  }
1433
1443
  }
1434
1444
  return Page({ session, ...props });
@@ -1443,20 +1453,173 @@ function defineAuth(authConfig = {}) {
1443
1453
  },
1444
1454
  getSession: getSession2,
1445
1455
  requireSession,
1456
+ requireRole,
1446
1457
  middleware,
1447
1458
  config: { matcher },
1448
1459
  protect
1449
1460
  };
1450
1461
  }
1451
1462
  __name(defineAuth, "defineAuth");
1463
+
1464
+ // src/client/server/safeRedirect.ts
1465
+ var DEFAULT_BLOCKED_PREFIXES = ["/api", "/login", "/_next"];
1466
+ var ASSET_EXTENSIONS = /\.(?:css|gif|ico|jpe?g|js|json|map|png|svg|webmanifest|webp)$/i;
1467
+ function getSafeRedirectPath(value, options = {}) {
1468
+ const {
1469
+ fallback = "/dashboard",
1470
+ blockedPrefixes = DEFAULT_BLOCKED_PREFIXES
1471
+ } = typeof options === "string" ? { fallback: options } : options;
1472
+ const path = Array.isArray(value) ? value[0] : value;
1473
+ if (!path || !path.startsWith("/") || // Protocol-relative: the browser treats `//host/x` as off-site.
1474
+ path.startsWith("//") || // A backslash is normalized to a forward slash by some browsers, so
1475
+ // `/\evil.test` is another way to spell the case above.
1476
+ path.startsWith("/\\") || blockedPrefixes.some((prefix) => path.startsWith(prefix)) || ASSET_EXTENSIONS.test(path.split("?")[0] ?? path)) {
1477
+ return fallback;
1478
+ }
1479
+ return path;
1480
+ }
1481
+ __name(getSafeRedirectPath, "getSafeRedirectPath");
1482
+
1483
+ // src/client/server/authCookiePersistence.ts
1484
+ var DEFAULTS = {
1485
+ authCookieNames: ["refreshToken", "najm.session"],
1486
+ rememberCookieName: "najm.remember",
1487
+ maxAgeSeconds: 7 * 24 * 60 * 60,
1488
+ loginPaths: ["/api/auth/login"],
1489
+ logoutPaths: ["/api/auth/logout"],
1490
+ refreshPaths: ["/api/auth/refresh", "/api/auth/session/recover"]
1491
+ };
1492
+ function cookieValue(header, name) {
1493
+ for (const part of header.split(";")) {
1494
+ const separator = part.indexOf("=");
1495
+ if (separator < 0) continue;
1496
+ if (part.slice(0, separator).trim() !== name) continue;
1497
+ return part.slice(separator + 1).trim();
1498
+ }
1499
+ return void 0;
1500
+ }
1501
+ __name(cookieValue, "cookieValue");
1502
+ function cookieName(setCookie) {
1503
+ const separator = setCookie.indexOf("=");
1504
+ return separator < 0 ? "" : setCookie.slice(0, separator).trim();
1505
+ }
1506
+ __name(cookieName, "cookieName");
1507
+ function makeSessionCookie(setCookie, authCookieNames = DEFAULTS.authCookieNames) {
1508
+ if (!authCookieNames.includes(cookieName(setCookie))) return setCookie;
1509
+ return setCookie.split(";").filter((part) => !/^\s*(?:expires|max-age)=/i.test(part)).join(";");
1510
+ }
1511
+ __name(makeSessionCookie, "makeSessionCookie");
1512
+ function isDeletionCookie(setCookie) {
1513
+ if (/^[^=]+=\s*(?:;|$)/.test(setCookie)) return true;
1514
+ if (/(?:^|;)\s*max-age=0(?:;|$)/i.test(setCookie)) return true;
1515
+ const expires = /(?:^|;)\s*expires=([^;]+)/i.exec(setCookie)?.[1];
1516
+ return expires ? new Date(expires).getTime() <= Date.now() : false;
1517
+ }
1518
+ __name(isDeletionCookie, "isDeletionCookie");
1519
+ function rememberCookie(name, mode, secure, maxAgeSeconds) {
1520
+ const attributes = [
1521
+ `${name}=${mode === "persistent" ? "1" : "0"}`,
1522
+ "Path=/",
1523
+ "HttpOnly",
1524
+ "SameSite=Lax"
1525
+ ];
1526
+ if (secure) attributes.push("Secure");
1527
+ if (mode === "persistent") attributes.push(`Max-Age=${maxAgeSeconds}`);
1528
+ return attributes.join("; ");
1529
+ }
1530
+ __name(rememberCookie, "rememberCookie");
1531
+ function clearedRememberCookie(name, secure) {
1532
+ return [
1533
+ `${name}=`,
1534
+ "Path=/",
1535
+ "HttpOnly",
1536
+ "SameSite=Lax",
1537
+ ...secure ? ["Secure"] : [],
1538
+ "Max-Age=0"
1539
+ ].join("; ");
1540
+ }
1541
+ __name(clearedRememberCookie, "clearedRememberCookie");
1542
+ function withAuthCookiePersistence(handler, options = {}) {
1543
+ const {
1544
+ authCookieNames = DEFAULTS.authCookieNames,
1545
+ rememberCookieName = DEFAULTS.rememberCookieName,
1546
+ maxAgeSeconds = DEFAULTS.maxAgeSeconds,
1547
+ loginPaths = DEFAULTS.loginPaths,
1548
+ logoutPaths = DEFAULTS.logoutPaths,
1549
+ refreshPaths = DEFAULTS.refreshPaths,
1550
+ isSetupResponse
1551
+ } = options;
1552
+ const resolveAction = /* @__PURE__ */ __name(async (request) => {
1553
+ const { pathname } = new URL(request.url);
1554
+ if (loginPaths.includes(pathname)) {
1555
+ const body = await request.clone().json().catch(() => null);
1556
+ return {
1557
+ type: "apply",
1558
+ mode: body?.rememberMe === true ? "persistent" : "session"
1559
+ };
1560
+ }
1561
+ if (logoutPaths.includes(pathname)) return { type: "clear" };
1562
+ if (refreshPaths.includes(pathname)) {
1563
+ const remembered = cookieValue(
1564
+ request.headers.get("cookie") ?? "",
1565
+ rememberCookieName
1566
+ );
1567
+ if (remembered === "0") return { type: "apply", mode: "session" };
1568
+ if (remembered === "1") return { type: "apply", mode: "persistent" };
1569
+ }
1570
+ return null;
1571
+ }, "resolveAction");
1572
+ const applyAction = /* @__PURE__ */ __name((response, action, secure) => {
1573
+ const headers = new Headers(response.headers);
1574
+ const setCookies = headers.getSetCookie();
1575
+ headers.delete("set-cookie");
1576
+ for (const setCookie of setCookies) {
1577
+ if (action.type === "setup" && authCookieNames.includes(cookieName(setCookie)) && !isDeletionCookie(setCookie)) {
1578
+ continue;
1579
+ }
1580
+ headers.append(
1581
+ "set-cookie",
1582
+ action.type === "apply" && action.mode === "session" ? makeSessionCookie(setCookie, authCookieNames) : setCookie
1583
+ );
1584
+ }
1585
+ headers.append(
1586
+ "set-cookie",
1587
+ action.type === "clear" || action.type === "setup" ? clearedRememberCookie(rememberCookieName, secure) : rememberCookie(rememberCookieName, action.mode, secure, maxAgeSeconds)
1588
+ );
1589
+ return new Response(response.body, {
1590
+ headers,
1591
+ status: response.status,
1592
+ statusText: response.statusText
1593
+ });
1594
+ }, "applyAction");
1595
+ return async (request) => {
1596
+ let action = await resolveAction(request);
1597
+ const response = await handler(request);
1598
+ if (!response.ok) return response;
1599
+ if (action?.type === "apply" && isSetupResponse) {
1600
+ const payload = await response.clone().json().catch(() => null);
1601
+ if (isSetupResponse(payload)) action = { type: "setup" };
1602
+ }
1603
+ if (!action) return response;
1604
+ return applyAction(
1605
+ response,
1606
+ action,
1607
+ new URL(request.url).protocol === "https:"
1608
+ );
1609
+ };
1610
+ }
1611
+ __name(withAuthCookiePersistence, "withAuthCookiePersistence");
1452
1612
  export {
1453
1613
  AuthConfigError,
1454
1614
  AuthTransportError,
1455
1615
  NoSessionError,
1456
1616
  createServerClient,
1457
1617
  defineAuth,
1618
+ getSafeRedirectPath,
1458
1619
  getServerSession,
1459
1620
  getSession,
1621
+ makeSessionCookie,
1460
1622
  withAuth,
1623
+ withAuthCookiePersistence,
1461
1624
  withAuthMiddleware
1462
1625
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "2.0.14",
3
+ "version": "2.0.15",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [