@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.d.cts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.cjs';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-CTSCI09F.cjs';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CTSCI09F.cjs';
5
+ import { I as IAuthConfig } from './session-jITwnc3m.cjs';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-jITwnc3m.cjs';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.cjs';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -65,6 +65,28 @@ declare const DEFAULT_TEMPLATES: {
65
65
  };
66
66
  };
67
67
 
68
+ interface ILoginEventDTO {
69
+ id: string;
70
+ method: string;
71
+ outcome: string;
72
+ failureReason: string | null;
73
+ ipAddress: string | null;
74
+ userAgent: string | null;
75
+ createdAt: string;
76
+ }
77
+ interface ILoginHistoryPageDTO {
78
+ events: ILoginEventDTO[];
79
+ nextCursor: string | null;
80
+ }
81
+ interface ISessionDTO {
82
+ id: string;
83
+ current: boolean;
84
+ ipAddress: string | null;
85
+ userAgent: string | null;
86
+ createdAt: string;
87
+ expiresAt: string;
88
+ }
89
+
68
90
  declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
69
91
  email: z.ZodPipe<z.ZodString, z.ZodEmail>;
70
92
  password: z.ZodString;
@@ -183,4 +205,4 @@ declare function startUserRetention(store: IStoreAdapter, options: IUserRetentio
183
205
 
184
206
  declare function validateAuthConfig(config: IAuthConfig): void;
185
207
 
186
- export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
208
+ export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, type ISessionDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.js';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-CTSCI09F.js';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CTSCI09F.js';
5
+ import { I as IAuthConfig } from './session-jITwnc3m.js';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-jITwnc3m.js';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.js';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -65,6 +65,28 @@ declare const DEFAULT_TEMPLATES: {
65
65
  };
66
66
  };
67
67
 
68
+ interface ILoginEventDTO {
69
+ id: string;
70
+ method: string;
71
+ outcome: string;
72
+ failureReason: string | null;
73
+ ipAddress: string | null;
74
+ userAgent: string | null;
75
+ createdAt: string;
76
+ }
77
+ interface ILoginHistoryPageDTO {
78
+ events: ILoginEventDTO[];
79
+ nextCursor: string | null;
80
+ }
81
+ interface ISessionDTO {
82
+ id: string;
83
+ current: boolean;
84
+ ipAddress: string | null;
85
+ userAgent: string | null;
86
+ createdAt: string;
87
+ expiresAt: string;
88
+ }
89
+
68
90
  declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
69
91
  email: z.ZodPipe<z.ZodString, z.ZodEmail>;
70
92
  password: z.ZodString;
@@ -183,4 +205,4 @@ declare function startUserRetention(store: IStoreAdapter, options: IUserRetentio
183
205
 
184
206
  declare function validateAuthConfig(config: IAuthConfig): void;
185
207
 
186
- export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
208
+ export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, type ISessionDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
package/dist/index.js CHANGED
@@ -86,6 +86,12 @@ var DEFAULTS = {
86
86
  account: { capacity: 3, refillPerSec: 3 / min(60) },
87
87
  accountField: "email"
88
88
  },
89
+ // reset: 10/15min per IP — the reset PIN is a 6-digit code looked up
90
+ // globally, so an unthrottled endpoint is brute-forceable into an account
91
+ // takeover. There's no email in the reset body to key an account bucket on,
92
+ // so this IP bucket is the guard. (Moving reset to a high-entropy opaque
93
+ // token is tracked as a follow-up hardening.)
94
+ reset: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
89
95
  // mfaVerify: 10/15min per IP — TOTP brute-force.
90
96
  mfaVerify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } }
91
97
  };
@@ -691,17 +697,23 @@ var SessionModel = class {
691
697
  this.store = store;
692
698
  }
693
699
  store;
