@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/dist/index.js CHANGED
@@ -280,7 +280,10 @@ var EVENT_KEYS = {
280
280
  // Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
281
281
  userPurged: "fonderie.user.purged",
282
282
  emailVerified: "fonderie.user.email_verified",
283
- passwordChanged: "fonderie.user.password_changed"
283
+ passwordChanged: "fonderie.user.password_changed",
284
+ // A session the server revoked — today: a refresh token reused after its
285
+ // grace (theft signal). { userId, sessionId, reason }.
286
+ sessionRevoked: "fonderie.session.revoked"
284
287
  };
285
288
 
286
289
  // src/services/jwt.ts
@@ -310,16 +313,19 @@ function issueTokenPair(userId, config, options) {
310
313
  const accessDuration = config.accessTokenDuration ?? "24h";
311
314
  const loginMethod = options.loginMethod;
312
315
  const phoneVerified = options.phoneVerified ?? false;
313
- const sid = randomUUID();
316
+ const sid = options.sid ?? randomUUID();
314
317
  const accessToken = jwt.sign(
315
318
  { sub: userId, type: "access", loginMethod, phoneVerified, sid },
316
319
  config.jwtSecret,
317
- { expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret) }
320
+ // jwtid: every token is unique. With the session id kept across rotations,
321
+ // two tokens issued in the same second would otherwise be byte-identical —
322
+ // and a rotated-away token indistinguishable from the live one.
323
+ { expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret), jwtid: randomUUID() }
318
324
  );
319
325
  const refreshToken = jwt.sign(
320
326
  { sub: userId, type: "refresh", loginMethod, phoneVerified, sid },
321
327
  config.jwtSecret,
322
- { expiresIn: duration, keyid: keyIdOf(config.jwtSecret) }
328
+ { expiresIn: duration, keyid: keyIdOf(config.jwtSecret), jwtid: randomUUID() }
323
329
  );
324
330
  return { accessToken, refreshToken, sid };
325
331
  }
@@ -863,6 +869,9 @@ var UserModel = class {
863
869
  };
864
870
  var MAX_LIST_LIMIT = 200;
865
871
 
872
+ // src/models/session.model.ts
873
+ import { createHash as createHash2 } from "crypto";
874
+
866
875
  // src/services/request-location.ts
867
876
  var LOCATION_TIMEOUT_MS = 500;
868
877
  var TEXT_MAX = 128;
@@ -945,6 +954,10 @@ async function resolveOnce(resolver, req, timeoutMs) {
945
954
  }
946
955
 
947
956
  // src/models/session.model.ts
957
+ function hashRefreshToken(token) {
958
+ return createHash2("sha256").update(token).digest("hex");
959
+ }
960
+ var REFRESH_GRACE_MS = 3e4;
948
961
  var SessionModel = class {
949
962
  // `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
950
963
  constructor(store, locate) {
@@ -956,12 +969,12 @@ var SessionModel = class {
956
969
  async create(userId, token, expiresAt, sid, meta) {
957
970
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
958
971
  await this.store.query(
959
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
960
- VALUES ($1, $2, $3, $4, $5, $6, $7)
972
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
973
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now())
961
974
  ON CONFLICT (token) DO NOTHING`,
962
975
  [
963
976
  userId,
964
- token,
977
+ hashRefreshToken(token),
965
978
  expiresAt,
966
979
  sid ?? null,
967
980
  meta?.userAgent ?? null,
@@ -971,7 +984,56 @@ var SessionModel = class {
971
984
  );
972
985
  }
973
986
  async delete(token) {
974
- await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
987
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE token IN ($1, $2)`, [hashRefreshToken(token), token]);
988
+ }
989
+ /**
990
+ * Where a presented refresh token stands: the session's current token, its
991
+ * previous one within the grace (a retry or race), its previous one AFTER
992
+ * the grace (reuse — a theft signal), or nothing known.
993
+ */
994
+ async match(token) {
995
+ const hash = hashRefreshToken(token);
996
+ const [current] = await this.store.query(
997
+ `SELECT id, user_id, sid, token FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
998
+ [hash, token]
999
+ );
1000
+ if (current) return { kind: "current", row: toRow(current) };
1001
+ const [previous] = await this.store.query(
1002
+ `SELECT id, user_id, sid, token, (previous_valid_until > now()) AS in_grace
1003
+ FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1004
+ [hash]
1005
+ );
1006
+ if (!previous) return { kind: "unknown" };
1007
+ return { kind: previous.in_grace ? "grace" : "reused", row: toRow(previous) };
1008
+ }
1009
+ /**
1010
+ * Rotate a session in place to a new refresh token: same row, same device.
1011
+ * From the CURRENT token it is optimistic — only if the stored token is
1012
+ * still the one matched; a concurrent rotation makes it return false and the
1013
+ * caller re-matches (and now finds the token as the previous one). The old
1014
+ * token becomes the previous, valid for the grace. From the PREVIOUS token
1015
+ * (within the grace) it is authorised by that hash and deadline alone, so
1016
+ * racing retries never starve; the previous and its deadline are kept.
1017
+ */
1018
+ async rotate(row, presented, next, from) {
1019
+ const rows = await this.store.query(
1020
+ `UPDATE fonderie_sessions
1021
+ SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,
1022
+ previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,
1023
+ token = $3,
1024
+ expires_at = $4,
1025
+ last_used_at = now()
1026
+ WHERE id = $1
1027
+ AND CASE WHEN $5 THEN token = $2
1028
+ ELSE previous_token_hash = $6 AND previous_valid_until > now() END
1029
+ RETURNING id`,
1030
+ [row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === "current", hashRefreshToken(presented), String(REFRESH_GRACE_MS)]
1031
+ );
1032
+ return rows.length === 1;
1033
+ }
1034
+ /** Revoke one session by its row id (any user) — reuse detection, admin. */
1035
+ async revokeById(id) {
1036
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);
975
1037
  }
976
1038
  // Delete the session an access token is bound to (by its sid claim). Lets
977
1039
  // logout revoke the current session without the client resending the refresh
@@ -1037,8 +1099,8 @@ var SessionModel = class {
1037
1099
  }
1038
1100
  async exists(token) {
1039
1101
  const rows = await this.store.query(
1040
- `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
1041
- [token]
1102
+ `SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,
1103
+ [hashRefreshToken(token), token]
1042
1104
  );
1043
1105
  return rows.length > 0;
1044
1106
  }
@@ -1051,6 +1113,9 @@ var SessionModel = class {
1051
1113
  return rows.length > 0;
1052
1114
  }
1053
1115
  };
