@fonderie/auth 7.17.1 → 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,
@@ -413,6 +419,79 @@ function validDuration(value, fallback, name) {
413
419
  return fallback;
414
420
  }
415
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
+ }
494
+
416
495
  // src/services/mfa.ts
417
496
  var import_node_crypto2 = require("crypto");
418
497
  var import_core2 = require("@fonderie/core");
@@ -1036,8 +1115,8 @@ var SessionModel = class {
1036
1115
  async create(userId, token, expiresAt, sid, meta) {
1037
1116
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
1038
1117
  await this.store.query(
1039
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
1040
- 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)
1041
1120
  ON CONFLICT (token) DO NOTHING`,
1042
1121
  [
1043
1122
  userId,
@@ -1046,7 +1125,8 @@ var SessionModel = class {
1046
1125
  sid ?? null,
1047
1126
  meta?.userAgent ?? null,
1048
1127
  meta?.ipAddress ?? null,
1049
- location ? JSON.stringify(location) : null
1128
+ location ? JSON.stringify(location) : null,
1129
+ meta?.clientKind ?? null
1050
1130
  ]
1051
1131
  );
1052
1132
  }
@@ -1061,12 +1141,12 @@ var SessionModel = class {
1061
1141
  async match(token) {
1062
1142
  const hash = hashRefreshToken(token);
1063
1143
  const [current] = await this.store.query(
1064
- `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`,
1065
1145
  [hash, token]
1066
1146
  );
1067
1147
  if (current) return { kind: "current", row: toRow(current) };
1068
1148
  const [previous] = await this.store.query(
1069
- `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
1070
1150
  FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1071
1151
  [hash]
1072
1152
  );
@@ -1132,7 +1212,7 @@ var SessionModel = class {
1132
1212
  // No token — that never leaves the server.
1133
1213
  async listLiveByUser(userId) {
1134
1214
  return this.store.query(
1135
- `SELECT id, sid,
1215
+ `SELECT id, sid, client_kind AS "clientKind",
1136
1216
  user_agent AS "userAgent",
1137
1217
  ip_address AS "ipAddress",
1138
1218
  location,
@@ -1181,7 +1261,8 @@ var SessionModel = class {
1181
1261
  }
1182
1262
  };
1183
1263
  function toRow(r) {
1184
- 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 };
1185
1266
  }
1186
1267
 
1187
1268
  // src/models/backup-code.model.ts
@@ -1312,7 +1393,8 @@ function requestMeta(ctx) {
1312
1393
  const meta = {
1313
1394
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1314
1395
  userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1315
- headers: ctx.request.headers
1396
+ headers: ctx.request.headers,
1397
+ clientKind: clientKindOf(ctx.request.headers)
1316
1398
  };
1317
1399
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1318
1400
  return meta;
@@ -1427,7 +1509,7 @@ function mfaController(store, config, issuer, bus) {
1427
1509
  await users.enableMfa(ctx.user.id);
1428
1510
  }
1429
1511
  }
1430
- 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)), {
1431
1513
  loginMethod: ctx.user.loginMethod,
1432
1514
  phoneVerified: ctx.user.phoneVerified
1433
1515
  });
@@ -1789,7 +1871,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1789
1871
  },
1790
1872
  reqOpts
1791
1873
  ));
1792
- 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)), {
1793
1875
  loginMethod: "email"
1794
1876
  });
1795
1877
  const registerMeta = requestMeta(ctx);
@@ -1932,7 +2014,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1932
2014
  mfaToken
1933
2015
  });
1934
2016
  }
1935
- 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)), {
1936
2018
  loginMethod: "email"
1937
2019
  });
1938
2020
  await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -2025,8 +2107,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2025
2107
  if (!payload || payload.type !== "refresh") {
2026
2108
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2027
2109
  }
2028
- const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
2029
- const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2110
+ const runtime = config.resolve?.(ctx);
2030
2111
  let issued = null;
2031
2112
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2032
2113
  const found = await sessions.match(token);
@@ -2040,6 +2121,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2040
2121
  );
2041
2122
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2042
2123
  }
2124
+ const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2125
+ const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2043
2126
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2044
2127
  await sessions.revokeById(found.row.id);
2045
2128
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
@@ -2221,7 +2304,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2221
2304
  );
2222
2305
  }
2223
2306
  await phoneVerif.deleteByUser(ctx.user.id);
2224
- 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)), {
2225
2308
  loginMethod: "phone",
2226
2309
  phoneVerified: true
2227
2310
  });
@@ -2398,6 +2481,7 @@ function toSessionDTO(row, currentSid) {
2398
2481
  ipAddress: row.ipAddress,
2399
2482
  userAgent: row.userAgent,
2400
2483
  location: sanitizeLocation(row.location),
2484
+ clientKind: row.clientKind ?? null,
2401
2485
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2402
2486
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2403
2487
  };
@@ -2946,7 +3030,7 @@ function oauthController(store, config, bus) {
2946
3030
  return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2947
3031
  }
2948
3032
  await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2949
- 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" });
2950
3034
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2951
3035
  await loginEvents.recordSafe({
2952
3036
  userId: upserted.id,
@@ -3097,7 +3181,7 @@ function oauthController(store, config, bus) {
3097
3181
  return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3098
3182
  }
3099
3183
  await announceOAuthUpsert(ctx, upserted, "google", fullUser);
3100
- 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)), {
3101
3185
  loginMethod: "google"
3102
3186
  });
3103
3187
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -3685,7 +3769,7 @@ var AuthModule = class {
3685
3769
  name = "@fonderie/auth";
3686
3770
  // Baked in at build time by tsup.base, so the operator's Modules page can
3687
3771
  // say what is actually deployed rather than 'not reported'.
3688
- version = "7.17.1";
3772
+ version = "7.18.0";
3689
3773
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3690
3774
  describeAdmin() {
3691
3775
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -4281,19 +4365,25 @@ function startUserRetention(store, options) {
4281
4365
  0 && (module.exports = {
4282
4366
  AUTH_CONFIG_KEYS,
4283
4367
  AuthModule,
4368
+ CLIENT_KINDS,
4284
4369
  DEFAULT_TEMPLATES,
4285
4370
  MESSAGE_KEYS,
4371
+ SESSION_POLICY_PRESETS,
4286
4372
  buildAuthAccountLimiter,
4287
4373
  buildAuthIpLimiter,
4374
+ clientKindOf,
4375
+ configForClient,
4288
4376
  describeAuthAdminRoutes,
4289
4377
  importUser,
4290
4378
  normalizeEmail,
4291
4379
  normalizeEmailSafe,
4292
4380
  purgeSoftDeletedUsers,
4381
+ readAuthRuntimeConfig,
4293
4382
  requireAuth,
4294
4383
  resolveLocation,
4295
4384
  sanitizeLocation,
4296
4385
  schemas,
4386
+ sessionPolicyConfigKeys,
4297
4387
  startUserRetention,
4298
4388
  toAdminUserDTO,
4299
4389
  toAdminUserPageDTO,