@fonderie/auth 7.22.0 → 7.22.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/brain/outcomes.md +1 -1
- package/dist/index.cjs +46 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +46 -39
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/brain/outcomes.md
CHANGED
|
@@ -142,7 +142,7 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
|
|
|
142
142
|
|
|
143
143
|
| Method | Path | Middleware chain (auth / validation / handler) |
|
|
144
144
|
|---|---|---|
|
|
145
|
-
| GET | `/_admin/users` | `async (ctx) => { const params = new URL(ctx.request.url).searchParams; const
|
|
145
|
+
| GET | `/_admin/users` | `async (ctx) => { const params = new URL(ctx.request.url).searchParams; // Accounts are stored under normalizeEmail: a search for the address a // user typed ('Jane+work@x.com') finds the account ('jane@x.com'). const raw = params.get('email')?.trim(); const email = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : undefined; 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)); }` |
|
|
146
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(); }` |
|
|
147
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), ); }` |
|
|
148
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'); }` |
|
package/dist/index.cjs
CHANGED
|
@@ -125,6 +125,41 @@ var import_middlewares = require("@fonderie/core/middlewares");
|
|
|
125
125
|
|
|
126
126
|
// src/services/rate-limit.ts
|
|
127
127
|
var import_rate_limit = require("@fonderie/rate-limit");
|
|
128
|
+
|
|
129
|
+
// src/services/email.ts
|
|
130
|
+
function normalizeEmail(email2) {
|
|
131
|
+
if (typeof email2 !== "string" || email2.length === 0) {
|
|
132
|
+
throw new Error("Invalid email");
|
|
133
|
+
}
|
|
134
|
+
const lower = email2.trim().toLowerCase();
|
|
135
|
+
if (lower.length === 0) {
|
|
136
|
+
throw new Error("Email cannot be empty");
|
|
137
|
+
}
|
|
138
|
+
const atIndex = lower.indexOf("@");
|
|
139
|
+
if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
|
|
140
|
+
throw new Error("Invalid email format");
|
|
141
|
+
}
|
|
142
|
+
const local = lower.substring(0, atIndex);
|
|
143
|
+
const domain = lower.substring(atIndex + 1);
|
|
144
|
+
if (!local || !domain) {
|
|
145
|
+
throw new Error("Invalid email format");
|
|
146
|
+
}
|
|
147
|
+
const plusIndex = local.indexOf("+");
|
|
148
|
+
const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
|
|
149
|
+
if (!normalizedLocal) {
|
|
150
|
+
throw new Error("Invalid email format");
|
|
151
|
+
}
|
|
152
|
+
return `${normalizedLocal}@${domain}`;
|
|
153
|
+
}
|
|
154
|
+
function normalizeEmailSafe(email2) {
|
|
155
|
+
try {
|
|
156
|
+
return normalizeEmail(email2);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/services/rate-limit.ts
|
|
128
163
|
var min = (n) => n * 60;
|
|
129
164
|
var DEFAULTS = {
|
|
130
165
|
// login: 10/15min per IP AND 5/15min per account (credential stuffing
|
|
@@ -177,7 +212,9 @@ function buildAuthAccountLimiter(route, store, config) {
|
|
|
177
212
|
return (0, import_rate_limit.rateLimit)({
|
|
178
213
|
store: r.store,
|
|
179
214
|
rule: r.account.rule,
|
|
180
|
-
|
|
215
|
+
// The account bucket is keyed the way accounts are stored, so a rotating
|
|
216
|
+
// '+tag' on the same address does not buy fresh attempts.
|
|
217
|
+
key: (0, import_rate_limit.byBodyField)(`auth:${route}`, r.account.field, r.account.field === "email" ? normalizeEmailSafe : void 0)
|
|
181
218
|
});
|
|
182
219
|
}
|
|
183
220
|
|
|
@@ -1633,39 +1670,6 @@ function checkCooldown(lastSentAt, cooldownMs) {
|
|
|
1633
1670
|
// src/controllers/auth.controller.ts
|
|
1634
1671
|
init_password();
|
|
1635
1672
|
|
|
1636
|
-
// src/services/email.ts
|
|
1637
|
-
function normalizeEmail(email2) {
|
|
1638
|
-
if (typeof email2 !== "string" || email2.length === 0) {
|
|
1639
|
-
throw new Error("Invalid email");
|
|
1640
|
-
}
|
|
1641
|
-
const lower = email2.trim().toLowerCase();
|
|
1642
|
-
if (lower.length === 0) {
|
|
1643
|
-
throw new Error("Email cannot be empty");
|
|
1644
|
-
}
|
|
1645
|
-
const atIndex = lower.indexOf("@");
|
|
1646
|
-
if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
|
|
1647
|
-
throw new Error("Invalid email format");
|
|
1648
|
-
}
|
|
1649
|
-
const local = lower.substring(0, atIndex);
|
|
1650
|
-
const domain = lower.substring(atIndex + 1);
|
|
1651
|
-
if (!local || !domain) {
|
|
1652
|
-
throw new Error("Invalid email format");
|
|
1653
|
-
}
|
|
1654
|
-
const plusIndex = local.indexOf("+");
|
|
1655
|
-
const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
|
|
1656
|
-
if (!normalizedLocal) {
|
|
1657
|
-
throw new Error("Invalid email format");
|
|
1658
|
-
}
|
|
1659
|
-
return `${normalizedLocal}@${domain}`;
|
|
1660
|
-
}
|
|
1661
|
-
function normalizeEmailSafe(email2) {
|
|
1662
|
-
try {
|
|
1663
|
-
return normalizeEmail(email2);
|
|
1664
|
-
} catch {
|
|
1665
|
-
return null;
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
1673
|
// src/models/password-reset.model.ts
|
|
1670
1674
|
var import_node_crypto5 = require("crypto");
|
|
1671
1675
|
var hashSecret = (value) => (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
|
|
@@ -3355,7 +3359,7 @@ function oauthController(store, config, bus) {
|
|
|
3355
3359
|
const meta = requestMeta(ctx);
|
|
3356
3360
|
await loginEvents.recordSafe({
|
|
3357
3361
|
userId: null,
|
|
3358
|
-
emailAttempted: claims.email ?? null,
|
|
3362
|
+
emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
|
|
3359
3363
|
method: "oauth-apple",
|
|
3360
3364
|
outcome: "failed",
|
|
3361
3365
|
failureReason: "token_replayed",
|
|
@@ -3403,7 +3407,7 @@ function oauthController(store, config, bus) {
|
|
|
3403
3407
|
if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
|
|
3404
3408
|
await loginEvents.recordSafe({
|
|
3405
3409
|
userId: null,
|
|
3406
|
-
emailAttempted: claims.email ?? null,
|
|
3410
|
+
emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
|
|
3407
3411
|
method: "oauth-google",
|
|
3408
3412
|
outcome: "failed",
|
|
3409
3413
|
failureReason: "token_replayed",
|
|
@@ -3761,7 +3765,8 @@ function adminRouteTable(store, bus) {
|
|
|
3761
3765
|
// see anything cannot discover who signed up this morning.
|
|
3762
3766
|
async (ctx) => {
|
|
3763
3767
|
const params = new URL(ctx.request.url).searchParams;
|
|
3764
|
-
const
|
|
3768
|
+
const raw = params.get("email")?.trim();
|
|
3769
|
+
const email2 = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : void 0;
|
|
3765
3770
|
if (email2) {
|
|
3766
3771
|
const user = await users.findByEmail(email2);
|
|
3767
3772
|
return user ? (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
|
|
@@ -3865,7 +3870,7 @@ var AuthModule = class {
|
|
|
3865
3870
|
name = "@fonderie/auth";
|
|
3866
3871
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3867
3872
|
// say what is actually deployed rather than 'not reported'.
|
|
3868
|
-
version = "7.22.
|
|
3873
|
+
version = "7.22.1";
|
|
3869
3874
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3870
3875
|
describeAdmin() {
|
|
3871
3876
|
return { routes: describeAuthAdminRoutes(this.store, this.bus) };
|
|
@@ -4721,7 +4726,9 @@ async function importUser(store, user) {
|
|
|
4721
4726
|
placeholders.push(`$${vals.length}`);
|
|
4722
4727
|
};
|
|
4723
4728
|
if (user.id !== void 0) add("id", user.id);
|
|
4724
|
-
|
|
4729
|
+
const email2 = normalizeEmailSafe(user.email);
|
|
4730
|
+
if (!email2) throw new Error(`[auth] importUser: '${user.email}' is not an email address`);
|
|
4731
|
+
add("email", email2);
|
|
4725
4732
|
if (user.passwordHash !== void 0) add("password_hash", user.passwordHash);
|
|
4726
4733
|
if (user.firstName !== void 0) add("first_name", user.firstName);
|
|
4727
4734
|
if (user.lastName !== void 0) add("last_name", user.lastName);
|