1116
+ function toRow(r) {
1117
+ return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };
1118
+ }
1054
1119
 
1055
1120
  // src/models/backup-code.model.ts
1056
1121
  var BackupCodeModel = class {
@@ -1432,8 +1497,8 @@ function normalizeEmailSafe(email2) {
1432
1497
  }
1433
1498
 
1434
1499
  // src/models/password-reset.model.ts
1435
- import { createHash as createHash2 } from "crypto";
1436
- var hashSecret = (value) => createHash2("sha256").update(value).digest("hex");
1500
+ import { createHash as createHash3 } from "crypto";
1501
+ var hashSecret = (value) => createHash3("sha256").update(value).digest("hex");
1437
1502
  var PasswordResetModel = class {
1438
1503
  constructor(store) {
1439
1504
  this.store = store;
@@ -1893,24 +1958,35 @@ function authController(store, config, bus, locales = defineLocales()) {
1893
1958
  if (!payload || payload.type !== "refresh") {
1894
1959
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
1895
1960
  }
1896
- const valid = await sessions.exists(token);
1897
- if (!valid) {
1898
- return setApiResponse3(
1899
- HTTP3.UNAUTHORIZED,
1900
- "TOKEN_REFRESH_FAILED",
1901
- "Session expired or already revoked"
1902
- );
1961
+ let issued = null;
1962
+ for (let attempt = 0; attempt < 3 && !issued; attempt++) {
1963
+ const found = await sessions.match(token);
1964
+ if (found.kind === "unknown") {
1965
+ return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
1966
+ }
1967
+ if (found.kind === "reused") {
1968
+ await sessions.revokeById(found.row.id);
1969
+ await background3(
1970
+ bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sessionId: found.row.id, reason: "refresh-reuse" })
1971
+ );
1972
+ return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
1973
+ }
1974
+ const user = await users.findById(found.row.userId);
1975
+ if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
1976
+ return setApiResponse3(HTTP3.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1977
+ }
1978
+ const pair = issueTokenPair(user.id, config, {
1979
+ loginMethod: payload.loginMethod ?? "email",
1980
+ phoneVerified: payload.phoneVerified ?? false,
1981
+ ...found.row.sid ? { sid: found.row.sid } : {}
1982
+ });
1983
+ const next = { token: pair.refreshToken, expiresAt: refreshTokenExpiry(pair.refreshToken) };
1984
+ if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
1903
1985
  }
1904
- const user = await users.findById(payload.sub);
1905
- if (!user || user.suspended || user.deletedAt) {
1906
- return setApiResponse3(HTTP3.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1986
+ if (!issued) {
1987
+ return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
1907
1988
  }
1908
- await sessions.delete(token);
1909
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1910
- loginMethod: payload.loginMethod ?? "email",
1911
- phoneVerified: payload.phoneVerified ?? false
1912
- });
1913
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1989
+ const { accessToken, refreshToken } = issued;
1914
1990
  return Response.json(
1915
1991
  {
1916
1992
  reason: "TOKENS_REFRESHED",
@@ -2600,7 +2676,7 @@ function userController(store, config, bus) {
2600
2676
  }
2601
2677
 
2602
2678
  // src/controllers/oauth.controller.ts
2603
- import { randomBytes as randomBytes4, createPublicKey, createHash as createHash3 } from "crypto";
2679
+ import { randomBytes as randomBytes4, createPublicKey, createHash as createHash4 } from "crypto";
2604
2680
  import jwt2 from "jsonwebtoken";
2605
2681
  import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
2606
2682
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
@@ -3077,7 +3153,7 @@ function oauthController(store, config, bus) {
3077
3153
  });
3078
3154
  return setApiResponse5(HTTP5.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3079
3155
  }
3080
- const tokenHash = createHash3("sha256").update(identityToken).digest("hex");
3156
+ const tokenHash = createHash4("sha256").update(identityToken).digest("hex");
3081
3157
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3082
3158
  const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
3083
3159
  if (!firstUse) {
@@ -3534,7 +3610,7 @@ var AuthModule = class {
3534
3610
  name = "@fonderie/auth";
3535
3611
  // Baked in at build time by tsup.base, so the operator's Modules page can
3536
3612
  // say what is actually deployed rather than 'not reported'.
3537
- version = "7.15.0";
3613
+ version = "7.16.0";
3538
3614
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3539
3615
  describeAdmin() {
3540
3616
  return { routes: describeAuthAdminRoutes(this.store) };