@fonderie/auth 7.14.4 → 7.16.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.
package/README.md CHANGED
@@ -68,6 +68,26 @@ request; its output is sanitized and bounded (coordinates ~1 km); if it throws o
68
68
  and the request is unaffected. Country is reliable; region and city are
69
69
  approximate.
70
70
 
71
+ ## Rotating the signing secret (nobody is signed out)
72
+
73
+ Every token carries the key id of the secret that signed it, and tokens are
74
+ verified against the current secret **and** any previous ones:
75
+
76
+ ```ts
77
+ new AuthModule(store, {
78
+ jwtSecret: process.env.JWT_SECRET!, // signs new tokens
79
+ jwtPreviousSecrets: process.env.JWT_PREVIOUS_SECRETS?.split(',') ?? [], // still verify
80
+ });
81
+ ```
82
+
83
+ 1. Put the current secret in `JWT_PREVIOUS_SECRETS` and a new one in `JWT_SECRET`; deploy.
84
+ 2. Signed-in users keep working; their next refresh gets tokens signed with the new key.
85
+ 3. After the longest session lifetime (`sessionDuration`), remove the old secret from
86
+ `JWT_PREVIOUS_SECRETS` and deploy again.
87
+
88
+ A weak previous secret fails readiness like a weak current one: it still verifies tokens.
89
+ See `docs/SESSION-DESIGN.md` for the session roadmap.
90
+
71
91
  ## Why this exists
72
92
 
73
93
  You've shipped this plumbing before — auth, teams, billing, messaging —
package/brain/outcomes.md CHANGED
@@ -95,6 +95,9 @@ expires_at TIMESTAMPTZ NOT NULL
95
95
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
96
96
  sid UUID
97
97
  location JSONB
98
+ previous_token_hash TEXT
99
+ previous_valid_until TIMESTAMPTZ
100
+ last_used_at TIMESTAMPTZ
98
101
  -- INDEX idx_fonderie_sessions_expires_at (expires_at)
99
102
  -- INDEX idx_fonderie_sessions_sid (sid)
