@fonderie/auth 7.14.3 → 7.15.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 —
@@ -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
@@ -331,6 +331,12 @@ var EVENT_KEYS = {
331
331
  // src/services/jwt.ts
332
332
  var import_node_crypto = require("crypto");
333
333
  var import_jsonwebtoken = __toESM(require("jsonwebtoken"), 1);
334
+ function keyIdOf(secret) {
335
+ return (0, import_node_crypto.createHash)("sha256").update(secret).digest("hex").slice(0, 16);
336
+ }
337
+ function keyRing(config) {
338
+ return [config.jwtSecret, ...config.jwtPreviousSecrets ?? []].filter((s) => typeof s === "string" && s.length > 0);
339
+ }
334
340
  function issueMfaPendingToken(userId, config, loginMethod) {
335
341
  return import_jsonwebtoken.default.sign(
336
342
  {
@@ -341,7 +347,7 @@ function issueMfaPendingToken(userId, config, loginMethod) {
341
347
  mfaPending: true
342
348
  },
343
349
  config.jwtSecret,
344
- { expiresIn: "5m" }
350
+ { expiresIn: "5m", keyid: keyIdOf(config.jwtSecret) }
345
351
  );
346
352
  }
347
353
  function issueTokenPair(userId, config, options) {
@@ -353,12 +359,12 @@ function issueTokenPair(userId, config, options) {
353
359
  const accessToken = import_jsonwebtoken.default.sign(
354
360
  { sub: userId, type: "access", loginMethod, phoneVerified, sid },
355
361
  config.jwtSecret,
356
- { expiresIn: accessDuration }
362
+ { expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret) }
357
363
  );
358
364
  const refreshToken = import_jsonwebtoken.default.sign(
359
365
  { sub: userId, type: "refresh", loginMethod, phoneVerified, sid },
360
366
  config.jwtSecret,
361
- { expiresIn: duration }
367
+ { expiresIn: duration, keyid: keyIdOf(config.jwtSecret) }
362
368
  );
363
369
  return { accessToken, refreshToken, sid };
364
370
  }
@@ -367,11 +373,16 @@ function refreshTokenExpiry(token) {
367
373
  return decoded?.exp ? new Date(decoded.exp * 1e3) : new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3);
368
374
  }
369
375
  function verifyToken(token, config) {
370
- try {
371
- return import_jsonwebtoken.default.verify(token, config.jwtSecret);
372
- } catch {
373
- return null;
376
+ const ring = keyRing(config);
377
+ const kid = import_jsonwebtoken.default.decode(token, { complete: true })?.header?.kid;
378
+ const candidates = typeof kid === "string" ? ring.filter((s) => keyIdOf(s) === kid) : ring;
379
+ for (const secret of candidates) {
380
+ try {
381
+ return import_jsonwebtoken.default.verify(token, secret);
382
+ } catch {
383
+ }
374
384
  }
385
+ return null;
375
386
  }
376
387
 
377
388
  // src/services/mfa.ts
@@ -3271,6 +3282,18 @@ function collectAuthConfigProblems(config) {
3271
3282
  reason: "JWT_SECRET_PLACEHOLDER"
3272
3283
  });
3273
3284
  }
3285
+ (config.jwtPreviousSecrets ?? []).forEach((previous, index) => {
3286
+ if ((0, import_core11.secretStrengthProblem)(previous ?? "")) {
3287
+ problems.push({
3288
+ module: MODULE,
3289
+ severity: "error",
3290
+ message: `jwtPreviousSecrets[${index}] is too short or a placeholder \u2014 it still verifies tokens`,
3291
+ domain: "auth",
3292
+ reason: "JWT_PREVIOUS_SECRET_WEAK",
3293
+ metadata: { index }
3294
+ });
3295
+ }
3296
+ });
3274
3297
  if (config.secureCookies === false) {
3275
3298
  problems.push({
3276
3299
  module: MODULE,
@@ -3556,7 +3579,7 @@ var AuthModule = class {
3556
3579
  name = "@fonderie/auth";
3557
3580
  // Baked in at build time by tsup.base, so the operator's Modules page can
3558
3581
  // say what is actually deployed rather than 'not reported'.
3559
- version = "7.14.3";
3582
+ version = "7.15.0";
3560
3583
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3561
3584
  describeAdmin() {
3562
3585
  return { routes: describeAuthAdminRoutes(this.store) };