@fonderie/auth 7.15.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.
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
  ```
@@ -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"
@@ -325,7 +328,10 @@ var EVENT_KEYS = {
325
328
  // Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
326
329
  userPurged: "fonderie.user.purged",
327
330
  emailVerified: "fonderie.user.email_verified",
328
- passwordChanged: "fonderie.user.password_changed"
331
+ passwordChanged: "fonderie.user.password_changed",
332
+ // A session the server revoked — today: a refresh token reused after its
333
+ // grace (theft signal). { userId, sessionId, reason }.
334
+ sessionRevoked: "fonderie.session.revoked"
329
335
  };
330
336
 
331
337
  // src/services/jwt.ts
@@ -351,20 +357,24 @@ function issueMfaPendingToken(userId, config, loginMethod) {
351
357
  );
352
358
  }
353
359
  function issueTokenPair(userId, config, options) {
354
- const duration = config.sessionDuration ?? "7d";
355
- 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);
356
363
  const loginMethod = options.loginMethod;
357
364
  const phoneVerified = options.phoneVerified ?? false;
358
- const sid = (0, import_node_crypto.randomUUID)();
365
+ const sid = options.sid ?? (0, import_node_crypto.randomUUID)();
359
366
  const accessToken = import_jsonwebtoken.default.sign(
360
- { sub: userId, type: "access", loginMethod, phoneVerified, sid },
367
+ { sub: userId, type: "access", loginMethod, phoneVerified, sid, auth_time: authTime },
361
368
  config.jwtSecret,
362
- { expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret) }
369
+ // jwtid: every token is unique. With the session id kept across rotations,
370
+ // two tokens issued in the same second would otherwise be byte-identical —
371
+ // and a rotated-away token indistinguishable from the live one.
372
+ { expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
363
373
  );
364
374
  const refreshToken = import_jsonwebtoken.default.sign(
365
- { sub: userId, type: "refresh", loginMethod, phoneVerified, sid },
375
+ { sub: userId, type: "refresh", loginMethod, phoneVerified, sid, auth_time: authTime },
366
376
  config.jwtSecret,
367
- { expiresIn: duration, keyid: keyIdOf(config.jwtSecret) }
377
+ { expiresIn: duration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
368
378
  );
369
379
  return { accessToken, refreshToken, sid };
370
380
  }
@@ -384,6 +394,13 @@ function verifyToken(token, config) {
384
394
  }
385
395
  return null;
386
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
+ }
387
404
 
388
405
  // src/services/mfa.ts
389
406
  var import_node_crypto2 = require("crypto");
@@ -908,6 +925,9 @@ var UserModel = class {
908
925
  };
909
926
  var MAX_LIST_LIMIT = 200;
910
927
 
928
+ // src/models/session.model.ts
929
+ var import_node_crypto4 = require("crypto");
930
+
911
931
  // src/services/request-location.ts
912
932
  var LOCATION_TIMEOUT_MS = 500;
913
933
  var TEXT_MAX = 128;
@@ -990,6 +1010,10 @@ async function resolveOnce(resolver, req, timeoutMs) {
990
1010
  }
991
1011
 
992
1012
  // src/models/session.model.ts
1013
+ function hashRefreshToken(token) {
1014
+ return (0, import_node_crypto4.createHash)("sha256").update(token).digest("hex");
1015
+ }
1016
+ var REFRESH_GRACE_MS = 3e4;
993
1017
  var SessionModel = class {
994
1018
  // `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
