@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/brain/outcomes.md CHANGED
@@ -98,6 +98,7 @@ location JSONB
98
98
  previous_token_hash TEXT
99
99
  previous_valid_until TIMESTAMPTZ
100
100
  last_used_at TIMESTAMPTZ
101
+ client_kind TEXT
101
102
  -- INDEX idx_fonderie_sessions_expires_at (expires_at)
102
103
  -- INDEX idx_fonderie_sessions_sid (sid)
103
104
  ```
@@ -93,6 +93,7 @@ interface IAuthSecrets {
93
93
  interface IAuthRuntimeConfig {
94
94
  sessionDuration?: string;
95
95
  sessionMaxAge?: string;
96
+ sessionPolicies?: Partial<Record<ClientKind, ISessionPolicy>>;
96
97
  verificationCooldown?: number;
97
98
  mfa?: boolean;
98
99
  requireVerification?: boolean;
@@ -103,7 +104,7 @@ interface IDataExportContributor {
103
104
  collect: (userId: string) => Promise<unknown> | unknown;
104
105
  }
105
106
 
106
- const AUTH_CONFIG_KEYS: Record<keyof IAuthRuntimeConfig, string>
107
+ const AUTH_CONFIG_KEYS: Record<Exclude<keyof IAuthRuntimeConfig, 'sessionPolicies'>, string>
107
108
 
108
109
  const MESSAGE_KEYS: { readonly emailRegistration: "email-registration"; readonly emailVerification: "email-verification"; readonly passwordReset: "password-reset"; readonly phoneOtp: "phone-otp"; readonly mfaEnabled: "mfa-enabled"; readonly mfaDisabled: "mfa-disabled"; readonly mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated"; readonly emailChanged: "email-changed"; readonly phoneChanged: "phone-changed"; readonly passwordRevoked: "password-revoked"; readonly oauthRegistration: "oauth-registration"; readonly oauthLinked: "oauth-linked"; readonly oauthUnlinked: "oauth-unlinked"; }
109
110
 
@@ -159,6 +160,7 @@ interface ISessionDTO {
159
160
  location: IRequestLocation | null;
160
161
  createdAt: string;
161
162
  expiresAt: string;
163
+ clientKind: string | null;
162
164
  }
163
165
 
164
166
  function sanitizeLocation(input: unknown): IRequestLocation | null
@@ -270,4 +272,24 @@ function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[]
270
272
  function toAdminUserDTO(user: IUser): IAdminUserDTO
271
273
 
272
274
  function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO
275
+
276
+ const CLIENT_KINDS: readonly ["mobile", "desktop", "web"]
277
+
278
+ const SESSION_POLICY_PRESETS: Readonly<Record<ClientKind, ISessionPolicy>>
279
+
280
+ function clientKindOf(headers: Headers): "mobile" | "desktop" | "web" | null
281
+
282
+ function configForClient(config: IAuthConfig, runtime: Partial<IAuthRuntimeConfig> | undefined, kind: "mobile" | "desktop" | "web" | null): IAuthConfig
283
+
284
+ function readAuthRuntimeConfig(read: (key: string) => unknown): Partial<IAuthRuntimeConfig>
285
+
286
+ function sessionPolicyConfigKeys(kind: "mobile" | "desktop" | "web"): Record<keyof ISessionPolicy, string>
287
+
288
+ type ClientKind = (typeof CLIENT_KINDS)[number];
289
+
290
+ interface ISessionPolicy {
291
+ sessionDuration?: string;
292
+ sessionMaxAge?: string;
293
+ accessTokenDuration?: string;
294
+ }
273
295
  ```
