@fonderie/auth 7.17.0 → 7.18.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, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
4
4
  import { IStoreAdapter } from '@fonderie/store';
5
5
  import { EventBus } from '@fonderie/events';
6
- import { I as IAuthConfig, a as IRequestLocation } from './session-C-ptRylj.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-C-ptRylj.cjs';
6
+ import { I as IAuthConfig, a as IRequestLocation } from './session-B-IgXFu8.cjs';
7
+ export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, C as CLIENT_KINDS, d as ClientKind, e as IAuthRateLimitConfig, f as IAuthRuntimeConfig, g as IAuthSecrets, h as IDataExportContributor, i as ILocationRequest, j as ISessionPolicy, L as LocationResolver, M as MESSAGE_KEYS, S as SESSION_POLICY_PRESETS, k as buildAuthAccountLimiter, l as buildAuthIpLimiter, m as clientKindOf, n as configForClient, r as readAuthRuntimeConfig, o as resolveLocation, s as sanitizeLocation, p as sessionPolicyConfigKeys, w as withSession } from './session-B-IgXFu8.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';
@@ -50,6 +50,8 @@ interface ISessionDTO {
50
50
  location: IRequestLocation | null;
51
51
  createdAt: string;
52
52
  expiresAt: string;
53
+ /** The platform declared at sign-in (mobile | desktop | web), or null. */
54
+ clientKind: string | null;
53
55
  }
54
56
 
55
57
  declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export { IMfaChallenge, ISession } from './types.js';
3
3
  import { IFonderieModule, IAdminDescription, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
4
4
  import { IStoreAdapter } from '@fonderie/store';
5
5
  import { EventBus } from '@fonderie/events';
6
- import { I as IAuthConfig, a as IRequestLocation } from './session-C-ptRylj.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-C-ptRylj.js';
6
+ import { I as IAuthConfig, a as IRequestLocation } from './session-B-IgXFu8.js';
7
+ export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, C as CLIENT_KINDS, d as ClientKind, e as IAuthRateLimitConfig, f as IAuthRuntimeConfig, g as IAuthSecrets, h as IDataExportContributor, i as ILocationRequest, j as ISessionPolicy, L as LocationResolver, M as MESSAGE_KEYS, S as SESSION_POLICY_PRESETS, k as buildAuthAccountLimiter, l as buildAuthIpLimiter, m as clientKindOf, n as configForClient, r as readAuthRuntimeConfig, o as resolveLocation, s as sanitizeLocation, p as sessionPolicyConfigKeys, w as withSession } from './session-B-IgXFu8.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';
@@ -50,6 +50,8 @@ interface ISessionDTO {
50
50
  location: IRequestLocation | null;
51
51
  createdAt: string;
52
52
  expiresAt: string;
53
+ /** The platform declared at sign-in (mobile | desktop | web), or null. */
54
+ clientKind: string | null;
53
55
  }
54
56
 
55
57
  declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
package/dist/index.js CHANGED
@@ -312,8 +312,8 @@ function issueMfaPendingToken(userId, config, loginMethod) {
312
312
  );
313
313
  }
314
314
  function issueTokenPair(userId, config, options) {
315
- const duration = config.sessionDuration ?? DEFAULT_SESSION_DURATION;
316
- const accessDuration = config.accessTokenDuration ?? DEFAULT_ACCESS_TOKEN_DURATION;
315
+ const duration = validDuration(config.sessionDuration, DEFAULT_SESSION_DURATION, "sessionDuration");
316
+ const accessDuration = validDuration(config.accessTokenDuration, DEFAULT_ACCESS_TOKEN_DURATION, "accessTokenDuration");
317
317
  const authTime = options.authTime ?? Math.floor(Date.now() / 1e3);
318
318
  const loginMethod = options.loginMethod;
319
319
  const phoneVerified = options.phoneVerified ?? false;
@@ -356,6 +356,90 @@ function durationMs(value) {
356
356
  const scale = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5, y: 315576e5 };
357
357
  return Number(m[1]) * (scale[unit] ?? 1e3);
358
358
  }
