@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/README.md CHANGED
@@ -37,6 +37,37 @@ import { withSession, requireAuth } from '@fonderie/auth';
37
37
  Also exports `toUserDTO`, `normalizeEmail`, and the full type surface
38
38
  (`IUser`, `ISession`, `IMfaChallenge`, …).
39
39
 
40
+ ## Where did that request come from? (optional)
41
+
42
+ Pass a `location` resolver and auth stamps a location on every event it
43
+ records — each login attempt, each registration, and each new session:
44
+
45
+ ```ts
46
+ import { geoFromHeaders } from '@fonderie/geo';
47
+
48
+ new AuthModule(store, {
49
+ providers: ['email'],
50
+ jwtSecret: process.env.JWT_SECRET!,
51
+ // Vercel/Cloudflare edge headers — zero infrastructure. Trust comes from
52
+ // the deployment, never the request.
53
+ location: ({ headers }) =>
54
+ geoFromHeaders(headers, { trust: process.env.VERCEL ? 'vercel' : undefined }),
55
+ });
56
+ ```
57
+
58
+ Login history then includes `registration` rows alongside sign-ins, and both
59
+ history events and active sessions carry `location: { country, subdivision,
60
+ city, timeZone, … } | null` (plus `isp`/`asn`/`proxy`/`hosting` if your
61
+ resolver knows them). Registration matters most when verification is not
62
+ enforced: the account is live from that request, so it is the first record of
63
+ where the user came from.
64
+
65
+ Run your migrations: `018_login_event_location.sql` and
66
+ `019_session_location.sql` add the columns. The resolver runs at most once per
67
+ request; its output is sanitized and bounded (coordinates ~1 km); if it throws or takes over 500 ms the row is written without a location
68
+ and the request is unaffected. Country is reliable; region and city are
69
+ approximate.
70
+
40
71
  ## Why this exists
41
72
 
42
73
  You've shipped this plumbing before — auth, teams, billing, messaging —
package/brain/outcomes.md CHANGED
@@ -38,6 +38,7 @@ failure_reason TEXT
38
38
  ip_address TEXT
39
39
  user_agent TEXT
40
40
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
41
+ location JSONB
41
42
  ```
42
43
 
43
44
  ### `fonderie_mfa_backup_codes`
@@ -93,6 +94,7 @@ ip_address TEXT
93
94
  expires_at TIMESTAMPTZ NOT NULL
94
95
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
95
96
  sid UUID
97
+ location JSONB
96
98
  -- INDEX idx_fonderie_sessions_expires_at (expires_at)
97
99
  -- INDEX idx_fonderie_sessions_sid (sid)
98
100
  ```
