@fonderie/auth 7.16.0 → 7.17.0

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 = config.sessionDuration ?? DEFAULT_SESSION_DURATION;
361
+ const accessDuration = config.accessTokenDuration ?? DEFAULT_ACCESS_TOKEN_DURATION;
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,13 @@ 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
+ }
393
404
 
394
405
  // src/services/mfa.ts
395
406
  var import_node_crypto2 = require("crypto");
@@ -1039,12 +1050,12 @@ var SessionModel = class {
1039
1050
  async match(token) {
1040
1051
  const hash = hashRefreshToken(token);
1041
1052
  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`,
1053
+ `SELECT id, user_id, sid, token, created_at FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1043
1054
  [hash, token]
1044
1055
  );
1045
1056
  if (current) return { kind: "current", row: toRow(current) };
1046
1057
  const [previous] = await this.store.query(
1047
- `SELECT id, user_id, sid, token, (previous_valid_until > now()) AS in_grace
1058
+ `SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
1048
1059
  FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1049
1060
  [hash]
1050
1061
  );
@@ -1159,7 +1170,7 @@ var SessionModel = class {
1159
1170
  }
1160
1171
  };
1161
1172
  function toRow(r) {
1162
- return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };
1173
+ 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
1174
  }
1164
1175
 
1165
1176
  // src/models/backup-code.model.ts
@@ -2003,6 +2014,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2003
2014
  if (!payload || payload.type !== "refresh") {
2004
2015
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2005
2016
  }
2017
+ const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
2018
+ const maxAgeMs = durationMs(resolvedRefresh.sessionMaxAge);
2006
2019
  let issued = null;
2007
2020
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2008
2021
  const found = await sessions.match(token);
@@ -2016,14 +2029,20 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2016
2029
  );
2017
2030
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2018
2031
  }
2032
+ if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2033
+ await sessions.revokeById(found.row.id);
2034
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2035
+ }
2019
2036
  const user = await users.findById(found.row.userId);
2020
2037
  if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
2021
2038
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2022
2039
  }
2023
- const pair = issueTokenPair(user.id, config, {
2040
+ const pair = issueTokenPair(user.id, resolvedRefresh, {
2024
2041
  loginMethod: payload.loginMethod ?? "email",
2025
2042
  phoneVerified: payload.phoneVerified ?? false,
2026
- ...found.row.sid ? { sid: found.row.sid } : {}
2043
+ ...found.row.sid ? { sid: found.row.sid } : {},
2044
+ // A refresh is not a sign-in: keep when the user last signed in.
2045
+ ...typeof payload.auth_time === "number" ? { authTime: payload.auth_time } : {}
2027
2046
  });
2028
2047
  const next = { token: pair.refreshToken, expiresAt: refreshTokenExpiry(pair.refreshToken) };
2029
2048
  if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
@@ -3655,7 +3674,7 @@ var AuthModule = class {
3655
3674
  name = "@fonderie/auth";
3656
3675
  // Baked in at build time by tsup.base, so the operator's Modules page can
3657
3676
  // say what is actually deployed rather than 'not reported'.
3658
- version = "7.16.0";
3677
+ version = "7.17.0";
3659
3678
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3660
3679
  describeAdmin() {
3661
3680
  return { routes: describeAuthAdminRoutes(this.store) };