@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/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
@@ -368,6 +368,79 @@ function validDuration(value, fallback, name) {
368
368
  return fallback;
369
369
  }
370
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
+ }
443
+
371
444
  // src/services/mfa.ts
372
445
  import { createHmac, randomBytes } from "crypto";
373
446
  import { constantTimeEqual } from "@fonderie/core";
@@ -991,8 +1064,8 @@ var SessionModel = class {
991
1064
  async create(userId, token, expiresAt, sid, meta) {
992
1065
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
993
1066
  await this.store.query(
994
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
995
- 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)
996
1069
  ON CONFLICT (token) DO NOTHING`,
997
1070
  [
998
1071
  userId,
@@ -1001,7 +1074,8 @@ var SessionModel = class {
1001
1074
  sid ?? null,
1002
1075
  meta?.userAgent ?? null,
1003
1076
  meta?.ipAddress ?? null,
1004
- location ? JSON.stringify(location) : null
1077
+ location ? JSON.stringify(location) : null,
1078
+ meta?.clientKind ?? null
1005
1079
  ]
1006
1080
  );
1007
1081
  }
@@ -1016,12 +1090,12 @@ var SessionModel = class {
1016
1090
  async match(token) {
1017
1091
  const hash = hashRefreshToken(token);
1018
1092
  const [current] = await this.store.query(
1019
- `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`,
1020
1094
  [hash, token]
1021
1095
  );
1022
1096
  if (current) return { kind: "current", row: toRow(current) };
1023
1097
  const [previous] = await this.store.query(
1024
- `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
1025
1099
  FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1026
1100
  [hash]
1027
1101
  );
@@ -1087,7 +1161,7 @@ var SessionModel = class {
1087
1161
  // No token — that never leaves the server.
1088
1162
  async listLiveByUser(userId) {
1089
1163
  return this.store.query(
1090
- `SELECT id, sid,
1164
+ `SELECT id, sid, client_kind AS "clientKind",
1091
1165
  user_agent AS "userAgent",
1092
1166
  ip_address AS "ipAddress",
1093
1167
  location,
@@ -1136,7 +1210,8 @@ var SessionModel = class {
1136
1210
  }
1137
1211
  };
1138
1212
  function toRow(r) {
1139
- 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 };
1140
1215
  }
1141
1216
 
1142
1217
  // src/models/backup-code.model.ts
@@ -1267,7 +1342,8 @@ function requestMeta(ctx) {
1267
1342
  const meta = {
1268
1343
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1269
1344
  userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1270
- headers: ctx.request.headers
1345
+ headers: ctx.request.headers,
1346
+ clientKind: clientKindOf(ctx.request.headers)
1271
1347
  };
1272
1348
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1273
1349
  return meta;
@@ -1382,7 +1458,7 @@ function mfaController(store, config, issuer, bus) {
1382
1458
  await users.enableMfa(ctx.user.id);
1383
1459
  }
1384
1460
  }
1385
- 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)), {
1386
1462
  loginMethod: ctx.user.loginMethod,
1387
1463
  phoneVerified: ctx.user.phoneVerified
1388
1464
  });
@@ -1744,7 +1820,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1744
1820
  },
1745
1821
  reqOpts
1746
1822
  ));
1747
- 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)), {
1748
1824
  loginMethod: "email"
1749
1825
  });
1750
1826
  const registerMeta = requestMeta(ctx);
@@ -1887,7 +1963,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1887
1963
  mfaToken
1888
1964
  });
1889
1965
  }
1890
- 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)), {
1891
1967
  loginMethod: "email"
1892
1968
  });
1893
1969
  await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -1980,8 +2056,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1980
2056
  if (!payload || payload.type !== "refresh") {
1981
2057
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
1982
2058
  }
1983
- const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
1984
- const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2059
+ const runtime = config.resolve?.(ctx);
1985
2060
  let issued = null;
1986
2061
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
1987
2062
  const found = await sessions.match(token);
@@ -1995,6 +2070,8 @@ function authController(store, config, bus, locales = defineLocales()) {
1995
2070
  );
1996
2071
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
1997
2072
  }
2073
+ const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2074
+ const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
1998
2075
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
1999
2076
  await sessions.revokeById(found.row.id);
2000
2077
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
@@ -2176,7 +2253,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2176
2253
  );
2177
2254
  }
2178
2255
  await phoneVerif.deleteByUser(ctx.user.id);
2179
- 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)), {
2180
2257
  loginMethod: "phone",
2181
2258
  phoneVerified: true
2182
2259
  });
@@ -2353,6 +2430,7 @@ function toSessionDTO(row, currentSid) {
2353
2430
  ipAddress: row.ipAddress,
2354
2431
  userAgent: row.userAgent,
2355
2432
  location: sanitizeLocation(row.location),
2433
+ clientKind: row.clientKind ?? null,
2356
2434
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2357
2435
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2358
2436
  };
@@ -2901,7 +2979,7 @@ function oauthController(store, config, bus) {
2901
2979
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2902
2980
  }
2903
2981
  await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2904
- 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" });
2905
2983
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2906
2984
  await loginEvents.recordSafe({
2907
2985
  userId: upserted.id,
@@ -3052,7 +3130,7 @@ function oauthController(store, config, bus) {
3052
3130
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3053
3131
  }
3054
3132
  await announceOAuthUpsert(ctx, upserted, "google", fullUser);
3055
- 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)), {
3056
3134
  loginMethod: "google"
3057
3135
  });
3058
3136
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -3640,7 +3718,7 @@ var AuthModule = class {
3640
3718
  name = "@fonderie/auth";
3641
3719
  // Baked in at build time by tsup.base, so the operator's Modules page can
3642
3720
  // say what is actually deployed rather than 'not reported'.
3643
- version = "7.17.1";
3721
+ version = "7.18.0";
3644
3722
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3645
3723
  describeAdmin() {
3646
3724
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -4235,19 +4313,25 @@ function startUserRetention(store, options) {
4235
4313
  export {
4236
4314
  AUTH_CONFIG_KEYS,
4237
4315
  AuthModule,
4316
+ CLIENT_KINDS,
4238
4317
  DEFAULT_TEMPLATES,
4239
4318
  MESSAGE_KEYS,
4319
+ SESSION_POLICY_PRESETS,
4240
4320
  buildAuthAccountLimiter,
4241
4321
  buildAuthIpLimiter,
4322
+ clientKindOf,
4323
+ configForClient,
4242
4324
  describeAuthAdminRoutes,
4243
4325
  importUser,
4244
4326
  normalizeEmail,
4245
4327
  normalizeEmailSafe,
4246
4328
  purgeSoftDeletedUsers,
4329
+ readAuthRuntimeConfig,
4247
4330
  requireAuth2 as requireAuth,
4248
4331
  resolveLocation,
4249
4332
  sanitizeLocation,
4250
4333
  schemas_exports as schemas,
4334
+ sessionPolicyConfigKeys,
4251
4335
  startUserRetention,
4252
4336
  toAdminUserDTO,
4253
4337
  toAdminUserPageDTO,