package/dist/index.cjs CHANGED
@@ -70,19 +70,25 @@ var index_exports = {};
70
70
  __export(index_exports, {
71
71
  AUTH_CONFIG_KEYS: () => AUTH_CONFIG_KEYS,
72
72
  AuthModule: () => AuthModule,
73
+ CLIENT_KINDS: () => CLIENT_KINDS,
73
74
  DEFAULT_TEMPLATES: () => DEFAULT_TEMPLATES,
74
75
  MESSAGE_KEYS: () => MESSAGE_KEYS,
76
+ SESSION_POLICY_PRESETS: () => SESSION_POLICY_PRESETS,
75
77
  buildAuthAccountLimiter: () => buildAuthAccountLimiter,
76
78
  buildAuthIpLimiter: () => buildAuthIpLimiter,
79
+ clientKindOf: () => clientKindOf,
80
+ configForClient: () => configForClient,
77
81
  describeAuthAdminRoutes: () => describeAuthAdminRoutes,
78
82
  importUser: () => importUser,
79
83
  normalizeEmail: () => normalizeEmail,
80
84
  normalizeEmailSafe: () => normalizeEmailSafe,
81
85
  purgeSoftDeletedUsers: () => purgeSoftDeletedUsers,
86
+ readAuthRuntimeConfig: () => readAuthRuntimeConfig,
82
87
  requireAuth: () => import_middlewares3.requireAuth,
83
88
  resolveLocation: () => resolveLocation,
84
89
  sanitizeLocation: () => sanitizeLocation,
85
90
  schemas: () => schemas_exports,
91
+ sessionPolicyConfigKeys: () => sessionPolicyConfigKeys,
86
92
  startUserRetention: () => startUserRetention,
87
93
  toAdminUserDTO: () => toAdminUserDTO,
88
94
  toAdminUserPageDTO: () => toAdminUserPageDTO,
@@ -357,8 +363,8 @@ function issueMfaPendingToken(userId, config, loginMethod) {
357
363
  );
358
364
  }
359
365
  function issueTokenPair(userId, config, options) {
360
- const duration = config.sessionDuration ?? DEFAULT_SESSION_DURATION;
361
- const accessDuration = config.accessTokenDuration ?? DEFAULT_ACCESS_TOKEN_DURATION;
366
+ const duration = validDuration(config.sessionDuration, DEFAULT_SESSION_DURATION, "sessionDuration");
367
+ const accessDuration = validDuration(config.accessTokenDuration, DEFAULT_ACCESS_TOKEN_DURATION, "accessTokenDuration");
362
368
  const authTime = options.authTime ?? Math.floor(Date.now() / 1e3);
363
369
  const loginMethod = options.loginMethod;
364
370
  const phoneVerified = options.phoneVerified ?? false;
@@ -401,6 +407,90 @@ function durationMs(value) {
401
407
  const scale = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5, y: 315576e5 };
402
408
  return Number(m[1]) * (scale[unit] ?? 1e3);
403
409
  }
410
+ var warnedDurations = /* @__PURE__ */ new Set();
411
+ function validDuration(value, fallback, name) {
412
+ if (value === void 0 || value === null || value === "") return fallback;
413
+ if (durationMs(String(value)) !== null) return String(value);
414
+ const key = `${name}=${String(value)}`;
415
+ if (!warnedDurations.has(key)) {
416
+ warnedDurations.add(key);
417
+ console.warn(`[auth] ${name} ${JSON.stringify(value)} is not a duration (e.g. '90d', '1h') \u2014 using ${fallback}`);
418
+ }
419
+ return fallback;
420
+ }
421
+
422
+ // src/services/session-policy.ts
423
+ var CLIENT_KINDS = ["mobile", "desktop", "web"];
424
+ var SESSION_POLICY_PRESETS = {
425
+ // Personal device; the token lives in the Keychain / Keystore.
426
+ mobile: { sessionDuration: "90d", sessionMaxAge: "365d" },
427
+ // Personal, but often a shared workstation.
428
+ desktop: { sessionDuration: "30d", sessionMaxAge: "180d" },
429
+ // Shared and public computers; cookies.
430
+ web: { sessionDuration: "14d", sessionMaxAge: "90d" }
431
+ };
432
+ function clientKindOf(headers) {
433
+ const v = headers.get("x-client-kind")?.trim().toLowerCase();
434
+ return CLIENT_KINDS.includes(v ?? "") ? v : null;
435
+ }
436
+ var FIELDS = ["sessionDuration", "sessionMaxAge", "accessTokenDuration"];
437
+ function configForClient(config, runtime, kind) {
438
+ const rt = runtime ?? {};
439
+ const code = config;
440
+ const out = { ...config, ...rt };
441
+ for (const field of FIELDS) {
442
+ 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);
443
+ if (value === void 0) delete out[field];
444
+ else out[field] = value;
445
+ }
446
+ return out;
447
+ }
448
+ function sessionPolicyConfigKeys(kind) {
449
+ return {
450
+ sessionDuration: `auth.session.duration.${kind}`,
451
+ sessionMaxAge: `auth.session.max_age.${kind}`,
452
+ accessTokenDuration: `auth.access.duration.${kind}`
453
+ };
454
+ }
455
+ function readAuthRuntimeConfig(read) {
456
+ const text = (key) => {
457
+ const v = read(key);
458
+ return typeof v === "string" && v.trim() ? v.trim() : void 0;
459
+ };
460
+ const bool2 = (key) => {
461
+ const v = read(key);
462
+ if (typeof v === "boolean") return v;
463
+ if (typeof v === "string" && ["true", "false"].includes(v.trim().toLowerCase())) return v.trim().toLowerCase() === "true";
464
+ return void 0;
465
+ };
466
+ const num = (key) => {
467
+ const v = read(key);
468
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v) : NaN;
469
+ return Number.isFinite(n) ? n : void 0;
470
+ };
471
+ const out = {};
472
+ const set = (k, v) => {
473
+ if (v !== void 0) out[k] = v;
474
+ };
475
+ set("sessionDuration", text("auth.session.duration"));
476
+ set("sessionMaxAge", text("auth.session.max_age"));
477
+ set("accessTokenDuration", text("auth.access.duration"));
478
+ set("verificationCooldown", num("auth.verification.cooldown"));
479
+ set("mfa", bool2("auth.mfa.enabled"));
480
+ set("requireVerification", bool2("auth.verification.required"));
481
+ const policies = {};
482
+ for (const kind of CLIENT_KINDS) {
483
+ const keys = sessionPolicyConfigKeys(kind);
484
+ const policy = {};
485
+ for (const field of FIELDS) {
486
+ const v = text(keys[field]);
487
+ if (v !== void 0) policy[field] = v;
488
+ }
489
+ if (Object.keys(policy).length) policies[kind] = policy;
490
+ }
491
+ if (Object.keys(policies).length) out.sessionPolicies = policies;
492
+ return out;
493
+ }
404
494
 
