@fonderie/auth 7.9.4 → 7.11.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
@@ -3,8 +3,8 @@ export { IMfaChallenge, ISession } from './types.cjs';
3
3
  import { IFonderieModule, IAdminDescription, IReadinessProblem, IFonderieApp, IAdminRoute } from '@fonderie/core';
4
4
  import { IStoreAdapter } from '@fonderie/store';
5
5
  import { EventBus } from '@fonderie/events';
6
- import { I as IAuthConfig } from './session-Bv4qu9xV.cjs';
7
- 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-Bv4qu9xV.cjs';
6
+ import { I as IAuthConfig, a as IRequestLocation } from './session-BBF5t-3Y.cjs';
7
+ export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, d as IAuthRateLimitConfig, e as IAuthRuntimeConfig, f as IAuthSecrets, g as IDataExportContributor, h as ILocationRequest, L as LocationResolver, M as MESSAGE_KEYS, i as buildAuthAccountLimiter, j as buildAuthIpLimiter, r as resolveLocation, s as sanitizeLocation, w as withSession } from './session-BBF5t-3Y.cjs';
8
8
  import { IUserDTO } from './dtos/user.cjs';
9
9
  export { toUserDTO } from './dtos/user.cjs';
10
10
  export { requireAuth, validate } from '@fonderie/core/middlewares';
@@ -96,6 +96,9 @@ interface ILoginEventDTO {
96
96
  failureReason: string | null;
97
97
  ipAddress: string | null;
98
98
  userAgent: string | null;
99
+ /** Where the attempt came from — present only when the app configures
100
+ * `location`. Country is reliable; region/city are approximate. */
101
+ location: IRequestLocation | null;
99
102
  createdAt: string;
100
103
  }
101
104
  interface ILoginHistoryPageDTO {
@@ -107,6 +110,8 @@ interface ISessionDTO {
107
110
  current: boolean;
108
111
  ipAddress: string | null;
109
112
  userAgent: string | null;
113
+ /** Where the session was opened from, when `location` is configured. */
114
+ location: IRequestLocation | null;
110
115
  createdAt: string;
111
116
  expiresAt: string;
112
117
  }
@@ -257,4 +262,4 @@ interface IAdminUserPageDTO {
257
262
  declare function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO;
258
263
  declare function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[];
259
264
 
260
- export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, type IAdminUserDTO, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, type ISessionDTO, IUser, IUserDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, describeAuthAdminRoutes, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, toAdminUserDTO, toAdminUserPageDTO, validateAuthConfig };
265
+ export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, type IAdminUserDTO, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, IRequestLocation, type ISessionDTO, IUser, IUserDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, describeAuthAdminRoutes, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, toAdminUserDTO, toAdminUserPageDTO, validateAuthConfig };
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export { IMfaChallenge, ISession } from './types.js';
3
3
  import { IFonderieModule, IAdminDescription, IReadinessProblem, IFonderieApp, IAdminRoute } from '@fonderie/core';
4
4
  import { IStoreAdapter } from '@fonderie/store';
5
5
  import { EventBus } from '@fonderie/events';
6
- import { I as IAuthConfig } from './session-Bv4qu9xV.js';
7
- 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-Bv4qu9xV.js';
6
+ import { I as IAuthConfig, a as IRequestLocation } from './session-BBF5t-3Y.js';
7
+ export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, d as IAuthRateLimitConfig, e as IAuthRuntimeConfig, f as IAuthSecrets, g as IDataExportContributor, h as ILocationRequest, L as LocationResolver, M as MESSAGE_KEYS, i as buildAuthAccountLimiter, j as buildAuthIpLimiter, r as resolveLocation, s as sanitizeLocation, w as withSession } from './session-BBF5t-3Y.js';
8
8
  import { IUserDTO } from './dtos/user.js';
9
9
  export { toUserDTO } from './dtos/user.js';
10
10
  export { requireAuth, validate } from '@fonderie/core/middlewares';
@@ -96,6 +96,9 @@ interface ILoginEventDTO {
96
96
  failureReason: string | null;
97
97
  ipAddress: string | null;
98
98
  userAgent: string | null;
99
+ /** Where the attempt came from — present only when the app configures
100
+ * `location`. Country is reliable; region/city are approximate. */
101
+ location: IRequestLocation | null;
99
102
  createdAt: string;
100
103
  }