100
103
  ```
@@ -73,6 +73,7 @@ interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {
73
73
 
74
74
  interface IAuthSecrets {
75
75
  jwtSecret: string;
76
+ jwtPreviousSecrets?: string[];
76
77
  mfaSecretKey?: string;
77
78
  google?: {
78
79
  clientId: string;
package/dist/index.cjs CHANGED
@@ -325,12 +325,21 @@ var EVENT_KEYS = {
325
325
  // Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
326
326
  userPurged: "fonderie.user.purged",
327
327
  emailVerified: "fonderie.user.email_verified",
328
- passwordChanged: "fonderie.user.password_changed"
328
+ passwordChanged: "fonderie.user.password_changed",
329
+ // A session the server revoked — today: a refresh token reused after its
330
+ // grace (theft signal). { userId, sessionId, reason }.
331
+ sessionRevoked: "fonderie.session.revoked"
329
332
  };
330
333
 
331
334
  // src/services/jwt.ts
332
335
  var import_node_crypto = require("crypto");
333
336
  var import_jsonwebtoken = __toESM(require("jsonwebtoken"), 1);
337
+ function keyIdOf(secret) {
338
+ return (0, import_node_crypto.createHash)("sha256").update(secret).digest("hex").slice(0, 16);
339
+ }
340
+ function keyRing(config) {
341
+ return [config.jwtSecret, ...config.jwtPreviousSecrets ?? []].filter((s) => typeof s === "string" && s.length > 0);
342
+ }
334
343
  function issueMfaPendingToken(userId, config, loginMethod) {
335
344
  return import_jsonwebtoken.default.sign(
336
345
  {
@@ -341,7 +350,7 @@ function issueMfaPendingToken(userId, config, loginMethod) {
341
350
  mfaPending: true
342
351
  },
343
352
  config.jwtSecret,
344
- { expiresIn: "5m" }
353
+ { expiresIn: "5m", keyid: keyIdOf(config.jwtSecret) }
345
354
  );
346
355
  }
347
356
  function issueTokenPair(userId, config, options) {
@@ -349,16 +358,19 @@ function issueTokenPair(userId, config, options) {
349
358
  const accessDuration = config.accessTokenDuration ?? "24h";
350
359
  const loginMethod = options.loginMethod;
351
360
  const phoneVerified = options.phoneVerified ?? false;
352
- const sid = (0, import_node_crypto.randomUUID)();
361
+ const sid = options.sid ?? (0, import_node_crypto.randomUUID)();
353
362
  const accessToken = import_jsonwebtoken.default.sign(
354
363
  { sub: userId, type: "access", loginMethod, phoneVerified, sid },
355
364
  config.jwtSecret,
356
- { expiresIn: accessDuration }
365
+ // jwtid: every token is unique. With the session id kept across rotations,
366
+ // two tokens issued in the same second would otherwise be byte-identical —
367
+ // and a rotated-away token indistinguishable from the live one.
368
+ { expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
357
369
  );
358
370
  const refreshToken = import_jsonwebtoken.default.sign(
359
371
  { sub: userId, type: "refresh", loginMethod, phoneVerified, sid },
360
372
  config.jwtSecret,
361
- { expiresIn: duration }
373
+ { expiresIn: duration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
362
374
  );
363
375
  return { accessToken, refreshToken, sid };
364
376
  }
@@ -367,11 +379,16 @@ function refreshTokenExpiry(token) {
367
379
  return decoded?.exp ? new Date(decoded.exp * 1e3) : new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3);
368
380
  }
369
381
  function verifyToken(token, config) {
370
- try {
371
- return import_jsonwebtoken.default.verify(token, config.jwtSecret);
372
- } catch {
373
- return null;
382
+ const ring = keyRing(config);
383
+ const kid = import_jsonwebtoken.default.decode(token, { complete: true })?.header?.kid;
384
+ const candidates = typeof kid === "string" ? ring.filter((s) => keyIdOf(s) === kid) : ring;
385
+ for (const secret of candidates) {
386
+ try {
387
+ return import_jsonwebtoken.default.verify(token, secret);
388
+ } catch {
389
+ }
374
390
  }
391
+ return null;
375
392
  }
376
393
 
377
394
  // src/services/mfa.ts
@@ -897,6 +914,9 @@ var UserModel = class {
897
914
  };
898
915
  var MAX_LIST_LIMIT = 200;
899
916
 
917
+ // src/models/session.model.ts
918
+ var import_node_crypto4 = require("crypto");
919
+
900
920
  // src/services/request-location.ts
901
921
  var LOCATION_TIMEOUT_MS = 500;
902
922
  var TEXT_MAX = 128;
@@ -979,6 +999,10 @@ async function resolveOnce(resolver, req, timeoutMs) {
979
999
  }
980
1000
 
981
1001
  // src/models/session.model.ts
1002
+ function hashRefreshToken(token) {
1003
+ return (0, import_node_crypto4.createHash)("sha256").update(token).digest("hex");
1004
+ }
1005
+ var REFRESH_GRACE_MS = 3e4;
982
1006
  var SessionModel = class {
983
1007
  // `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
