@fonderie/auth 7.15.0 → 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/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
  ```
package/dist/index.cjs CHANGED
@@ -325,7 +325,10 @@ 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
@@ -355,16 +358,19 @@ function issueTokenPair(userId, config, options) {
355
358
  const accessDuration = config.accessTokenDuration ?? "24h";
356
359
  const loginMethod = options.loginMethod;
357
360
  const phoneVerified = options.phoneVerified ?? false;
358
- const sid = (0, import_node_crypto.randomUUID)();
361
+ const sid = options.sid ?? (0, import_node_crypto.randomUUID)();
359
362
  const accessToken = import_jsonwebtoken.default.sign(
360
363
  { sub: userId, type: "access", loginMethod, phoneVerified, sid },
361
364
  config.jwtSecret,
362
- { expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret) }
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)() }
363
369
  );
364
370
  const refreshToken = import_jsonwebtoken.default.sign(
365
371
  { sub: userId, type: "refresh", loginMethod, phoneVerified, sid },
366
372
  config.jwtSecret,
367
- { expiresIn: duration, keyid: keyIdOf(config.jwtSecret) }
373
+ { expiresIn: duration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
368
374
  );
369
375
  return { accessToken, refreshToken, sid };
370
376
  }
@@ -908,6 +914,9 @@ var UserModel = class {
908
914
  };
909
915
  var MAX_LIST_LIMIT = 200;
910
916
 
917
+ // src/models/session.model.ts
918
+ var import_node_crypto4 = require("crypto");
919
+
911
920
  // src/services/request-location.ts
912
921
  var LOCATION_TIMEOUT_MS = 500;
913
922
  var TEXT_MAX = 128;
@@ -990,6 +999,10 @@ async function resolveOnce(resolver, req, timeoutMs) {
990
999
  }
991
1000
 
992
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;
993
1006
  var SessionModel = class {
994
1007
  // `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
