@fonderie/auth 7.17.1 → 7.19.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 +2 -1
- package/brain/signatures.md +25 -2
- package/dist/index.cjs +151 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -4
- package/dist/index.d.ts +7 -4
- package/dist/index.js +143 -31
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +24 -11
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +24 -11
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/021_session_client_kind.sql +6 -0
- package/dist/{session-C-ptRylj.d.cts → session-B-IgXFu8.d.cts} +32 -2
- package/dist/{session-C-ptRylj.d.ts → session-B-IgXFu8.d.ts} +32 -2
- package/package.json +1 -1
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
|
```
|
|
@@ -144,7 +145,7 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
|
|
|
144
145
|
| GET | `/_admin/users` | `async (ctx) => { const params = new URL(ctx.request.url).searchParams; const email = params.get('email')?.trim().toLowerCase(); if (email) { const user = await users.findByEmail(email); return user ? setApiResponse(HTTP.OK, 'USER', 'User', toAdminUserDTO(user)) : NOT_FOUND(); } const rawLimit = Number(params.get('limit') ?? 50); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50; const cursorParam = params.get('cursor'); const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null; if (cursorParam && !cursor) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'Invalid cursor'); const deleted = params.get('deleted') === '1' || params.get('deleted') === 'true'; const page = await users.list({ limit, ...(cursor ? { cursor } : {}), ...(deleted ? { deleted } : {}), }); return setApiResponse(HTTP.OK, 'USERS', 'Users', toAdminUserPageDTO(page)); }` |
|
|
145
146
|
| GET | `/_admin/users/:id` | `async (ctx) => { // Read-only view: a soft-deleted account resolves (deletedAt set), so // an operator arriving from billing sees what happened to it. const user = await users.findByIdIncludingDeleted(idOf(ctx)); return user ? setApiResponse(HTTP.OK, 'USER', 'User', toAdminUserDTO(user)) : NOT_FOUND(); }` |
|
|
146
147
|
| GET | `/_admin/users/:id/login-history` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); const params = new URL(ctx.request.url).searchParams; const rawLimit = Number(params.get('limit') ?? 50); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50; const cursorParam = params.get('cursor'); const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null; if (cursorParam && !cursor) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'Invalid cursor'); const page = await events.listByUser({ userId: idOf(ctx), limit, ...(cursor ? { cursor } : {}), }); return setApiResponse( HTTP.OK, 'LOGIN_HISTORY', 'Login history', toLoginHistoryPageDTO(page), ); }` |
|
|
147
|
-
| DELETE | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); await sessions.deleteByUser(idOf(ctx)); return setApiResponse(HTTP.OK, 'SESSIONS_REVOKED', 'All sessions revoked'); }` |
|
|
148
|
+
| DELETE | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); await sessions.deleteByUser(idOf(ctx)); await background(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: 'admin' } satisfies ISessionRevokedEvent)); return setApiResponse(HTTP.OK, 'SESSIONS_REVOKED', 'All sessions revoked'); }` |
|
|
148
149
|
| GET | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); const rows = await sessions.listLiveByUser(idOf(ctx)); return setApiResponse( HTTP.OK, 'SESSIONS', 'Live sessions', rows.map((r) => toSessionDTO(r, null)), ); }` |
|
|
149
150
|
| POST | `/_admin/users/:id/suspend` | `setSuspended(true)` |
|
|
150
151
|
| POST | `/_admin/users/:id/unsuspend` | `setSuspended(false)` |
|
package/brain/signatures.md
CHANGED
|
@@ -52,6 +52,7 @@ new AuthModule(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undef
|
|
|
52
52
|
.name: "@fonderie/auth"
|
|
53
53
|
.version: string
|
|
54
54
|
.describeAdmin(): IAdminDescription
|
|
55
|
+
.describeEvents(): IEventCatalogEntry<unknown>[]
|
|
55
56
|
.checkReadiness(): IReadinessProblem[]
|
|
56
57
|
.install(app: IFonderieApp): void
|
|
57
58
|
|
|
@@ -93,6 +94,7 @@ interface IAuthSecrets {
|
|
|
93
94
|
interface IAuthRuntimeConfig {
|
|
94
95
|
sessionDuration?: string;
|
|
95
96
|
sessionMaxAge?: string;
|
|
97
|
+
sessionPolicies?: Partial<Record<ClientKind, ISessionPolicy>>;
|
|
96
98
|
verificationCooldown?: number;
|
|
97
99
|
mfa?: boolean;
|
|
98
100
|
requireVerification?: boolean;
|
|
@@ -103,7 +105,7 @@ interface IDataExportContributor {
|
|
|
103
105
|
collect: (userId: string) => Promise<unknown> | unknown;
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
const AUTH_CONFIG_KEYS: Record<keyof IAuthRuntimeConfig, string>
|
|
108
|
+
const AUTH_CONFIG_KEYS: Record<Exclude<keyof IAuthRuntimeConfig, 'sessionPolicies'>, string>
|
|
107
109
|
|
|
108
110
|
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
111
|
|
|
@@ -159,6 +161,7 @@ interface ISessionDTO {
|
|
|
159
161
|
location: IRequestLocation | null;
|
|
160
162
|
createdAt: string;
|
|
161
163
|
expiresAt: string;
|
|
164
|
+
clientKind: string | null;
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
function sanitizeLocation(input: unknown): IRequestLocation | null
|
|
@@ -265,9 +268,29 @@ interface IAdminUserDTO extends IUserDTO {
|
|
|
265
268
|
createdAt: string;
|
|
266
269
|
}
|
|
267
270
|
|
|
268
|
-
function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[]
|
|
271
|
+
function describeAuthAdminRoutes(store: IStoreAdapter, bus?: EventBus | undefined): IAdminRoute[]
|
|
269
272
|
|
|
270
273
|
function toAdminUserDTO(user: IUser): IAdminUserDTO
|
|
271
274
|
|
|
272
275
|
function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO
|
|
276
|
+
|
|
277
|
+
const CLIENT_KINDS: readonly ["mobile", "desktop", "web"]
|
|
278
|
+
|
|
279
|
+
const SESSION_POLICY_PRESETS: Readonly<Record<ClientKind, ISessionPolicy>>
|
|
280
|
+
|
|
281
|
+
function clientKindOf(headers: Headers): "mobile" | "desktop" | "web" | null
|
|
282
|
+
|
|
283
|
+
function configForClient(config: IAuthConfig, runtime: Partial<IAuthRuntimeConfig> | undefined, kind: "mobile" | "desktop" | "web" | null): IAuthConfig
|
|
284
|
+
|
|
285
|
+
function readAuthRuntimeConfig(read: (key: string) => unknown): Partial<IAuthRuntimeConfig>
|
|
286
|
+
|
|
287
|
+
function sessionPolicyConfigKeys(kind: "mobile" | "desktop" | "web"): Record<keyof ISessionPolicy, string>
|
|
288
|
+
|
|
289
|
+
type ClientKind = (typeof CLIENT_KINDS)[number];
|
|
290
|
+
|
|
291
|
+
interface ISessionPolicy {
|
|
292
|
+
sessionDuration?: string;
|
|
293
|
+
sessionMaxAge?: string;
|
|
294
|
+
accessTokenDuration?: string;
|
|
295
|
+
}
|
|
273
296
|
```
|
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,
|
|
@@ -329,8 +335,10 @@ var EVENT_KEYS = {
|
|
|
329
335
|
userPurged: "fonderie.user.purged",
|
|
330
336
|
emailVerified: "fonderie.user.email_verified",
|
|
331
337
|
passwordChanged: "fonderie.user.password_changed",
|
|
332
|
-
//
|
|
333
|
-
//
|
|
338
|
+
// Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
|
|
339
|
+
// the user's devices live (@fonderie/sse) so a revoked device signs out at
|
|
340
|
+
// once. Reasons: 'terminated' (signed out from the devices list),
|
|
341
|
+
// 'password-changed', 'admin', 'refresh-reuse' (theft signal).
|
|
334
342
|
sessionRevoked: "fonderie.session.revoked"
|
|
335
343
|
};
|
|
336
344
|
|
|
@@ -413,6 +421,79 @@ function validDuration(value, fallback, name) {
|
|
|
413
421
|
return fallback;
|
|
414
422
|
}
|
|
415
423
|
|
|
424
|
+
// src/services/session-policy.ts
|
|
425
|
+
var CLIENT_KINDS = ["mobile", "desktop", "web"];
|
|
426
|
+
var SESSION_POLICY_PRESETS = {
|
|
427
|
+
// Personal device; the token lives in the Keychain / Keystore.
|
|
428
|
+
mobile: { sessionDuration: "90d", sessionMaxAge: "365d" },
|
|
429
|
+
// Personal, but often a shared workstation.
|
|
430
|
+
desktop: { sessionDuration: "30d", sessionMaxAge: "180d" },
|
|
431
|
+
// Shared and public computers; cookies.
|
|
432
|
+
web: { sessionDuration: "14d", sessionMaxAge: "90d" }
|
|
433
|
+
};
|
|
434
|
+
function clientKindOf(headers) {
|
|
435
|
+
const v = headers.get("x-client-kind")?.trim().toLowerCase();
|
|
436
|
+
return CLIENT_KINDS.includes(v ?? "") ? v : null;
|
|
437
|
+
}
|
|
438
|
+
var FIELDS = ["sessionDuration", "sessionMaxAge", "accessTokenDuration"];
|
|
439
|
+
function configForClient(config, runtime, kind) {
|
|
440
|
+
const rt = runtime ?? {};
|
|
441
|
+
const code = config;
|
|
442
|
+
const out = { ...config, ...rt };
|
|
443
|
+
for (const field of FIELDS) {
|
|
444
|
+
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);
|
|
445
|
+
if (value === void 0) delete out[field];
|
|
446
|
+
else out[field] = value;
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
function sessionPolicyConfigKeys(kind) {
|
|
451
|
+
return {
|
|
452
|
+
sessionDuration: `auth.session.duration.${kind}`,
|
|
453
|
+
sessionMaxAge: `auth.session.max_age.${kind}`,
|
|
454
|
+
accessTokenDuration: `auth.access.duration.${kind}`
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function readAuthRuntimeConfig(read) {
|
|
458
|
+
const text = (key) => {
|
|
459
|
+
const v = read(key);
|
|
460
|
+
return typeof v === "string" && v.trim() ? v.trim() : void 0;
|
|
461
|
+
};
|
|
462
|
+
const bool2 = (key) => {
|
|
463
|
+
const v = read(key);
|
|
464
|
+
if (typeof v === "boolean") return v;
|
|
465
|
+
if (typeof v === "string" && ["true", "false"].includes(v.trim().toLowerCase())) return v.trim().toLowerCase() === "true";
|
|
466
|
+
return void 0;
|
|
467
|
+
};
|
|
468
|
+
const num = (key) => {
|
|
469
|
+
const v = read(key);
|
|
470
|
+
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v) : NaN;
|
|
471
|
+
return Number.isFinite(n) ? n : void 0;
|
|
472
|
+
};
|
|
473
|
+
const out = {};
|
|
474
|
+
const set = (k, v) => {
|
|
475
|
+
if (v !== void 0) out[k] = v;
|
|
476
|
+
};
|
|
477
|
+
set("sessionDuration", text("auth.session.duration"));
|
|
478
|
+
set("sessionMaxAge", text("auth.session.max_age"));
|
|
479
|
+
set("accessTokenDuration", text("auth.access.duration"));
|
|
480
|
+
set("verificationCooldown", num("auth.verification.cooldown"));
|
|
481
|
+
set("mfa", bool2("auth.mfa.enabled"));
|
|
482
|
+
set("requireVerification", bool2("auth.verification.required"));
|
|
483
|
+
const policies = {};
|
|
484
|
+
for (const kind of CLIENT_KINDS) {
|
|
485
|
+
const keys = sessionPolicyConfigKeys(kind);
|
|
486
|
+
const policy = {};
|
|
487
|
+
for (const field of FIELDS) {
|
|
488
|
+
const v = text(keys[field]);
|
|
489
|
+
if (v !== void 0) policy[field] = v;
|
|
490
|
+
}
|
|
491
|
+
if (Object.keys(policy).length) policies[kind] = policy;
|
|
492
|
+
}
|
|
493
|
+
if (Object.keys(policies).length) out.sessionPolicies = policies;
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
416
497
|
// src/services/mfa.ts
|
|
417
498
|
var import_node_crypto2 = require("crypto");
|
|
418
499
|
var import_core2 = require("@fonderie/core");
|
|
@@ -1036,8 +1117,8 @@ var SessionModel = class {
|
|
|
1036
1117
|
async create(userId, token, expiresAt, sid, meta) {
|
|
1037
1118
|
const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
|
|
1038
1119
|
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())
|
|
1120
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at, client_kind)
|
|
1121
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
|
|
1041
1122
|
ON CONFLICT (token) DO NOTHING`,
|
|
1042
1123
|
[
|
|
1043
1124
|
userId,
|
|
@@ -1046,7 +1127,8 @@ var SessionModel = class {
|
|
|
1046
1127
|
sid ?? null,
|
|
1047
1128
|
meta?.userAgent ?? null,
|
|
1048
1129
|
meta?.ipAddress ?? null,
|
|
1049
|
-
location ? JSON.stringify(location) : null
|
|
1130
|
+
location ? JSON.stringify(location) : null,
|
|
1131
|
+
meta?.clientKind ?? null
|
|
1050
1132
|
]
|
|
1051
1133
|
);
|
|
1052
1134
|
}
|
|
@@ -1061,12 +1143,12 @@ var SessionModel = class {
|
|
|
1061
1143
|
async match(token) {
|
|
1062
1144
|
const hash = hashRefreshToken(token);
|
|
1063
1145
|
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`,
|
|
1146
|
+
`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
1147
|
[hash, token]
|
|
1066
1148
|
);
|
|
1067
1149
|
if (current) return { kind: "current", row: toRow(current) };
|
|
1068
1150
|
const [previous] = await this.store.query(
|
|
1069
|
-
`SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
|
|
1151
|
+
`SELECT id, user_id, sid, token, created_at, client_kind, (previous_valid_until > now()) AS in_grace
|
|
1070
1152
|
FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
|
|
1071
1153
|
[hash]
|
|
1072
1154
|
);
|
|
@@ -1132,7 +1214,7 @@ var SessionModel = class {
|
|
|
1132
1214
|
// No token — that never leaves the server.
|
|
1133
1215
|
async listLiveByUser(userId) {
|
|
1134
1216
|
return this.store.query(
|
|
1135
|
-
`SELECT id, sid,
|
|
1217
|
+
`SELECT id, sid, client_kind AS "clientKind",
|
|
1136
1218
|
user_agent AS "userAgent",
|
|
1137
1219
|
ip_address AS "ipAddress",
|
|
1138
1220
|
location,
|
|
@@ -1147,22 +1229,30 @@ var SessionModel = class {
|
|
|
1147
1229
|
// Terminate one session by row id, scoped to its owner (a user can never
|
|
1148
1230
|
// delete another user's session). Returns whether a row was removed.
|
|
1149
1231
|
async terminateById(userId, id) {
|
|
1232
|
+
return await this.terminateByIdReturningSid(userId, id) !== void 0;
|
|
1233
|
+
}
|
|
1234
|
+
/** Terminate one of the user's sessions; its sid (null if unbound), or undefined if none was removed. */
|
|
1235
|
+
async terminateByIdReturningSid(userId, id) {
|
|
1150
1236
|
const rows = await this.store.query(
|
|
1151
|
-
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING
|
|
1237
|
+
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING sid`,
|
|
1152
1238
|
[id, userId]
|
|
1153
1239
|
);
|
|
1154
|
-
return rows.length
|
|
1240
|
+
return rows.length ? rows[0]?.sid ?? null : void 0;
|
|
1155
1241
|
}
|
|
1156
1242
|
// Terminate all of a user's sessions except the current one (by sid).
|
|
1157
1243
|
// Returns the number of sessions removed.
|
|
1158
1244
|
async terminateOthers(userId, keepSid) {
|
|
1245
|
+
return (await this.terminateOthersReturningSids(userId, keepSid)).length;
|
|
1246
|
+
}
|
|
1247
|
+
/** Terminate all of the user's sessions but `keepSid`; the sids removed. */
|
|
1248
|
+
async terminateOthersReturningSids(userId, keepSid) {
|
|
1159
1249
|
const rows = await this.store.query(
|
|
1160
1250
|
`DELETE FROM fonderie_sessions
|
|
1161
1251
|
WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
|
|
1162
|
-
RETURNING
|
|
1252
|
+
RETURNING sid`,
|
|
1163
1253
|
[userId, keepSid]
|
|
1164
1254
|
);
|
|
1165
|
-
return rows.
|
|
1255
|
+
return rows.map((r) => r.sid);
|
|
1166
1256
|
}
|
|
1167
1257
|
async exists(token) {
|
|
1168
1258
|
const rows = await this.store.query(
|
|
@@ -1181,7 +1271,8 @@ var SessionModel = class {
|
|
|
1181
1271
|
}
|
|
1182
1272
|
};
|
|
1183
1273
|
function toRow(r) {
|
|
1184
|
-
|
|
1274
|
+
const kind = CLIENT_KINDS.includes(r.client_kind ?? "") ? r.client_kind : null;
|
|
1275
|
+
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
1276
|
}
|
|
1186
1277
|
|
|
1187
1278
|
// src/models/backup-code.model.ts
|
|
@@ -1312,7 +1403,8 @@ function requestMeta(ctx) {
|
|
|
1312
1403
|
const meta = {
|
|
1313
1404
|
ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
|
|
1314
1405
|
userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
|
|
1315
|
-
headers: ctx.request.headers
|
|
1406
|
+
headers: ctx.request.headers,
|
|
1407
|
+
clientKind: clientKindOf(ctx.request.headers)
|
|
1316
1408
|
};
|
|
1317
1409
|
if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
|
|
1318
1410
|
return meta;
|
|
@@ -1427,7 +1519,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
1427
1519
|
await users.enableMfa(ctx.user.id);
|
|
1428
1520
|
}
|
|
1429
1521
|
}
|
|
1430
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
|
|
1522
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
1431
1523
|
loginMethod: ctx.user.loginMethod,
|
|
1432
1524
|
phoneVerified: ctx.user.phoneVerified
|
|
1433
1525
|
});
|
|
@@ -1789,7 +1881,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
1789
1881
|
},
|
|
1790
1882
|
reqOpts
|
|
1791
1883
|
));
|
|
1792
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1884
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
1793
1885
|
loginMethod: "email"
|
|
1794
1886
|
});
|
|
1795
1887
|
const registerMeta = requestMeta(ctx);
|
|
@@ -1932,7 +2024,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
1932
2024
|
mfaToken
|
|
1933
2025
|
});
|
|
1934
2026
|
}
|
|
1935
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
2027
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
1936
2028
|
loginMethod: "email"
|
|
1937
2029
|
});
|
|
1938
2030
|
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
@@ -2025,8 +2117,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
2025
2117
|
if (!payload || payload.type !== "refresh") {
|
|
2026
2118
|
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
|
|
2027
2119
|
}
|
|
2028
|
-
const
|
|
2029
|
-
const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
|
|
2120
|
+
const runtime = config.resolve?.(ctx);
|
|
2030
2121
|
let issued = null;
|
|
2031
2122
|
for (let attempt = 0; attempt < 3 && !issued; attempt++) {
|
|
2032
2123
|
const found = await sessions.match(token);
|
|
@@ -2036,10 +2127,12 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
2036
2127
|
if (found.kind === "reused") {
|
|
2037
2128
|
await sessions.revokeById(found.row.id);
|
|
2038
2129
|
await (0, import_core6.background)(
|
|
2039
|
-
bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId,
|
|
2130
|
+
bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
|
|
2040
2131
|
);
|
|
2041
2132
|
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
2042
2133
|
}
|
|
2134
|
+
const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
|
|
2135
|
+
const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
|
|
2043
2136
|
if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
|
|
2044
2137
|
await sessions.revokeById(found.row.id);
|
|
2045
2138
|
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
|
|
@@ -2221,7 +2314,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
2221
2314
|
);
|
|
2222
2315
|
}
|
|
2223
2316
|
await phoneVerif.deleteByUser(ctx.user.id);
|
|
2224
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
|
|
2317
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
2225
2318
|
loginMethod: "phone",
|
|
2226
2319
|
phoneVerified: true
|
|
2227
2320
|
});
|
|
@@ -2398,6 +2491,7 @@ function toSessionDTO(row, currentSid) {
|
|
|
2398
2491
|
ipAddress: row.ipAddress,
|
|
2399
2492
|
userAgent: row.userAgent,
|
|
2400
2493
|
location: sanitizeLocation(row.location),
|
|
2494
|
+
clientKind: row.clientKind ?? null,
|
|
2401
2495
|
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
2402
2496
|
expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
|
|
2403
2497
|
};
|
|
@@ -2471,10 +2565,11 @@ function userController(store, config, bus) {
|
|
|
2471
2565
|
if (!id) {
|
|
2472
2566
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2473
2567
|
}
|
|
2474
|
-
const removed = await sessions.
|
|
2475
|
-
if (
|
|
2568
|
+
const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
|
|
2569
|
+
if (removed === void 0) {
|
|
2476
2570
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2477
2571
|
}
|
|
2572
|
+
await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
|
|
2478
2573
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
|
|
2479
2574
|
},
|
|
2480
2575
|
terminateOtherSessions: async (ctx) => {
|
|
@@ -2486,7 +2581,9 @@ function userController(store, config, bus) {
|
|
|
2486
2581
|
"Cannot terminate others without a current session context"
|
|
2487
2582
|
);
|
|
2488
2583
|
}
|
|
2489
|
-
const
|
|
2584
|
+
const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
|
|
2585
|
+
const count = sids.length;
|
|
2586
|
+
if (count) await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
|
|
2490
2587
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
|
|
2491
2588
|
},
|
|
2492
2589
|
updateProfile: async (ctx) => {
|
|
@@ -2648,6 +2745,7 @@ function userController(store, config, bus) {
|
|
|
2648
2745
|
const hash = await hashPassword2(newPassword);
|
|
2649
2746
|
await users.updatePassword(ctx.user.id, hash);
|
|
2650
2747
|
await sessions.deleteByUser(ctx.user.id);
|
|
2748
|
+
await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
|
|
2651
2749
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
2652
2750
|
},
|
|
2653
2751
|
/**
|
|
@@ -2946,7 +3044,7 @@ function oauthController(store, config, bus) {
|
|
|
2946
3044
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
|
|
2947
3045
|
}
|
|
2948
3046
|
await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
|
|
2949
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
|
|
3047
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: "apple" });
|
|
2950
3048
|
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2951
3049
|
await loginEvents.recordSafe({
|
|
2952
3050
|
userId: upserted.id,
|
|
@@ -3097,7 +3195,7 @@ function oauthController(store, config, bus) {
|
|
|
3097
3195
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
3098
3196
|
}
|
|
3099
3197
|
await announceOAuthUpsert(ctx, upserted, "google", fullUser);
|
|
3100
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
|
|
3198
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
3101
3199
|
loginMethod: "google"
|
|
3102
3200
|
});
|
|
3103
3201
|
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
@@ -3542,6 +3640,7 @@ function extractToken(request) {
|
|
|
3542
3640
|
|
|
3543
3641
|
// src/admin.ts
|
|
3544
3642
|
var import_core12 = require("@fonderie/core");
|
|
3643
|
+
var import_core13 = require("@fonderie/core");
|
|
3545
3644
|
function toAdminUserDTO(user) {
|
|
3546
3645
|
return {
|
|
3547
3646
|
...toUserDTO(user),
|
|
@@ -3558,7 +3657,7 @@ function toAdminUserPageDTO(page) {
|
|
|
3558
3657
|
var ADMIN_PREFIX = "/_admin";
|
|
3559
3658
|
var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
|
|
3560
3659
|
var NOT_FOUND = () => (0, import_core12.setApiResponse)(import_core12.HTTP.NOT_FOUND, "NOT_FOUND", "No such user");
|
|
3561
|
-
function adminRouteTable(store) {
|
|
3660
|
+
function adminRouteTable(store, bus) {
|
|
3562
3661
|
const users = new UserModel(store);
|
|
3563
3662
|
const sessions = new SessionModel(store);
|
|
3564
3663
|
const events = new LoginEventModel(store);
|
|
@@ -3631,6 +3730,7 @@ function adminRouteTable(store) {
|
|
|
3631
3730
|
async (ctx) => {
|
|
3632
3731
|
if (!await users.findById(idOf(ctx))) return NOT_FOUND();
|
|
3633
3732
|
await sessions.deleteByUser(idOf(ctx));
|
|
3733
|
+
await (0, import_core13.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
|
|
3634
3734
|
return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "SESSIONS_REVOKED", "All sessions revoked");
|
|
3635
3735
|
}
|
|
3636
3736
|
],
|
|
@@ -3663,8 +3763,8 @@ function adminRouteTable(store) {
|
|
|
3663
3763
|
["POST", "/_admin/users/:id/unsuspend", setSuspended(false)]
|
|
3664
3764
|
];
|
|
3665
3765
|
}
|
|
3666
|
-
function describeAuthAdminRoutes(store) {
|
|
3667
|
-
return adminRouteTable(store).map(([method, path, h]) => ({
|
|
3766
|
+
function describeAuthAdminRoutes(store, bus) {
|
|
3767
|
+
return adminRouteTable(store, bus).map(([method, path, h]) => ({
|
|
3668
3768
|
method,
|
|
3669
3769
|
path: path.slice(ADMIN_PREFIX.length),
|
|
3670
3770
|
handlers: [h]
|
|
@@ -3685,10 +3785,22 @@ var AuthModule = class {
|
|
|
3685
3785
|
name = "@fonderie/auth";
|
|
3686
3786
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3687
3787
|
// say what is actually deployed rather than 'not reported'.
|
|
3688
|
-
version = "7.
|
|
3788
|
+
version = "7.19.0";
|
|
3689
3789
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3690
3790
|
describeAdmin() {
|
|
3691
|
-
return { routes: describeAuthAdminRoutes(this.store) };
|
|
3791
|
+
return { routes: describeAuthAdminRoutes(this.store, this.bus) };
|
|
3792
|
+
}
|
|
3793
|
+
// Live sign-out (docs/SESSION-DESIGN.md, Phase 5): a revoked device hears it
|
|
3794
|
+
// at once over @fonderie/sse — only the user it is about, ids only.
|
|
3795
|
+
describeEvents() {
|
|
3796
|
+
const revoked = {
|
|
3797
|
+
type: EVENT_KEYS.sessionRevoked,
|
|
3798
|
+
description: "Some of your sessions were signed out \u2014 sign out if this device is one of them",
|
|
3799
|
+
audience: "user",
|
|
3800
|
+
scope: (p) => ({ userId: p.userId }),
|
|
3801
|
+
project: (p) => ({ sids: p.sids, reason: p.reason })
|
|
3802
|
+
};
|
|
3803
|
+
return [revoked];
|
|
3692
3804
|
}
|
|
3693
3805
|
// Report config problems for app.checkProductionReadiness() (data, not throw).
|
|
3694
3806
|
checkReadiness() {
|
|
@@ -3704,7 +3816,7 @@ var AuthModule = class {
|
|
|
3704
3816
|
};
|
|
3705
3817
|
|
|
3706
3818
|
// src/templates.ts
|
|
3707
|
-
var
|
|
3819
|
+
var import_core14 = require("@fonderie/core");
|
|
3708
3820
|
|
|
3709
3821
|
// src/templates.es.ts
|
|
3710
3822
|
var ES_TEMPLATES = {
|
|
@@ -4181,7 +4293,7 @@ change your password \u2014 someone else may be able to sign in as you.`
|
|
|
4181
4293
|
If you did this, you're all set. If not, contact support right away.`
|
|
4182
4294
|
}
|
|
4183
4295
|
};
|
|
4184
|
-
var DEFAULT_TEMPLATES = (0,
|
|
4296
|
+
var DEFAULT_TEMPLATES = (0, import_core14.withTranslations)(EN_TEMPLATES, { fr: FR_TEMPLATES, es: ES_TEMPLATES });
|
|
4185
4297
|
var SAMPLE_PAYLOADS = {
|
|
4186
4298
|
[MESSAGE_KEYS.passwordRevoked]: { provider: "Google" },
|
|
4187
4299
|
[MESSAGE_KEYS.oauthRegistration]: { provider: "Google", appName: "Fonderie" },
|
|
@@ -4281,19 +4393,25 @@ function startUserRetention(store, options) {
|
|
|
4281
4393
|
0 && (module.exports = {
|
|
4282
4394
|
AUTH_CONFIG_KEYS,
|
|
4283
4395
|
AuthModule,
|
|
4396
|
+
CLIENT_KINDS,
|
|
4284
4397
|
DEFAULT_TEMPLATES,
|
|
4285
4398
|
MESSAGE_KEYS,
|
|
4399
|
+
SESSION_POLICY_PRESETS,
|
|
4286
4400
|
buildAuthAccountLimiter,
|
|
4287
4401
|
buildAuthIpLimiter,
|
|
4402
|
+
clientKindOf,
|
|
4403
|
+
configForClient,
|
|
4288
4404
|
describeAuthAdminRoutes,
|
|
4289
4405
|
importUser,
|
|
4290
4406
|
normalizeEmail,
|
|
4291
4407
|
normalizeEmailSafe,
|
|
4292
4408
|
purgeSoftDeletedUsers,
|
|
4409
|
+
readAuthRuntimeConfig,
|
|
4293
4410
|
requireAuth,
|
|
4294
4411
|
resolveLocation,
|
|
4295
4412
|
sanitizeLocation,
|
|
4296
4413
|
schemas,
|
|
4414
|
+
sessionPolicyConfigKeys,
|
|
4297
4415
|
startUserRetention,
|
|
4298
4416
|
toAdminUserDTO,
|
|
4299
4417
|
toAdminUserPageDTO,
|