984
1008
  constructor(store, locate) {
@@ -990,12 +1014,12 @@ var SessionModel = class {
990
1014
  async create(userId, token, expiresAt, sid, meta) {
991
1015
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
992
1016
  await this.store.query(
993
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
994
- VALUES ($1, $2, $3, $4, $5, $6, $7)
1017
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
1018
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now())
995
1019
  ON CONFLICT (token) DO NOTHING`,
996
1020
  [
997
1021
  userId,
998
- token,
1022
+ hashRefreshToken(token),
999
1023
  expiresAt,
1000
1024
  sid ?? null,
1001
1025
  meta?.userAgent ?? null,
@@ -1005,7 +1029,56 @@ var SessionModel = class {
1005
1029
  );
1006
1030
  }
1007
1031
  async delete(token) {
1008
- await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
1032
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE token IN ($1, $2)`, [hashRefreshToken(token), token]);
1033
+ }
1034
+ /**
1035
+ * Where a presented refresh token stands: the session's current token, its
1036
+ * previous one within the grace (a retry or race), its previous one AFTER
1037
+ * the grace (reuse — a theft signal), or nothing known.
1038
+ */
1039
+ async match(token) {
1040
+ const hash = hashRefreshToken(token);
1041
+ 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`,
1043
+ [hash, token]
1044
+ );
1045
+ if (current) return { kind: "current", row: toRow(current) };
1046
+ const [previous] = await this.store.query(
1047
+ `SELECT id, user_id, sid, token, (previous_valid_until > now()) AS in_grace
1048
+ FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1049
+ [hash]
1050
+ );
1051
+ if (!previous) return { kind: "unknown" };
1052
+ return { kind: previous.in_grace ? "grace" : "reused", row: toRow(previous) };
1053
+ }
1054
+ /**
1055
+ * Rotate a session in place to a new refresh token: same row, same device.
1056
+ * From the CURRENT token it is optimistic — only if the stored token is
1057
+ * still the one matched; a concurrent rotation makes it return false and the
1058
+ * caller re-matches (and now finds the token as the previous one). The old
1059
+ * token becomes the previous, valid for the grace. From the PREVIOUS token
1060
+ * (within the grace) it is authorised by that hash and deadline alone, so
1061
+ * racing retries never starve; the previous and its deadline are kept.
1062
+ */
1063
+ async rotate(row, presented, next, from) {
1064
+ const rows = await this.store.query(
1065
+ `UPDATE fonderie_sessions
1066
+ SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,
1067
+ previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,
1068
+ token = $3,
1069
+ expires_at = $4,
1070
+ last_used_at = now()
1071
+ WHERE id = $1
1072
+ AND CASE WHEN $5 THEN token = $2
1073
+ ELSE previous_token_hash = $6 AND previous_valid_until > now() END
1074
+ RETURNING id`,
1075
+ [row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === "current", hashRefreshToken(presented), String(REFRESH_GRACE_MS)]
1076
+ );
1077
+ return rows.length === 1;
1078
+ }
1079
+ /** Revoke one session by its row id (any user) — reuse detection, admin. */
1080
+ async revokeById(id) {
1081
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);
1009
1082
  }
1010
1083
  // Delete the session an access token is bound to (by its sid claim). Lets
1011
1084
  // logout revoke the current session without the client resending the refresh
@@ -1071,8 +1144,8 @@ var SessionModel = class {
1071
1144
  }
1072
1145
  async exists(token) {
1073
1146
  const rows = await this.store.query(
1074
- `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
1075
- [token]
1147
+ `SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,
1148
+ [hashRefreshToken(token), token]
1076
1149
  );
1077
1150
  return rows.length > 0;
1078
1151
  }
@@ -1085,6 +1158,9 @@ var SessionModel = class {
1085
1158
  return rows.length > 0;
1086
1159
  }
1087
1160
  };
1161
+ function toRow(r) {
1162
+ return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };
1163
+ }
1088
1164
 
1089
1165
  // src/models/backup-code.model.ts