995
1008
  constructor(store, locate) {
@@ -1001,12 +1014,12 @@ var SessionModel = class {
1001
1014
  async create(userId, token, expiresAt, sid, meta) {
1002
1015
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
1003
1016
  await this.store.query(
1004
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
1005
- 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())
1006
1019
  ON CONFLICT (token) DO NOTHING`,
1007
1020
  [
1008
1021
  userId,
1009
- token,
1022
+ hashRefreshToken(token),
1010
1023
  expiresAt,
1011
1024
  sid ?? null,
1012
1025
  meta?.userAgent ?? null,
@@ -1016,7 +1029,56 @@ var SessionModel = class {
1016
1029
  );
1017
1030
  }
1018
1031
  async delete(token) {
1019
- 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]);
1020
1082
  }
1021
1083
  // Delete the session an access token is bound to (by its sid claim). Lets
1022
1084
  // logout revoke the current session without the client resending the refresh
@@ -1082,8 +1144,8 @@ var SessionModel = class {
1082
1144
  }
1083
1145
  async exists(token) {
1084
1146
  const rows = await this.store.query(
1085
- `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
1086
- [token]
1147
+ `SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,
1148
+ [hashRefreshToken(token), token]
1087
1149
  );
1088
1150
  return rows.length > 0;
1089
1151
  }
@@ -1096,6 +1158,9 @@ var SessionModel = class {
1096
1158
  return rows.length > 0;
1097
1159
  }
1098
1160
  };
1161
+ function toRow(r) {
1162
+ return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };
1163
+ }
1099
1164
 
1100
1165
  // src/models/backup-code.model.ts
1101
1166
  var BackupCodeModel = class {
@@ -1430,7 +1495,7 @@ function mfaController(store, config, issuer, bus) {
1430
1495
  }
1431
1496
 
1432
1497
  // src/controllers/auth.controller.ts
1433
- var import_node_crypto5 = require("crypto");
1498
+ var import_node_crypto6 = require("crypto");
1434
1499
  var import_events2 = require("@fonderie/events");
1435
1500
  var import_core6 = require("@fonderie/core");
1436
1501
 
@@ -1477,8 +1542,8 @@ function normalizeEmailSafe(email2) {
1477
1542
  }
1478
1543
 
1479
1544
  // src/models/password-reset.model.ts
1480
- var import_node_crypto4 = require("crypto");
1481
- 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");
1482
1547
  var PasswordResetModel = class {
1483
1548
  constructor(store) {
1484
1549
  this.store = store;
@@ -1672,7 +1737,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1672
1737
  if (!row) {
1673
1738
  return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1674
1739
  }
1675
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1740
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1676
1741
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1677
1742
  await emailVerif.create(row.id, pin, expiresAt);
1678
1743
  const user = await users.findById(row.id);
@@ -1743,7 +1808,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1743
1808
  lastName ?? null,
1744
1809
  locale
1745
1810
  );
1746
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1811
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1747
1812
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1748
1813
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1749
1814
  const user = await users.findById(id);
@@ -1887,7 +1952,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1887
1952
  "Account suspended. Please contact support."
1888
1953
  );
1889
1954
  }
1890
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1955
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1891
1956
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1892
1957
  await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
1893
1958
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1938,24 +2003,35 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1938
2003
  if (!payload || payload.type !== "refresh") {
1939
2004
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
1940
2005
  }
1941
- const valid = await sessions.exists(token);
1942
- if (!valid) {
1943
- return (0, import_core6.setApiResponse)(
1944
- import_core6.HTTP.UNAUTHORIZED,
1945
- "TOKEN_REFRESH_FAILED",
1946
- "Session expired or already revoked"
1947
- );
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;
1948
2030
  }
1949
- const user = await users.findById(payload.sub);
1950
- if (!user || user.suspended || user.deletedAt) {
1951
- 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");
1952
2033
  }
1953
- await sessions.delete(token);
1954
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1955
- loginMethod: payload.loginMethod ?? "email",
1956
- phoneVerified: payload.phoneVerified ?? false
1957
- });
1958
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
2034
+ const { accessToken, refreshToken } = issued;
1959
2035
  return Response.json(
1960
2036
  {
1961
2037
  reason: "TOKENS_REFRESHED",
@@ -1996,8 +2072,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1996
2072
  "Password reset email sent (if account exists)."
1997
2073
  );
1998
2074
  }
1999
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2000
- 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");
2001
2077
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
2002
2078
  await passwordReset.create(user.id, pin, token, expiresAt);
2003
2079
  const base = resolved.passwordResetUrl;
@@ -2203,7 +2279,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2203
2279
  }
2204
2280
  );
2205
2281
  }
2206
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2282
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2207
2283
  const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
2208
2284
  await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
2209
2285
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -2242,7 +2318,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2242
2318
  }
2243
2319
  );
2244
2320
  }
2245
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2321
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2246
2322
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2247
2323
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2248
2324
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -2259,7 +2335,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2259
2335
  }
2260
2336
 
2261
2337
  // src/controllers/user.controller.ts
2262
- var import_node_crypto6 = require("crypto");
2338
+ var import_node_crypto7 = require("crypto");
2263
2339
  var import_core8 = require("@fonderie/core");
2264
2340
  var import_events3 = require("@fonderie/events");
2265
2341
 
@@ -2453,7 +2529,7 @@ function userController(store, config, bus) {
2453
2529
  if (existing) {
2454
2530
  return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2455
2531
  }
2456
- const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2532
+ const pin = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
2457
2533
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2458
2534
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2459
2535
  await users.updateEmail(ctx.user.id, normalised);
@@ -2495,7 +2571,7 @@ function userController(store, config, bus) {
2495
2571
  if (existing) {
2496
2572
  return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2497
2573
  }
2498
- const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2574
+ const otp = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
2499
2575
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
2500
2576
  await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
2501
2577
  await users.updatePhone(ctx.user.id, normalised);
@@ -2645,7 +2721,7 @@ function userController(store, config, bus) {
2645
2721
  }
2646
2722
 
2647
2723
  // src/controllers/oauth.controller.ts
2648
- var import_node_crypto7 = require("crypto");
2724
+ var import_node_crypto8 = require("crypto");
2649
2725
  var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2650
2726
  var import_core9 = require("@fonderie/core");
2651
2727
  var import_events4 = require("@fonderie/events");
@@ -2720,7 +2796,7 @@ async function getApplePublicKey(kid) {
2720
2796
  }
2721
2797
  if (!jwk) return null;
2722
2798
  try {
2723
- return (0, import_node_crypto7.createPublicKey)({ key: jwk, format: "jwk" });
2799
+ return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
2724
2800
  } catch {
2725
2801
  return null;
2726
2802
  }
@@ -2878,7 +2954,7 @@ function oauthController(store, config, bus) {
2878
2954
  "Google OAuth not configured"
2879
2955
  );
2880
2956
  }
2881
- const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
2957
+ const state = (0, import_node_crypto8.randomBytes)(16).toString("hex");
2882
2958
  const params = new URLSearchParams({
2883
2959
  client_id: google.clientId,
2884
2960
  redirect_uri: google.redirectUri,
@@ -3026,7 +3102,7 @@ function oauthController(store, config, bus) {
3026
3102
  if (!apple) {
3027
3103
  return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3028
3104
  }
3029
- const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
3105
+ const state = (0, import_node_crypto8.randomBytes)(16).toString("hex");
3030
3106
  const params = new URLSearchParams({
3031
3107
  client_id: apple.clientId,
3032
3108
  redirect_uri: apple.redirectUri,
@@ -3122,7 +3198,7 @@ function oauthController(store, config, bus) {
3122
3198
  });
3123
3199
  return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3124
3200
  }
3125
- 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");
3126
3202
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3127
3203
  const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
3128
3204
  if (!firstUse) {
@@ -3579,7 +3655,7 @@ var AuthModule = class {
3579
3655
  name = "@fonderie/auth";
3580
3656
  // Baked in at build time by tsup.base, so the operator's Modules page can
3581
3657
  // say what is actually deployed rather than 'not reported'.
3582
- version = "7.15.0";
3658
+ version = "7.16.0";
3583
3659
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3584
3660
  describeAdmin() {
3585
3661
  return { routes: describeAuthAdminRoutes(this.store) };