995
1019
  constructor(store, locate) {
@@ -1001,12 +1025,12 @@ var SessionModel = class {
1001
1025
  async create(userId, token, expiresAt, sid, meta) {
1002
1026
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
1003
1027
  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)
1028
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
1029
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now())
1006
1030
  ON CONFLICT (token) DO NOTHING`,
1007
1031
  [
1008
1032
  userId,
1009
- token,
1033
+ hashRefreshToken(token),
1010
1034
  expiresAt,
1011
1035
  sid ?? null,
1012
1036
  meta?.userAgent ?? null,
@@ -1016,7 +1040,56 @@ var SessionModel = class {
1016
1040
  );
1017
1041
  }
1018
1042
  async delete(token) {
1019
- await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
1043
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE token IN ($1, $2)`, [hashRefreshToken(token), token]);
1044
+ }
1045
+ /**
1046
+ * Where a presented refresh token stands: the session's current token, its
1047
+ * previous one within the grace (a retry or race), its previous one AFTER
1048
+ * the grace (reuse — a theft signal), or nothing known.
1049
+ */
1050
+ async match(token) {
1051
+ const hash = hashRefreshToken(token);
1052
+ const [current] = await this.store.query(
1053
+ `SELECT id, user_id, sid, token, created_at FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1054
+ [hash, token]
1055
+ );
1056
+ if (current) return { kind: "current", row: toRow(current) };
1057
+ const [previous] = await this.store.query(
1058
+ `SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
1059
+ FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1060
+ [hash]
1061
+ );
1062
+ if (!previous) return { kind: "unknown" };
1063
+ return { kind: previous.in_grace ? "grace" : "reused", row: toRow(previous) };
1064
+ }
1065
+ /**
1066
+ * Rotate a session in place to a new refresh token: same row, same device.
1067
+ * From the CURRENT token it is optimistic — only if the stored token is
1068
+ * still the one matched; a concurrent rotation makes it return false and the
1069
+ * caller re-matches (and now finds the token as the previous one). The old
1070
+ * token becomes the previous, valid for the grace. From the PREVIOUS token
1071
+ * (within the grace) it is authorised by that hash and deadline alone, so
1072
+ * racing retries never starve; the previous and its deadline are kept.
1073
+ */
1074
+ async rotate(row, presented, next, from) {
1075
+ const rows = await this.store.query(
1076
+ `UPDATE fonderie_sessions
1077
+ SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,
1078
+ previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,
1079
+ token = $3,
1080
+ expires_at = $4,
1081
+ last_used_at = now()
1082
+ WHERE id = $1
1083
+ AND CASE WHEN $5 THEN token = $2
1084
+ ELSE previous_token_hash = $6 AND previous_valid_until > now() END
1085
+ RETURNING id`,
1086
+ [row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === "current", hashRefreshToken(presented), String(REFRESH_GRACE_MS)]
1087
+ );
1088
+ return rows.length === 1;
1089
+ }
1090
+ /** Revoke one session by its row id (any user) — reuse detection, admin. */
1091
+ async revokeById(id) {
1092
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);
1020
1093
  }
1021
1094
  // Delete the session an access token is bound to (by its sid claim). Lets
1022
1095
  // logout revoke the current session without the client resending the refresh
@@ -1082,8 +1155,8 @@ var SessionModel = class {
1082
1155
  }
1083
1156
  async exists(token) {
1084
1157
  const rows = await this.store.query(
1085
- `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
1086
- [token]
1158
+ `SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,
1159
+ [hashRefreshToken(token), token]
1087
1160
  );
1088
1161
  return rows.length > 0;
1089
1162
  }
@@ -1096,6 +1169,9 @@ var SessionModel = class {
1096
1169
  return rows.length > 0;
1097
1170
  }
1098
1171
  };
1172
+ function toRow(r) {
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() };
1174
+ }
1099
1175
 
1100
1176
  // src/models/backup-code.model.ts
1101
1177
  var BackupCodeModel = class {
@@ -1430,7 +1506,7 @@ function mfaController(store, config, issuer, bus) {
1430
1506
  }
1431
1507
 
1432
1508
  // src/controllers/auth.controller.ts
1433
- var import_node_crypto5 = require("crypto");
1509
+ var import_node_crypto6 = require("crypto");
1434
1510
  var import_events2 = require("@fonderie/events");
1435
1511
  var import_core6 = require("@fonderie/core");
1436
1512
 
@@ -1477,8 +1553,8 @@ function normalizeEmailSafe(email2) {
1477
1553
  }
1478
1554
 
1479
1555
  // 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");
1556
+ var import_node_crypto5 = require("crypto");
1557
+ var hashSecret = (value) => (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
1482
1558
  var PasswordResetModel = class {
1483
1559
  constructor(store) {
1484
1560
  this.store = store;
@@ -1672,7 +1748,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1672
1748
  if (!row) {
1673
1749
  return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1674
1750
  }
1675
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1751
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1676
1752
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1677
1753
  await emailVerif.create(row.id, pin, expiresAt);
1678
1754
  const user = await users.findById(row.id);
@@ -1743,7 +1819,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1743
1819
  lastName ?? null,
1744
1820
  locale
1745
1821
  );
1746
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1822
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1747
1823
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1748
1824
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1749
1825
  const user = await users.findById(id);
@@ -1887,7 +1963,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1887
1963
  "Account suspended. Please contact support."
1888
1964
  );
1889
1965
  }
1890
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1966
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1891
1967
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1892
1968
  await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
1893
1969
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1938,24 +2014,43 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1938
2014
  if (!payload || payload.type !== "refresh") {
1939
2015
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
1940
2016
  }
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
- );
2017
+ const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
2018
+ const maxAgeMs = durationMs(resolvedRefresh.sessionMaxAge);
2019
+ let issued = null;
2020
+ for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2021
+ const found = await sessions.match(token);
2022
+ if (found.kind === "unknown") {
2023
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2024
+ }
2025
+ if (found.kind === "reused") {
2026
+ await sessions.revokeById(found.row.id);
2027
+ await (0, import_core6.background)(
2028
+ bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sessionId: found.row.id, reason: "refresh-reuse" })
2029
+ );
2030
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
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
+ }
2036
+ const user = await users.findById(found.row.userId);
2037
+ if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
2038
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2039
+ }
2040
+ const pair = issueTokenPair(user.id, resolvedRefresh, {
2041
+ loginMethod: payload.loginMethod ?? "email",
2042
+ phoneVerified: payload.phoneVerified ?? false,
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 } : {}
2046
+ });
2047
+ const next = { token: pair.refreshToken, expiresAt: refreshTokenExpiry(pair.refreshToken) };
2048
+ if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
1948
2049
  }
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");
2050
+ if (!issued) {
2051
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
1952
2052
  }
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));
2053
+ const { accessToken, refreshToken } = issued;
1959
2054
  return Response.json(
1960
2055
  {
1961
2056
  reason: "TOKENS_REFRESHED",
@@ -1996,8 +2091,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1996
2091
  "Password reset email sent (if account exists)."
1997
2092
  );
1998
2093
  }