1090
1166
  var BackupCodeModel = class {
@@ -1419,7 +1495,7 @@ function mfaController(store, config, issuer, bus) {
1419
1495
  }
1420
1496
 
1421
1497
  // src/controllers/auth.controller.ts
1422
- var import_node_crypto5 = require("crypto");
1498
+ var import_node_crypto6 = require("crypto");
1423
1499
  var import_events2 = require("@fonderie/events");
1424
1500
  var import_core6 = require("@fonderie/core");
1425
1501
 
@@ -1466,8 +1542,8 @@ function normalizeEmailSafe(email2) {
1466
1542
  }
1467
1543
 
1468
1544
  // src/models/password-reset.model.ts
1469
- var import_node_crypto4 = require("crypto");
1470
- var hashSecret = (value) => (0, import_node_crypto4.createHash)("sha256").update(value).digest("hex");
1545
+ var import_node_crypto5 = require("crypto");
1546
+ var hashSecret = (value) => (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
1471
1547
  var PasswordResetModel = class {
1472
1548
  constructor(store) {
1473
1549
  this.store = store;
@@ -1661,7 +1737,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1661
1737
  if (!row) {
1662
1738
  return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1663
1739
  }
1664
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1740
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1665
1741
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1666
1742
  await emailVerif.create(row.id, pin, expiresAt);
1667
1743
  const user = await users.findById(row.id);
@@ -1732,7 +1808,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1732
1808
  lastName ?? null,
1733
1809
  locale
1734
1810
  );
1735
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1811
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1736
1812
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1737
1813
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1738
1814
  const user = await users.findById(id);
@@ -1876,7 +1952,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1876
1952
  "Account suspended. Please contact support."
1877
1953
  );
1878
1954
  }
1879
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1955
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1880
1956
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1881
1957
  await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
1882
1958
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1927,24 +2003,35 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1927
2003
  if (!payload || payload.type !== "refresh") {
1928
2004
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
1929
2005
  }
1930
- const valid = await sessions.exists(token);
1931
- if (!valid) {
1932
- return (0, import_core6.setApiResponse)(
1933
- import_core6.HTTP.UNAUTHORIZED,
1934
- "TOKEN_REFRESH_FAILED",
1935
- "Session expired or already revoked"
1936
- );
2006
+ let issued = null;
2007
+ for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2008
+ const found = await sessions.match(token);
2009
+ if (found.kind === "unknown") {
2010
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2011
+ }
2012
+ if (found.kind === "reused") {
2013
+ await sessions.revokeById(found.row.id);
2014
+ await (0, import_core6.background)(
2015
+ bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sessionId: found.row.id, reason: "refresh-reuse" })
2016
+ );
2017
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2018
+ }
2019
+ const user = await users.findById(found.row.userId);
2020
+ if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
2021
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2022
+ }
2023
+ const pair = issueTokenPair(user.id, config, {
2024
+ loginMethod: payload.loginMethod ?? "email",
2025
+ phoneVerified: payload.phoneVerified ?? false,
2026
+ ...found.row.sid ? { sid: found.row.sid } : {}
2027
+ });
2028
+ const next = { token: pair.refreshToken, expiresAt: refreshTokenExpiry(pair.refreshToken) };
2029
+ if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
1937
2030
  }
1938
- const user = await users.findById(payload.sub);
1939
- if (!user || user.suspended || user.deletedAt) {
1940
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2031
+ if (!issued) {
2032
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
1941
2033
  }
1942
- await sessions.delete(token);
1943
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1944
- loginMethod: payload.loginMethod ?? "email",
1945
- phoneVerified: payload.phoneVerified ?? false
1946
- });
1947
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
2034
+ const { accessToken, refreshToken } = issued;
1948
2035
  return Response.json(
1949
2036
  {
1950
2037
  reason: "TOKENS_REFRESHED",
@@ -1985,8 +2072,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1985
2072
  "Password reset email sent (if account exists)."
1986
2073
  );
1987
2074
  }
1988
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1989
- const token = (0, import_node_crypto5.randomBytes)(32).toString("hex");
2075
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2076
+ const token = (0, import_node_crypto6.randomBytes)(32).toString("hex");
1990
2077
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
1991
2078
  await passwordReset.create(user.id, pin, token, expiresAt);
1992
2079
  const base = resolved.passwordResetUrl;
@@ -2192,7 +2279,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2192
2279
  }
2193
2280
  );
2194
2281
  }
2195
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2282
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2196
2283
  const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
2197
2284
  await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
2198
2285
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -2231,7 +2318,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2231
2318
  }
2232
2319
  );
2233
2320
  }
2234
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2321
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2235
2322
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2236
2323
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2237
2324
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -2248,7 +2335,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2248
2335
  }
2249
2336
 
2250
2337
  // src/controllers/user.controller.ts
2251
- var import_node_crypto6 = require("crypto");
2338
+ var import_node_crypto7 = require("crypto");
2252
2339
  var import_core8 = require("@fonderie/core");
2253
2340
  var import_events3 = require("@fonderie/events");
2254
2341
 
