@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/dist/index.d.cts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { IUser } from './types.cjs';
|
|
2
2
|
export { IMfaChallenge, ISession } from './types.cjs';
|
|
3
|
-
import { IFonderieModule, IAdminDescription, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
|
|
3
|
+
import { IFonderieModule, IAdminDescription, IEventCatalogEntry, 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-
|
|
7
|
-
export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, d as
|
|
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';
|
|
@@ -19,6 +19,7 @@ declare class AuthModule implements IFonderieModule {
|
|
|
19
19
|
readonly version: string;
|
|
20
20
|
constructor(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undefined);
|
|
21
21
|
describeAdmin(): IAdminDescription;
|
|
22
|
+
describeEvents(): IEventCatalogEntry[];
|
|
22
23
|
checkReadiness(): IReadinessProblem[];
|
|
23
24
|
install(app: IFonderieApp): void;
|
|
24
25
|
}
|
|
@@ -50,6 +51,8 @@ interface ISessionDTO {
|
|
|
50
51
|
location: IRequestLocation | null;
|
|
51
52
|
createdAt: string;
|
|
52
53
|
expiresAt: string;
|
|
54
|
+
/** The platform declared at sign-in (mobile | desktop | web), or null. */
|
|
55
|
+
clientKind: string | null;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
@@ -201,6 +204,6 @@ interface IAdminUserPageDTO {
|
|
|
201
204
|
nextCursor: string | null;
|
|
202
205
|
}
|
|
203
206
|
declare function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO;
|
|
204
|
-
declare function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[];
|
|
207
|
+
declare function describeAuthAdminRoutes(store: IStoreAdapter, bus?: EventBus): IAdminRoute[];
|
|
205
208
|
|
|
206
209
|
export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, type IAdminUserDTO, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, IRequestLocation, type ISessionDTO, IUser, IUserDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, describeAuthAdminRoutes, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, toAdminUserDTO, toAdminUserPageDTO, validateAuthConfig };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { IUser } from './types.js';
|
|
2
2
|
export { IMfaChallenge, ISession } from './types.js';
|
|
3
|
-
import { IFonderieModule, IAdminDescription, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
|
|
3
|
+
import { IFonderieModule, IAdminDescription, IEventCatalogEntry, 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-
|
|
7
|
-
export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, d as
|
|
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';
|
|
@@ -19,6 +19,7 @@ declare class AuthModule implements IFonderieModule {
|
|
|
19
19
|
readonly version: string;
|
|
20
20
|
constructor(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undefined);
|
|
21
21
|
describeAdmin(): IAdminDescription;
|
|
22
|
+
describeEvents(): IEventCatalogEntry[];
|
|
22
23
|
checkReadiness(): IReadinessProblem[];
|
|
23
24
|
install(app: IFonderieApp): void;
|
|
24
25
|
}
|
|
@@ -50,6 +51,8 @@ interface ISessionDTO {
|
|
|
50
51
|
location: IRequestLocation | null;
|
|
51
52
|
createdAt: string;
|
|
52
53
|
expiresAt: string;
|
|
54
|
+
/** The platform declared at sign-in (mobile | desktop | web), or null. */
|
|
55
|
+
clientKind: string | null;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
@@ -201,6 +204,6 @@ interface IAdminUserPageDTO {
|
|
|
201
204
|
nextCursor: string | null;
|
|
202
205
|
}
|
|
203
206
|
declare function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO;
|
|
204
|
-
declare function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[];
|
|
207
|
+
declare function describeAuthAdminRoutes(store: IStoreAdapter, bus?: EventBus): IAdminRoute[];
|
|
205
208
|
|
|
206
209
|
export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, type IAdminUserDTO, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, IRequestLocation, type ISessionDTO, IUser, IUserDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, describeAuthAdminRoutes, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, toAdminUserDTO, toAdminUserPageDTO, validateAuthConfig };
|
package/dist/index.js
CHANGED
|
@@ -284,8 +284,10 @@ var EVENT_KEYS = {
|
|
|
284
284
|
userPurged: "fonderie.user.purged",
|
|
285
285
|
emailVerified: "fonderie.user.email_verified",
|
|
286
286
|
passwordChanged: "fonderie.user.password_changed",
|
|
287
|
-
//
|
|
288
|
-
//
|
|
287
|
+
// Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
|
|
288
|
+
// the user's devices live (@fonderie/sse) so a revoked device signs out at
|
|
289
|
+
// once. Reasons: 'terminated' (signed out from the devices list),
|
|
290
|
+
// 'password-changed', 'admin', 'refresh-reuse' (theft signal).
|
|
289
291
|
sessionRevoked: "fonderie.session.revoked"
|
|
290
292
|
};
|
|
291
293
|
|
|
@@ -368,6 +370,79 @@ function validDuration(value, fallback, name) {
|
|
|
368
370
|
return fallback;
|
|
369
371
|
}
|
|
370
372
|
|
|
373
|
+
// src/services/session-policy.ts
|
|
374
|
+
var CLIENT_KINDS = ["mobile", "desktop", "web"];
|
|
375
|
+
var SESSION_POLICY_PRESETS = {
|
|
376
|
+
// Personal device; the token lives in the Keychain / Keystore.
|
|
377
|
+
mobile: { sessionDuration: "90d", sessionMaxAge: "365d" },
|
|
378
|
+
// Personal, but often a shared workstation.
|
|
379
|
+
desktop: { sessionDuration: "30d", sessionMaxAge: "180d" },
|
|
380
|
+
// Shared and public computers; cookies.
|
|
381
|
+
web: { sessionDuration: "14d", sessionMaxAge: "90d" }
|
|
382
|
+
};
|
|
383
|
+
function clientKindOf(headers) {
|
|
384
|
+
const v = headers.get("x-client-kind")?.trim().toLowerCase();
|
|
385
|
+
return CLIENT_KINDS.includes(v ?? "") ? v : null;
|
|
386
|
+
}
|
|
387
|
+
var FIELDS = ["sessionDuration", "sessionMaxAge", "accessTokenDuration"];
|
|
388
|
+
function configForClient(config, runtime, kind) {
|
|
389
|
+
const rt = runtime ?? {};
|
|
390
|
+
const code = config;
|
|
391
|
+
const out = { ...config, ...rt };
|
|
392
|
+
for (const field of FIELDS) {
|
|
393
|
+
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);
|
|
394
|
+
if (value === void 0) delete out[field];
|
|
395
|
+
else out[field] = value;
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
function sessionPolicyConfigKeys(kind) {
|
|
400
|
+
return {
|
|
401
|
+
sessionDuration: `auth.session.duration.${kind}`,
|
|
402
|
+
sessionMaxAge: `auth.session.max_age.${kind}`,
|
|
403
|
+
accessTokenDuration: `auth.access.duration.${kind}`
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function readAuthRuntimeConfig(read) {
|
|
407
|
+
const text = (key) => {
|
|
408
|
+
const v = read(key);
|
|
409
|
+
return typeof v === "string" && v.trim() ? v.trim() : void 0;
|
|
410
|
+
};
|
|
411
|
+
const bool2 = (key) => {
|
|
412
|
+
const v = read(key);
|
|
413
|
+
if (typeof v === "boolean") return v;
|
|
414
|
+
if (typeof v === "string" && ["true", "false"].includes(v.trim().toLowerCase())) return v.trim().toLowerCase() === "true";
|
|
415
|
+
return void 0;
|
|
416
|
+
};
|
|
417
|
+
const num = (key) => {
|
|
418
|
+
const v = read(key);
|
|
419
|
+
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v) : NaN;
|
|
420
|
+
return Number.isFinite(n) ? n : void 0;
|
|
421
|
+
};
|
|
422
|
+
const out = {};
|
|
423
|
+
const set = (k, v) => {
|
|
424
|
+
if (v !== void 0) out[k] = v;
|
|
425
|
+
};
|
|
426
|
+
set("sessionDuration", text("auth.session.duration"));
|
|
427
|
+
set("sessionMaxAge", text("auth.session.max_age"));
|
|
428
|
+
set("accessTokenDuration", text("auth.access.duration"));
|
|
429
|
+
set("verificationCooldown", num("auth.verification.cooldown"));
|
|
430
|
+
set("mfa", bool2("auth.mfa.enabled"));
|
|
431
|
+
set("requireVerification", bool2("auth.verification.required"));
|
|
432
|
+
const policies = {};
|
|
433
|
+
for (const kind of CLIENT_KINDS) {
|
|
434
|
+
const keys = sessionPolicyConfigKeys(kind);
|
|
435
|
+
const policy = {};
|
|
436
|
+
for (const field of FIELDS) {
|
|
437
|
+
const v = text(keys[field]);
|
|
438
|
+
if (v !== void 0) policy[field] = v;
|
|
439
|
+
}
|
|
440
|
+
if (Object.keys(policy).length) policies[kind] = policy;
|
|
441
|
+
}
|
|
442
|
+
if (Object.keys(policies).length) out.sessionPolicies = policies;
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
|
|
371
446
|
// src/services/mfa.ts
|
|
372
447
|
import { createHmac, randomBytes } from "crypto";
|
|
373
448
|
import { constantTimeEqual } from "@fonderie/core";
|
|
@@ -991,8 +1066,8 @@ var SessionModel = class {
|
|
|
991
1066
|
async create(userId, token, expiresAt, sid, meta) {
|
|
992
1067
|
const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
|
|
993
1068
|
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())
|
|
1069
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at, client_kind)
|
|
1070
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
|
|
996
1071
|
ON CONFLICT (token) DO NOTHING`,
|
|
997
1072
|
[
|
|
998
1073
|
userId,
|
|
@@ -1001,7 +1076,8 @@ var SessionModel = class {
|
|
|
1001
1076
|
sid ?? null,
|
|
1002
1077
|
meta?.userAgent ?? null,
|
|
1003
1078
|
meta?.ipAddress ?? null,
|
|
1004
|
-
location ? JSON.stringify(location) : null
|
|
1079
|
+
location ? JSON.stringify(location) : null,
|
|
1080
|
+
meta?.clientKind ?? null
|
|
1005
1081
|
]
|
|
1006
1082
|
);
|
|
1007
1083
|
}
|
|
@@ -1016,12 +1092,12 @@ var SessionModel = class {
|
|
|
1016
1092
|
async match(token) {
|
|
1017
1093
|
const hash = hashRefreshToken(token);
|
|
1018
1094
|
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`,
|
|
1095
|
+
`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
1096
|
[hash, token]
|
|
1021
1097
|
);
|
|
1022
1098
|
if (current) return { kind: "current", row: toRow(current) };
|
|
1023
1099
|
const [previous] = await this.store.query(
|
|
1024
|
-
`SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
|
|
1100
|
+
`SELECT id, user_id, sid, token, created_at, client_kind, (previous_valid_until > now()) AS in_grace
|
|
1025
1101
|
FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
|
|
1026
1102
|
[hash]
|
|
1027
1103
|
);
|
|
@@ -1087,7 +1163,7 @@ var SessionModel = class {
|
|
|
1087
1163
|
// No token — that never leaves the server.
|
|
1088
1164
|
async listLiveByUser(userId) {
|
|
1089
1165
|
return this.store.query(
|
|
1090
|
-
`SELECT id, sid,
|
|
1166
|
+
`SELECT id, sid, client_kind AS "clientKind",
|
|
1091
1167
|
user_agent AS "userAgent",
|
|
1092
1168
|
ip_address AS "ipAddress",
|
|
1093
1169
|
location,
|
|
@@ -1102,22 +1178,30 @@ var SessionModel = class {
|
|
|
1102
1178
|
// Terminate one session by row id, scoped to its owner (a user can never
|
|
1103
1179
|
// delete another user's session). Returns whether a row was removed.
|
|
1104
1180
|
async terminateById(userId, id) {
|
|
1181
|
+
return await this.terminateByIdReturningSid(userId, id) !== void 0;
|
|
1182
|
+
}
|
|
1183
|
+
/** Terminate one of the user's sessions; its sid (null if unbound), or undefined if none was removed. */
|
|
1184
|
+
async terminateByIdReturningSid(userId, id) {
|
|
1105
1185
|
const rows = await this.store.query(
|
|
1106
|
-
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING
|
|
1186
|
+
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING sid`,
|
|
1107
1187
|
[id, userId]
|
|
1108
1188
|
);
|
|
1109
|
-
return rows.length
|
|
1189
|
+
return rows.length ? rows[0]?.sid ?? null : void 0;
|
|
1110
1190
|
}
|
|
1111
1191
|
// Terminate all of a user's sessions except the current one (by sid).
|
|
1112
1192
|
// Returns the number of sessions removed.
|
|
1113
1193
|
async terminateOthers(userId, keepSid) {
|
|
1194
|
+
return (await this.terminateOthersReturningSids(userId, keepSid)).length;
|
|
1195
|
+
}
|
|
1196
|
+
/** Terminate all of the user's sessions but `keepSid`; the sids removed. */
|
|
1197
|
+
async terminateOthersReturningSids(userId, keepSid) {
|
|
1114
1198
|
const rows = await this.store.query(
|
|
1115
1199
|
`DELETE FROM fonderie_sessions
|
|
1116
1200
|
WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
|
|
1117
|
-
RETURNING
|
|
1201
|
+
RETURNING sid`,
|
|
1118
1202
|
[userId, keepSid]
|
|
1119
1203
|
);
|
|
1120
|
-
return rows.
|
|
1204
|
+
return rows.map((r) => r.sid);
|
|
1121
1205
|
}
|
|
1122
1206
|
async exists(token) {
|
|
1123
1207
|
const rows = await this.store.query(
|
|
@@ -1136,7 +1220,8 @@ var SessionModel = class {
|
|
|
1136
1220
|
}
|
|
1137
1221
|
};
|
|
1138
1222
|
function toRow(r) {
|
|
1139
|
-
|
|
1223
|
+
const kind = CLIENT_KINDS.includes(r.client_kind ?? "") ? r.client_kind : null;
|
|
1224
|
+
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
1225
|
}
|
|
1141
1226
|
|
|
1142
1227
|
// src/models/backup-code.model.ts
|
|
@@ -1267,7 +1352,8 @@ function requestMeta(ctx) {
|
|
|
1267
1352
|
const meta = {
|
|
1268
1353
|
ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
|
|
1269
1354
|
userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
|
|
1270
|
-
headers: ctx.request.headers
|
|
1355
|
+
headers: ctx.request.headers,
|
|
1356
|
+
clientKind: clientKindOf(ctx.request.headers)
|
|
1271
1357
|
};
|
|
1272
1358
|
if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
|
|
1273
1359
|
return meta;
|
|
@@ -1382,7 +1468,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
1382
1468
|
await users.enableMfa(ctx.user.id);
|
|
1383
1469
|
}
|
|
1384
1470
|
}
|
|
1385
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
|
|
1471
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
1386
1472
|
loginMethod: ctx.user.loginMethod,
|
|
1387
1473
|
phoneVerified: ctx.user.phoneVerified
|
|
1388
1474
|
});
|
|
@@ -1744,7 +1830,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1744
1830
|
},
|
|
1745
1831
|
reqOpts
|
|
1746
1832
|
));
|
|
1747
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1833
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
1748
1834
|
loginMethod: "email"
|
|
1749
1835
|
});
|
|
1750
1836
|
const registerMeta = requestMeta(ctx);
|
|
@@ -1887,7 +1973,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1887
1973
|
mfaToken
|
|
1888
1974
|
});
|
|
1889
1975
|
}
|
|
1890
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1976
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
1891
1977
|
loginMethod: "email"
|
|
1892
1978
|
});
|
|
1893
1979
|
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
@@ -1980,8 +2066,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1980
2066
|
if (!payload || payload.type !== "refresh") {
|
|
1981
2067
|
return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
|
|
1982
2068
|
}
|
|
1983
|
-
const
|
|
1984
|
-
const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
|
|
2069
|
+
const runtime = config.resolve?.(ctx);
|
|
1985
2070
|
let issued = null;
|
|
1986
2071
|
for (let attempt = 0; attempt < 3 && !issued; attempt++) {
|
|
1987
2072
|
const found = await sessions.match(token);
|
|
@@ -1991,10 +2076,12 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1991
2076
|
if (found.kind === "reused") {
|
|
1992
2077
|
await sessions.revokeById(found.row.id);
|
|
1993
2078
|
await background3(
|
|
1994
|
-
bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId,
|
|
2079
|
+
bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
|
|
1995
2080
|
);
|
|
1996
2081
|
return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
1997
2082
|
}
|
|
2083
|
+
const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
|
|
2084
|
+
const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
|
|
1998
2085
|
if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
|
|
1999
2086
|
await sessions.revokeById(found.row.id);
|
|
2000
2087
|
return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
|
|
@@ -2176,7 +2263,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2176
2263
|
);
|
|
2177
2264
|
}
|
|
2178
2265
|
await phoneVerif.deleteByUser(ctx.user.id);
|
|
2179
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
|
|
2266
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
2180
2267
|
loginMethod: "phone",
|
|
2181
2268
|
phoneVerified: true
|
|
2182
2269
|
});
|
|
@@ -2353,6 +2440,7 @@ function toSessionDTO(row, currentSid) {
|
|
|
2353
2440
|
ipAddress: row.ipAddress,
|
|
2354
2441
|
userAgent: row.userAgent,
|
|
2355
2442
|
location: sanitizeLocation(row.location),
|
|
2443
|
+
clientKind: row.clientKind ?? null,
|
|
2356
2444
|
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
2357
2445
|
expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
|
|
2358
2446
|
};
|
|
@@ -2426,10 +2514,11 @@ function userController(store, config, bus) {
|
|
|
2426
2514
|
if (!id) {
|
|
2427
2515
|
return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2428
2516
|
}
|
|
2429
|
-
const removed = await sessions.
|
|
2430
|
-
if (
|
|
2517
|
+
const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
|
|
2518
|
+
if (removed === void 0) {
|
|
2431
2519
|
return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2432
2520
|
}
|
|
2521
|
+
await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
|
|
2433
2522
|
return setApiResponse4(HTTP4.OK, "SESSION_TERMINATED", "Session terminated.", { id });
|
|
2434
2523
|
},
|
|
2435
2524
|
terminateOtherSessions: async (ctx) => {
|
|
@@ -2441,7 +2530,9 @@ function userController(store, config, bus) {
|
|
|
2441
2530
|
"Cannot terminate others without a current session context"
|
|
2442
2531
|
);
|
|
2443
2532
|
}
|
|
2444
|
-
const
|
|
2533
|
+
const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
|
|
2534
|
+
const count = sids.length;
|
|
2535
|
+
if (count) await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
|
|
2445
2536
|
return setApiResponse4(HTTP4.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
|
|
2446
2537
|
},
|
|
2447
2538
|
updateProfile: async (ctx) => {
|
|
@@ -2603,6 +2694,7 @@ function userController(store, config, bus) {
|
|
|
2603
2694
|
const hash = await hashPassword2(newPassword);
|
|
2604
2695
|
await users.updatePassword(ctx.user.id, hash);
|
|
2605
2696
|
await sessions.deleteByUser(ctx.user.id);
|
|
2697
|
+
await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
|
|
2606
2698
|
return setApiResponse4(HTTP4.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
2607
2699
|
},
|
|
2608
2700
|
/**
|
|
@@ -2901,7 +2993,7 @@ function oauthController(store, config, bus) {
|
|
|
2901
2993
|
return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
|
|
2902
2994
|
}
|
|
2903
2995
|
await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
|
|
2904
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
|
|
2996
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: "apple" });
|
|
2905
2997
|
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2906
2998
|
await loginEvents.recordSafe({
|
|
2907
2999
|
userId: upserted.id,
|
|
@@ -3052,7 +3144,7 @@ function oauthController(store, config, bus) {
|
|
|
3052
3144
|
return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
3053
3145
|
}
|
|
3054
3146
|
await announceOAuthUpsert(ctx, upserted, "google", fullUser);
|
|
3055
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
|
|
3147
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
3056
3148
|
loginMethod: "google"
|
|
3057
3149
|
});
|
|
3058
3150
|
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
@@ -3497,6 +3589,7 @@ function extractToken(request) {
|
|
|
3497
3589
|
|
|
3498
3590
|
// src/admin.ts
|
|
3499
3591
|
import { HTTP as HTTP7, encodeKeysetCursor as encodeKeysetCursor2, setApiResponse as setApiResponse7 } from "@fonderie/core";
|
|
3592
|
+
import { background as background6 } from "@fonderie/core";
|
|
3500
3593
|
function toAdminUserDTO(user) {
|
|
3501
3594
|
return {
|
|
3502
3595
|
...toUserDTO(user),
|
|
@@ -3513,7 +3606,7 @@ function toAdminUserPageDTO(page) {
|
|
|
3513
3606
|
var ADMIN_PREFIX = "/_admin";
|
|
3514
3607
|
var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
|
|
3515
3608
|
var NOT_FOUND = () => setApiResponse7(HTTP7.NOT_FOUND, "NOT_FOUND", "No such user");
|
|
3516
|
-
function adminRouteTable(store) {
|
|
3609
|
+
function adminRouteTable(store, bus) {
|
|
3517
3610
|
const users = new UserModel(store);
|
|
3518
3611
|
const sessions = new SessionModel(store);
|
|
3519
3612
|
const events = new LoginEventModel(store);
|
|
@@ -3586,6 +3679,7 @@ function adminRouteTable(store) {
|
|
|
3586
3679
|
async (ctx) => {
|
|
3587
3680
|
if (!await users.findById(idOf(ctx))) return NOT_FOUND();
|
|
3588
3681
|
await sessions.deleteByUser(idOf(ctx));
|
|
3682
|
+
await background6(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
|
|
3589
3683
|
return setApiResponse7(HTTP7.OK, "SESSIONS_REVOKED", "All sessions revoked");
|
|
3590
3684
|
}
|
|
3591
3685
|
],
|
|
@@ -3618,8 +3712,8 @@ function adminRouteTable(store) {
|
|
|
3618
3712
|
["POST", "/_admin/users/:id/unsuspend", setSuspended(false)]
|
|
3619
3713
|
];
|
|
3620
3714
|
}
|
|
3621
|
-
function describeAuthAdminRoutes(store) {
|
|
3622
|
-
return adminRouteTable(store).map(([method, path, h]) => ({
|
|
3715
|
+
function describeAuthAdminRoutes(store, bus) {
|
|
3716
|
+
return adminRouteTable(store, bus).map(([method, path, h]) => ({
|
|
3623
3717
|
method,
|
|
3624
3718
|
path: path.slice(ADMIN_PREFIX.length),
|
|
3625
3719
|
handlers: [h]
|
|
@@ -3640,10 +3734,22 @@ var AuthModule = class {
|
|
|
3640
3734
|
name = "@fonderie/auth";
|
|
3641
3735
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3642
3736
|
// say what is actually deployed rather than 'not reported'.
|
|
3643
|
-
version = "7.
|
|
3737
|
+
version = "7.19.0";
|
|
3644
3738
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3645
3739
|
describeAdmin() {
|
|
3646
|
-
return { routes: describeAuthAdminRoutes(this.store) };
|
|
3740
|
+
return { routes: describeAuthAdminRoutes(this.store, this.bus) };
|
|
3741
|
+
}
|
|
3742
|
+
// Live sign-out (docs/SESSION-DESIGN.md, Phase 5): a revoked device hears it
|
|
3743
|
+
// at once over @fonderie/sse — only the user it is about, ids only.
|
|
3744
|
+
describeEvents() {
|
|
3745
|
+
const revoked = {
|
|
3746
|
+
type: EVENT_KEYS.sessionRevoked,
|
|
3747
|
+
description: "Some of your sessions were signed out \u2014 sign out if this device is one of them",
|
|
3748
|
+
audience: "user",
|
|
3749
|
+
scope: (p) => ({ userId: p.userId }),
|
|
3750
|
+
project: (p) => ({ sids: p.sids, reason: p.reason })
|
|
3751
|
+
};
|
|
3752
|
+
return [revoked];
|
|
3647
3753
|
}
|
|
3648
3754
|
// Report config problems for app.checkProductionReadiness() (data, not throw).
|
|
3649
3755
|
checkReadiness() {
|
|
@@ -4235,19 +4341,25 @@ function startUserRetention(store, options) {
|
|
|
4235
4341
|
export {
|
|
4236
4342
|
AUTH_CONFIG_KEYS,
|
|
4237
4343
|
AuthModule,
|
|
4344
|
+
CLIENT_KINDS,
|
|
4238
4345
|
DEFAULT_TEMPLATES,
|
|
4239
4346
|
MESSAGE_KEYS,
|
|
4347
|
+
SESSION_POLICY_PRESETS,
|
|
4240
4348
|
buildAuthAccountLimiter,
|
|
4241
4349
|
buildAuthIpLimiter,
|
|
4350
|
+
clientKindOf,
|
|
4351
|
+
configForClient,
|
|
4242
4352
|
describeAuthAdminRoutes,
|
|
4243
4353
|
importUser,
|
|
4244
4354
|
normalizeEmail,
|
|
4245
4355
|
normalizeEmailSafe,
|
|
4246
4356
|
purgeSoftDeletedUsers,
|
|
4357
|
+
readAuthRuntimeConfig,
|
|
4247
4358
|
requireAuth2 as requireAuth,
|
|
4248
4359
|
resolveLocation,
|
|
4249
4360
|
sanitizeLocation,
|
|
4250
4361
|
schemas_exports as schemas,
|
|
4362
|
+
sessionPolicyConfigKeys,
|
|
4251
4363
|
startUserRetention,
|
|
4252
4364
|
toAdminUserDTO,
|
|
4253
4365
|
toAdminUserPageDTO,
|