1999
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2000
- const token = (0, import_node_crypto5.randomBytes)(32).toString("hex");
2094
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2095
+ const token = (0, import_node_crypto6.randomBytes)(32).toString("hex");
2001
2096
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
2002
2097
  await passwordReset.create(user.id, pin, token, expiresAt);
2003
2098
  const base = resolved.passwordResetUrl;
@@ -2203,7 +2298,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2203
2298
  }
2204
2299
  );
2205
2300
  }
2206
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2301
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2207
2302
  const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
2208
2303
  await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
2209
2304
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -2242,7 +2337,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2242
2337
  }
2243
2338
  );
2244
2339
  }
2245
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2340
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2246
2341
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2247
2342
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2248
2343
  await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -2259,7 +2354,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2259
2354
  }
2260
2355
 
2261
2356
  // src/controllers/user.controller.ts
2262
- var import_node_crypto6 = require("crypto");
2357
+ var import_node_crypto7 = require("crypto");
2263
2358
  var import_core8 = require("@fonderie/core");
2264
2359
  var import_events3 = require("@fonderie/events");
2265
2360
 
@@ -2453,7 +2548,7 @@ function userController(store, config, bus) {
2453
2548
  if (existing) {
2454
2549
  return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2455
2550
  }
2456
- const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2551
+ const pin = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
2457
2552
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2458
2553
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2459
2554
  await users.updateEmail(ctx.user.id, normalised);
@@ -2495,7 +2590,7 @@ function userController(store, config, bus) {
2495
2590
  if (existing) {
2496
2591
  return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2497
2592
  }
2498
- const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2593
+ const otp = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
2499
2594
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
2500
2595
  await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
2501
2596
  await users.updatePhone(ctx.user.id, normalised);
@@ -2645,7 +2740,7 @@ function userController(store, config, bus) {
2645
2740
  }
2646
2741
 
2647
2742
  // src/controllers/oauth.controller.ts
2648
- var import_node_crypto7 = require("crypto");
2743
+ var import_node_crypto8 = require("crypto");
2649
2744
  var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2650
2745
  var import_core9 = require("@fonderie/core");
2651
2746
  var import_events4 = require("@fonderie/events");
@@ -2720,7 +2815,7 @@ async function getApplePublicKey(kid) {
2720
2815
  }
2721
2816
  if (!jwk) return null;
2722
2817
  try {
2723
- return (0, import_node_crypto7.createPublicKey)({ key: jwk, format: "jwk" });
2818
+ return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
2724
2819
  } catch {
2725
2820
  return null;
2726
2821
  }
@@ -2878,7 +2973,7 @@ function oauthController(store, config, bus) {
2878
2973
  "Google OAuth not configured"
2879
2974
  );
2880
2975
  }
2881
- const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
2976
+ const state = (0, import_node_crypto8.randomBytes)(16).toString("hex");
2882
2977
  const params = new URLSearchParams({
2883
2978
  client_id: google.clientId,
2884
2979
  redirect_uri: google.redirectUri,
@@ -3026,7 +3121,7 @@ function oauthController(store, config, bus) {
3026
3121
  if (!apple) {
3027
3122
  return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3028
3123
  }
3029
- const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
3124
+ const state = (0, import_node_crypto8.randomBytes)(16).toString("hex");
3030
3125
  const params = new URLSearchParams({
3031
3126
  client_id: apple.clientId,
3032
3127
  redirect_uri: apple.redirectUri,
@@ -3122,7 +3217,7 @@ function oauthController(store, config, bus) {
3122
3217
  });
3123
3218
  return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3124
3219
  }
3125
- const tokenHash = (0, import_node_crypto7.createHash)("sha256").update(identityToken).digest("hex");
3220
+ const tokenHash = (0, import_node_crypto8.createHash)("sha256").update(identityToken).digest("hex");
3126
3221
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3127
3222
  const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
3128
3223
  if (!firstUse) {
@@ -3579,7 +3674,7 @@ var AuthModule = class {
3579
3674
  name = "@fonderie/auth";
3580
3675
  // Baked in at build time by tsup.base, so the operator's Modules page can
3581
3676
  // say what is actually deployed rather than 'not reported'.
3582
- version = "7.15.0";
3677
+ version = "7.17.0";
3583
3678
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3584
3679
  describeAdmin() {
3585
3680
  return { routes: describeAuthAdminRoutes(this.store) };