@@ -2442,7 +2529,7 @@ function userController(store, config, bus) {
2442
2529
  if (existing) {
2443
2530
  return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2444
2531
  }
2445
- const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2532
+ const pin = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
2446
2533
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2447
2534
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2448
2535
  await users.updateEmail(ctx.user.id, normalised);
@@ -2484,7 +2571,7 @@ function userController(store, config, bus) {
2484
2571
  if (existing) {
2485
2572
  return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2486
2573
  }
2487
- const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2574
+ const otp = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
2488
2575
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
2489
2576
  await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
2490
2577
  await users.updatePhone(ctx.user.id, normalised);
@@ -2634,7 +2721,7 @@ function userController(store, config, bus) {
2634
2721
  }
2635
2722
 
2636
2723
  // src/controllers/oauth.controller.ts
2637
- var import_node_crypto7 = require("crypto");
2724
+ var import_node_crypto8 = require("crypto");
2638
2725
  var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2639
2726
  var import_core9 = require("@fonderie/core");
2640
2727
  var import_events4 = require("@fonderie/events");
@@ -2709,7 +2796,7 @@ async function getApplePublicKey(kid) {
2709
2796
  }
2710
2797
  if (!jwk) return null;
2711
2798
  try {
2712
- return (0, import_node_crypto7.createPublicKey)({ key: jwk, format: "jwk" });
2799
+ return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
2713
2800
  } catch {
2714
2801
  return null;
2715
2802
  }
@@ -2867,7 +2954,7 @@ function oauthController(store, config, bus) {
2867
2954
  "Google OAuth not configured"
2868
2955
  );
2869
2956
  }
2870
- const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
2957
+ const state = (0, import_node_crypto8.randomBytes)(16).toString("hex");
2871
2958
  const params = new URLSearchParams({
2872
2959
  client_id: google.clientId,
2873
2960
  redirect_uri: google.redirectUri,
@@ -3015,7 +3102,7 @@ function oauthController(store, config, bus) {
3015
3102
  if (!apple) {
3016
3103
  return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3017
3104
  }
3018
- const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
3105
+ const state = (0, import_node_crypto8.randomBytes)(16).toString("hex");
3019
3106
  const params = new URLSearchParams({
3020
3107
  client_id: apple.clientId,
3021
3108
  redirect_uri: apple.redirectUri,
@@ -3111,7 +3198,7 @@ function oauthController(store, config, bus) {
3111
3198
  });
3112
3199
  return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3113
3200
  }
3114
- const tokenHash = (0, import_node_crypto7.createHash)("sha256").update(identityToken).digest("hex");
3201
+ const tokenHash = (0, import_node_crypto8.createHash)("sha256").update(identityToken).digest("hex");
3115
3202
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3116
3203
  const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
3117
3204
  if (!firstUse) {
@@ -3271,6 +3358,18 @@ function collectAuthConfigProblems(config) {
3271
3358
  reason: "JWT_SECRET_PLACEHOLDER"
3272
3359
  });
3273
3360
  }
3361
+ (config.jwtPreviousSecrets ?? []).forEach((previous, index) => {
3362
+ if ((0, import_core11.secretStrengthProblem)(previous ?? "")) {
3363
+ problems.push({
3364
+ module: MODULE,
3365
+ severity: "error",
3366
+ message: `jwtPreviousSecrets[${index}] is too short or a placeholder \u2014 it still verifies tokens`,
3367
+ domain: "auth",
3368
+ reason: "JWT_PREVIOUS_SECRET_WEAK",
3369
+ metadata: { index }
3370
+ });
3371
+ }
3372
+ });
3274
3373
  if (config.secureCookies === false) {
3275
3374
  problems.push({
3276
3375
  module: MODULE,
@@ -3556,7 +3655,7 @@ var AuthModule = class {
3556
3655
  name = "@fonderie/auth";
3557
3656
  // Baked in at build time by tsup.base, so the operator's Modules page can
3558
3657
  // say what is actually deployed rather than 'not reported'.
3559
- version = "7.14.4";
3658
+ version = "7.16.0";
3560
3659
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3561
3660
  describeAdmin() {
3562
3661
  return { routes: describeAuthAdminRoutes(this.store) };