@fonderie/auth 5.0.0 → 5.0.1
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/README.md +2 -2
- package/brain/outcomes.md +1 -0
- package/brain/signatures.md +20 -0
- package/dist/dtos/user.cjs +26 -3
- package/dist/dtos/user.cjs.map +1 -1
- package/dist/dtos/user.js +26 -3
- package/dist/dtos/user.js.map +1 -1
- package/dist/index.cjs +232 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -7
- package/dist/index.d.ts +24 -7
- package/dist/index.js +221 -19
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +19 -1
- 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 +19 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-DxsmQjXX.d.cts → session-CTSCI09F.d.cts} +8 -2
- package/dist/{session-DxsmQjXX.d.ts → session-CTSCI09F.d.ts} +8 -2
- package/package.json +8 -9
package/dist/index.d.cts
CHANGED
|
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.cjs';
|
|
|
2
2
|
import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
|
|
3
3
|
import { IStoreAdapter } from '@fonderie/store';
|
|
4
4
|
import { EventBus } from '@fonderie/events';
|
|
5
|
-
import { I as IAuthConfig } from './session-
|
|
6
|
-
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, M as MESSAGE_KEYS,
|
|
5
|
+
import { I as IAuthConfig } from './session-CTSCI09F.cjs';
|
|
6
|
+
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CTSCI09F.cjs';
|
|
7
7
|
export { IUserDTO, toUserDTO } from './dtos/user.cjs';
|
|
8
8
|
export { requireAuth, validate } from '@fonderie/core/middlewares';
|
|
9
9
|
import { z } from 'zod';
|
|
@@ -54,10 +54,15 @@ declare const updateProfileSchema: z.ZodObject<{
|
|
|
54
54
|
declare const updatePreferencesSchema: z.ZodObject<{
|
|
55
55
|
locale: z.ZodOptional<z.ZodString>;
|
|
56
56
|
timezone: z.ZodOptional<z.ZodString>;
|
|
57
|
-
notifications: z.ZodOptional<z.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
notifications: z.ZodOptional<z.ZodObject<{
|
|
58
|
+
email: z.ZodOptional<z.ZodBoolean>;
|
|
59
|
+
inApp: z.ZodOptional<z.ZodBoolean>;
|
|
60
|
+
sms: z.ZodOptional<z.ZodBoolean>;
|
|
61
|
+
push: z.ZodOptional<z.ZodBoolean>;
|
|
62
|
+
}, z.core.$strip>>;
|
|
63
|
+
emailDigest: z.ZodOptional<z.ZodString>;
|
|
64
|
+
dateFormat: z.ZodOptional<z.ZodString>;
|
|
65
|
+
timeFormat: z.ZodOptional<z.ZodString>;
|
|
61
66
|
}, z.core.$strip>;
|
|
62
67
|
declare const updateEmailSchema: z.ZodObject<{
|
|
63
68
|
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
@@ -118,6 +123,18 @@ declare function importUser(store: IStoreAdapter, user: IImportUser): Promise<{
|
|
|
118
123
|
id: string;
|
|
119
124
|
}>;
|
|
120
125
|
|
|
126
|
+
interface IPurgeOptions {
|
|
127
|
+
olderThanDays: number;
|
|
128
|
+
}
|
|
129
|
+
declare function purgeSoftDeletedUsers(store: IStoreAdapter, { olderThanDays }: IPurgeOptions): Promise<number>;
|
|
130
|
+
interface IUserRetentionScheduleOptions extends IPurgeOptions {
|
|
131
|
+
intervalMs?: number;
|
|
132
|
+
onPurge?: (deleted: number) => void;
|
|
133
|
+
}
|
|
134
|
+
declare function startUserRetention(store: IStoreAdapter, options: IUserRetentionScheduleOptions): {
|
|
135
|
+
stop: () => void;
|
|
136
|
+
};
|
|
137
|
+
|
|
121
138
|
declare function validateAuthConfig(config: IAuthConfig): void;
|
|
122
139
|
|
|
123
|
-
export { AuthModule, type ChangePasswordInput, IAuthConfig, type IImportUser, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, schemas, validateAuthConfig };
|
|
140
|
+
export { AuthModule, type ChangePasswordInput, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.js';
|
|
|
2
2
|
import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
|
|
3
3
|
import { IStoreAdapter } from '@fonderie/store';
|
|
4
4
|
import { EventBus } from '@fonderie/events';
|
|
5
|
-
import { I as IAuthConfig } from './session-
|
|
6
|
-
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, M as MESSAGE_KEYS,
|
|
5
|
+
import { I as IAuthConfig } from './session-CTSCI09F.js';
|
|
6
|
+
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CTSCI09F.js';
|
|
7
7
|
export { IUserDTO, toUserDTO } from './dtos/user.js';
|
|
8
8
|
export { requireAuth, validate } from '@fonderie/core/middlewares';
|
|
9
9
|
import { z } from 'zod';
|
|
@@ -54,10 +54,15 @@ declare const updateProfileSchema: z.ZodObject<{
|
|
|
54
54
|
declare const updatePreferencesSchema: z.ZodObject<{
|
|
55
55
|
locale: z.ZodOptional<z.ZodString>;
|
|
56
56
|
timezone: z.ZodOptional<z.ZodString>;
|
|
57
|
-
notifications: z.ZodOptional<z.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
notifications: z.ZodOptional<z.ZodObject<{
|
|
58
|
+
email: z.ZodOptional<z.ZodBoolean>;
|
|
59
|
+
inApp: z.ZodOptional<z.ZodBoolean>;
|
|
60
|
+
sms: z.ZodOptional<z.ZodBoolean>;
|
|
61
|
+
push: z.ZodOptional<z.ZodBoolean>;
|
|
62
|
+
}, z.core.$strip>>;
|
|
63
|
+
emailDigest: z.ZodOptional<z.ZodString>;
|
|
64
|
+
dateFormat: z.ZodOptional<z.ZodString>;
|
|
65
|
+
timeFormat: z.ZodOptional<z.ZodString>;
|
|
61
66
|
}, z.core.$strip>;
|
|
62
67
|
declare const updateEmailSchema: z.ZodObject<{
|
|
63
68
|
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
@@ -118,6 +123,18 @@ declare function importUser(store: IStoreAdapter, user: IImportUser): Promise<{
|
|
|
118
123
|
id: string;
|
|
119
124
|
}>;
|
|
120
125
|
|
|
126
|
+
interface IPurgeOptions {
|
|
127
|
+
olderThanDays: number;
|
|
128
|
+
}
|
|
129
|
+
declare function purgeSoftDeletedUsers(store: IStoreAdapter, { olderThanDays }: IPurgeOptions): Promise<number>;
|
|
130
|
+
interface IUserRetentionScheduleOptions extends IPurgeOptions {
|
|
131
|
+
intervalMs?: number;
|
|
132
|
+
onPurge?: (deleted: number) => void;
|
|
133
|
+
}
|
|
134
|
+
declare function startUserRetention(store: IStoreAdapter, options: IUserRetentionScheduleOptions): {
|
|
135
|
+
stop: () => void;
|
|
136
|
+
};
|
|
137
|
+
|
|
121
138
|
declare function validateAuthConfig(config: IAuthConfig): void;
|
|
122
139
|
|
|
123
|
-
export { AuthModule, type ChangePasswordInput, IAuthConfig, type IImportUser, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, schemas, validateAuthConfig };
|
|
140
|
+
export { AuthModule, type ChangePasswordInput, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
|
package/dist/index.js
CHANGED
|
@@ -165,10 +165,18 @@ var updateProfileSchema = z.object({
|
|
|
165
165
|
var updatePreferencesSchema = z.object({
|
|
166
166
|
locale: z.string().max(35).optional(),
|
|
167
167
|
timezone: z.string().max(64).optional(),
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
// Typed at last — these four accepted `unknown`, letting null and
|
|
169
|
+
// arbitrary JSON into stored preferences that the user DTO then served
|
|
170
|
+
// against string-typed client fields.
|
|
171
|
+
notifications: z.object({
|
|
172
|
+
email: z.boolean().optional(),
|
|
173
|
+
inApp: z.boolean().optional(),
|
|
174
|
+
sms: z.boolean().optional(),
|
|
175
|
+
push: z.boolean().optional()
|
|
176
|
+
}).optional(),
|
|
177
|
+
emailDigest: z.string().max(20).optional(),
|
|
178
|
+
dateFormat: z.string().max(30).optional(),
|
|
179
|
+
timeFormat: z.string().max(30).optional()
|
|
172
180
|
}).refine(
|
|
173
181
|
(o) => Object.values(o).some((v) => v !== void 0),
|
|
174
182
|
"Provide at least one preference field"
|
|
@@ -284,7 +292,7 @@ function verifyToken(token, config) {
|
|
|
284
292
|
}
|
|
285
293
|
|
|
286
294
|
// src/services/mfa.ts
|
|
287
|
-
import { createHmac, randomBytes } from "crypto";
|
|
295
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
288
296
|
var STEP = 30;
|
|
289
297
|
var DRIFT = 1;
|
|
290
298
|
var DIGITS = 6;
|
|
@@ -355,10 +363,16 @@ function generateTotpUri(email2, secret, issuer) {
|
|
|
355
363
|
});
|
|
356
364
|
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email2)}?${params}`;
|
|
357
365
|
}
|
|
366
|
+
function safeCodeEqual(a, b) {
|
|
367
|
+
const bufA = Buffer.from(a);
|
|
368
|
+
const bufB = Buffer.from(b);
|
|
369
|
+
if (bufA.length !== bufB.length) return false;
|
|
370
|
+
return timingSafeEqual(bufA, bufB);
|
|
371
|
+
}
|
|
358
372
|
function verifyTotpToken(token, secret) {
|
|
359
373
|
const t = timeCounter();
|
|
360
374
|
for (let i = -DRIFT; i <= DRIFT; i++) {
|
|
361
|
-
if (hotp(secret, t + i)
|
|
375
|
+
if (safeCodeEqual(hotp(secret, t + i), token)) {
|
|
362
376
|
return true;
|
|
363
377
|
}
|
|
364
378
|
}
|
|
@@ -371,6 +385,41 @@ function generateBackupCodes(count = 8) {
|
|
|
371
385
|
// src/controllers/mfa.controller.ts
|
|
372
386
|
init_password();
|
|
373
387
|
|
|
388
|
+
// src/services/mfa-crypto.ts
|
|
389
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "crypto";
|
|
390
|
+
var PREFIX = "mfa.v1:";
|
|
391
|
+
function makeMfaCipher(keyHex) {
|
|
392
|
+
if (!keyHex) {
|
|
393
|
+
return { encrypt: (plain) => plain, decrypt: (stored) => stored };
|
|
394
|
+
}
|
|
395
|
+
const key = Buffer.from(keyHex, "hex");
|
|
396
|
+
if (key.length !== 32) {
|
|
397
|
+
throw new Error("[auth] mfaSecretKey must be 32 bytes (64 hex chars, e.g. `openssl rand -hex 32`)");
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
encrypt(plain) {
|
|
401
|
+
const iv = randomBytes2(12);
|
|
402
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
403
|
+
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
404
|
+
const tag = cipher.getAuthTag();
|
|
405
|
+
return `${PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
|
|
406
|
+
},
|
|
407
|
+
decrypt(stored) {
|
|
408
|
+
if (!stored.startsWith(PREFIX)) return stored;
|
|
409
|
+
const [ivHex, tagHex, dataHex] = stored.slice(PREFIX.length).split(":");
|
|
410
|
+
if (!ivHex || !tagHex || !dataHex) {
|
|
411
|
+
throw new Error("[auth] malformed mfa secret ciphertext");
|
|
412
|
+
}
|
|
413
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
|
|
414
|
+
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
|
415
|
+
return Buffer.concat([
|
|
416
|
+
decipher.update(Buffer.from(dataHex, "hex")),
|
|
417
|
+
decipher.final()
|
|
418
|
+
]).toString("utf8");
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
374
423
|
// src/dtos/user.ts
|
|
375
424
|
import { stringOrEmpty, booleanOrFalse } from "@fonderie/core";
|
|
376
425
|
var DEFAULT_PREFERENCES = {
|
|
@@ -381,8 +430,27 @@ var DEFAULT_PREFERENCES = {
|
|
|
381
430
|
dateFormat: "MM/DD/YYYY",
|
|
382
431
|
timeFormat: "hh:mm A"
|
|
383
432
|
};
|
|
433
|
+
function cleanPreferences(prefs) {
|
|
434
|
+
const out = {};
|
|
435
|
+
if (typeof prefs.locale === "string") out.locale = prefs.locale;
|
|
436
|
+
if (typeof prefs.timezone === "string") out.timezone = prefs.timezone;
|
|
437
|
+
if (typeof prefs.emailDigest === "string") out.emailDigest = prefs.emailDigest;
|
|
438
|
+
if (typeof prefs.dateFormat === "string") out.dateFormat = prefs.dateFormat;
|
|
439
|
+
if (typeof prefs.timeFormat === "string") out.timeFormat = prefs.timeFormat;
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
function cleanNotifications(value) {
|
|
443
|
+
if (!value || typeof value !== "object") return {};
|
|
444
|
+
const out = {};
|
|
445
|
+
for (const key of ["email", "inApp", "sms", "push"]) {
|
|
446
|
+
const v = value[key];
|
|
447
|
+
if (typeof v === "boolean") out[key] = v;
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
384
451
|
function toUserDTO(user, phoneVerified = false) {
|
|
385
452
|
const prefs = user.preferences ?? {};
|
|
453
|
+
const cleaned = cleanPreferences(prefs);
|
|
386
454
|
return {
|
|
387
455
|
id: stringOrEmpty(user.id),
|
|
388
456
|
email: stringOrEmpty(user.email),
|
|
@@ -394,9 +462,13 @@ function toUserDTO(user, phoneVerified = false) {
|
|
|
394
462
|
lastLogin: user.lastLogin instanceof Date ? user.lastLogin.toISOString() : "",
|
|
395
463
|
preferences: {
|
|
396
464
|
...DEFAULT_PREFERENCES,
|
|
397
|
-
...
|
|
398
|
-
|
|
399
|
-
|
|
465
|
+
...cleaned,
|
|
466
|
+
notifications: {
|
|
467
|
+
...DEFAULT_PREFERENCES.notifications,
|
|
468
|
+
...cleanNotifications(prefs.notifications)
|
|
469
|
+
},
|
|
470
|
+
locale: user.locale || cleaned.locale || "en-US",
|
|
471
|
+
timezone: user.timezone || cleaned.timezone || "UTC"
|
|
400
472
|
},
|
|
401
473
|
isEmailVerified: user.emailVerifiedAt !== null,
|
|
402
474
|
isPhoneVerified: phoneVerified,
|
|
@@ -427,7 +499,6 @@ var USER_COLUMNS = `
|
|
|
427
499
|
whitelist,
|
|
428
500
|
ip_whitelist AS "ipWhitelist",
|
|
429
501
|
mfa_enabled AS "mfaEnabled",
|
|
430
|
-
mfa_secret AS "mfaSecret",
|
|
431
502
|
email_verified_at AS "emailVerifiedAt",
|
|
432
503
|
deleted_at AS "deletedAt",
|
|
433
504
|
created_at AS "createdAt",
|
|
@@ -636,6 +707,25 @@ var SessionModel = class {
|
|
|
636
707
|
async delete(token) {
|
|
637
708
|
await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
|
|
638
709
|
}
|
|
710
|
+
// Revoke every session for a user (e.g. on password change). Access tokens
|
|
711
|
+
// bound to these sessions via the sid claim die on their next request.
|
|
712
|
+
async deleteByUser(userId) {
|
|
713
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);
|
|
714
|
+
}
|
|
715
|
+
// Session metadata for a user (no tokens) — for the data-export / SAR bundle.
|
|
716
|
+
async listByUser(userId) {
|
|
717
|
+
return this.store.query(
|
|
718
|
+
`SELECT id,
|
|
719
|
+
user_agent AS "userAgent",
|
|
720
|
+
ip_address AS "ipAddress",
|
|
721
|
+
created_at AS "createdAt",
|
|
722
|
+
expires_at AS "expiresAt"
|
|
723
|
+
FROM fonderie_sessions
|
|
724
|
+
WHERE user_id = $1
|
|
725
|
+
ORDER BY created_at DESC`,
|
|
726
|
+
[userId]
|
|
727
|
+
);
|
|
728
|
+
}
|
|
639
729
|
async exists(token) {
|
|
640
730
|
const rows = await this.store.query(
|
|
641
731
|
`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
|
|
@@ -693,6 +783,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
693
783
|
const users = new UserModel(store);
|
|
694
784
|
const sessions = new SessionModel(store);
|
|
695
785
|
const backupCodes = new BackupCodeModel(store);
|
|
786
|
+
const mfaCipher = makeMfaCipher(config.mfaSecretKey);
|
|
696
787
|
return {
|
|
697
788
|
// ── 1. Setup ───────────────────────────────────────────────
|
|
698
789
|
setup: async (ctx) => {
|
|
@@ -702,7 +793,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
702
793
|
const codeHashes = await Promise.all(plainCodes.map((c) => hashPassword(c)));
|
|
703
794
|
const qr = await QRCode.toDataURL(uri);
|
|
704
795
|
await Promise.all([
|
|
705
|
-
users.saveMfaPendingSecret(ctx.user.id, secret),
|
|
796
|
+
users.saveMfaPendingSecret(ctx.user.id, mfaCipher.encrypt(secret)),
|
|
706
797
|
backupCodes.replace(ctx.user.id, codeHashes)
|
|
707
798
|
]);
|
|
708
799
|
return setApiResponse2(
|
|
@@ -724,7 +815,8 @@ function mfaController(store, config, issuer, bus) {
|
|
|
724
815
|
if (typeof token !== "string") {
|
|
725
816
|
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
|
|
726
817
|
}
|
|
727
|
-
const
|
|
818
|
+
const pendingCipher = await users.getMfaPendingSecret(ctx.user.id);
|
|
819
|
+
const pendingSecret = pendingCipher ? mfaCipher.decrypt(pendingCipher) : null;
|
|
728
820
|
if (pendingSecret) {
|
|
729
821
|
if (!verifyTotpToken(token, pendingSecret)) {
|
|
730
822
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
@@ -771,10 +863,11 @@ function mfaController(store, config, issuer, bus) {
|
|
|
771
863
|
"Use the mfaToken from the login response"
|
|
772
864
|
);
|
|
773
865
|
}
|
|
774
|
-
const
|
|
775
|
-
if (!
|
|
866
|
+
const storedSecret = await users.getMfaSecret(ctx.user.id);
|
|
867
|
+
if (!storedSecret) {
|
|
776
868
|
return setApiResponse2(HTTP2.BAD_REQUEST, "MFA_NOT_CONFIGURED", "MFA not configured");
|
|
777
869
|
}
|
|
870
|
+
const secret = mfaCipher.decrypt(storedSecret);
|
|
778
871
|
if (!verifyTotpToken(token, secret)) {
|
|
779
872
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
780
873
|
}
|
|
@@ -783,7 +876,8 @@ function mfaController(store, config, issuer, bus) {
|
|
|
783
876
|
}
|
|
784
877
|
}
|
|
785
878
|
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
|
|
786
|
-
loginMethod: ctx.user.loginMethod
|
|
879
|
+
loginMethod: ctx.user.loginMethod,
|
|
880
|
+
phoneVerified: ctx.user.phoneVerified
|
|
787
881
|
});
|
|
788
882
|
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
789
883
|
const fullUser = await users.findById(ctx.user.id);
|
|
@@ -796,7 +890,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
796
890
|
explanation: "MFA verified successfully.",
|
|
797
891
|
result: {
|
|
798
892
|
tokens: { access: accessToken, refresh: refreshToken },
|
|
799
|
-
user: toUserDTO(fullUser)
|
|
893
|
+
user: toUserDTO(fullUser, ctx.user.phoneVerified)
|
|
800
894
|
}
|
|
801
895
|
},
|
|
802
896
|
{
|
|
@@ -815,7 +909,8 @@ function mfaController(store, config, issuer, bus) {
|
|
|
815
909
|
if (!ctx.user.mfaEnabled) {
|
|
816
910
|
return setApiResponse2(HTTP2.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
|
|
817
911
|
}
|
|
818
|
-
const
|
|
912
|
+
const storedSecret = await users.getMfaSecret(ctx.user.id);
|
|
913
|
+
const secret = storedSecret ? mfaCipher.decrypt(storedSecret) : null;
|
|
819
914
|
if (!secret || !verifyTotpToken(token, secret)) {
|
|
820
915
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
|
|
821
916
|
}
|
|
@@ -844,7 +939,8 @@ function mfaController(store, config, issuer, bus) {
|
|
|
844
939
|
if (!user || !user.mfaEnabled) {
|
|
845
940
|
return setApiResponse2(HTTP2.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
|
|
846
941
|
}
|
|
847
|
-
const
|
|
942
|
+
const storedSecret = await users.getMfaSecret(ctx.user.id);
|
|
943
|
+
const secret = storedSecret ? mfaCipher.decrypt(storedSecret) : null;
|
|
848
944
|
if (!secret || !verifyTotpToken(token, secret)) {
|
|
849
945
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
|
|
850
946
|
}
|
|
@@ -1629,6 +1725,7 @@ function isValidPhone2(phone2) {
|
|
|
1629
1725
|
}
|
|
1630
1726
|
function userController(store, config, bus) {
|
|
1631
1727
|
const users = new UserModel(store);
|
|
1728
|
+
const sessions = new SessionModel(store);
|
|
1632
1729
|
const emailVerif = new EmailVerificationModel(store);
|
|
1633
1730
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
1634
1731
|
return {
|
|
@@ -1803,6 +1900,7 @@ function userController(store, config, bus) {
|
|
|
1803
1900
|
}
|
|
1804
1901
|
const hash = await hashPassword2(newPassword);
|
|
1805
1902
|
await users.updatePassword(ctx.user.id, hash);
|
|
1903
|
+
await sessions.deleteByUser(ctx.user.id);
|
|
1806
1904
|
return setApiResponse4(HTTP4.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
1807
1905
|
},
|
|
1808
1906
|
deleteMe: async (ctx) => {
|
|
@@ -1822,6 +1920,42 @@ function userController(store, config, bus) {
|
|
|
1822
1920
|
headers: cookieHeaders(clearedTokenCookies(config))
|
|
1823
1921
|
}
|
|
1824
1922
|
);
|
|
1923
|
+
},
|
|
1924
|
+
// Subject Access Request — the authenticated user's own data as a portable
|
|
1925
|
+
// JSON bundle. Only auth-owned data, and only safe fields: no password hash,
|
|
1926
|
+
// no MFA secret, no session tokens (session metadata only).
|
|
1927
|
+
exportMe: async (ctx) => {
|
|
1928
|
+
const user = await users.findById(ctx.user.id);
|
|
1929
|
+
if (!user) return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1930
|
+
const sessionRows = await sessions.listByUser(ctx.user.id);
|
|
1931
|
+
const bundle = {
|
|
1932
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1933
|
+
profile: toUserDTO(user, ctx.user.phoneVerified),
|
|
1934
|
+
sessions: sessionRows.map((s) => ({
|
|
1935
|
+
id: s.id,
|
|
1936
|
+
userAgent: s.userAgent,
|
|
1937
|
+
ipAddress: s.ipAddress,
|
|
1938
|
+
createdAt: s.createdAt instanceof Date ? s.createdAt.toISOString() : s.createdAt,
|
|
1939
|
+
expiresAt: s.expiresAt instanceof Date ? s.expiresAt.toISOString() : s.expiresAt
|
|
1940
|
+
})),
|
|
1941
|
+
security: {
|
|
1942
|
+
mfaEnabled: user.mfaEnabled === true,
|
|
1943
|
+
emailVerified: user.emailVerifiedAt !== null
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
const contributors = config.dataExportContributors ?? [];
|
|
1947
|
+
if (contributors.length > 0) {
|
|
1948
|
+
const modules = {};
|
|
1949
|
+
for (const c of contributors) {
|
|
1950
|
+
try {
|
|
1951
|
+
modules[c.name] = await c.collect(ctx.user.id);
|
|
1952
|
+
} catch {
|
|
1953
|
+
modules[c.name] = null;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
bundle["modules"] = modules;
|
|
1957
|
+
}
|
|
1958
|
+
return setApiResponse4(HTTP4.OK, "DATA_EXPORT", "Your account data.", bundle);
|
|
1825
1959
|
}
|
|
1826
1960
|
};
|
|
1827
1961
|
}
|
|
@@ -1966,6 +2100,7 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
1966
2100
|
R("updatePhone", "PUT", "/users/phone", requireAuth, verifyGate, validate(updatePhoneSchema), user.updatePhone),
|
|
1967
2101
|
R("changePassword", "PUT", "/users/password", requireAuth, validate(changePasswordSchema), user.changePassword),
|
|
1968
2102
|
R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
|
|
2103
|
+
R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
|
|
1969
2104
|
// MFA (email sessions only — requireVerified is always enforced here
|
|
1970
2105
|
// because MFA is a security feature and email verification is meaningful)
|
|
1971
2106
|
R("mfaSetup", "POST", "/auth/mfa/setup", requireAuth, requireEmailLogin, requireVerified, mfa.setup),
|
|
@@ -2007,10 +2142,40 @@ function collectAuthConfigProblems(config) {
|
|
|
2007
2142
|
if (config.secureCookies === false) {
|
|
2008
2143
|
problems.push({
|
|
2009
2144
|
module: MODULE,
|
|
2010
|
-
severity: "warning",
|
|
2145
|
+
severity: process.env["NODE_ENV"] === "production" ? "error" : "warning",
|
|
2011
2146
|
message: "secureCookies is false \u2014 auth cookies may be sent over non-HTTPS connections in production"
|
|
2012
2147
|
});
|
|
2013
2148
|
}
|
|
2149
|
+
if (config.google) {
|
|
2150
|
+
const clientSecret = config.google.clientSecret ?? "";
|
|
2151
|
+
if (!clientSecret || !config.google.clientId || !config.google.redirectUri) {
|
|
2152
|
+
problems.push({
|
|
2153
|
+
module: MODULE,
|
|
2154
|
+
severity: "error",
|
|
2155
|
+
message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
|
|
2156
|
+
});
|
|
2157
|
+
} else if (PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
2158
|
+
problems.push({
|
|
2159
|
+
module: MODULE,
|
|
2160
|
+
severity: "error",
|
|
2161
|
+
message: "google.clientSecret looks like a placeholder or dev-default value"
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
if (config.mfa && !config.mfaSecretKey) {
|
|
2166
|
+
problems.push({
|
|
2167
|
+
module: MODULE,
|
|
2168
|
+
severity: process.env["NODE_ENV"] === "production" ? "error" : "warning",
|
|
2169
|
+
message: "mfa is enabled without mfaSecretKey \u2014 TOTP secrets are stored plaintext at rest; set a 32-byte key (openssl rand -hex 32)"
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
if (config.mfaSecretKey && !/^[0-9a-fA-F]{64}$/.test(config.mfaSecretKey)) {
|
|
2173
|
+
problems.push({
|
|
2174
|
+
module: MODULE,
|
|
2175
|
+
severity: "error",
|
|
2176
|
+
message: "mfaSecretKey must be 64 hex characters (32 bytes, e.g. `openssl rand -hex 32`)"
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2014
2179
|
return problems;
|
|
2015
2180
|
}
|
|
2016
2181
|
function validateAuthConfig(config) {
|
|
@@ -2131,6 +2296,41 @@ async function importUser(store, user) {
|
|
|
2131
2296
|
if (!row) throw new Error("[auth] importUser: insert returned no row");
|
|
2132
2297
|
return row;
|
|
2133
2298
|
}
|
|
2299
|
+
|
|
2300
|
+
// src/services/retention.ts
|
|
2301
|
+
async function purgeSoftDeletedUsers(store, { olderThanDays }) {
|
|
2302
|
+
if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
|
|
2303
|
+
throw new Error("[auth] purgeSoftDeletedUsers: olderThanDays must be a non-negative number");
|
|
2304
|
+
}
|
|
2305
|
+
const rows = await store.query(
|
|
2306
|
+
`DELETE FROM fonderie_users
|
|
2307
|
+
WHERE deleted_at IS NOT NULL
|
|
2308
|
+
AND deleted_at < now() - make_interval(days => $1)
|
|
2309
|
+
RETURNING id`,
|
|
2310
|
+
[olderThanDays]
|
|
2311
|
+
);
|
|
2312
|
+
return rows.length;
|
|
2313
|
+
}
|
|
2314
|
+
function startUserRetention(store, options) {
|
|
2315
|
+
const intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1e3;
|
|
2316
|
+
let stopped = false;
|
|
2317
|
+
const run = async () => {
|
|
2318
|
+
if (stopped) return;
|
|
2319
|
+
try {
|
|
2320
|
+
const deleted = await purgeSoftDeletedUsers(store, { olderThanDays: options.olderThanDays });
|
|
2321
|
+
options.onPurge?.(deleted);
|
|
2322
|
+
} catch (err) {
|
|
2323
|
+
console.error("[auth] scheduled user retention purge failed:", err);
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
const timer = setInterval(run, intervalMs);
|
|
2327
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
2328
|
+
void run();
|
|
2329
|
+
return { stop: () => {
|
|
2330
|
+
stopped = true;
|
|
2331
|
+
clearInterval(timer);
|
|
2332
|
+
} };
|
|
2333
|
+
}
|
|
2134
2334
|
export {
|
|
2135
2335
|
AUTH_CONFIG_KEYS,
|
|
2136
2336
|
AuthModule,
|
|
@@ -2140,8 +2340,10 @@ export {
|
|
|
2140
2340
|
importUser,
|
|
2141
2341
|
normalizeEmail,
|
|
2142
2342
|
normalizeEmailSafe,
|
|
2343
|
+
purgeSoftDeletedUsers,
|
|
2143
2344
|
requireAuth2 as requireAuth,
|
|
2144
2345
|
schemas_exports as schemas,
|
|
2346
|
+
startUserRetention,
|
|
2145
2347
|
toUserDTO,
|
|
2146
2348
|
validate,
|
|
2147
2349
|
validateAuthConfig,
|