359
+ var warnedDurations = /* @__PURE__ */ new Set();
360
+ function validDuration(value, fallback, name) {
361
+ if (value === void 0 || value === null || value === "") return fallback;
362
+ if (durationMs(String(value)) !== null) return String(value);
363
+ const key = `${name}=${String(value)}`;
364
+ if (!warnedDurations.has(key)) {
365
+ warnedDurations.add(key);
366
+ console.warn(`[auth] ${name} ${JSON.stringify(value)} is not a duration (e.g. '90d', '1h') \u2014 using ${fallback}`);
367
+ }
368
+ return fallback;
369
+ }
370
+
371
+ // src/services/session-policy.ts
372
+ var CLIENT_KINDS = ["mobile", "desktop", "web"];
373
+ var SESSION_POLICY_PRESETS = {
374
+ // Personal device; the token lives in the Keychain / Keystore.
375
+ mobile: { sessionDuration: "90d", sessionMaxAge: "365d" },
376
+ // Personal, but often a shared workstation.
377
+ desktop: { sessionDuration: "30d", sessionMaxAge: "180d" },
378
+ // Shared and public computers; cookies.
379
+ web: { sessionDuration: "14d", sessionMaxAge: "90d" }
380
+ };
381
+ function clientKindOf(headers) {
382
+ const v = headers.get("x-client-kind")?.trim().toLowerCase();
383
+ return CLIENT_KINDS.includes(v ?? "") ? v : null;
384
+ }
385
+ var FIELDS = ["sessionDuration", "sessionMaxAge", "accessTokenDuration"];
386
+ function configForClient(config, runtime, kind) {
387
+ const rt = runtime ?? {};
388
+ const code = config;
389
+ const out = { ...config, ...rt };
390
+ for (const field of FIELDS) {
391
+ const value = (kind ? rt.sessionPolicies?.[kind]?.[field] : void 0) ?? rt[field] ?? (kind ? code.sessionPolicies?.[kind]?.[field] : void 0) ?? code[field] ?? (kind ? SESSION_POLICY_PRESETS[kind][field] : void 0);
392
+ if (value === void 0) delete out[field];
393
+ else out[field] = value;
394
+ }
395
+ return out;
396
+ }
397
+ function sessionPolicyConfigKeys(kind) {
398
+ return {
399
+ sessionDuration: `auth.session.duration.${kind}`,
400
+ sessionMaxAge: `auth.session.max_age.${kind}`,
401
+ accessTokenDuration: `auth.access.duration.${kind}`
402
+ };
403
+ }
404
+ function readAuthRuntimeConfig(read) {
405
+ const text = (key) => {
406
+ const v = read(key);
407
+ return typeof v === "string" && v.trim() ? v.trim() : void 0;
408
+ };
409
+ const bool2 = (key) => {
410
+ const v = read(key);
411
+ if (typeof v === "boolean") return v;
412
+ if (typeof v === "string" && ["true", "false"].includes(v.trim().toLowerCase())) return v.trim().toLowerCase() === "true";
413
+ return void 0;
414
+ };
415
+ const num = (key) => {
416
+ const v = read(key);
417
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v) : NaN;
418
+ return Number.isFinite(n) ? n : void 0;
419
+ };
420
+ const out = {};
421
+ const set = (k, v) => {
422
+ if (v !== void 0) out[k] = v;
423
+ };
424
+ set("sessionDuration", text("auth.session.duration"));
425
+ set("sessionMaxAge", text("auth.session.max_age"));
426
+ set("accessTokenDuration", text("auth.access.duration"));
427
+ set("verificationCooldown", num("auth.verification.cooldown"));
428
+ set("mfa", bool2("auth.mfa.enabled"));
429
+ set("requireVerification", bool2("auth.verification.required"));
430
+ const policies = {};
431
+ for (const kind of CLIENT_KINDS) {
432
+ const keys = sessionPolicyConfigKeys(kind);
433
+ const policy = {};
434
+ for (const field of FIELDS) {
435
+ const v = text(keys[field]);
436
+ if (v !== void 0) policy[field] = v;
437
+ }
438
+ if (Object.keys(policy).length) policies[kind] = policy;
439
+ }
440
+ if (Object.keys(policies).length) out.sessionPolicies = policies;
441
+ return out;
442
+ }
359
443
 