405
495
  // src/services/mfa.ts
406
496
  var import_node_crypto2 = require("crypto");
@@ -1025,8 +1115,8 @@ var SessionModel = class {
1025
1115
  async create(userId, token, expiresAt, sid, meta) {
1026
1116
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
1027
1117
  await this.store.query(
1028
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
1029
- VALUES ($1, $2, $3, $4, $5, $6, $7, now())
1118
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at, client_kind)
1119
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
1030
1120
  ON CONFLICT (token) DO NOTHING`,
1031
1121
  [
1032
1122
  userId,
@@ -1035,7 +1125,8 @@ var SessionModel = class {
1035
1125
  sid ?? null,
1036
1126
  meta?.userAgent ?? null,
1037
1127
  meta?.ipAddress ?? null,
1038
- location ? JSON.stringify(location) : null
1128
+ location ? JSON.stringify(location) : null,
1129
+ meta?.clientKind ?? null
1039
1130
  ]
1040
1131
  );
1041
1132
  }
@@ -1050,12 +1141,12 @@ var SessionModel = class {
1050
1141
  async match(token) {
1051
1142
  const hash = hashRefreshToken(token);
1052
1143
  const [current] = await this.store.query(
1053
- `SELECT id, user_id, sid, token, created_at FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1144
+ `SELECT id, user_id, sid, token, created_at, client_kind FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1054
1145
  [hash, token]
1055
1146
  );
1056
1147
  if (current) return { kind: "current", row: toRow(current) };
1057
1148
  const [previous] = await this.store.query(
1058
- `SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
1149
+ `SELECT id, user_id, sid, token, created_at, client_kind, (previous_valid_until > now()) AS in_grace
1059
1150
  FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1060
1151
  [hash]
1061
1152
  );
@@ -1121,7 +1212,7 @@ var SessionModel = class {
1121
1212
  // No token — that never leaves the server.
1122
1213
  async listLiveByUser(userId) {
1123
1214
  return this.store.query(
1124
- `SELECT id, sid,
1215
+ `SELECT id, sid, client_kind AS "clientKind",
1125
1216
  user_agent AS "userAgent",
1126
1217
  ip_address AS "ipAddress",
1127
1218
  location,
@@ -1170,7 +1261,8 @@ var SessionModel = class {
1170
1261
  }
1171
1262
  };
1172
1263
  function toRow(r) {
1173
- 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() };
1264
+ const kind = CLIENT_KINDS.includes(r.client_kind ?? "") ? r.client_kind : null;
1265
+ 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 };
1174
1266
  }
1175
1267
 
1176
1268
  // src/models/backup-code.model.ts
@@ -1301,7 +1393,8 @@ function requestMeta(ctx) {
1301
1393
  const meta = {
1302
1394
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1303
1395
  userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1304
- headers: ctx.request.headers
1396
+ headers: ctx.request.headers,
1397
+ clientKind: clientKindOf(ctx.request.headers)
1305
1398
  };
1306
1399
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1307
1400
  return meta;
@@ -1416,7 +1509,7 @@ function mfaController(store, config, issuer, bus) {
1416
1509
  await users.enableMfa(ctx.user.id);
1417
1510
  }
1418
1511
  }
1419
- const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
1512
+ const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1420
1513
  loginMethod: ctx.user.loginMethod,
1421
1514
  phoneVerified: ctx.user.phoneVerified
1422
1515
  });
@@ -1778,7 +1871,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1778
1871
  },
1779
1872
  reqOpts
1780
1873
  ));
1781
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1874
+ const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1782
1875
  loginMethod: "email"
1783
1876
  });
1784
1877
  const registerMeta = requestMeta(ctx);
@@ -1921,7 +2014,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1921
2014
  mfaToken
1922
2015
  });
1923
2016
  }
1924
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
2017
+ const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1925
2018
  loginMethod: "email"
1926
2019
  });
1927
2020
  await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -2014,8 +2107,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2014
2107
  if (!payload || payload.type !== "refresh") {
2015
2108
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2016
2109
  }
2017
- const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
2018
- const maxAgeMs = durationMs(resolvedRefresh.sessionMaxAge);
2110
+ const runtime = config.resolve?.(ctx);
2019
2111
  let issued = null;
2020
2112
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2021
2113
  const found = await sessions.match(token);
@@ -2029,6 +2121,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2029
2121
  );
2030
2122
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2031
2123
  }
2124
+ const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2125
+ const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2032
2126
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2033
2127
  await sessions.revokeById(found.row.id);
2034
2128
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
@@ -2210,7 +2304,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2210
2304
  );
2211
2305
  }
2212
2306
  await phoneVerif.deleteByUser(ctx.user.id);
2213
- const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
2307
+ const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2214
2308
  loginMethod: "phone",
2215
2309
  phoneVerified: true
2216
2310
  });
@@ -2387,6 +2481,7 @@ function toSessionDTO(row, currentSid) {
2387
2481
  ipAddress: row.ipAddress,
2388
2482
  userAgent: row.userAgent,
2389
2483
  location: sanitizeLocation(row.location),
2484
+ clientKind: row.clientKind ?? null,
2390
2485
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2391
2486
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2392
2487
  };
@@ -2935,7 +3030,7 @@ function oauthController(store, config, bus) {
2935
3030
  return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2936
3031
  }
2937
3032
  await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2938
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
3033
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: "apple" });
2939
3034
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2940
3035
  await loginEvents.recordSafe({
2941
3036
  userId: upserted.id,
@@ -3086,7 +3181,7 @@ function oauthController(store, config, bus) {
3086
3181
  return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3087
3182
  }
3088
3183
  await announceOAuthUpsert(ctx, upserted, "google", fullUser);
3089
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
3184
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
3090
3185
  loginMethod: "google"
3091
3186
  });
3092
3187
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -3674,7 +3769,7 @@ var AuthModule = class {
3674
3769
  name = "@fonderie/auth";
3675
3770
  // Baked in at build time by tsup.base, so the operator's Modules page can
3676
3771
  // say what is actually deployed rather than 'not reported'.
3677
- version = "7.17.0";
3772
+ version = "7.18.0";
3678
3773
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3679
3774
  describeAdmin() {
3680
3775
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -4270,19 +4365,25 @@ function startUserRetention(store, options) {
4270
4365
  0 && (module.exports = {
4271
4366
  AUTH_CONFIG_KEYS,
4272
4367
  AuthModule,
4368
+ CLIENT_KINDS,
4273
4369
  DEFAULT_TEMPLATES,
4274
4370
  MESSAGE_KEYS,
4371
+ SESSION_POLICY_PRESETS,
4275
4372
  buildAuthAccountLimiter,
4276
4373
  buildAuthIpLimiter,
4374
+ clientKindOf,
4375
+ configForClient,
4277
4376
  describeAuthAdminRoutes,
4278
4377
  importUser,
4279
4378
  normalizeEmail,
4280
4379
  normalizeEmailSafe,
4281
4380
  purgeSoftDeletedUsers,
4381
+ readAuthRuntimeConfig,
4282
4382
  requireAuth,
4283
4383
  resolveLocation,
4284
4384
  sanitizeLocation,
4285
4385
  schemas,
4386
+ sessionPolicyConfigKeys,
4286
4387
  startUserRetention,
4287
4388
  toAdminUserDTO,
4288
4389
  toAdminUserPageDTO,