@fonderie/auth 7.14.4 → 7.16.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/README.md +20 -0
- package/brain/outcomes.md +3 -0
- package/brain/signatures.md +1 -0
- package/dist/index.cjs +149 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +136 -37
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +80 -10
- 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 +81 -11
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/020_session_rotation.sql +22 -0
- package/dist/{session-BBF5t-3Y.d.cts → session-auNb2L8m.d.cts} +1 -0
- package/dist/{session-BBF5t-3Y.d.ts → session-auNb2L8m.d.ts} +1 -0
- package/env.json +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,26 @@ request; its output is sanitized and bounded (coordinates ~1 km); if it throws o
|
|
|
68
68
|
and the request is unaffected. Country is reliable; region and city are
|
|
69
69
|
approximate.
|
|
70
70
|
|
|
71
|
+
## Rotating the signing secret (nobody is signed out)
|
|
72
|
+
|
|
73
|
+
Every token carries the key id of the secret that signed it, and tokens are
|
|
74
|
+
verified against the current secret **and** any previous ones:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
new AuthModule(store, {
|
|
78
|
+
jwtSecret: process.env.JWT_SECRET!, // signs new tokens
|
|
79
|
+
jwtPreviousSecrets: process.env.JWT_PREVIOUS_SECRETS?.split(',') ?? [], // still verify
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
1. Put the current secret in `JWT_PREVIOUS_SECRETS` and a new one in `JWT_SECRET`; deploy.
|
|
84
|
+
2. Signed-in users keep working; their next refresh gets tokens signed with the new key.
|
|
85
|
+
3. After the longest session lifetime (`sessionDuration`), remove the old secret from
|
|
86
|
+
`JWT_PREVIOUS_SECRETS` and deploy again.
|
|
87
|
+
|
|
88
|
+
A weak previous secret fails readiness like a weak current one: it still verifies tokens.
|
|
89
|
+
See `docs/SESSION-DESIGN.md` for the session roadmap.
|
|
90
|
+
|
|
71
91
|
## Why this exists
|
|
72
92
|
|
|
73
93
|
You've shipped this plumbing before — auth, teams, billing, messaging —
|
package/brain/outcomes.md
CHANGED
|
@@ -95,6 +95,9 @@ expires_at TIMESTAMPTZ NOT NULL
|
|
|
95
95
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
96
96
|
sid UUID
|
|
97
97
|
location JSONB
|
|
98
|
+
previous_token_hash TEXT
|
|
99
|
+
previous_valid_until TIMESTAMPTZ
|
|
100
|
+
last_used_at TIMESTAMPTZ
|
|
98
101
|
-- INDEX idx_fonderie_sessions_expires_at (expires_at)
|
|
99
102
|
-- INDEX idx_fonderie_sessions_sid (sid)
|
|
100
103
|
```
|
package/brain/signatures.md
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -325,12 +325,21 @@ var EVENT_KEYS = {
|
|
|
325
325
|
// Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
|
|
326
326
|
userPurged: "fonderie.user.purged",
|
|
327
327
|
emailVerified: "fonderie.user.email_verified",
|
|
328
|
-
passwordChanged: "fonderie.user.password_changed"
|
|
328
|
+
passwordChanged: "fonderie.user.password_changed",
|
|
329
|
+
// A session the server revoked — today: a refresh token reused after its
|
|
330
|
+
// grace (theft signal). { userId, sessionId, reason }.
|
|
331
|
+
sessionRevoked: "fonderie.session.revoked"
|
|
329
332
|
};
|
|
330
333
|
|
|
331
334
|
// src/services/jwt.ts
|
|
332
335
|
var import_node_crypto = require("crypto");
|
|
333
336
|
var import_jsonwebtoken = __toESM(require("jsonwebtoken"), 1);
|
|
337
|
+
function keyIdOf(secret) {
|
|
338
|
+
return (0, import_node_crypto.createHash)("sha256").update(secret).digest("hex").slice(0, 16);
|
|
339
|
+
}
|
|
340
|
+
function keyRing(config) {
|
|
341
|
+
return [config.jwtSecret, ...config.jwtPreviousSecrets ?? []].filter((s) => typeof s === "string" && s.length > 0);
|
|
342
|
+
}
|
|
334
343
|
function issueMfaPendingToken(userId, config, loginMethod) {
|
|
335
344
|
return import_jsonwebtoken.default.sign(
|
|
336
345
|
{
|
|
@@ -341,7 +350,7 @@ function issueMfaPendingToken(userId, config, loginMethod) {
|
|
|
341
350
|
mfaPending: true
|
|
342
351
|
},
|
|
343
352
|
config.jwtSecret,
|
|
344
|
-
{ expiresIn: "5m" }
|
|
353
|
+
{ expiresIn: "5m", keyid: keyIdOf(config.jwtSecret) }
|
|
345
354
|
);
|
|
346
355
|
}
|
|
347
356
|
function issueTokenPair(userId, config, options) {
|
|
@@ -349,16 +358,19 @@ function issueTokenPair(userId, config, options) {
|
|
|
349
358
|
const accessDuration = config.accessTokenDuration ?? "24h";
|
|
350
359
|
const loginMethod = options.loginMethod;
|
|
351
360
|
const phoneVerified = options.phoneVerified ?? false;
|
|
352
|
-
const sid = (0, import_node_crypto.randomUUID)();
|
|
361
|
+
const sid = options.sid ?? (0, import_node_crypto.randomUUID)();
|
|
353
362
|
const accessToken = import_jsonwebtoken.default.sign(
|
|
354
363
|
{ sub: userId, type: "access", loginMethod, phoneVerified, sid },
|
|
355
364
|
config.jwtSecret,
|
|
356
|
-
|
|
365
|
+
// jwtid: every token is unique. With the session id kept across rotations,
|
|
366
|
+
// two tokens issued in the same second would otherwise be byte-identical —
|
|
367
|
+
// and a rotated-away token indistinguishable from the live one.
|
|
368
|
+
{ expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
|
|
357
369
|
);
|
|
358
370
|
const refreshToken = import_jsonwebtoken.default.sign(
|
|
359
371
|
{ sub: userId, type: "refresh", loginMethod, phoneVerified, sid },
|
|
360
372
|
config.jwtSecret,
|
|
361
|
-
{ expiresIn: duration }
|
|
373
|
+
{ expiresIn: duration, keyid: keyIdOf(config.jwtSecret), jwtid: (0, import_node_crypto.randomUUID)() }
|
|
362
374
|
);
|
|
363
375
|
return { accessToken, refreshToken, sid };
|
|
364
376
|
}
|
|
@@ -367,11 +379,16 @@ function refreshTokenExpiry(token) {
|
|
|
367
379
|
return decoded?.exp ? new Date(decoded.exp * 1e3) : new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3);
|
|
368
380
|
}
|
|
369
381
|
function verifyToken(token, config) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
382
|
+
const ring = keyRing(config);
|
|
383
|
+
const kid = import_jsonwebtoken.default.decode(token, { complete: true })?.header?.kid;
|
|
384
|
+
const candidates = typeof kid === "string" ? ring.filter((s) => keyIdOf(s) === kid) : ring;
|
|
385
|
+
for (const secret of candidates) {
|
|
386
|
+
try {
|
|
387
|
+
return import_jsonwebtoken.default.verify(token, secret);
|
|
388
|
+
} catch {
|
|
389
|
+
}
|
|
374
390
|
}
|
|
391
|
+
return null;
|
|
375
392
|
}
|
|
376
393
|
|
|
377
394
|
// src/services/mfa.ts
|
|
@@ -897,6 +914,9 @@ var UserModel = class {
|
|
|
897
914
|
};
|
|
898
915
|
var MAX_LIST_LIMIT = 200;
|
|
899
916
|
|
|
917
|
+
// src/models/session.model.ts
|
|
918
|
+
var import_node_crypto4 = require("crypto");
|
|
919
|
+
|
|
900
920
|
// src/services/request-location.ts
|
|
901
921
|
var LOCATION_TIMEOUT_MS = 500;
|
|
902
922
|
var TEXT_MAX = 128;
|
|
@@ -979,6 +999,10 @@ async function resolveOnce(resolver, req, timeoutMs) {
|
|
|
979
999
|
}
|
|
980
1000
|
|
|
981
1001
|
// src/models/session.model.ts
|
|
1002
|
+
function hashRefreshToken(token) {
|
|
1003
|
+
return (0, import_node_crypto4.createHash)("sha256").update(token).digest("hex");
|
|
1004
|
+
}
|
|
1005
|
+
var REFRESH_GRACE_MS = 3e4;
|
|
982
1006
|
var SessionModel = class {
|
|
983
1007
|
// `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
|
|
984
1008
|
constructor(store, locate) {
|
|
@@ -990,12 +1014,12 @@ var SessionModel = class {
|
|
|
990
1014
|
async create(userId, token, expiresAt, sid, meta) {
|
|
991
1015
|
const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
|
|
992
1016
|
await this.store.query(
|
|
993
|
-
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
|
|
994
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
1017
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
|
|
1018
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
995
1019
|
ON CONFLICT (token) DO NOTHING`,
|
|
996
1020
|
[
|
|
997
1021
|
userId,
|
|
998
|
-
token,
|
|
1022
|
+
hashRefreshToken(token),
|
|
999
1023
|
expiresAt,
|
|
1000
1024
|
sid ?? null,
|
|
1001
1025
|
meta?.userAgent ?? null,
|
|
@@ -1005,7 +1029,56 @@ var SessionModel = class {
|
|
|
1005
1029
|
);
|
|
1006
1030
|
}
|
|
1007
1031
|
async delete(token) {
|
|
1008
|
-
await this.store.query(`DELETE FROM fonderie_sessions WHERE token
|
|
1032
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE token IN ($1, $2)`, [hashRefreshToken(token), token]);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Where a presented refresh token stands: the session's current token, its
|
|
1036
|
+
* previous one within the grace (a retry or race), its previous one AFTER
|
|
1037
|
+
* the grace (reuse — a theft signal), or nothing known.
|
|
1038
|
+
*/
|
|
1039
|
+
async match(token) {
|
|
1040
|
+
const hash = hashRefreshToken(token);
|
|
1041
|
+
const [current] = await this.store.query(
|
|
1042
|
+
`SELECT id, user_id, sid, token FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
|
|
1043
|
+
[hash, token]
|
|
1044
|
+
);
|
|
1045
|
+
if (current) return { kind: "current", row: toRow(current) };
|
|
1046
|
+
const [previous] = await this.store.query(
|
|
1047
|
+
`SELECT id, user_id, sid, token, (previous_valid_until > now()) AS in_grace
|
|
1048
|
+
FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
|
|
1049
|
+
[hash]
|
|
1050
|
+
);
|
|
1051
|
+
if (!previous) return { kind: "unknown" };
|
|
1052
|
+
return { kind: previous.in_grace ? "grace" : "reused", row: toRow(previous) };
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Rotate a session in place to a new refresh token: same row, same device.
|
|
1056
|
+
* From the CURRENT token it is optimistic — only if the stored token is
|
|
1057
|
+
* still the one matched; a concurrent rotation makes it return false and the
|
|
1058
|
+
* caller re-matches (and now finds the token as the previous one). The old
|
|
1059
|
+
* token becomes the previous, valid for the grace. From the PREVIOUS token
|
|
1060
|
+
* (within the grace) it is authorised by that hash and deadline alone, so
|
|
1061
|
+
* racing retries never starve; the previous and its deadline are kept.
|
|
1062
|
+
*/
|
|
1063
|
+
async rotate(row, presented, next, from) {
|
|
1064
|
+
const rows = await this.store.query(
|
|
1065
|
+
`UPDATE fonderie_sessions
|
|
1066
|
+
SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,
|
|
1067
|
+
previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,
|
|
1068
|
+
token = $3,
|
|
1069
|
+
expires_at = $4,
|
|
1070
|
+
last_used_at = now()
|
|
1071
|
+
WHERE id = $1
|
|
1072
|
+
AND CASE WHEN $5 THEN token = $2
|
|
1073
|
+
ELSE previous_token_hash = $6 AND previous_valid_until > now() END
|
|
1074
|
+
RETURNING id`,
|
|
1075
|
+
[row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === "current", hashRefreshToken(presented), String(REFRESH_GRACE_MS)]
|
|
1076
|
+
);
|
|
1077
|
+
return rows.length === 1;
|
|
1078
|
+
}
|
|
1079
|
+
/** Revoke one session by its row id (any user) — reuse detection, admin. */
|
|
1080
|
+
async revokeById(id) {
|
|
1081
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);
|
|
1009
1082
|
}
|
|
1010
1083
|
// Delete the session an access token is bound to (by its sid claim). Lets
|
|
1011
1084
|
// logout revoke the current session without the client resending the refresh
|
|
@@ -1071,8 +1144,8 @@ var SessionModel = class {
|
|
|
1071
1144
|
}
|
|
1072
1145
|
async exists(token) {
|
|
1073
1146
|
const rows = await this.store.query(
|
|
1074
|
-
`SELECT id FROM fonderie_sessions WHERE token
|
|
1075
|
-
[token]
|
|
1147
|
+
`SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,
|
|
1148
|
+
[hashRefreshToken(token), token]
|
|
1076
1149
|
);
|
|
1077
1150
|
return rows.length > 0;
|
|
1078
1151
|
}
|
|
@@ -1085,6 +1158,9 @@ var SessionModel = class {
|
|
|
1085
1158
|
return rows.length > 0;
|
|
1086
1159
|
}
|
|
1087
1160
|
};
|
|
1161
|
+
function toRow(r) {
|
|
1162
|
+
return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };
|
|
1163
|
+
}
|
|
1088
1164
|
|
|
1089
1165
|
// src/models/backup-code.model.ts
|
|
1090
1166
|
var BackupCodeModel = class {
|
|
@@ -1419,7 +1495,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
1419
1495
|
}
|
|
1420
1496
|
|
|
1421
1497
|
// src/controllers/auth.controller.ts
|
|
1422
|
-
var
|
|
1498
|
+
var import_node_crypto6 = require("crypto");
|
|
1423
1499
|
var import_events2 = require("@fonderie/events");
|
|
1424
1500
|
var import_core6 = require("@fonderie/core");
|
|
1425
1501
|
|
|
@@ -1466,8 +1542,8 @@ function normalizeEmailSafe(email2) {
|
|
|
1466
1542
|
}
|
|
1467
1543
|
|
|
1468
1544
|
// src/models/password-reset.model.ts
|
|
1469
|
-
var
|
|
1470
|
-
var hashSecret = (value) => (0,
|
|
1545
|
+
var import_node_crypto5 = require("crypto");
|
|
1546
|
+
var hashSecret = (value) => (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
|
|
1471
1547
|
var PasswordResetModel = class {
|
|
1472
1548
|
constructor(store) {
|
|
1473
1549
|
this.store = store;
|
|
@@ -1661,7 +1737,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
1661
1737
|
if (!row) {
|
|
1662
1738
|
return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
|
|
1663
1739
|
}
|
|
1664
|
-
const pin = (0,
|
|
1740
|
+
const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
|
|
1665
1741
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
1666
1742
|
await emailVerif.create(row.id, pin, expiresAt);
|
|
1667
1743
|
const user = await users.findById(row.id);
|
|
@@ -1732,7 +1808,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
1732
1808
|
lastName ?? null,
|
|
1733
1809
|
locale
|
|
1734
1810
|
);
|
|
1735
|
-
const otp = (0,
|
|
1811
|
+
const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
|
|
1736
1812
|
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
|
1737
1813
|
await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
|
|
1738
1814
|
const user = await users.findById(id);
|
|
@@ -1876,7 +1952,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
1876
1952
|
"Account suspended. Please contact support."
|
|
1877
1953
|
);
|
|
1878
1954
|
}
|
|
1879
|
-
const otp = (0,
|
|
1955
|
+
const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
|
|
1880
1956
|
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
|
1881
1957
|
await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
|
|
1882
1958
|
await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -1927,24 +2003,35 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
1927
2003
|
if (!payload || payload.type !== "refresh") {
|
|
1928
2004
|
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
|
|
1929
2005
|
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
"TOKEN_REFRESH_FAILED",
|
|
1935
|
-
|
|
1936
|
-
)
|
|
2006
|
+
let issued = null;
|
|
2007
|
+
for (let attempt = 0; attempt < 3 && !issued; attempt++) {
|
|
2008
|
+
const found = await sessions.match(token);
|
|
2009
|
+
if (found.kind === "unknown") {
|
|
2010
|
+
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
2011
|
+
}
|
|
2012
|
+
if (found.kind === "reused") {
|
|
2013
|
+
await sessions.revokeById(found.row.id);
|
|
2014
|
+
await (0, import_core6.background)(
|
|
2015
|
+
bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sessionId: found.row.id, reason: "refresh-reuse" })
|
|
2016
|
+
);
|
|
2017
|
+
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
2018
|
+
}
|
|
2019
|
+
const user = await users.findById(found.row.userId);
|
|
2020
|
+
if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
|
|
2021
|
+
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
|
|
2022
|
+
}
|
|
2023
|
+
const pair = issueTokenPair(user.id, config, {
|
|
2024
|
+
loginMethod: payload.loginMethod ?? "email",
|
|
2025
|
+
phoneVerified: payload.phoneVerified ?? false,
|
|
2026
|
+
...found.row.sid ? { sid: found.row.sid } : {}
|
|
2027
|
+
});
|
|
2028
|
+
const next = { token: pair.refreshToken, expiresAt: refreshTokenExpiry(pair.refreshToken) };
|
|
2029
|
+
if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
|
|
1937
2030
|
}
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
|
|
2031
|
+
if (!issued) {
|
|
2032
|
+
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
1941
2033
|
}
|
|
1942
|
-
|
|
1943
|
-
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1944
|
-
loginMethod: payload.loginMethod ?? "email",
|
|
1945
|
-
phoneVerified: payload.phoneVerified ?? false
|
|
1946
|
-
});
|
|
1947
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
2034
|
+
const { accessToken, refreshToken } = issued;
|
|
1948
2035
|
return Response.json(
|
|
1949
2036
|
{
|
|
1950
2037
|
reason: "TOKENS_REFRESHED",
|
|
@@ -1985,8 +2072,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
1985
2072
|
"Password reset email sent (if account exists)."
|
|
1986
2073
|
);
|
|
1987
2074
|
}
|
|
1988
|
-
const pin = (0,
|
|
1989
|
-
const token = (0,
|
|
2075
|
+
const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
|
|
2076
|
+
const token = (0, import_node_crypto6.randomBytes)(32).toString("hex");
|
|
1990
2077
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
|
|
1991
2078
|
await passwordReset.create(user.id, pin, token, expiresAt);
|
|
1992
2079
|
const base = resolved.passwordResetUrl;
|
|
@@ -2192,7 +2279,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
2192
2279
|
}
|
|
2193
2280
|
);
|
|
2194
2281
|
}
|
|
2195
|
-
const otp = (0,
|
|
2282
|
+
const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
|
|
2196
2283
|
const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
|
|
2197
2284
|
await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
|
|
2198
2285
|
await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -2231,7 +2318,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
2231
2318
|
}
|
|
2232
2319
|
);
|
|
2233
2320
|
}
|
|
2234
|
-
const pin = (0,
|
|
2321
|
+
const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
|
|
2235
2322
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
2236
2323
|
await emailVerif.replace(ctx.user.id, pin, expiresAt);
|
|
2237
2324
|
await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -2248,7 +2335,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
2248
2335
|
}
|
|
2249
2336
|
|
|
2250
2337
|
// src/controllers/user.controller.ts
|
|
2251
|
-
var
|
|
2338
|
+
var import_node_crypto7 = require("crypto");
|
|
2252
2339
|
var import_core8 = require("@fonderie/core");
|
|
2253
2340
|
var import_events3 = require("@fonderie/events");
|
|
2254
2341
|
|
|
@@ -2442,7 +2529,7 @@ function userController(store, config, bus) {
|
|
|
2442
2529
|
if (existing) {
|
|
2443
2530
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
|
|
2444
2531
|
}
|
|
2445
|
-
const pin = (0,
|
|
2532
|
+
const pin = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
|
|
2446
2533
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
2447
2534
|
await emailVerif.replace(ctx.user.id, pin, expiresAt);
|
|
2448
2535
|
await users.updateEmail(ctx.user.id, normalised);
|
|
@@ -2484,7 +2571,7 @@ function userController(store, config, bus) {
|
|
|
2484
2571
|
if (existing) {
|
|
2485
2572
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
|
|
2486
2573
|
}
|
|
2487
|
-
const otp = (0,
|
|
2574
|
+
const otp = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
|
|
2488
2575
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
2489
2576
|
await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
|
|
2490
2577
|
await users.updatePhone(ctx.user.id, normalised);
|
|
@@ -2634,7 +2721,7 @@ function userController(store, config, bus) {
|
|
|
2634
2721
|
}
|
|
2635
2722
|
|
|
2636
2723
|
// src/controllers/oauth.controller.ts
|
|
2637
|
-
var
|
|
2724
|
+
var import_node_crypto8 = require("crypto");
|
|
2638
2725
|
var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
|
|
2639
2726
|
var import_core9 = require("@fonderie/core");
|
|
2640
2727
|
var import_events4 = require("@fonderie/events");
|
|
@@ -2709,7 +2796,7 @@ async function getApplePublicKey(kid) {
|
|
|
2709
2796
|
}
|
|
2710
2797
|
if (!jwk) return null;
|
|
2711
2798
|
try {
|
|
2712
|
-
return (0,
|
|
2799
|
+
return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
|
|
2713
2800
|
} catch {
|
|
2714
2801
|
return null;
|
|
2715
2802
|
}
|
|
@@ -2867,7 +2954,7 @@ function oauthController(store, config, bus) {
|
|
|
2867
2954
|
"Google OAuth not configured"
|
|
2868
2955
|
);
|
|
2869
2956
|
}
|
|
2870
|
-
const state = (0,
|
|
2957
|
+
const state = (0, import_node_crypto8.randomBytes)(16).toString("hex");
|
|
2871
2958
|
const params = new URLSearchParams({
|
|
2872
2959
|
client_id: google.clientId,
|
|
2873
2960
|
redirect_uri: google.redirectUri,
|
|
@@ -3015,7 +3102,7 @@ function oauthController(store, config, bus) {
|
|
|
3015
3102
|
if (!apple) {
|
|
3016
3103
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
3017
3104
|
}
|
|
3018
|
-
const state = (0,
|
|
3105
|
+
const state = (0, import_node_crypto8.randomBytes)(16).toString("hex");
|
|
3019
3106
|
const params = new URLSearchParams({
|
|
3020
3107
|
client_id: apple.clientId,
|
|
3021
3108
|
redirect_uri: apple.redirectUri,
|
|
@@ -3111,7 +3198,7 @@ function oauthController(store, config, bus) {
|
|
|
3111
3198
|
});
|
|
3112
3199
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
3113
3200
|
}
|
|
3114
|
-
const tokenHash = (0,
|
|
3201
|
+
const tokenHash = (0, import_node_crypto8.createHash)("sha256").update(identityToken).digest("hex");
|
|
3115
3202
|
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
3116
3203
|
const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
|
|
3117
3204
|
if (!firstUse) {
|
|
@@ -3271,6 +3358,18 @@ function collectAuthConfigProblems(config) {
|
|
|
3271
3358
|
reason: "JWT_SECRET_PLACEHOLDER"
|
|
3272
3359
|
});
|
|
3273
3360
|
}
|
|
3361
|
+
(config.jwtPreviousSecrets ?? []).forEach((previous, index) => {
|
|
3362
|
+
if ((0, import_core11.secretStrengthProblem)(previous ?? "")) {
|
|
3363
|
+
problems.push({
|
|
3364
|
+
module: MODULE,
|
|
3365
|
+
severity: "error",
|
|
3366
|
+
message: `jwtPreviousSecrets[${index}] is too short or a placeholder \u2014 it still verifies tokens`,
|
|
3367
|
+
domain: "auth",
|
|
3368
|
+
reason: "JWT_PREVIOUS_SECRET_WEAK",
|
|
3369
|
+
metadata: { index }
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3372
|
+
});
|
|
3274
3373
|
if (config.secureCookies === false) {
|
|
3275
3374
|
problems.push({
|
|
3276
3375
|
module: MODULE,
|
|
@@ -3556,7 +3655,7 @@ var AuthModule = class {
|
|
|
3556
3655
|
name = "@fonderie/auth";
|
|
3557
3656
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3558
3657
|
// say what is actually deployed rather than 'not reported'.
|
|
3559
|
-
version = "7.
|
|
3658
|
+
version = "7.16.0";
|
|
3560
3659
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3561
3660
|
describeAdmin() {
|
|
3562
3661
|
return { routes: describeAuthAdminRoutes(this.store) };
|