@fonderie/auth 5.1.0 → 5.3.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.cjs CHANGED
@@ -126,6 +126,12 @@ var DEFAULTS = {
126
126
  account: { capacity: 3, refillPerSec: 3 / min(60) },
127
127
  accountField: "email"
128
128
  },
129
+ // reset: 10/15min per IP — the reset PIN is a 6-digit code looked up
130
+ // globally, so an unthrottled endpoint is brute-forceable into an account
131
+ // takeover. There's no email in the reset body to key an account bucket on,
132
+ // so this IP bucket is the guard. (Moving reset to a high-entropy opaque
133
+ // token is tracked as a follow-up hardening.)
134
+ reset: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
129
135
  // mfaVerify: 10/15min per IP — TOTP brute-force.
130
136
  mfaVerify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } }
131
137
  };
@@ -731,17 +737,23 @@ var SessionModel = class {
731
737
  this.store = store;
732
738
  }
733
739
  store;
734
- async create(userId, token, expiresAt, sid) {
740
+ async create(userId, token, expiresAt, sid, meta) {
735
741
  await this.store.query(
736
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
737
- VALUES ($1, $2, $3, $4)
742
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
743
+ VALUES ($1, $2, $3, $4, $5, $6)
738
744
  ON CONFLICT (token) DO NOTHING`,
739
- [userId, token, expiresAt, sid ?? null]
745
+ [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
740
746
  );
741
747
  }
742
748
  async delete(token) {
743
749
  await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
744
750
  }
751
+ // Delete the session an access token is bound to (by its sid claim). Lets
752
+ // logout revoke the current session without the client resending the refresh
753
+ // token — the access token already identifies its session.
754
+ async deleteBySid(sid) {
755
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);
756
+ }
745
757
  // Revoke every session for a user (e.g. on password change). Access tokens
746
758
  // bound to these sessions via the sid claim die on their next request.
747
759
  async deleteByUser(userId) {
@@ -761,6 +773,42 @@ var SessionModel = class {
761
773
  [userId]
762
774
  );
763
775
  }
776
+ // Live sessions for the Active Sessions screen: unexpired only, carrying the
777
+ // sid so the caller can flag the current one (row.sid === the JWT's sid).
778
+ // No token — that never leaves the server.
779
+ async listLiveByUser(userId) {
780
+ return this.store.query(
781
+ `SELECT id, sid,
782
+ user_agent AS "userAgent",
783
+ ip_address AS "ipAddress",
784
+ created_at AS "createdAt",
785
+ expires_at AS "expiresAt"
786
+ FROM fonderie_sessions
787
+ WHERE user_id = $1 AND expires_at > now()
788
+ ORDER BY created_at DESC`,
789
+ [userId]
790
+ );
791
+ }
792
+ // Terminate one session by row id, scoped to its owner (a user can never
793
+ // delete another user's session). Returns whether a row was removed.
794
+ async terminateById(userId, id) {
795
+ const rows = await this.store.query(
796
+ `DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
797
+ [id, userId]
798
+ );
799
+ return rows.length > 0;
800
+ }
801
+ // Terminate all of a user's sessions except the current one (by sid).
802
+ // Returns the number of sessions removed.
803
+ async terminateOthers(userId, keepSid) {
804
+ const rows = await this.store.query(
805
+ `DELETE FROM fonderie_sessions
806
+ WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
807
+ RETURNING id`,
808
+ [userId, keepSid]
809
+ );
810
+ return rows.length;
811
+ }
764
812
  async exists(token) {
765
813
  const rows = await this.store.query(
766
814
  `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
@@ -813,11 +861,92 @@ var BackupCodeModel = class {
813
861
  }
814
862
  };
815
863
 
864
+ // src/models/login-event.model.ts
865
+ var LoginEventModel = class {
866
+ constructor(store) {
867
+ this.store = store;
868
+ }
869
+ store;
870
+ async record(e) {
871
+ await this.store.query(
872
+ `INSERT INTO fonderie_login_events
873
+ (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
874
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
875
+ [
876
+ e.userId,
877
+ e.emailAttempted ?? null,
878
+ e.method,
879
+ e.outcome,
880
+ e.failureReason ?? null,
881
+ e.ipAddress,
882
+ e.userAgent
883
+ ]
884
+ );
885
+ }
886
+ // Fire-and-forget variant for the login paths: recording history must never
887
+ // block or fail a login (same posture as the bus.emit(...).catch(() => {})
888
+ // notification sends).
889
+ recordSafe(e) {
890
+ void this.record(e).catch(() => {
891
+ });
892
+ }
893
+ // One page of a user's own login history, newest first. Keyset-paginated on
894
+ // (created_at, id) — mirrors audit's event list so the two log UIs share a
895
+ // cursor contract. The +1 over-fetch that detects a next page lives here so
896
+ // no outer clamp can shave it off.
897
+ async listByUser(query) {
898
+ const limit = Math.min(query.limit ?? 50, MAX_HISTORY_LIMIT);
899
+ const params = [query.userId];
900
+ const where = ["user_id = $1"];
901
+ if (query.outcome) {
902
+ params.push(query.outcome);
903
+ where.push(`outcome = $${params.length}`);
904
+ }
905
+ if (query.from) {
906
+ params.push(query.from);
907
+ where.push(`created_at >= $${params.length}`);
908
+ }
909
+ if (query.to) {
910
+ params.push(query.to);
911
+ where.push(`created_at <= $${params.length}`);
912
+ }
913
+ if (query.cursor) {
914
+ params.push(query.cursor.createdAt, query.cursor.id);
915
+ where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
916
+ }
917
+ params.push(limit + 1);
918
+ const rows = await this.store.query(
919
+ `SELECT id, method, outcome, failure_reason AS "failureReason",
920
+ ip_address AS "ipAddress", user_agent AS "userAgent",
921
+ created_at AS "createdAt", created_at::text AS "createdAtRaw"
922
+ FROM fonderie_login_events
923
+ WHERE ${where.join(" AND ")}
924
+ ORDER BY created_at DESC, id DESC
925
+ LIMIT $${params.length}`,
926
+ params
927
+ );
928
+ return { events: rows.slice(0, limit), hasMore: rows.length > limit };
929
+ }
930
+ };
931
+ var MAX_HISTORY_LIMIT = 200;
932
+
933
+ // src/services/request-meta.ts
934
+ var UA_MAX = 512;
935
+ function requestMeta(ctx) {
936
+ const ip = ctx.meta["clientIp"];
937
+ const ua = ctx.request.headers.get("user-agent");
938
+ return {
939
+ ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
940
+ userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
941
+ };
942
+ }
943
+
816
944
  // src/controllers/mfa.controller.ts
817
945
  function mfaController(store, config, issuer, bus) {
818
946
  const users = new UserModel(store);
819
947
  const sessions = new SessionModel(store);
820
948
  const backupCodes = new BackupCodeModel(store);
949
+ const loginEvents = new LoginEventModel(store);
821
950
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
822
951
  return {
823
952
  // ── 1. Setup ───────────────────────────────────────────────
@@ -847,6 +976,16 @@ function mfaController(store, config, issuer, bus) {
847
976
  verify: async (ctx) => {
848
977
  const body = ctx.meta["body"];
849
978
  const token = body?.["token"];
979
+ const isLoginCompletion = ctx.user.mfaPending === true;
980
+ const meta = requestMeta(ctx);
981
+ const failedMfa = () => loginEvents.recordSafe({
982
+ userId: ctx.user.id,
983
+ emailAttempted: ctx.user.email,
984
+ method: "mfa",
985
+ outcome: "failed",
986
+ failureReason: "invalid_code",
987
+ ...meta
988
+ });
850
989
  if (typeof token !== "string") {
851
990
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
852
991
  }
@@ -887,6 +1026,7 @@ function mfaController(store, config, issuer, bus) {
887
1026
  );
888
1027
  const matched = checks.find((r) => r.match);
889
1028
  if (!matched) {
1029
+ if (isLoginCompletion) failedMfa();
890
1030
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
891
1031
  }
892
1032
  await backupCodes.consume(matched.id);
@@ -904,6 +1044,7 @@ function mfaController(store, config, issuer, bus) {
904
1044
  }
905
1045
  const secret = mfaCipher.decrypt(storedSecret);
906
1046
  if (!verifyTotpToken(token, secret)) {
1047
+ if (isLoginCompletion) failedMfa();
907
1048
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
908
1049
  }
909
1050
  if (!ctx.user.mfaEnabled) {
@@ -914,7 +1055,16 @@ function mfaController(store, config, issuer, bus) {
914
1055
  loginMethod: ctx.user.loginMethod,
915
1056
  phoneVerified: ctx.user.phoneVerified
916
1057
  });
917
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1058
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1059
+ if (isLoginCompletion) {
1060
+ loginEvents.recordSafe({
1061
+ userId: ctx.user.id,
1062
+ emailAttempted: ctx.user.email,
1063
+ method: "mfa",
1064
+ outcome: "success",
1065
+ ...meta
1066
+ });
1067
+ }
918
1068
  const fullUser = await users.findById(ctx.user.id);
919
1069
  if (!fullUser) {
920
1070
  return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
@@ -1183,6 +1333,7 @@ function extractRefreshToken(ctx) {
1183
1333
  function authController(store, config, bus) {
1184
1334
  const users = new UserModel(store);
1185
1335
  const sessions = new SessionModel(store);
1336
+ const loginEvents = new LoginEventModel(store);
1186
1337
  const passwordReset = new PasswordResetModel(store);
1187
1338
  const emailVerif = new EmailVerificationModel(store);
1188
1339
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1252,7 +1403,7 @@ function authController(store, config, bus) {
1252
1403
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1253
1404
  loginMethod: "email"
1254
1405
  });
1255
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1406
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1256
1407
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1257
1408
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1258
1409
  return Response.json(
@@ -1316,7 +1467,7 @@ function authController(store, config, bus) {
1316
1467
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1317
1468
  loginMethod: "phone"
1318
1469
  });
1319
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1470
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1320
1471
  return Response.json(
1321
1472
  {
1322
1473
  reason: "USER_PHONE_REGISTERED",
@@ -1340,6 +1491,7 @@ function authController(store, config, bus) {
1340
1491
  },
1341
1492
  login: async (ctx) => {
1342
1493
  const body = ctx.meta["body"];
1494
+ const meta = requestMeta(ctx);
1343
1495
  if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
1344
1496
  const { email: rawEmail, password: password2 } = body;
1345
1497
  const email2 = normalizeEmailSafe(rawEmail);
@@ -1348,6 +1500,14 @@ function authController(store, config, bus) {
1348
1500
  }
1349
1501
  const user = await users.findByEmail(email2);
1350
1502
  if (!user || !user.passwordHash) {
1503
+ loginEvents.recordSafe({
1504
+ userId: null,
1505
+ emailAttempted: email2,
1506
+ method: "password",
1507
+ outcome: "failed",
1508
+ failureReason: "unknown_email",
1509
+ ...meta
1510
+ });
1351
1511
  return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1352
1512
  }
1353
1513
  const { valid, needsRehash } = await verifyPasswordForLogin(
@@ -1356,6 +1516,14 @@ function authController(store, config, bus) {
1356
1516
  config.legacyVerify
1357
1517
  );
1358
1518
  if (!valid) {
1519
+ loginEvents.recordSafe({
1520
+ userId: user.id,
1521
+ emailAttempted: email2,
1522
+ method: "password",
1523
+ outcome: "failed",
1524
+ failureReason: "bad_password",
1525
+ ...meta
1526
+ });
1359
1527
  return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1360
1528
  }
1361
1529
  if (needsRehash) {
@@ -1377,7 +1545,14 @@ function authController(store, config, bus) {
1377
1545
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1378
1546
  loginMethod: "email"
1379
1547
  });
1380
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1548
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1549
+ loginEvents.recordSafe({
1550
+ userId: user.id,
1551
+ emailAttempted: email2,
1552
+ method: "password",
1553
+ outcome: "success",
1554
+ ...meta
1555
+ });
1381
1556
  const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
1382
1557
  const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
1383
1558
  return Response.json(
@@ -1422,7 +1597,7 @@ function authController(store, config, bus) {
1422
1597
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1423
1598
  loginMethod: "phone"
1424
1599
  });
1425
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1600
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1426
1601
  return Response.json(
1427
1602
  {
1428
1603
  reason: "USER_PHONE_OTP_SENT",
@@ -1445,6 +1620,10 @@ function authController(store, config, bus) {
1445
1620
  );
1446
1621
  },
1447
1622
  logout: async (ctx) => {
1623
+ const sid = ctx.user?.sid;
1624
+ if (sid) {
1625
+ await sessions.deleteBySid(sid).catch(() => void 0);
1626
+ }
1448
1627
  const token = extractRefreshToken(ctx);
1449
1628
  if (token) {
1450
1629
  await sessions.delete(token).catch(() => void 0);
@@ -1483,7 +1662,7 @@ function authController(store, config, bus) {
1483
1662
  loginMethod: payload.loginMethod ?? "email",
1484
1663
  phoneVerified: payload.phoneVerified ?? false
1485
1664
  });
1486
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1665
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1487
1666
  return Response.json(
1488
1667
  {
1489
1668
  reason: "TOKENS_REFRESHED",
@@ -1617,7 +1796,7 @@ function authController(store, config, bus) {
1617
1796
  loginMethod: "phone",
1618
1797
  phoneVerified: true
1619
1798
  });
1620
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1799
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1621
1800
  const verifiedUser = await users.findById(ctx.user.id);
1622
1801
  if (!verifiedUser) {
1623
1802
  return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
@@ -1750,8 +1929,40 @@ function authController(store, config, bus) {
1750
1929
 
1751
1930
  // src/controllers/user.controller.ts
1752
1931
  var import_node_crypto5 = require("crypto");
1753
- var import_core6 = require("@fonderie/core");
1932
+ var import_core7 = require("@fonderie/core");
1754
1933
  var import_events3 = require("@fonderie/events");
1934
+
1935
+ // src/dtos/login-activity.ts
1936
+ var import_core6 = require("@fonderie/core");
1937
+ function toLoginEventDTO(row) {
1938
+ return {
1939
+ id: row.id,
1940
+ method: row.method,
1941
+ outcome: row.outcome,
1942
+ failureReason: row.failureReason,
1943
+ ipAddress: row.ipAddress,
1944
+ userAgent: row.userAgent,
1945
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
1946
+ };
1947
+ }
1948
+ function toLoginHistoryPageDTO(page) {
1949
+ const last = page.events[page.events.length - 1];
1950
+ const nextCursor = page.hasMore && last ? (0, import_core6.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
1951
+ return { events: page.events.map(toLoginEventDTO), nextCursor };
1952
+ }
1953
+ var decodeLoginCursor = import_core6.decodeKeysetCursor;
1954
+ function toSessionDTO(row, currentSid) {
1955
+ return {
1956
+ id: row.id,
1957
+ current: currentSid !== null && row.sid === currentSid,
1958
+ ipAddress: row.ipAddress,
1959
+ userAgent: row.userAgent,
1960
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
1961
+ expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
1962
+ };
1963
+ }
1964
+
1965
+ // src/controllers/user.controller.ts
1755
1966
  function normalizePhone2(phone2) {
1756
1967
  return phone2.trim().replace(/[\s()\-\.]/g, "");
1757
1968
  }
@@ -1761,18 +1972,82 @@ function isValidPhone2(phone2) {
1761
1972
  function userController(store, config, bus) {
1762
1973
  const users = new UserModel(store);
1763
1974
  const sessions = new SessionModel(store);
1975
+ const loginEvents = new LoginEventModel(store);
1764
1976
  const emailVerif = new EmailVerificationModel(store);
1765
1977
  const phoneVerif = new PhoneVerificationModel(store);
1766
1978
  return {
1767
1979
  me: async (ctx) => {
1768
1980
  const user = await users.findById(ctx.user.id);
1769
1981
  if (!user) {
1770
- return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1982
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1771
1983
  }
1772
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
1984
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
1773
1985
  user: toUserDTO(user, ctx.user.phoneVerified)
1774
1986
  });
1775
1987
  },
1988
+ // ── Login history (append-only; the caller's own attempts) ────────
1989
+ loginHistory: async (ctx) => {
1990
+ const params = new URL(ctx.request.url).searchParams;
1991
+ const rawLimit = Number(params.get("limit") ?? 50);
1992
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
1993
+ const outcomeParam = params.get("outcome");
1994
+ if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
1995
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
1996
+ }
1997
+ const cursorParam = params.get("cursor");
1998
+ let cursor = null;
1999
+ if (cursorParam) {
2000
+ cursor = decodeLoginCursor(cursorParam);
2001
+ if (!cursor) {
2002
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2003
+ }
2004
+ }
2005
+ const query = { userId: ctx.user.id, limit };
2006
+ if (outcomeParam) query.outcome = outcomeParam;
2007
+ if (cursor) query.cursor = cursor;
2008
+ const from = params.get("from");
2009
+ const to = params.get("to");
2010
+ if (from) query.from = new Date(from);
2011
+ if (to) query.to = new Date(to);
2012
+ const page = await loginEvents.listByUser(query);
2013
+ return (0, import_core7.setApiResponse)(
2014
+ import_core7.HTTP.OK,
2015
+ "LOGIN_HISTORY_FETCHED",
2016
+ "Login history retrieved.",
2017
+ toLoginHistoryPageDTO(page)
2018
+ );
2019
+ },
2020
+ // ── Active sessions (live only; the caller's own) ─────────────────
2021
+ listSessions: async (ctx) => {
2022
+ const rows = await sessions.listLiveByUser(ctx.user.id);
2023
+ const currentSid = ctx.user.sid ?? null;
2024
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2025
+ sessions: rows.map((r) => toSessionDTO(r, currentSid))
2026
+ });
2027
+ },
2028
+ terminateSession: async (ctx) => {
2029
+ const id = ctx.meta["params"]?.["id"];
2030
+ if (!id) {
2031
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2032
+ }
2033
+ const removed = await sessions.terminateById(ctx.user.id, id);
2034
+ if (!removed) {
2035
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2036
+ }
2037
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2038
+ },
2039
+ terminateOtherSessions: async (ctx) => {
2040
+ const currentSid = ctx.user.sid ?? null;
2041
+ if (!currentSid) {
2042
+ return (0, import_core7.setApiResponse)(
2043
+ import_core7.HTTP.UNPROCESSABLE,
2044
+ "NO_CURRENT_SESSION",
2045
+ "Cannot terminate others without a current session context"
2046
+ );
2047
+ }
2048
+ const count = await sessions.terminateOthers(ctx.user.id, currentSid);
2049
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2050
+ },
1776
2051
  updateProfile: async (ctx) => {
1777
2052
  const body = ctx.meta["body"];
1778
2053
  const allowed = ["firstName", "lastName", "avatarUrl"];
@@ -1781,18 +2056,18 @@ function userController(store, config, bus) {
1781
2056
  if (body?.[key] !== void 0) fields[key] = body[key];
1782
2057
  }
1783
2058
  if (Object.keys(fields).length === 0) {
1784
- return (0, import_core6.setApiResponse)(
1785
- import_core6.HTTP.UNPROCESSABLE,
2059
+ return (0, import_core7.setApiResponse)(
2060
+ import_core7.HTTP.UNPROCESSABLE,
1786
2061
  "INVALID_PARAMETER",
1787
2062
  "Provide at least one of: firstName, lastName, avatarUrl"
1788
2063
  );
1789
2064
  }
1790
2065
  const row = await users.update(ctx.user.id, fields);
1791
2066
  if (!row) {
1792
- return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2067
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1793
2068
  }
1794
2069
  const updated = await users.findById(ctx.user.id);
1795
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
2070
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
1796
2071
  user: toUserDTO(updated, ctx.user.phoneVerified)
1797
2072
  });
1798
2073
  },
@@ -1809,14 +2084,14 @@ function userController(store, config, bus) {
1809
2084
  if (Object.keys(patch).length > 0) fields.patch = patch;
1810
2085
  const row = await users.updatePreferences(ctx.user.id, fields);
1811
2086
  if (!row) {
1812
- return (0, import_core6.setApiResponse)(
1813
- import_core6.HTTP.UNPROCESSABLE,
2087
+ return (0, import_core7.setApiResponse)(
2088
+ import_core7.HTTP.UNPROCESSABLE,
1814
2089
  "INVALID_PARAMETER",
1815
2090
  "Provide at least one preference field"
1816
2091
  );
1817
2092
  }
1818
2093
  const updated = await users.findById(ctx.user.id);
1819
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2094
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
1820
2095
  user: toUserDTO(updated, ctx.user.phoneVerified)
1821
2096
  });
1822
2097
  },
@@ -1825,23 +2100,23 @@ function userController(store, config, bus) {
1825
2100
  const newEmail = body?.["email"];
1826
2101
  const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
1827
2102
  if (!normalised) {
1828
- return (0, import_core6.setApiResponse)(
1829
- import_core6.HTTP.UNPROCESSABLE,
2103
+ return (0, import_core7.setApiResponse)(
2104
+ import_core7.HTTP.UNPROCESSABLE,
1830
2105
  "INVALID_PARAMETER",
1831
2106
  "A valid email address is required"
1832
2107
  );
1833
2108
  }
1834
2109
  const oldEmail = ctx.user.email;
1835
2110
  if (normalised === oldEmail) {
1836
- return (0, import_core6.setApiResponse)(
1837
- import_core6.HTTP.UNPROCESSABLE,
2111
+ return (0, import_core7.setApiResponse)(
2112
+ import_core7.HTTP.UNPROCESSABLE,
1838
2113
  "INVALID_PARAMETER",
1839
2114
  "New email must differ from current email"
1840
2115
  );
1841
2116
  }
1842
2117
  const existing = await users.findByEmail(normalised);
1843
2118
  if (existing) {
1844
- return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2119
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
1845
2120
  }
1846
2121
  const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1847
2122
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
@@ -1863,8 +2138,8 @@ function userController(store, config, bus) {
1863
2138
  }).catch(() => {
1864
2139
  });
1865
2140
  }
1866
- return (0, import_core6.setApiResponse)(
1867
- import_core6.HTTP.OK,
2141
+ return (0, import_core7.setApiResponse)(
2142
+ import_core7.HTTP.OK,
1868
2143
  "EMAIL_UPDATED",
1869
2144
  "Email updated. A verification code has been sent to your new address.",
1870
2145
  {
@@ -1876,8 +2151,8 @@ function userController(store, config, bus) {
1876
2151
  const body = ctx.meta["body"];
1877
2152
  const newPhone = body?.["phone"];
1878
2153
  if (!isValidPhone2(newPhone)) {
1879
- return (0, import_core6.setApiResponse)(
1880
- import_core6.HTTP.UNPROCESSABLE,
2154
+ return (0, import_core7.setApiResponse)(
2155
+ import_core7.HTTP.UNPROCESSABLE,
1881
2156
  "INVALID_PARAMETER",
1882
2157
  "A valid phone number is required"
1883
2158
  );
@@ -1885,7 +2160,7 @@ function userController(store, config, bus) {
1885
2160
  const normalised = normalizePhone2(newPhone);
1886
2161
  const existing = await users.findByPhone(normalised);
1887
2162
  if (existing) {
1888
- return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2163
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
1889
2164
  }
1890
2165
  const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1891
2166
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
@@ -1907,8 +2182,8 @@ function userController(store, config, bus) {
1907
2182
  }).catch(() => {
1908
2183
  });
1909
2184
  }
1910
- return (0, import_core6.setApiResponse)(
1911
- import_core6.HTTP.OK,
2185
+ return (0, import_core7.setApiResponse)(
2186
+ import_core7.HTTP.OK,
1912
2187
  "PHONE_UPDATED",
1913
2188
  "Phone number updated. A verification code has been sent to your new number.",
1914
2189
  {
@@ -1921,22 +2196,22 @@ function userController(store, config, bus) {
1921
2196
  const currentPassword = body?.["currentPassword"];
1922
2197
  const newPassword = body?.["newPassword"];
1923
2198
  if (typeof currentPassword !== "string" || !currentPassword) {
1924
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
2199
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
1925
2200
  }
1926
2201
  if (typeof newPassword !== "string" || newPassword.length < 8) {
1927
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
2202
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
1928
2203
  }
1929
2204
  const user = await users.findById(ctx.user.id);
1930
- if (!user) return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2205
+ if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1931
2206
  const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
1932
2207
  const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
1933
2208
  if (!valid) {
1934
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
2209
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
1935
2210
  }
1936
2211
  const hash = await hashPassword2(newPassword);
1937
2212
  await users.updatePassword(ctx.user.id, hash);
1938
2213
  await sessions.deleteByUser(ctx.user.id);
1939
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2214
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
1940
2215
  },
1941
2216
  deleteMe: async (ctx) => {
1942
2217
  const userId = ctx.user.id;
@@ -1961,7 +2236,7 @@ function userController(store, config, bus) {
1961
2236
  // no MFA secret, no session tokens (session metadata only).
1962
2237
  exportMe: async (ctx) => {
1963
2238
  const user = await users.findById(ctx.user.id);
1964
- if (!user) return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2239
+ if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1965
2240
  const sessionRows = await sessions.listByUser(ctx.user.id);
1966
2241
  const bundle = {
1967
2242
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1970,8 +2245,8 @@ function userController(store, config, bus) {
1970
2245
  id: s.id,
1971
2246
  userAgent: s.userAgent,
1972
2247
  ipAddress: s.ipAddress,
1973
- createdAt: (0, import_core6.dateOrEmpty)(s.createdAt),
1974
- expiresAt: (0, import_core6.dateOrEmpty)(s.expiresAt)
2248
+ createdAt: (0, import_core7.dateOrEmpty)(s.createdAt),
2249
+ expiresAt: (0, import_core7.dateOrEmpty)(s.expiresAt)
1975
2250
  })),
1976
2251
  security: {
1977
2252
  mfaEnabled: user.mfaEnabled === true,
@@ -1990,22 +2265,23 @@ function userController(store, config, bus) {
1990
2265
  }
1991
2266
  bundle["modules"] = modules;
1992
2267
  }
1993
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
2268
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
1994
2269
  }
1995
2270
  };
1996
2271
  }
1997
2272
 
1998
2273
  // src/controllers/oauth.controller.ts
1999
- var import_core7 = require("@fonderie/core");
2274
+ var import_core8 = require("@fonderie/core");
2000
2275
  function oauthController(store, config) {
2001
2276
  const users = new UserModel(store);
2002
2277
  const sessions = new SessionModel(store);
2278
+ const loginEvents = new LoginEventModel(store);
2003
2279
  return {
2004
2280
  googleInit: async (_ctx) => {
2005
2281
  const google = config.google;
2006
2282
  if (!google) {
2007
- return (0, import_core7.setApiResponse)(
2008
- import_core7.HTTP.NOT_IMPLEMENTED,
2283
+ return (0, import_core8.setApiResponse)(
2284
+ import_core8.HTTP.NOT_IMPLEMENTED,
2009
2285
  "NOT_CONFIGURED",
2010
2286
  "Google OAuth not configured"
2011
2287
  );
@@ -2017,8 +2293,8 @@ function oauthController(store, config) {
2017
2293
  scope: "openid email profile"
2018
2294
  });
2019
2295
  const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
2020
- return (0, import_core7.setApiResponse)(
2021
- import_core7.HTTP.OK,
2296
+ return (0, import_core8.setApiResponse)(
2297
+ import_core8.HTTP.OK,
2022
2298
  "GOOGLE_AUTH_URL",
2023
2299
  "Redirect the user to the returned URL to begin Google OAuth.",
2024
2300
  { url }
@@ -2027,8 +2303,8 @@ function oauthController(store, config) {
2027
2303
  googleCallback: async (ctx) => {
2028
2304
  const google = config.google;
2029
2305
  if (!google) {
2030
- return (0, import_core7.setApiResponse)(
2031
- import_core7.HTTP.NOT_IMPLEMENTED,
2306
+ return (0, import_core8.setApiResponse)(
2307
+ import_core8.HTTP.NOT_IMPLEMENTED,
2032
2308
  "NOT_CONFIGURED",
2033
2309
  "Google OAuth not configured"
2034
2310
  );
@@ -2036,7 +2312,7 @@ function oauthController(store, config) {
2036
2312
  const url = new URL(ctx.request.url);
2037
2313
  const code = url.searchParams.get("code");
2038
2314
  if (!code) {
2039
- return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2315
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2040
2316
  }
2041
2317
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
2042
2318
  method: "POST",
@@ -2051,8 +2327,8 @@ function oauthController(store, config) {
2051
2327
  });
2052
2328
  const tokenData = await tokenRes.json();
2053
2329
  if (!tokenData.id_token) {
2054
- return (0, import_core7.setApiResponse)(
2055
- import_core7.HTTP.BAD_REQUEST,
2330
+ return (0, import_core8.setApiResponse)(
2331
+ import_core8.HTTP.BAD_REQUEST,
2056
2332
  "GOOGLE_AUTH_FAILED",
2057
2333
  "OAuth token exchange failed"
2058
2334
  );
@@ -2061,24 +2337,40 @@ function oauthController(store, config) {
2061
2337
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
2062
2338
  );
2063
2339
  if (!payload.email) {
2064
- return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
2340
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
2065
2341
  }
2066
2342
  const normalizedEmail = normalizeEmailSafe(payload.email);
2067
2343
  if (!normalizedEmail) {
2068
- return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
2344
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
2069
2345
  }
2346
+ const meta = requestMeta(ctx);
2070
2347
  const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
2071
2348
  if (!upserted) {
2072
- return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2349
+ loginEvents.recordSafe({
2350
+ userId: null,
2351
+ emailAttempted: normalizedEmail,
2352
+ method: "oauth-google",
2353
+ outcome: "failed",
2354
+ failureReason: "provider_upsert_failed",
2355
+ ...meta
2356
+ });
2357
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2073
2358
  }
2074
2359
  const fullUser = await users.findById(upserted.id);
2075
2360
  if (!fullUser) {
2076
- return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2361
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2077
2362
  }
2078
2363
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
2079
2364
  loginMethod: "google"
2080
2365
  });
2081
- await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
2366
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2367
+ loginEvents.recordSafe({
2368
+ userId: upserted.id,
2369
+ emailAttempted: normalizedEmail,
2370
+ method: "oauth-google",
2371
+ outcome: "success",
2372
+ ...meta
2373
+ });
2082
2374
  return Response.json(
2083
2375
  {
2084
2376
  reason: "GOOGLE_AUTH_SUCCESS",
@@ -2121,7 +2413,7 @@ function buildAuthRoutes(store, config, bus) {
2121
2413
  R("refresh", "POST", "/auth/refresh", (0, import_middlewares.validate)(refreshSchema), auth.refresh),
2122
2414
  // Email — Password Recovery (Public)
2123
2415
  R("forgotPassword", "POST", "/auth/email/forgot", ipLimit("forgot"), (0, import_middlewares.validate)(forgotPasswordSchema), acctLimit("forgot"), auth.forgotPassword),
2124
- R("resetPassword", "POST", "/auth/email/reset", (0, import_middlewares.validate)(resetPasswordSchema), auth.resetPassword),
2416
+ R("resetPassword", "POST", "/auth/email/reset", ipLimit("reset"), (0, import_middlewares.validate)(resetPasswordSchema), auth.resetPassword),
2125
2417
  // Verification (Protected — email or phone, determined by loginMethod)
2126
2418
  R("verifyEmail", "POST", "/auth/verify", import_middlewares2.requireAuth, (0, import_middlewares.validate)(verifySchema), auth.verify),
2127
2419
  R("sendVerification", "GET", "/auth/send-verification", import_middlewares2.requireAuth, auth.sendVerification),
@@ -2136,6 +2428,13 @@ function buildAuthRoutes(store, config, bus) {
2136
2428
  R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
2137
2429
  R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
2138
2430
  R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
2431
+ // Security surfaces (Protected; the caller's own login history + sessions).
2432
+ // The literal /others route is registered before /:id so it isn't
2433
+ // captured as an id.
2434
+ R("loginHistory", "GET", "/auth/login-history", import_middlewares2.requireAuth, user.loginHistory),
2435
+ R("listSessions", "GET", "/auth/sessions", import_middlewares2.requireAuth, user.listSessions),
2436
+ R("terminateOtherSessions", "DELETE", "/auth/sessions/others", import_middlewares2.requireAuth, user.terminateOtherSessions),
2437
+ R("terminateSession", "DELETE", "/auth/sessions/:id", import_middlewares2.requireAuth, user.terminateSession),
2139
2438
  // MFA (email sessions only — requireVerified is always enforced here
2140
2439
  // because MFA is a security feature and email verification is meaningful)
2141
2440
  R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
@@ -2155,17 +2454,17 @@ function buildAuthRoutes(store, config, bus) {
2155
2454
  }
2156
2455
 
2157
2456
  // src/services/config-guard.ts
2158
- var import_core8 = require("@fonderie/core");
2457
+ var import_core9 = require("@fonderie/core");
2159
2458
  var MODULE = "@fonderie/auth";
2160
2459
  function collectAuthConfigProblems(config) {
2161
2460
  const problems = [];
2162
2461
  const secret = config.jwtSecret ?? "";
2163
- const secretProblem = (0, import_core8.secretStrengthProblem)(secret);
2462
+ const secretProblem = (0, import_core9.secretStrengthProblem)(secret);
2164
2463
  if (secretProblem === "too-short") {
2165
2464
  problems.push({
2166
2465
  module: MODULE,
2167
2466
  severity: "error",
2168
- message: `jwtSecret must be at least ${import_core8.MIN_SECRET_LENGTH} characters (got ${secret.length})`
2467
+ message: `jwtSecret must be at least ${import_core9.MIN_SECRET_LENGTH} characters (got ${secret.length})`
2169
2468
  });
2170
2469
  } else if (secretProblem === "placeholder") {
2171
2470
  problems.push({
@@ -2189,7 +2488,7 @@ function collectAuthConfigProblems(config) {
2189
2488
  severity: "error",
2190
2489
  message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
2191
2490
  });
2192
- } else if (import_core8.PLACEHOLDER_SECRET.test(clientSecret)) {
2491
+ } else if (import_core9.PLACEHOLDER_SECRET.test(clientSecret)) {
2193
2492
  problems.push({
2194
2493
  module: MODULE,
2195
2494
  severity: "error",
@@ -2256,7 +2555,10 @@ function withSession(store, config) {
2256
2555
  ...user,
2257
2556
  loginMethod: payload.loginMethod ?? "email",
2258
2557
  phoneVerified: payload.phoneVerified ?? false,
2259
- mfaPending: payload.mfaPending ?? false
2558
+ mfaPending: payload.mfaPending ?? false,
2559
+ // The session this request is authenticated on, so the sessions
2560
+ // API can flag the current row and "terminate others" can spare it.
2561
+ sid: payload.sid ?? null
2260
2562
  }
2261
2563
  });
2262
2564
  return next();