101
104
  interface ILoginHistoryPageDTO {
@@ -107,6 +110,8 @@ interface ISessionDTO {
107
110
  current: boolean;
108
111
  ipAddress: string | null;
109
112
  userAgent: string | null;
113
+ /** Where the session was opened from, when `location` is configured. */
114
+ location: IRequestLocation | null;
110
115
  createdAt: string;
111
116
  expiresAt: string;
112
117
  }
@@ -257,4 +262,4 @@ interface IAdminUserPageDTO {
257
262
  declare function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO;
258
263
  declare function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[];
259
264
 
260
- export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, type IAdminUserDTO, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, type ISessionDTO, IUser, IUserDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, describeAuthAdminRoutes, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, toAdminUserDTO, toAdminUserPageDTO, validateAuthConfig };
265
+ export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, type IAdminUserDTO, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, IRequestLocation, type ISessionDTO, IUser, IUserDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, describeAuthAdminRoutes, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, toAdminUserDTO, toAdminUserPageDTO, validateAuthConfig };
package/dist/index.js CHANGED
@@ -838,18 +838,111 @@ var UserModel = class {
838
838
  };
839
839
  var MAX_LIST_LIMIT = 200;
840
840
 
841
+ // src/services/request-location.ts
842
+ var LOCATION_TIMEOUT_MS = 500;
843
+ var TEXT_MAX = 128;
844
+ function str(v, re) {
845
+ if (typeof v !== "string") return null;
846
+ const t = v.trim();
847
+ if (t.length === 0 || t.length > TEXT_MAX) return null;
848
+ return re && !re.test(t) ? null : t;
849
+ }
850
+ function upperCode(v, re) {
851
+ const t = str(v, re);
852
+ return t ? t.toUpperCase() : null;
853
+ }
854
+ function bool(v) {
855
+ return typeof v === "boolean" ? v : null;
856
+ }
857
+ function coord(v, limit) {
858
+ if (typeof v !== "number" || !Number.isFinite(v) || Math.abs(v) > limit) return null;
859
+ return Math.round(v * 100) / 100;
860
+ }
861
+ function radius(v) {
862
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0 || v > 2e4) return null;
863
+ return Math.round(v);
864
+ }
865
+ function placeId(v) {
866
+ const n = typeof v === "string" && /^\d{1,10}$/.test(v) ? Number(v) : v;
867
+ return typeof n === "number" && Number.isInteger(n) && n > 0 && n <= 2147483647 ? n : null;
868
+ }
869
+ function sanitizeLocation(input) {
870
+ if (!input || typeof input !== "object") return null;
871
+ const i = input;
872
+ const out = {
873
+ country: upperCode(i["country"], /^[A-Za-z]{2}$/),
874
+ countryName: str(i["countryName"]),
875
+ subdivision: upperCode(i["subdivision"], /^[A-Za-z0-9]{1,3}$/),
876
+ subdivisionName: str(i["subdivisionName"]),
877
+ city: str(i["city"]),
878
+ postalCode: upperCode(i["postalCode"], /^[A-Za-z0-9][A-Za-z0-9 -]{0,11}$/),
879
+ continent: upperCode(i["continent"], /^[A-Za-z]{2}$/),
880
+ timeZone: str(i["timeZone"], /^(UTC|[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+)$/),
881
+ latitude: coord(i["latitude"], 90),
882
+ longitude: coord(i["longitude"], 180),
883
+ accuracyRadius: radius(i["accuracyRadius"]),
884
+ geonameId: placeId(i["geonameId"]),
885
+ isp: str(i["isp"]),
886
+ org: str(i["org"]),
887
+ asn: upperCode(i["asn"], /^AS\d{1,10}$/i),
888
+ mobile: bool(i["mobile"]),
889
+ proxy: bool(i["proxy"]),
890
+ hosting: bool(i["hosting"])
891
+ };
892
+ const known = Object.fromEntries(Object.entries(out).filter(([, v]) => v !== null));
893
+ return Object.keys(known).length > 0 ? known : null;
894
+ }
895
+ var perRequest = /* @__PURE__ */ new WeakMap();
896
+ function resolveLocation(resolver, req, timeoutMs = LOCATION_TIMEOUT_MS) {
897
+ if (!resolver) return Promise.resolve(null);
898
+ const cached = perRequest.get(req.headers);
899
+ if (cached && cached.resolver === resolver) return cached.result;
900
+ const result = resolveOnce(resolver, req, timeoutMs);
901
+ perRequest.set(req.headers, { resolver, result });
902
+ return result;
903
+ }
904
+ async function resolveOnce(resolver, req, timeoutMs) {
905
+ let timer;
906
+ try {
907
+ const result = await Promise.race([
908
+ Promise.resolve().then(() => resolver(req)),
909
+ new Promise((resolve2) => {
910
+ timer = setTimeout(() => resolve2(null), timeoutMs);
911
+ })
912
+ ]);
913
+ return sanitizeLocation(result);
914
+ } catch (err) {
915
+ console.warn("[auth] location resolver failed; recording without a location:", err);
916
+ return null;
917
+ } finally {
918
+ if (timer) clearTimeout(timer);
919
+ }
920
+ }
921
+
841
922
  // src/models/session.model.ts
842
923
  var SessionModel = class {
843
- constructor(store) {
924
+ // `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
925
+ constructor(store, locate) {
844
926
  this.store = store;
927
+ this.locate = locate;
845
928
  }
846
929
  store;
930
+ locate;
847
931
  async create(userId, token, expiresAt, sid, meta) {
932
+ const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
848
933
  await this.store.query(
849
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
850
- VALUES ($1, $2, $3, $4, $5, $6)
934
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
935
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
851
936
  ON CONFLICT (token) DO NOTHING`,
852
- [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
937
+ [
938
+ userId,
939
+ token,
940
+ expiresAt,
941
+ sid ?? null,
942
+ meta?.userAgent ?? null,
943
+ meta?.ipAddress ?? null,
944
+ location ? JSON.stringify(location) : null
945
+ ]
853
946
  );
854
947
  }
855
948
  async delete(token) {
@@ -888,6 +981,7 @@ var SessionModel = class {
888
981
  `SELECT id, sid,
889
982
  user_agent AS "userAgent",
890
983
  ip_address AS "ipAddress",
984
+ location,
891
985
  created_at AS "createdAt",
892
986
  expires_at AS "expiresAt"
893
987
  FROM fonderie_sessions
@@ -971,15 +1065,20 @@ var BackupCodeModel = class {
971
1065
  // src/models/login-event.model.ts
972
1066
  import { background } from "@fonderie/core";
973
1067
  var LoginEventModel = class {
974
- constructor(store) {
1068
+ // `locate` is IAuthConfig.location. Absent ⇒ rows carry no location,
1069
+ // exactly as before.
1070
+ constructor(store, locate) {
975
1071
  this.store = store;
1072
+ this.locate = locate;
976
1073
  }
977
1074
  store;
1075
+ locate;
978
1076
  async record(e) {
1077
+ const location = this.locate ? await resolveLocation(this.locate, { ip: e.ipAddress, headers: e.headers ?? new Headers() }) : null;
979
1078
  await this.store.query(
980
1079
  `INSERT INTO fonderie_login_events
981
- (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
982
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
1080
+ (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent, location)
1081
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
983
1082
  [
984
1083
  e.userId,
985
1084
  e.emailAttempted ?? null,
@@ -987,7 +1086,8 @@ var LoginEventModel = class {
987
1086
  e.outcome,
988
1087
  e.failureReason ?? null,
989
1088
  e.ipAddress,
990
- e.userAgent
1089
+ e.userAgent,
1090
+ location ? JSON.stringify(location) : null
991
1091
  ]
992
1092
  );
993
1093
  }
@@ -1026,7 +1126,7 @@ var LoginEventModel = class {
1026
1126
  params.push(limit + 1);
1027
1127
  const rows = await this.store.query(
1028
1128
  `SELECT id, method, outcome, failure_reason AS "failureReason",
1029
- ip_address AS "ipAddress", user_agent AS "userAgent",
1129
+ ip_address AS "ipAddress", user_agent AS "userAgent", location,
1030
1130
  created_at AS "createdAt", created_at::text AS "createdAtRaw"
1031
1131
  FROM fonderie_login_events
1032
1132
  WHERE ${where.join(" AND ")}
@@ -1054,7 +1154,8 @@ function requestMeta(ctx) {
1054
1154
  const ua = ctx.request.headers.get("user-agent");
1055
1155
  const meta = {
1056
1156
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1057
- userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
1157
+ userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1158
+ headers: ctx.request.headers
1058
1159
  };
1059
1160
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1060
1161
  return meta;
@@ -1063,9 +1164,9 @@ function requestMeta(ctx) {
1063
1164
  // src/controllers/mfa.controller.ts
1064
1165
  function mfaController(store, config, issuer, bus) {
1065
1166
  const users = new UserModel(store);
1066
- const sessions = new SessionModel(store);
1167
+ const sessions = new SessionModel(store, config.location);
1067
1168
  const backupCodes = new BackupCodeModel(store);
1068
- const loginEvents = new LoginEventModel(store);
1169
+ const loginEvents = new LoginEventModel(store, config.location);
1069
1170
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
1070
1171
  return {
1071
1172
  // ── 1. Setup ───────────────────────────────────────────────
@@ -1463,8 +1564,8 @@ function extractRefreshToken(ctx) {
1463
1564
  }
1464
1565
  function authController(store, config, bus) {
1465
1566
  const users = new UserModel(store);
1466
- const sessions = new SessionModel(store);
1467
- const loginEvents = new LoginEventModel(store);
1567
+ const sessions = new SessionModel(store, config.location);
1568
+ const loginEvents = new LoginEventModel(store, config.location);
1468
1569
  const passwordReset = new PasswordResetModel(store);
1469
1570
  const emailVerif = new EmailVerificationModel(store);
1470
1571
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1532,7 +1633,15 @@ function authController(store, config, bus) {
1532
1633
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1533
1634
  loginMethod: "email"
1534
1635
  });
1535
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1636
+ const registerMeta = requestMeta(ctx);
1637
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, registerMeta);
1638
+ await loginEvents.recordSafe({
1639
+ userId: user.id,
1640
+ emailAttempted: normalizedEmail,
1641
+ method: "registration",
1642
+ outcome: "success",
1643
+ ...registerMeta
1644
+ });
1536
1645
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1537
1646
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1538
1647
  return Response.json(
@@ -2091,6 +2200,9 @@ function toLoginEventDTO(row) {
2091
2200
  failureReason: row.failureReason,
2092
2201
  ipAddress: row.ipAddress,
2093
2202
  userAgent: row.userAgent,
2203
+ // Re-sanitized on read: the column is JSONB and could have been written
2204
+ // by anything with database access.
2205
+ location: sanitizeLocation(row.location),
2094
2206
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
2095
2207
  };
2096
2208
  }
@@ -2106,6 +2218,7 @@ function toSessionDTO(row, currentSid) {
2106
2218
  current: currentSid !== null && row.sid === currentSid,
2107
2219
  ipAddress: row.ipAddress,
2108
2220
  userAgent: row.userAgent,
2221
+ location: sanitizeLocation(row.location),
2109
2222
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2110
2223
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2111
2224
  };
@@ -2120,8 +2233,8 @@ function isValidPhone2(phone2) {
2120
2233
  }
2121
2234
  function userController(store, config, bus) {
2122
2235
  const users = new UserModel(store);
2123
- const sessions = new SessionModel(store);
2124
- const loginEvents = new LoginEventModel(store);
2236
+ const sessions = new SessionModel(store, config.location);
2237
+ const loginEvents = new LoginEventModel(store, config.location);
2125
2238
  const emailVerif = new EmailVerificationModel(store);
2126
2239
  const phoneVerif = new PhoneVerificationModel(store);
2127
2240
  return {
@@ -2565,8 +2678,8 @@ async function verifyAppleIdToken(idToken, opts) {
2565
2678
  var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2566
2679
  function oauthController(store, config, bus) {
2567
2680
  const users = new UserModel(store);
2568
- const sessions = new SessionModel(store);
2569
- const loginEvents = new LoginEventModel(store);
2681
+ const sessions = new SessionModel(store, config.location);
2682
+ const loginEvents = new LoginEventModel(store, config.location);
2570
2683
  const consumedTokens = new ConsumedTokenModel(store);
2571
2684
  const announceOAuthUpsert = async (ctx, upserted, provider, user) => {
2572
2685
  if (!bus) return;
@@ -3355,7 +3468,7 @@ var AuthModule = class {
3355
3468
  name = "@fonderie/auth";
3356
3469
  // Baked in at build time by tsup.base, so the operator's Modules page can
3357
3470
  // say what is actually deployed rather than 'not reported'.
3358
- version = "7.9.4";
3471
+ version = "7.11.0";
3359
3472
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3360
3473
  describeAdmin() {
3361
3474
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -3632,6 +3745,8 @@ export {
3632
3745
  normalizeEmailSafe,
3633
3746
  purgeSoftDeletedUsers,
3634
3747
  requireAuth2 as requireAuth,
3748
+ resolveLocation,
3749
+ sanitizeLocation,
3635
3750
  schemas_exports as schemas,
3636
3751
  startUserRetention,
3637
3752
  toAdminUserDTO,