694
- async create(userId, token, expiresAt, sid) {
700
+ async create(userId, token, expiresAt, sid, meta) {
695
701
  await this.store.query(
696
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
697
- VALUES ($1, $2, $3, $4)
702
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
703
+ VALUES ($1, $2, $3, $4, $5, $6)
698
704
  ON CONFLICT (token) DO NOTHING`,
699
- [userId, token, expiresAt, sid ?? null]
705
+ [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
700
706
  );
701
707
  }
702
708
  async delete(token) {
703
709
  await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
704
710
  }
711
+ // Delete the session an access token is bound to (by its sid claim). Lets
712
+ // logout revoke the current session without the client resending the refresh
713
+ // token — the access token already identifies its session.
714
+ async deleteBySid(sid) {
715
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);
716
+ }
705
717
  // Revoke every session for a user (e.g. on password change). Access tokens
706
718
  // bound to these sessions via the sid claim die on their next request.
707
719
  async deleteByUser(userId) {
@@ -721,6 +733,42 @@ var SessionModel = class {
721
733
  [userId]
722
734
  );
723
735
  }
736
+ // Live sessions for the Active Sessions screen: unexpired only, carrying the
737
+ // sid so the caller can flag the current one (row.sid === the JWT's sid).
738
+ // No token — that never leaves the server.
739
+ async listLiveByUser(userId) {
740
+ return this.store.query(
741
+ `SELECT id, sid,
742
+ user_agent AS "userAgent",
743
+ ip_address AS "ipAddress",
744
+ created_at AS "createdAt",
745
+ expires_at AS "expiresAt"
746
+ FROM fonderie_sessions
747
+ WHERE user_id = $1 AND expires_at > now()
748
+ ORDER BY created_at DESC`,
749
+ [userId]
750
+ );
751
+ }
752
+ // Terminate one session by row id, scoped to its owner (a user can never
753
+ // delete another user's session). Returns whether a row was removed.
754
+ async terminateById(userId, id) {
755
+ const rows = await this.store.query(
756
+ `DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
757
+ [id, userId]
758
+ );
759
+ return rows.length > 0;
760
+ }
761
+ // Terminate all of a user's sessions except the current one (by sid).
762
+ // Returns the number of sessions removed.
763
+ async terminateOthers(userId, keepSid) {
764
+ const rows = await this.store.query(
765
+ `DELETE FROM fonderie_sessions
766
+ WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
767
+ RETURNING id`,
768
+ [userId, keepSid]
769
+ );
770
+ return rows.length;
771
+ }
724
772
  async exists(token) {
725
773
  const rows = await this.store.query(
726
774
  `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
@@ -773,11 +821,92 @@ var BackupCodeModel = class {
773
821
  }
774
822
  };
775
823
 
824
+ // src/models/login-event.model.ts
825
+ var LoginEventModel = class {
826
+ constructor(store) {
827
+ this.store = store;
828
+ }
829
+ store;
830
+ async record(e) {
831
+ await this.store.query(
832
+ `INSERT INTO fonderie_login_events
833
+ (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
834
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
835
+ [
836
+ e.userId,
837
+ e.emailAttempted ?? null,
838
+ e.method,
839
+ e.outcome,
840
+ e.failureReason ?? null,
841
+ e.ipAddress,
842
+ e.userAgent
843
+ ]
844
+ );
845
+ }
846
+ // Fire-and-forget variant for the login paths: recording history must never
847
+ // block or fail a login (same posture as the bus.emit(...).catch(() => {})
848
+ // notification sends).
849
+ recordSafe(e) {
850
+ void this.record(e).catch(() => {
851
+ });
852
+ }
853
+ // One page of a user's own login history, newest first. Keyset-paginated on
854
+ // (created_at, id) — mirrors audit's event list so the two log UIs share a
855
+ // cursor contract. The +1 over-fetch that detects a next page lives here so
856
+ // no outer clamp can shave it off.
857
+ async listByUser(query) {
858
+ const limit = Math.min(query.limit ?? 50, MAX_HISTORY_LIMIT);
859
+ const params = [query.userId];
860
+ const where = ["user_id = $1"];
861
+ if (query.outcome) {
862
+ params.push(query.outcome);
863
+ where.push(`outcome = $${params.length}`);
864
+ }
865
+ if (query.from) {
866
+ params.push(query.from);
867
+ where.push(`created_at >= $${params.length}`);
868
+ }
869
+ if (query.to) {
870
+ params.push(query.to);
871
+ where.push(`created_at <= $${params.length}`);
872
+ }
873
+ if (query.cursor) {
874
+ params.push(query.cursor.createdAt, query.cursor.id);
875
+ where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
876
+ }
877
+ params.push(limit + 1);
878
+ const rows = await this.store.query(
879
+ `SELECT id, method, outcome, failure_reason AS "failureReason",
880
+ ip_address AS "ipAddress", user_agent AS "userAgent",
881
+ created_at AS "createdAt", created_at::text AS "createdAtRaw"
882
+ FROM fonderie_login_events
883
+ WHERE ${where.join(" AND ")}
884
+ ORDER BY created_at DESC, id DESC
885
+ LIMIT $${params.length}`,
886
+ params
887
+ );
888
+ return { events: rows.slice(0, limit), hasMore: rows.length > limit };
889
+ }
890
+ };
891
+ var MAX_HISTORY_LIMIT = 200;
892
+
893
+ // src/services/request-meta.ts
894
+ var UA_MAX = 512;
895
+ function requestMeta(ctx) {
896
+ const ip = ctx.meta["clientIp"];
897
+ const ua = ctx.request.headers.get("user-agent");
898
+ return {
899
+ ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
900
+ userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
901
+ };
902
+ }
903
+
776
904
  // src/controllers/mfa.controller.ts
777
905
  function mfaController(store, config, issuer, bus) {
778
906
  const users = new UserModel(store);
779
907
  const sessions = new SessionModel(store);
780
908
  const backupCodes = new BackupCodeModel(store);
909
+ const loginEvents = new LoginEventModel(store);
781
910
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
782
911
  return {
783
912
  // ── 1. Setup ───────────────────────────────────────────────
@@ -807,6 +936,16 @@ function mfaController(store, config, issuer, bus) {
807
936
  verify: async (ctx) => {
808
937
  const body = ctx.meta["body"];
809
938
  const token = body?.["token"];
939
+ const isLoginCompletion = ctx.user.mfaPending === true;
940
+ const meta = requestMeta(ctx);
941
+ const failedMfa = () => loginEvents.recordSafe({
942
+ userId: ctx.user.id,
943
+ emailAttempted: ctx.user.email,
944
+ method: "mfa",
945
+ outcome: "failed",
946
+ failureReason: "invalid_code",
947
+ ...meta
948
+ });
810
949
  if (typeof token !== "string") {
811
950
  return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
812
951
  }
@@ -847,6 +986,7 @@ function mfaController(store, config, issuer, bus) {
847
986
  );
848
987
  const matched = checks.find((r) => r.match);
849
988
  if (!matched) {
989
+ if (isLoginCompletion) failedMfa();
850
990
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
851
991
  }
852
992
  await backupCodes.consume(matched.id);
@@ -864,6 +1004,7 @@ function mfaController(store, config, issuer, bus) {
864
1004
  }
865
1005
  const secret = mfaCipher.decrypt(storedSecret);
866
1006
  if (!verifyTotpToken(token, secret)) {
1007
+ if (isLoginCompletion) failedMfa();
867
1008
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
868
1009
  }
869
1010
  if (!ctx.user.mfaEnabled) {
@@ -874,7 +1015,16 @@ function mfaController(store, config, issuer, bus) {
874
1015
  loginMethod: ctx.user.loginMethod,
875
1016
  phoneVerified: ctx.user.phoneVerified
876
1017
  });
877
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1018
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1019
+ if (isLoginCompletion) {
1020
+ loginEvents.recordSafe({
1021
+ userId: ctx.user.id,
1022
+ emailAttempted: ctx.user.email,
1023
+ method: "mfa",
1024
+ outcome: "success",
1025
+ ...meta
1026
+ });
1027
+ }
878
1028
  const fullUser = await users.findById(ctx.user.id);
879
1029
  if (!fullUser) {
880
1030
  return setApiResponse2(HTTP2.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
@@ -1143,6 +1293,7 @@ function extractRefreshToken(ctx) {
1143
1293
  function authController(store, config, bus) {
1144
1294
  const users = new UserModel(store);
1145
1295
  const sessions = new SessionModel(store);
1296
+ const loginEvents = new LoginEventModel(store);
1146
1297
  const passwordReset = new PasswordResetModel(store);
1147
1298
  const emailVerif = new EmailVerificationModel(store);
1148
1299
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1212,7 +1363,7 @@ function authController(store, config, bus) {
1212
1363
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1213
1364
  loginMethod: "email"
1214
1365
  });
1215
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1366
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1216
1367
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1217
1368
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1218
1369
  return Response.json(
@@ -1276,7 +1427,7 @@ function authController(store, config, bus) {
1276
1427
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1277
1428
  loginMethod: "phone"
1278
1429
  });
1279
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1430
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1280
1431
  return Response.json(
1281
1432
  {
1282
1433
  reason: "USER_PHONE_REGISTERED",
@@ -1300,6 +1451,7 @@ function authController(store, config, bus) {
1300
1451
  },
1301
1452
  login: async (ctx) => {
1302
1453
  const body = ctx.meta["body"];
1454
+ const meta = requestMeta(ctx);
1303
1455
  if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
1304
1456
  const { email: rawEmail, password: password2 } = body;
1305
1457
  const email2 = normalizeEmailSafe(rawEmail);
@@ -1308,6 +1460,14 @@ function authController(store, config, bus) {
1308
1460
  }
1309
1461
  const user = await users.findByEmail(email2);
1310
1462
  if (!user || !user.passwordHash) {
1463
+ loginEvents.recordSafe({
1464
+ userId: null,
1465
+ emailAttempted: email2,
1466
+ method: "password",
1467
+ outcome: "failed",
1468
+ failureReason: "unknown_email",
1469
+ ...meta
1470
+ });
1311
1471
  return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1312
1472
  }
1313
1473
  const { valid, needsRehash } = await verifyPasswordForLogin(
@@ -1316,6 +1476,14 @@ function authController(store, config, bus) {
1316
1476
  config.legacyVerify
1317
1477
  );
1318
1478
  if (!valid) {
1479
+ loginEvents.recordSafe({
1480
+ userId: user.id,
1481
+ emailAttempted: email2,
1482
+ method: "password",
1483
+ outcome: "failed",
1484
+ failureReason: "bad_password",
1485
+ ...meta
1486
+ });
1319
1487
  return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1320
1488
  }
1321
1489
  if (needsRehash) {
@@ -1337,7 +1505,14 @@ function authController(store, config, bus) {
1337
1505
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1338
1506
  loginMethod: "email"
1339
1507
  });
1340
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1508
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1509
+ loginEvents.recordSafe({
1510
+ userId: user.id,
1511
+ emailAttempted: email2,
1512
+ method: "password",
1513
+ outcome: "success",
1514
+ ...meta
1515
+ });
1341
1516
  const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
1342
1517
  const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
1343
1518
  return Response.json(
@@ -1382,7 +1557,7 @@ function authController(store, config, bus) {
1382
1557
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1383
1558
  loginMethod: "phone"
1384
1559
  });
1385
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1560
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1386
1561
  return Response.json(
1387
1562
  {
1388
1563
  reason: "USER_PHONE_OTP_SENT",
@@ -1405,6 +1580,10 @@ function authController(store, config, bus) {
1405
1580
  );
1406
1581
  },
1407
1582
  logout: async (ctx) => {
1583
+ const sid = ctx.user?.sid;
1584
+ if (sid) {
1585
+ await sessions.deleteBySid(sid).catch(() => void 0);
1586
+ }
1408
1587
  const token = extractRefreshToken(ctx);
1409
1588
  if (token) {
1410
1589
  await sessions.delete(token).catch(() => void 0);
@@ -1443,7 +1622,7 @@ function authController(store, config, bus) {
1443
1622
  loginMethod: payload.loginMethod ?? "email",
1444
1623
  phoneVerified: payload.phoneVerified ?? false
1445
1624
  });
1446
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1625
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1447
1626
  return Response.json(
1448
1627
  {
1449
1628
  reason: "TOKENS_REFRESHED",
@@ -1577,7 +1756,7 @@ function authController(store, config, bus) {
1577
1756
  loginMethod: "phone",
1578
1757
  phoneVerified: true
1579
1758
  });
1580
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1759
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1581
1760
  const verifiedUser = await users.findById(ctx.user.id);
1582
1761
  if (!verifiedUser) {
1583
1762
  return setApiResponse3(HTTP3.NOT_FOUND, "NOT_FOUND", "User not found");
@@ -1712,6 +1891,38 @@ function authController(store, config, bus) {
1712
1891
  import { randomInt as randomInt2 } from "crypto";
1713
1892
  import { setApiResponse as setApiResponse4, HTTP as HTTP4, dateOrEmpty as dateOrEmpty2 } from "@fonderie/core";
1714
1893
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
1894
+
1895
+ // src/dtos/login-activity.ts
1896
+ import { encodeKeysetCursor, decodeKeysetCursor } from "@fonderie/core";
1897
+ function toLoginEventDTO(row) {
1898
+ return {
1899
+ id: row.id,
1900
+ method: row.method,
1901
+ outcome: row.outcome,
1902
+ failureReason: row.failureReason,
1903
+ ipAddress: row.ipAddress,
1904
+ userAgent: row.userAgent,
1905
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
1906
+ };
1907
+ }
1908
+ function toLoginHistoryPageDTO(page) {
1909
+ const last = page.events[page.events.length - 1];
1910
+ const nextCursor = page.hasMore && last ? encodeKeysetCursor(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
1911
+ return { events: page.events.map(toLoginEventDTO), nextCursor };
1912
+ }
1913
+ var decodeLoginCursor = decodeKeysetCursor;
1914
+ function toSessionDTO(row, currentSid) {
1915
+ return {
1916
+ id: row.id,
1917
+ current: currentSid !== null && row.sid === currentSid,
1918
+ ipAddress: row.ipAddress,
1919
+ userAgent: row.userAgent,
1920
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
1921
+ expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
1922
+ };
1923
+ }
1924
+
1925
+ // src/controllers/user.controller.ts
1715
1926
  function normalizePhone2(phone2) {
1716
1927
  return phone2.trim().replace(/[\s()\-\.]/g, "");
1717
1928
  }
@@ -1721,6 +1932,7 @@ function isValidPhone2(phone2) {
1721
1932
  function userController(store, config, bus) {
1722
1933
  const users = new UserModel(store);
1723
1934
  const sessions = new SessionModel(store);
1935
+ const loginEvents = new LoginEventModel(store);
1724
1936
  const emailVerif = new EmailVerificationModel(store);
1725
1937
  const phoneVerif = new PhoneVerificationModel(store);
1726
1938
  return {
@@ -1733,6 +1945,69 @@ function userController(store, config, bus) {
1733
1945
  user: toUserDTO(user, ctx.user.phoneVerified)
1734
1946
  });
1735
1947
  },
1948
+ // ── Login history (append-only; the caller's own attempts) ────────
1949
+ loginHistory: async (ctx) => {
1950
+ const params = new URL(ctx.request.url).searchParams;
1951
+ const rawLimit = Number(params.get("limit") ?? 50);
1952
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
1953
+ const outcomeParam = params.get("outcome");
1954
+ if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
1955
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
1956
+ }
1957
+ const cursorParam = params.get("cursor");
1958
+ let cursor = null;
1959
+ if (cursorParam) {
1960
+ cursor = decodeLoginCursor(cursorParam);
1961
+ if (!cursor) {
1962
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
1963
+ }
1964
+ }
1965
+ const query = { userId: ctx.user.id, limit };
1966
+ if (outcomeParam) query.outcome = outcomeParam;
1967
+ if (cursor) query.cursor = cursor;
1968
+ const from = params.get("from");
1969
+ const to = params.get("to");
1970
+ if (from) query.from = new Date(from);
1971
+ if (to) query.to = new Date(to);
1972
+ const page = await loginEvents.listByUser(query);
1973
+ return setApiResponse4(
1974
+ HTTP4.OK,
1975
+ "LOGIN_HISTORY_FETCHED",
1976
+ "Login history retrieved.",
1977
+ toLoginHistoryPageDTO(page)
1978
+ );
1979
+ },
1980
+ // ── Active sessions (live only; the caller's own) ─────────────────
1981
+ listSessions: async (ctx) => {
1982
+ const rows = await sessions.listLiveByUser(ctx.user.id);
1983
+ const currentSid = ctx.user.sid ?? null;
1984
+ return setApiResponse4(HTTP4.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
1985
+ sessions: rows.map((r) => toSessionDTO(r, currentSid))
1986
+ });
1987
+ },
1988
+ terminateSession: async (ctx) => {
1989
+ const id = ctx.meta["params"]?.["id"];
1990
+ if (!id) {
1991
+ return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
1992
+ }
1993
+ const removed = await sessions.terminateById(ctx.user.id, id);
1994
+ if (!removed) {
1995
+ return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
1996
+ }
1997
+ return setApiResponse4(HTTP4.OK, "SESSION_TERMINATED", "Session terminated.", { id });
1998
+ },
1999
+ terminateOtherSessions: async (ctx) => {
2000
+ const currentSid = ctx.user.sid ?? null;
2001
+ if (!currentSid) {
2002
+ return setApiResponse4(
2003
+ HTTP4.UNPROCESSABLE,
2004
+ "NO_CURRENT_SESSION",
2005
+ "Cannot terminate others without a current session context"
2006
+ );
2007
+ }
2008
+ const count = await sessions.terminateOthers(ctx.user.id, currentSid);
2009
+ return setApiResponse4(HTTP4.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2010
+ },
1736
2011
  updateProfile: async (ctx) => {
1737
2012
  const body = ctx.meta["body"];
1738
2013
  const allowed = ["firstName", "lastName", "avatarUrl"];
@@ -1960,6 +2235,7 @@ import { setApiResponse as setApiResponse5, HTTP as HTTP5 } from "@fonderie/core
1960
2235
  function oauthController(store, config) {
1961
2236
  const users = new UserModel(store);
1962
2237
  const sessions = new SessionModel(store);
2238
+ const loginEvents = new LoginEventModel(store);
1963
2239
  return {
1964
2240
  googleInit: async (_ctx) => {
1965
2241
  const google = config.google;
@@ -2027,8 +2303,17 @@ function oauthController(store, config) {
2027
2303
  if (!normalizedEmail) {
2028
2304
  return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
2029
2305
  }
2306
+ const meta = requestMeta(ctx);
2030
2307
  const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
2031
2308
  if (!upserted) {
2309
+ loginEvents.recordSafe({
2310
+ userId: null,
2311
+ emailAttempted: normalizedEmail,
2312
+ method: "oauth-google",
2313
+ outcome: "failed",
2314
+ failureReason: "provider_upsert_failed",
2315
+ ...meta
2316
+ });
2032
2317
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2033
2318
  }
2034
2319
  const fullUser = await users.findById(upserted.id);
@@ -2038,7 +2323,14 @@ function oauthController(store, config) {
2038
2323
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
2039
2324
  loginMethod: "google"
2040
2325
  });
2041
- await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
2326
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2327
+ loginEvents.recordSafe({
2328
+ userId: upserted.id,
2329
+ emailAttempted: normalizedEmail,
2330
+ method: "oauth-google",
2331
+ outcome: "success",
2332
+ ...meta
2333
+ });
2042
2334
  return Response.json(
2043
2335
  {
2044
2336
  reason: "GOOGLE_AUTH_SUCCESS",
@@ -2081,7 +2373,7 @@ function buildAuthRoutes(store, config, bus) {
2081
2373
  R("refresh", "POST", "/auth/refresh", validate(refreshSchema), auth.refresh),
2082
2374
  // Email — Password Recovery (Public)
2083
2375
  R("forgotPassword", "POST", "/auth/email/forgot", ipLimit("forgot"), validate(forgotPasswordSchema), acctLimit("forgot"), auth.forgotPassword),
2084
- R("resetPassword", "POST", "/auth/email/reset", validate(resetPasswordSchema), auth.resetPassword),
2376
+ R("resetPassword", "POST", "/auth/email/reset", ipLimit("reset"), validate(resetPasswordSchema), auth.resetPassword),
2085
2377
  // Verification (Protected — email or phone, determined by loginMethod)
2086
2378
  R("verifyEmail", "POST", "/auth/verify", requireAuth, validate(verifySchema), auth.verify),
2087
2379
  R("sendVerification", "GET", "/auth/send-verification", requireAuth, auth.sendVerification),
@@ -2096,6 +2388,13 @@ function buildAuthRoutes(store, config, bus) {
2096
2388
  R("changePassword", "PUT", "/users/password", requireAuth, validate(changePasswordSchema), user.changePassword),
2097
2389
  R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
2098
2390
  R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
2391
+ // Security surfaces (Protected; the caller's own login history + sessions).
2392
+ // The literal /others route is registered before /:id so it isn't
2393
+ // captured as an id.
2394
+ R("loginHistory", "GET", "/auth/login-history", requireAuth, user.loginHistory),
2395
+ R("listSessions", "GET", "/auth/sessions", requireAuth, user.listSessions),
2396
+ R("terminateOtherSessions", "DELETE", "/auth/sessions/others", requireAuth, user.terminateOtherSessions),
2397
+ R("terminateSession", "DELETE", "/auth/sessions/:id", requireAuth, user.terminateSession),
2099
2398
  // MFA (email sessions only — requireVerified is always enforced here
2100
2399
  // because MFA is a security feature and email verification is meaningful)
2101
2400
  R("mfaSetup", "POST", "/auth/mfa/setup", requireAuth, requireEmailLogin, requireVerified, mfa.setup),
@@ -2216,7 +2515,10 @@ function withSession(store, config) {
2216
2515
  ...user,
2217
2516
  loginMethod: payload.loginMethod ?? "email",
2218
2517
  phoneVerified: payload.phoneVerified ?? false,
2219
- mfaPending: payload.mfaPending ?? false
2518
+ mfaPending: payload.mfaPending ?? false,
2519
+ // The session this request is authenticated on, so the sessions
2520
+ // API can flag the current row and "terminate others" can spare it.
2521
+ sid: payload.sid ?? null
2220
2522
  }
2221
2523
  });
2222
2524
  return next();