@fonderie/auth 7.16.0 → 7.17.1

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.
@@ -92,6 +92,7 @@ interface IAuthSecrets {
92
92
 
93
93
  interface IAuthRuntimeConfig {
94
94
  sessionDuration?: string;
95
+ sessionMaxAge?: string;
95
96
  verificationCooldown?: number;
96
97
  mfa?: boolean;
97
98
  requireVerification?: boolean;
package/dist/index.cjs CHANGED
@@ -288,8 +288,11 @@ var import_events = require("@fonderie/events");
288
288
 
289
289
  // src/config.ts
290
290
  var DEFAULT_VERIFICATION_COOLDOWN = 5 * 60 * 1e3;
291
+ var DEFAULT_SESSION_DURATION = "90d";
292
+ var DEFAULT_ACCESS_TOKEN_DURATION = "1h";
291
293
  var AUTH_CONFIG_KEYS = {
292
294
  sessionDuration: "auth.session.duration",
295
+ sessionMaxAge: "auth.session.max_age",
293
296
  verificationCooldown: "auth.verification.cooldown",
294
297
  mfa: "auth.mfa.enabled",
295
298
  requireVerification: "auth.verification.required"
@@ -354,13 +357,14 @@ function issueMfaPendingToken(userId, config, loginMethod) {
354
357
  );
355
358
  }
356
359
  function issueTokenPair(userId, config, options) {
357
- const duration = config.sessionDuration ?? "7d";
358
- const accessDuration = config.accessTokenDuration ?? "24h";
360
+ const duration = validDuration(config.sessionDuration, DEFAULT_SESSION_DURATION, "sessionDuration");
361
+ const accessDuration = validDuration(config.accessTokenDuration, DEFAULT_ACCESS_TOKEN_DURATION, "accessTokenDuration");
362
+ const authTime = options.authTime ?? Math.floor(Date.now() / 1e3);
359
363
  const loginMethod = options.loginMethod;
360
364
  const phoneVerified = options.phoneVerified ?? false;
361
365
  const sid = options.sid ?? (0, import_node_crypto.randomUUID)();
362
366
  const accessToken = import_jsonwebtoken.default.sign(
363
- { sub: userId, type: "access", loginMethod, phoneVerified, sid },
367
+ { sub: userId, type: "access", loginMethod, phoneVerified, sid, auth_time: authTime },
364
368
  config.jwtSecret,
365
369
  // jwtid: every token is unique. With the session id kept across rotations,
366
370
  // two tokens issued in the same second would otherwise be byte-identical —
@@ -368,7 +372,7 @@ function issueTokenPair(userId, config, options) {
368
372
  { expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
369
373
  );
370
374
  const refreshToken = import_jsonwebtoken.default.sign(
371
- { sub: userId, type: "refresh", loginMethod, phoneVerified, sid },
375
+ { sub: userId, type: "refresh", loginMethod, phoneVerified, sid, auth_time: authTime },
372
376
  config.jwtSecret,
373
377
  { expiresIn: duration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
374
378
  );
@@ -390,6 +394,24 @@ function verifyToken(token, config) {
390
394
  }
391
395
  return null;
392
396
  }
397
+ function durationMs(value) {
398
+ const m = /^\s*(\d+)\s*(ms|s|m|h|d|w|y)?\s*$/i.exec(value ?? "");
399
+ if (!m) return null;
400
+ const unit = (m[2] ?? "s").toLowerCase();
401
+ const scale = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5, y: 315576e5 };
402
+ return Number(m[1]) * (scale[unit] ?? 1e3);
403
+ }
404
+ var warnedDurations = /* @__PURE__ */ new Set();
405
+ function validDuration(value, fallback, name) {
406
+ if (value === void 0 || value === null || value === "") return fallback;
407
+ if (durationMs(String(value)) !== null) return String(value);
408
+ const key = `${name}=${String(value)}`;
409
+ if (!warnedDurations.has(key)) {
410
+ warnedDurations.add(key);
411
+ console.warn(`[auth] ${name} ${JSON.stringify(value)} is not a duration (e.g. '90d', '1h') \u2014 using ${fallback}`);
412
+ }
413
+ return fallback;
414
+ }
393
415
 
394
416
  // src/services/mfa.ts
395
417
  var import_node_crypto2 = require("crypto");
@@ -1039,12 +1061,12 @@ var SessionModel = class {
1039
1061
  async match(token) {
1040
1062
  const hash = hashRefreshToken(token);
1041
1063
  const [current] = await this.store.query(
1042
- `SELECT id, user_id, sid, token FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1064
+ `SELECT id, user_id, sid, token, created_at FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1043
1065
  [hash, token]
1044
1066
  );
1045
1067
  if (current) return { kind: "current", row: toRow(current) };
1046
1068
  const [previous] = await this.store.query(
1047
- `SELECT id, user_id, sid, token, (previous_valid_until > now()) AS in_grace
1069
+ `SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
1048
1070
  FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1049
1071
  [hash]
1050
1072
  );
@@ -1159,7 +1181,7 @@ var SessionModel = class {
1159
1181
  }
1160
1182
  };
1161
1183
  function toRow(r) {
1162
- return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };
1184
+ return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token, createdAt: r.created_at ? new Date(r.created_at) : /* @__PURE__ */ new Date() };
1163
1185
  }
1164
1186
 
1165
1187
  // src/models/backup-code.model.ts
@@ -2003,6 +2025,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2003
2025
  if (!payload || payload.type !== "refresh") {
2004
2026
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2005
2027
  }
2028
+ const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
2029
+ const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2006
2030
  let issued = null;
2007
2031
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2008
2032
  const found = await sessions.match(token);
@@ -2016,14 +2040,20 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2016
2040
  );
2017
2041
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2018
2042
  }
2043
+ if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2044
+ await sessions.revokeById(found.row.id);
2045
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2046
+ }
2019
2047
  const user = await users.findById(found.row.userId);
2020
2048
  if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
2021
2049
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2022
2050
  }
2023
- const pair = issueTokenPair(user.id, config, {
2051
+ const pair = issueTokenPair(user.id, resolvedRefresh, {
2024
2052
  loginMethod: payload.loginMethod ?? "email",
2025
2053
  phoneVerified: payload.phoneVerified ?? false,
2026
- ...found.row.sid ? { sid: found.row.sid } : {}
2054
+ ...found.row.sid ? { sid: found.row.sid } : {},
2055
+ // A refresh is not a sign-in: keep when the user last signed in.
2056
+ ...typeof payload.auth_time === "number" ? { authTime: payload.auth_time } : {}
2027
2057
  });
2028
2058
  const next = { token: pair.refreshToken, expiresAt: refreshTokenExpiry(pair.refreshToken) };
2029
2059
  if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
@@ -3655,7 +3685,7 @@ var AuthModule = class {
3655
3685
  name = "@fonderie/auth";
3656
3686
  // Baked in at build time by tsup.base, so the operator's Modules page can
3657
3687
  // say what is actually deployed rather than 'not reported'.
3658
- version = "7.16.0";
3688
+ version = "7.17.1";
3659
3689
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3660
3690
  describeAdmin() {
3661
3691
  return { routes: describeAuthAdminRoutes(this.store) };