@@ -56,6 +56,7 @@ new AuthModule(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undef
56
56
  .install(app: IFonderieApp): void
57
57
 
58
58
  interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {
59
+ location?: LocationResolver;
59
60
  secureCookies?: boolean;
60
61
  rateLimit?: IAuthRateLimitConfig | false;
61
62
  accessTokenDuration?: string;
@@ -139,6 +140,7 @@ interface ILoginEventDTO {
139
140
  failureReason: string | null;
140
141
  ipAddress: string | null;
141
142
  userAgent: string | null;
143
+ location: IRequestLocation | null;
142
144
  createdAt: string;
143
145
  }
144
146
 
@@ -152,10 +154,43 @@ interface ISessionDTO {
152
154
  current: boolean;
153
155
  ipAddress: string | null;
154
156
  userAgent: string | null;
157
+ location: IRequestLocation | null;
155
158
  createdAt: string;
156
159
  expiresAt: string;
157
160
  }
158
161
 
162
+ function sanitizeLocation(input: unknown): IRequestLocation | null
163
+
164
+ function resolveLocation(resolver: LocationResolver | undefined, req: ILocationRequest, timeoutMs?: number): Promise<IRequestLocation | null>
165
+
166
+ interface IRequestLocation {
167
+ country?: string | null;
168
+ countryName?: string | null;
169
+ subdivision?: string | null;
170
+ subdivisionName?: string | null;
171
+ city?: string | null;
172
+ postalCode?: string | null;
173
+ continent?: string | null;
174
+ timeZone?: string | null;
175
+ latitude?: number | null;
176
+ longitude?: number | null;
177
+ accuracyRadius?: number | null;
178
+ geonameId?: number | null;
179
+ isp?: string | null;
180
+ org?: string | null;
181
+ asn?: string | null;
182
+ mobile?: boolean | null;
183
+ proxy?: boolean | null;
184
+ hosting?: boolean | null;
185
+ }
186
+
187
+ interface ILocationRequest {
188
+ ip: string | null;
189
+ headers: Headers;
190
+ }
191
+
192
+ type LocationResolver = (req: ILocationRequest) => IRequestLocation | null | undefined | Promise<IRequestLocation | null | undefined>;
193
+
159
194
  function validate(schema: IRequestSchema): Middleware
160
195
 
161
196
  namespace schemas — exports: ChangePasswordInput, LoginInput, RegisterInput, ResetPasswordInput, appleNativeSchema, changePasswordSchema, forgotPasswordSchema, loginSchema, mfaTokenSchema, refreshSchema, registerSchema, resetPasswordSchema, updateEmailSchema, updatePhoneSchema, updatePreferencesSchema, updateProfileSchema, verifySchema
package/dist/index.cjs CHANGED
@@ -80,6 +80,8 @@ __export(index_exports, {
80
80
  normalizeEmailSafe: () => normalizeEmailSafe,
81
81
  purgeSoftDeletedUsers: () => purgeSoftDeletedUsers,
82
82
  requireAuth: () => import_middlewares3.requireAuth,
83
+ resolveLocation: () => resolveLocation,
84
+ sanitizeLocation: () => sanitizeLocation,
83
85
  schemas: () => schemas_exports,
84
86
  startUserRetention: () => startUserRetention,
85
87
  toAdminUserDTO: () => toAdminUserDTO,
@@ -881,18 +883,111 @@ var UserModel = class {
881
883
  };
882
884
  var MAX_LIST_LIMIT = 200;
883
885
 
886
+ // src/services/request-location.ts
887
+ var LOCATION_TIMEOUT_MS = 500;
888
+ var TEXT_MAX = 128;
889
+ function str(v, re) {
890
+ if (typeof v !== "string") return null;
891
+ const t = v.trim();
892
+ if (t.length === 0 || t.length > TEXT_MAX) return null;
893
+ return re && !re.test(t) ? null : t;
894
+ }
895
+ function upperCode(v, re) {
896
+ const t = str(v, re);
897
+ return t ? t.toUpperCase() : null;
898
+ }
899
+ function bool(v) {
900
+ return typeof v === "boolean" ? v : null;
901
+ }
902
+ function coord(v, limit) {
903
+ if (typeof v !== "number" || !Number.isFinite(v) || Math.abs(v) > limit) return null;
904
+ return Math.round(v * 100) / 100;
905
+ }
906
+ function radius(v) {
907
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0 || v > 2e4) return null;
908
+ return Math.round(v);
909
+ }
910
+ function placeId(v) {
911
+ const n = typeof v === "string" && /^\d{1,10}$/.test(v) ? Number(v) : v;
912
+ return typeof n === "number" && Number.isInteger(n) && n > 0 && n <= 2147483647 ? n : null;
913
+ }
914
+ function sanitizeLocation(input) {
915
+ if (!input || typeof input !== "object") return null;
916
+ const i = input;
917
+ const out = {
918
+ country: upperCode(i["country"], /^[A-Za-z]{2}$/),
919
+ countryName: str(i["countryName"]),
920
+ subdivision: upperCode(i["subdivision"], /^[A-Za-z0-9]{1,3}$/),
921
+ subdivisionName: str(i["subdivisionName"]),
922
+ city: str(i["city"]),
923
+ postalCode: upperCode(i["postalCode"], /^[A-Za-z0-9][A-Za-z0-9 -]{0,11}$/),
924
+ continent: upperCode(i["continent"], /^[A-Za-z]{2}$/),
925
+ timeZone: str(i["timeZone"], /^(UTC|[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+)$/),
926
+ latitude: coord(i["latitude"], 90),
927
+ longitude: coord(i["longitude"], 180),
928
+ accuracyRadius: radius(i["accuracyRadius"]),
929
+ geonameId: placeId(i["geonameId"]),
930
+ isp: str(i["isp"]),
931
+ org: str(i["org"]),
932
+ asn: upperCode(i["asn"], /^AS\d{1,10}$/i),
933
+ mobile: bool(i["mobile"]),
934
+ proxy: bool(i["proxy"]),
935
+ hosting: bool(i["hosting"])
936
+ };
937
+ const known = Object.fromEntries(Object.entries(out).filter(([, v]) => v !== null));
938
+ return Object.keys(known).length > 0 ? known : null;
939
+ }
940
+ var perRequest = /* @__PURE__ */ new WeakMap();
941
+ function resolveLocation(resolver, req, timeoutMs = LOCATION_TIMEOUT_MS) {
942
+ if (!resolver) return Promise.resolve(null);
943
+ const cached = perRequest.get(req.headers);
944
+ if (cached && cached.resolver === resolver) return cached.result;
945
+ const result = resolveOnce(resolver, req, timeoutMs);
946
+ perRequest.set(req.headers, { resolver, result });
947
+ return result;
948
+ }
949
+ async function resolveOnce(resolver, req, timeoutMs) {
950
+ let timer;
951
+ try {
952
+ const result = await Promise.race([
953
+ Promise.resolve().then(() => resolver(req)),
954
+ new Promise((resolve2) => {
955
+ timer = setTimeout(() => resolve2(null), timeoutMs);
956
+ })
957
+ ]);
958
+ return sanitizeLocation(result);
959
+ } catch (err) {
960
+ console.warn("[auth] location resolver failed; recording without a location:", err);
961
+ return null;
962
+ } finally {
963
+ if (timer) clearTimeout(timer);
964
+ }
965
+ }
966
+
884
967
  // src/models/session.model.ts
885
968
  var SessionModel = class {
886
- constructor(store) {
969
+ // `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
970
+ constructor(store, locate) {
887
971
  this.store = store;
972
+ this.locate = locate;
888
973
  }
889
974
  store;
975
+ locate;
890
976
  async create(userId, token, expiresAt, sid, meta) {
977
+ const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
891
978
  await this.store.query(
892
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
893
- VALUES ($1, $2, $3, $4, $5, $6)
979
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
980
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
894
981
  ON CONFLICT (token) DO NOTHING`,
895
- [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
982
+ [
983
+ userId,
984
+ token,
985
+ expiresAt,
986
+ sid ?? null,
987
+ meta?.userAgent ?? null,
988
+ meta?.ipAddress ?? null,
989
+ location ? JSON.stringify(location) : null
990
+ ]
896
991
  );
897
992
  }
898
993
  async delete(token) {
@@ -931,6 +1026,7 @@ var SessionModel = class {
931
1026
  `SELECT id, sid,
932
1027
  user_agent AS "userAgent",
933
1028
  ip_address AS "ipAddress",
1029
+ location,
934
1030
  created_at AS "createdAt",
935
1031
  expires_at AS "expiresAt"
936
1032
  FROM fonderie_sessions
@@ -1014,15 +1110,20 @@ var BackupCodeModel = class {
1014
1110
  // src/models/login-event.model.ts
1015
1111
  var import_core4 = require("@fonderie/core");
1016
1112
  var LoginEventModel = class {
1017
- constructor(store) {
1113
+ // `locate` is IAuthConfig.location. Absent ⇒ rows carry no location,
1114
+ // exactly as before.
1115
+ constructor(store, locate) {
1018
1116
  this.store = store;
1117
+ this.locate = locate;
1019
1118
  }
1020
1119
  store;
1120
+ locate;
1021
1121
  async record(e) {
1122
+ const location = this.locate ? await resolveLocation(this.locate, { ip: e.ipAddress, headers: e.headers ?? new Headers() }) : null;
1022
1123
  await this.store.query(
1023
1124
  `INSERT INTO fonderie_login_events
1024
- (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
1025
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
1125
+ (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent, location)
1126
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
1026
1127
  [
1027
1128
  e.userId,
1028
1129
  e.emailAttempted ?? null,
@@ -1030,7 +1131,8 @@ var LoginEventModel = class {
1030
1131
  e.outcome,
1031
1132
  e.failureReason ?? null,
1032
1133
  e.ipAddress,
1033
- e.userAgent
1134
+ e.userAgent,
1135
+ location ? JSON.stringify(location) : null
1034
1136
  ]
1035
1137
  );
1036
1138
  }
@@ -1069,7 +1171,7 @@ var LoginEventModel = class {
1069
1171
  params.push(limit + 1);
1070
1172
  const rows = await this.store.query(
1071
1173
  `SELECT id, method, outcome, failure_reason AS "failureReason",
1072
- ip_address AS "ipAddress", user_agent AS "userAgent",
1174
+ ip_address AS "ipAddress", user_agent AS "userAgent", location,
1073
1175
  created_at AS "createdAt", created_at::text AS "createdAtRaw"
1074
1176
  FROM fonderie_login_events
1075
1177
  WHERE ${where.join(" AND ")}
@@ -1097,7 +1199,8 @@ function requestMeta(ctx) {
1097
1199
  const ua = ctx.request.headers.get("user-agent");
1098
1200
  const meta = {
1099
1201
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1100
- userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
1202
+ userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1203
+ headers: ctx.request.headers
1101
1204
  };
1102
1205
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1103
1206
  return meta;
@@ -1106,9 +1209,9 @@ function requestMeta(ctx) {
1106
1209
  // src/controllers/mfa.controller.ts
1107
1210
  function mfaController(store, config, issuer, bus) {
1108
1211
  const users = new UserModel(store);
1109
- const sessions = new SessionModel(store);
1212
+ const sessions = new SessionModel(store, config.location);
1110
1213
  const backupCodes = new BackupCodeModel(store);
1111
- const loginEvents = new LoginEventModel(store);
1214
+ const loginEvents = new LoginEventModel(store, config.location);
1112
1215
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
1113
1216
  return {
1114
1217
  // ── 1. Setup ───────────────────────────────────────────────
@@ -1506,8 +1609,8 @@ function extractRefreshToken(ctx) {
1506
1609
  }
1507
1610
  function authController(store, config, bus) {
1508
1611
  const users = new UserModel(store);
1509
- const sessions = new SessionModel(store);
1510
- const loginEvents = new LoginEventModel(store);
1612
+ const sessions = new SessionModel(store, config.location);
1613
+ const loginEvents = new LoginEventModel(store, config.location);
1511
1614
  const passwordReset = new PasswordResetModel(store);
1512
1615
  const emailVerif = new EmailVerificationModel(store);
1513
1616
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1575,7 +1678,15 @@ function authController(store, config, bus) {
1575
1678
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1576
1679
  loginMethod: "email"
1577
1680
  });
1578
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1681
+ const registerMeta = requestMeta(ctx);
1682
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, registerMeta);
1683
+ await loginEvents.recordSafe({
1684
+ userId: user.id,
1685
+ emailAttempted: normalizedEmail,
1686
+ method: "registration",
1687
+ outcome: "success",
1688
+ ...registerMeta
1689
+ });
1579
1690
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1580
1691
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1581
1692
  return Response.json(
@@ -2134,6 +2245,9 @@ function toLoginEventDTO(row) {
2134
2245
  failureReason: row.failureReason,
2135
2246
  ipAddress: row.ipAddress,
2136
2247
  userAgent: row.userAgent,
2248
+ // Re-sanitized on read: the column is JSONB and could have been written
2249
+ // by anything with database access.
2250
+ location: sanitizeLocation(row.location),
2137
2251
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
2138
2252
  };
2139
2253
  }
@@ -2149,6 +2263,7 @@ function toSessionDTO(row, currentSid) {
2149
2263
  current: currentSid !== null && row.sid === currentSid,
2150
2264
  ipAddress: row.ipAddress,
2151
2265
  userAgent: row.userAgent,
2266
+ location: sanitizeLocation(row.location),
2152
2267
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2153
2268
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2154
2269
  };
@@ -2163,8 +2278,8 @@ function isValidPhone2(phone2) {
2163
2278
  }
2164
2279
  function userController(store, config, bus) {
2165
2280
  const users = new UserModel(store);
2166
- const sessions = new SessionModel(store);
2167
- const loginEvents = new LoginEventModel(store);
2281
+ const sessions = new SessionModel(store, config.location);
2282
+ const loginEvents = new LoginEventModel(store, config.location);
2168
2283
  const emailVerif = new EmailVerificationModel(store);
2169
2284
  const phoneVerif = new PhoneVerificationModel(store);
2170
2285
  return {
@@ -2608,8 +2723,8 @@ async function verifyAppleIdToken(idToken, opts) {
2608
2723
  var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2609
2724
  function oauthController(store, config, bus) {
2610
2725
  const users = new UserModel(store);
2611
- const sessions = new SessionModel(store);
2612
- const loginEvents = new LoginEventModel(store);
2726
+ const sessions = new SessionModel(store, config.location);
2727
+ const loginEvents = new LoginEventModel(store, config.location);
2613
2728
  const consumedTokens = new ConsumedTokenModel(store);
2614
2729
  const announceOAuthUpsert = async (ctx, upserted, provider, user) => {
2615
2730
  if (!bus) return;
@@ -3398,7 +3513,7 @@ var AuthModule = class {
3398
3513
  name = "@fonderie/auth";
3399
3514
  // Baked in at build time by tsup.base, so the operator's Modules page can
3400
3515
  // say what is actually deployed rather than 'not reported'.
3401
- version = "7.9.4";
3516
+ version = "7.11.0";
3402
3517
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3403
3518
  describeAdmin() {
3404
3519
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -3676,6 +3791,8 @@ function startUserRetention(store, options) {
3676
3791
  normalizeEmailSafe,
3677
3792
  purgeSoftDeletedUsers,
3678
3793
  requireAuth,
3794
+ resolveLocation,
3795
+ sanitizeLocation,
3679
3796
  schemas,
3680
3797
  startUserRetention,
3681
3798
  toAdminUserDTO,