@fonderie/auth 7.9.3 → 7.10.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-DU9QTf7f.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-DU9QTf7f.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-DU9QTf7f.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-DU9QTf7f.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,106 @@ 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 sanitizeLocation(input) {
866
+ if (!input || typeof input !== "object") return null;
867
+ const i = input;
868
+ const out = {
869
+ country: upperCode(i["country"], /^[A-Za-z]{2}$/),
870
+ countryName: str(i["countryName"]),
871
+ subdivision: upperCode(i["subdivision"], /^[A-Za-z0-9]{1,3}$/),
872
+ subdivisionName: str(i["subdivisionName"]),
873
+ city: str(i["city"]),
874
+ postalCode: upperCode(i["postalCode"], /^[A-Za-z0-9][A-Za-z0-9 -]{0,11}$/),
875
+ continent: upperCode(i["continent"], /^[A-Za-z]{2}$/),
876
+ timeZone: str(i["timeZone"], /^(UTC|[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+)$/),
877
+ latitude: coord(i["latitude"], 90),
878
+ longitude: coord(i["longitude"], 180),
879
+ accuracyRadius: radius(i["accuracyRadius"]),
880
+ isp: str(i["isp"]),
881
+ org: str(i["org"]),
882
+ asn: upperCode(i["asn"], /^AS\d{1,10}$/i),
883
+ mobile: bool(i["mobile"]),
884
+ proxy: bool(i["proxy"]),
885
+ hosting: bool(i["hosting"])
886
+ };
887
+ const known = Object.fromEntries(Object.entries(out).filter(([, v]) => v !== null));
888
+ return Object.keys(known).length > 0 ? known : null;
889
+ }
890
+ var perRequest = /* @__PURE__ */ new WeakMap();
891
+ function resolveLocation(resolver, req, timeoutMs = LOCATION_TIMEOUT_MS) {
892
+ if (!resolver) return Promise.resolve(null);
893
+ const cached = perRequest.get(req.headers);
894
+ if (cached && cached.resolver === resolver) return cached.result;
895
+ const result = resolveOnce(resolver, req, timeoutMs);
896
+ perRequest.set(req.headers, { resolver, result });
897
+ return result;
898
+ }
899
+ async function resolveOnce(resolver, req, timeoutMs) {
900
+ let timer;
901
+ try {
902
+ const result = await Promise.race([
903
+ Promise.resolve().then(() => resolver(req)),
904
+ new Promise((resolve2) => {
905
+ timer = setTimeout(() => resolve2(null), timeoutMs);
906
+ })
907
+ ]);
908
+ return sanitizeLocation(result);
909
+ } catch (err) {
910
+ console.warn("[auth] location resolver failed; recording without a location:", err);
911
+ return null;
912
+ } finally {
913
+ if (timer) clearTimeout(timer);
914
+ }
915
+ }
916
+
841
917
  // src/models/session.model.ts
842
918
  var SessionModel = class {
843
- constructor(store) {
919
+ // `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
920
+ constructor(store, locate) {
844
921
  this.store = store;
922
+ this.locate = locate;
845
923
  }
846
924
  store;
925
+ locate;
847
926
  async create(userId, token, expiresAt, sid, meta) {
927
+ const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
848
928
  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)
929
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
930
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
851
931
  ON CONFLICT (token) DO NOTHING`,
852
- [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
932
+ [
933
+ userId,
934
+ token,
935
+ expiresAt,
936
+ sid ?? null,
937
+ meta?.userAgent ?? null,
938
+ meta?.ipAddress ?? null,
939
+ location ? JSON.stringify(location) : null
940
+ ]
853
941
  );
854
942
  }
855
943
  async delete(token) {
@@ -888,6 +976,7 @@ var SessionModel = class {
888
976
  `SELECT id, sid,
889
977
  user_agent AS "userAgent",
890
978
  ip_address AS "ipAddress",
979
+ location,
891
980
  created_at AS "createdAt",
892
981
  expires_at AS "expiresAt"
893
982
  FROM fonderie_sessions
@@ -971,15 +1060,20 @@ var BackupCodeModel = class {
971
1060
  // src/models/login-event.model.ts
972
1061
  import { background } from "@fonderie/core";
973
1062
  var LoginEventModel = class {
974
- constructor(store) {
1063
+ // `locate` is IAuthConfig.location. Absent ⇒ rows carry no location,
1064
+ // exactly as before.
1065
+ constructor(store, locate) {
975
1066
  this.store = store;
1067
+ this.locate = locate;
976
1068
  }
977
1069
  store;
1070
+ locate;
978
1071
  async record(e) {
1072
+ const location = this.locate ? await resolveLocation(this.locate, { ip: e.ipAddress, headers: e.headers ?? new Headers() }) : null;
979
1073
  await this.store.query(
980
1074
  `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)`,
1075
+ (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent, location)
1076
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
983
1077
  [
984
1078
  e.userId,
985
1079
  e.emailAttempted ?? null,
@@ -987,7 +1081,8 @@ var LoginEventModel = class {
987
1081
  e.outcome,
988
1082
  e.failureReason ?? null,
989
1083
  e.ipAddress,
990
- e.userAgent
1084
+ e.userAgent,
1085
+ location ? JSON.stringify(location) : null
991
1086
  ]
992
1087
  );
993
1088
  }
@@ -1026,7 +1121,7 @@ var LoginEventModel = class {
1026
1121
  params.push(limit + 1);
1027
1122
  const rows = await this.store.query(
1028
1123
  `SELECT id, method, outcome, failure_reason AS "failureReason",
1029
- ip_address AS "ipAddress", user_agent AS "userAgent",
1124
+ ip_address AS "ipAddress", user_agent AS "userAgent", location,
1030
1125
  created_at AS "createdAt", created_at::text AS "createdAtRaw"
1031
1126
  FROM fonderie_login_events
1032
1127
  WHERE ${where.join(" AND ")}
@@ -1054,7 +1149,8 @@ function requestMeta(ctx) {
1054
1149
  const ua = ctx.request.headers.get("user-agent");
1055
1150
  const meta = {
1056
1151
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1057
- userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
1152
+ userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1153
+ headers: ctx.request.headers
1058
1154
  };
1059
1155
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1060
1156
  return meta;
@@ -1063,9 +1159,9 @@ function requestMeta(ctx) {
1063
1159
  // src/controllers/mfa.controller.ts
1064
1160
  function mfaController(store, config, issuer, bus) {
1065
1161
  const users = new UserModel(store);
1066
- const sessions = new SessionModel(store);
1162
+ const sessions = new SessionModel(store, config.location);
1067
1163
  const backupCodes = new BackupCodeModel(store);
1068
- const loginEvents = new LoginEventModel(store);
1164
+ const loginEvents = new LoginEventModel(store, config.location);
1069
1165
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
1070
1166
  return {
1071
1167
  // ── 1. Setup ───────────────────────────────────────────────
@@ -1463,8 +1559,8 @@ function extractRefreshToken(ctx) {
1463
1559
  }
1464
1560
  function authController(store, config, bus) {
1465
1561
  const users = new UserModel(store);
1466
- const sessions = new SessionModel(store);
1467
- const loginEvents = new LoginEventModel(store);
1562
+ const sessions = new SessionModel(store, config.location);
1563
+ const loginEvents = new LoginEventModel(store, config.location);
1468
1564
  const passwordReset = new PasswordResetModel(store);
1469
1565
  const emailVerif = new EmailVerificationModel(store);
1470
1566
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1532,7 +1628,15 @@ function authController(store, config, bus) {
1532
1628
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1533
1629
  loginMethod: "email"
1534
1630
  });
1535
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1631
+ const registerMeta = requestMeta(ctx);
1632
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, registerMeta);
1633
+ await loginEvents.recordSafe({
1634
+ userId: user.id,
1635
+ emailAttempted: normalizedEmail,
1636
+ method: "registration",
1637
+ outcome: "success",
1638
+ ...registerMeta
1639
+ });
1536
1640
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1537
1641
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1538
1642
  return Response.json(
@@ -2091,6 +2195,9 @@ function toLoginEventDTO(row) {
2091
2195
  failureReason: row.failureReason,
2092
2196
  ipAddress: row.ipAddress,
2093
2197
  userAgent: row.userAgent,
2198
+ // Re-sanitized on read: the column is JSONB and could have been written
2199
+ // by anything with database access.
2200
+ location: sanitizeLocation(row.location),
2094
2201
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
2095
2202
  };
2096
2203
  }
@@ -2106,6 +2213,7 @@ function toSessionDTO(row, currentSid) {
2106
2213
  current: currentSid !== null && row.sid === currentSid,
2107
2214
  ipAddress: row.ipAddress,
2108
2215
  userAgent: row.userAgent,
2216
+ location: sanitizeLocation(row.location),
2109
2217
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2110
2218
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2111
2219
  };
@@ -2120,8 +2228,8 @@ function isValidPhone2(phone2) {
2120
2228
  }
2121
2229
  function userController(store, config, bus) {
2122
2230
  const users = new UserModel(store);
2123
- const sessions = new SessionModel(store);
2124
- const loginEvents = new LoginEventModel(store);
2231
+ const sessions = new SessionModel(store, config.location);
2232
+ const loginEvents = new LoginEventModel(store, config.location);
2125
2233
  const emailVerif = new EmailVerificationModel(store);
2126
2234
  const phoneVerif = new PhoneVerificationModel(store);
2127
2235
  return {
@@ -2565,8 +2673,8 @@ async function verifyAppleIdToken(idToken, opts) {
2565
2673
  var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2566
2674
  function oauthController(store, config, bus) {
2567
2675
  const users = new UserModel(store);
2568
- const sessions = new SessionModel(store);
2569
- const loginEvents = new LoginEventModel(store);
2676
+ const sessions = new SessionModel(store, config.location);
2677
+ const loginEvents = new LoginEventModel(store, config.location);
2570
2678
  const consumedTokens = new ConsumedTokenModel(store);
2571
2679
  const announceOAuthUpsert = async (ctx, upserted, provider, user) => {
2572
2680
  if (!bus) return;
@@ -3355,7 +3463,7 @@ var AuthModule = class {
3355
3463
  name = "@fonderie/auth";
3356
3464
  // Baked in at build time by tsup.base, so the operator's Modules page can
3357
3465
  // say what is actually deployed rather than 'not reported'.
3358
- version = "7.9.3";
3466
+ version = "7.10.0";
3359
3467
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3360
3468
  describeAdmin() {
3361
3469
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -3632,6 +3740,8 @@ export {
3632
3740
  normalizeEmailSafe,
3633
3741
  purgeSoftDeletedUsers,
3634
3742
  requireAuth2 as requireAuth,
3743
+ resolveLocation,
3744
+ sanitizeLocation,
3635
3745
  schemas_exports as schemas,
3636
3746
  startUserRetention,
3637
3747
  toAdminUserDTO,