360
444
  // src/services/mfa.ts
361
445
  import { createHmac, randomBytes } from "crypto";
@@ -980,8 +1064,8 @@ var SessionModel = class {
980
1064
  async create(userId, token, expiresAt, sid, meta) {
981
1065
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
982
1066
  await this.store.query(
983
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
984
- VALUES ($1, $2, $3, $4, $5, $6, $7, now())
1067
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at, client_kind)
1068
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
985
1069
  ON CONFLICT (token) DO NOTHING`,
986
1070
  [
987
1071
  userId,
@@ -990,7 +1074,8 @@ var SessionModel = class {
990
1074
  sid ?? null,
991
1075
  meta?.userAgent ?? null,
992
1076
  meta?.ipAddress ?? null,
993
- location ? JSON.stringify(location) : null
1077
+ location ? JSON.stringify(location) : null,
1078
+ meta?.clientKind ?? null
994
1079
  ]
995
1080
  );
996
1081
  }
@@ -1005,12 +1090,12 @@ var SessionModel = class {
1005
1090
  async match(token) {
1006
1091
  const hash = hashRefreshToken(token);
1007
1092
  const [current] = await this.store.query(
1008
- `SELECT id, user_id, sid, token, created_at FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1093
+ `SELECT id, user_id, sid, token, created_at, client_kind FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1009
1094
  [hash, token]
1010
1095
  );
1011
1096
  if (current) return { kind: "current", row: toRow(current) };
1012
1097
  const [previous] = await this.store.query(
1013
- `SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
1098
+ `SELECT id, user_id, sid, token, created_at, client_kind, (previous_valid_until > now()) AS in_grace
1014
1099
  FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1015
1100
  [hash]
1016
1101
  );
@@ -1076,7 +1161,7 @@ var SessionModel = class {
1076
1161
  // No token — that never leaves the server.
1077
1162
  async listLiveByUser(userId) {
1078
1163
  return this.store.query(
1079
- `SELECT id, sid,
1164
+ `SELECT id, sid, client_kind AS "clientKind",
1080
1165
  user_agent AS "userAgent",
1081
1166
  ip_address AS "ipAddress",
1082
1167
  location,
@@ -1125,7 +1210,8 @@ var SessionModel = class {
1125
1210
  }
1126
1211
  };
1127
1212
  function toRow(r) {
1128
- return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token, createdAt: r.created_at ? new Date(r.created_at) : /* @__PURE__ */ new Date() };
1213
+ const kind = CLIENT_KINDS.includes(r.client_kind ?? "") ? r.client_kind : null;
1214
+ return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token, createdAt: r.created_at ? new Date(r.created_at) : /* @__PURE__ */ new Date(), clientKind: kind };
1129
1215
  }
1130
1216
 
1131
1217
  // src/models/backup-code.model.ts
@@ -1256,7 +1342,8 @@ function requestMeta(ctx) {
1256
1342
  const meta = {
1257
1343
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1258
1344
  userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1259
- headers: ctx.request.headers
1345
+ headers: ctx.request.headers,
1346
+ clientKind: clientKindOf(ctx.request.headers)
1260
1347
  };
1261
1348
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1262
1349
  return meta;
@@ -1371,7 +1458,7 @@ function mfaController(store, config, issuer, bus) {
1371
1458
  await users.enableMfa(ctx.user.id);
1372
1459
  }
1373
1460
  }
1374
- const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
1461
+ const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1375
1462
  loginMethod: ctx.user.loginMethod,
1376
1463
  phoneVerified: ctx.user.phoneVerified
1377
1464
  });
@@ -1733,7 +1820,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1733
1820
  },
1734
1821
  reqOpts
1735
1822
  ));
1736
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1823
+ const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1737
1824
  loginMethod: "email"
1738
1825
  });
1739
1826
  const registerMeta = requestMeta(ctx);
@@ -1876,7 +1963,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1876
1963
  mfaToken
1877
1964
  });
1878
1965
  }
1879
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1966
+ const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1880
1967
  loginMethod: "email"
1881
1968
  });
1882
1969
  await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -1969,8 +2056,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1969
2056
  if (!payload || payload.type !== "refresh") {
1970
2057
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
1971
2058
  }
1972
- const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
1973
- const maxAgeMs = durationMs(resolvedRefresh.sessionMaxAge);
2059
+ const runtime = config.resolve?.(ctx);
1974
2060
  let issued = null;
1975
2061
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
1976
2062
  const found = await sessions.match(token);
@@ -1984,6 +2070,8 @@ function authController(store, config, bus, locales = defineLocales()) {
1984
2070
  );
1985
2071
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
1986
2072
  }
2073
+ const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2074
+ const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
1987
2075
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
1988
2076
  await sessions.revokeById(found.row.id);
1989
2077
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
@@ -2165,7 +2253,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2165
2253
  );
2166
2254
  }
2167
2255
  await phoneVerif.deleteByUser(ctx.user.id);
2168
- const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
2256
+ const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2169
2257
  loginMethod: "phone",
2170
2258
  phoneVerified: true
2171
2259
  });
@@ -2342,6 +2430,7 @@ function toSessionDTO(row, currentSid) {
2342
2430
  ipAddress: row.ipAddress,
2343
2431
  userAgent: row.userAgent,
2344
2432
  location: sanitizeLocation(row.location),
2433
+ clientKind: row.clientKind ?? null,
2345
2434
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2346
2435
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2347
2436
  };
@@ -2890,7 +2979,7 @@ function oauthController(store, config, bus) {
2890
2979
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2891
2980
  }
2892
2981
  await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2893
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
2982
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: "apple" });
2894
2983
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2895
2984
  await loginEvents.recordSafe({
2896
2985
  userId: upserted.id,
@@ -3041,7 +3130,7 @@ function oauthController(store, config, bus) {
3041
3130
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3042
3131
  }
3043
3132
  await announceOAuthUpsert(ctx, upserted, "google", fullUser);
3044
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
3133
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
3045
3134
  loginMethod: "google"
3046
3135
  });
3047
3136
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -3629,7 +3718,7 @@ var AuthModule = class {
3629
3718
  name = "@fonderie/auth";
3630
3719
  // Baked in at build time by tsup.base, so the operator's Modules page can
3631
3720
  // say what is actually deployed rather than 'not reported'.
3632
- version = "7.17.0";
3721
+ version = "7.18.0";
3633
3722
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3634
3723
  describeAdmin() {
3635
3724
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -4224,19 +4313,25 @@ function startUserRetention(store, options) {
4224
4313
  export {
4225
4314
  AUTH_CONFIG_KEYS,
4226
4315
  AuthModule,
4316
+ CLIENT_KINDS,
4227
4317
  DEFAULT_TEMPLATES,
4228
4318
  MESSAGE_KEYS,
4319
+ SESSION_POLICY_PRESETS,
4229
4320
  buildAuthAccountLimiter,
4230
4321
  buildAuthIpLimiter,
4322
+ clientKindOf,
4323
+ configForClient,
4231
4324
  describeAuthAdminRoutes,
4232
4325
  importUser,
4233
4326
  normalizeEmail,
4234
4327
  normalizeEmailSafe,
4235
4328
  purgeSoftDeletedUsers,
4329
+ readAuthRuntimeConfig,
4236
4330
  requireAuth2 as requireAuth,
4237
4331
  resolveLocation,
4238
4332
  sanitizeLocation,
4239
4333
  schemas_exports as schemas,
4334
+ sessionPolicyConfigKeys,
4240
4335
  startUserRetention,
4241
4336
  toAdminUserDTO,
4242
4337
  toAdminUserPageDTO,