@fonderie/auth 7.22.0 → 7.23.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 +1 -1
- package/brain/signatures.md +3 -0
- package/dist/index.cjs +362 -261
- 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 +320 -219
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +21 -0
- 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 +21 -0
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-C1A21bvb.d.cts → session-DZ7QOw1J.d.cts} +3 -0
- package/dist/{session-C1A21bvb.d.ts → session-DZ7QOw1J.d.ts} +3 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -44,7 +44,7 @@ var init_password = __esm({
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
// src/routes.ts
|
|
47
|
-
import { setApiResponse as
|
|
47
|
+
import { setApiResponse as setApiResponse7, HTTP as HTTP7 } from "@fonderie/core";
|
|
48
48
|
import { requireAuth, requireAnyAuth, requireVerified } from "@fonderie/core/middlewares";
|
|
49
49
|
|
|
50
50
|
// src/middlewares/require-email-login.ts
|
|
@@ -74,6 +74,41 @@ import {
|
|
|
74
74
|
rateLimit,
|
|
75
75
|
StoreAdapterStore
|
|
76
76
|
} from "@fonderie/rate-limit";
|
|
77
|
+
|
|
78
|
+
// src/services/email.ts
|
|
79
|
+
function normalizeEmail(email2) {
|
|
80
|
+
if (typeof email2 !== "string" || email2.length === 0) {
|
|
81
|
+
throw new Error("Invalid email");
|
|
82
|
+
}
|
|
83
|
+
const lower = email2.trim().toLowerCase();
|
|
84
|
+
if (lower.length === 0) {
|
|
85
|
+
throw new Error("Email cannot be empty");
|
|
86
|
+
}
|
|
87
|
+
const atIndex = lower.indexOf("@");
|
|
88
|
+
if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
|
|
89
|
+
throw new Error("Invalid email format");
|
|
90
|
+
}
|
|
91
|
+
const local = lower.substring(0, atIndex);
|
|
92
|
+
const domain = lower.substring(atIndex + 1);
|
|
93
|
+
if (!local || !domain) {
|
|
94
|
+
throw new Error("Invalid email format");
|
|
95
|
+
}
|
|
96
|
+
const plusIndex = local.indexOf("+");
|
|
97
|
+
const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
|
|
98
|
+
if (!normalizedLocal) {
|
|
99
|
+
throw new Error("Invalid email format");
|
|
100
|
+
}
|
|
101
|
+
return `${normalizedLocal}@${domain}`;
|
|
102
|
+
}
|
|
103
|
+
function normalizeEmailSafe(email2) {
|
|
104
|
+
try {
|
|
105
|
+
return normalizeEmail(email2);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/services/rate-limit.ts
|
|
77
112
|
var min = (n) => n * 60;
|
|
78
113
|
var DEFAULTS = {
|
|
79
114
|
// login: 10/15min per IP AND 5/15min per account (credential stuffing
|
|
@@ -126,7 +161,9 @@ function buildAuthAccountLimiter(route, store, config) {
|
|
|
126
161
|
return rateLimit({
|
|
127
162
|
store: r.store,
|
|
128
163
|
rule: r.account.rule,
|
|
129
|
-
|
|
164
|
+
// The account bucket is keyed the way accounts are stored, so a rotating
|
|
165
|
+
// '+tag' on the same address does not buy fresh attempts.
|
|
166
|
+
key: byBodyField(`auth:${route}`, r.account.field, r.account.field === "email" ? normalizeEmailSafe : void 0)
|
|
130
167
|
});
|
|
131
168
|
}
|
|
132
169
|
|
|
@@ -301,6 +338,10 @@ var EVENT_KEYS = {
|
|
|
301
338
|
// 'password-changed', 'admin', 'refresh-reuse' (theft signal).
|
|
302
339
|
sessionRevoked: "fonderie.session.revoked"
|
|
303
340
|
};
|
|
341
|
+
var deletionGracePeriodDays = (config) => {
|
|
342
|
+
const days = config.accountDeletion?.gracePeriodDays;
|
|
343
|
+
return typeof days === "number" && Number.isFinite(days) && days >= 0 ? days : 30;
|
|
344
|
+
};
|
|
304
345
|
|
|
305
346
|
// src/services/jwt.ts
|
|
306
347
|
import { createHash, randomUUID } from "crypto";
|
|
@@ -717,6 +758,27 @@ var UserModel = class {
|
|
|
717
758
|
);
|
|
718
759
|
return row ?? null;
|
|
719
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* An ARCHIVED (soft-deleted, not yet purged) account holding this address.
|
|
763
|
+
* Only for telling its owner — after they prove it is theirs — that it is
|
|
764
|
+
* scheduled for deletion, and for refusing to create a second account on
|
|
765
|
+
* the address meanwhile. Never for signing in.
|
|
766
|
+
*/
|
|
767
|
+
async findArchivedByEmail(email2) {
|
|
768
|
+
const [row] = await this.store.query(
|
|
769
|
+
`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NOT NULL`,
|
|
770
|
+
[email2]
|
|
771
|
+
);
|
|
772
|
+
return row ?? null;
|
|
773
|
+
}
|
|
774
|
+
/** As findArchivedByEmail, for a phone number. */
|
|
775
|
+
async findArchivedByPhone(phone2) {
|
|
776
|
+
const [row] = await this.store.query(
|
|
777
|
+
`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NOT NULL`,
|
|
778
|
+
[phone2]
|
|
779
|
+
);
|
|
780
|
+
return row ?? null;
|
|
781
|
+
}
|
|
720
782
|
async findByPhone(phone2) {
|
|
721
783
|
const [row] = await this.store.query(
|
|
722
784
|
`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,
|
|
@@ -1571,7 +1633,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
1571
1633
|
// src/controllers/auth.controller.ts
|
|
1572
1634
|
import { randomBytes as randomBytes3, randomInt } from "crypto";
|
|
1573
1635
|
import { NOTIFICATION_EVENT as NOTIFICATION_EVENT2 } from "@fonderie/events";
|
|
1574
|
-
import { setApiResponse as
|
|
1636
|
+
import { setApiResponse as setApiResponse4, HTTP as HTTP4, background as background3, canonicalLocale, defineLocales } from "@fonderie/core";
|
|
1575
1637
|
|
|
1576
1638
|
// src/services/cooldown.ts
|
|
1577
1639
|
function checkCooldown(lastSentAt, cooldownMs) {
|
|
@@ -1582,37 +1644,26 @@ function checkCooldown(lastSentAt, cooldownMs) {
|
|
|
1582
1644
|
// src/controllers/auth.controller.ts
|
|
1583
1645
|
init_password();
|
|
1584
1646
|
|
|
1585
|
-
// src/services/
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
}
|
|
1590
|
-
const lower = email2.trim().toLowerCase();
|
|
1591
|
-
if (lower.length === 0) {
|
|
1592
|
-
throw new Error("Email cannot be empty");
|
|
1593
|
-
}
|
|
1594
|
-
const atIndex = lower.indexOf("@");
|
|
1595
|
-
if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
|
|
1596
|
-
throw new Error("Invalid email format");
|
|
1597
|
-
}
|
|
1598
|
-
const local = lower.substring(0, atIndex);
|
|
1599
|
-
const domain = lower.substring(atIndex + 1);
|
|
1600
|
-
if (!local || !domain) {
|
|
1601
|
-
throw new Error("Invalid email format");
|
|
1602
|
-
}
|
|
1603
|
-
const plusIndex = local.indexOf("+");
|
|
1604
|
-
const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
|
|
1605
|
-
if (!normalizedLocal) {
|
|
1606
|
-
throw new Error("Invalid email format");
|
|
1607
|
-
}
|
|
1608
|
-
return `${normalizedLocal}@${domain}`;
|
|
1647
|
+
// src/services/pending-deletion.ts
|
|
1648
|
+
import { setApiResponse as setApiResponse3, HTTP as HTTP3 } from "@fonderie/core";
|
|
1649
|
+
function deletionDate(deletedAt, config) {
|
|
1650
|
+
return new Date(deletedAt.getTime() + deletionGracePeriodDays(config) * 24 * 60 * 60 * 1e3);
|
|
1609
1651
|
}
|
|
1610
|
-
function
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1652
|
+
function pendingDeletionResponse(deletedAt, config) {
|
|
1653
|
+
const deleteOn = deletionDate(deletedAt, config);
|
|
1654
|
+
return setApiResponse3(
|
|
1655
|
+
HTTP3.FORBIDDEN,
|
|
1656
|
+
"ACCOUNT_PENDING_DELETION",
|
|
1657
|
+
`This account is scheduled for deletion on ${deleteOn.toISOString().slice(0, 10)}.`,
|
|
1658
|
+
{ requestedAt: deletedAt.toISOString(), deleteOn: deleteOn.toISOString() }
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
function archivedAddressResponse() {
|
|
1662
|
+
return setApiResponse3(
|
|
1663
|
+
HTTP3.CONFLICT,
|
|
1664
|
+
"ACCOUNT_PENDING_DELETION",
|
|
1665
|
+
"An account using this address is scheduled for deletion. Sign in to keep it, or wait until it is deleted."
|
|
1666
|
+
);
|
|
1616
1667
|
}
|
|
1617
1668
|
|
|
1618
1669
|
// src/models/password-reset.model.ts
|
|
@@ -1787,19 +1838,20 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1787
1838
|
if (typeof email2 === "string" && typeof password2 === "string") {
|
|
1788
1839
|
const normalizedEmail = normalizeEmailSafe(email2);
|
|
1789
1840
|
if (!normalizedEmail) {
|
|
1790
|
-
return
|
|
1841
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
|
|
1791
1842
|
}
|
|
1792
1843
|
if (password2.length < 8) {
|
|
1793
|
-
return
|
|
1794
|
-
|
|
1844
|
+
return setApiResponse4(
|
|
1845
|
+
HTTP4.UNPROCESSABLE,
|
|
1795
1846
|
"INVALID_PARAMETER",
|
|
1796
1847
|
"password must be at least 8 characters"
|
|
1797
1848
|
);
|
|
1798
1849
|
}
|
|
1799
1850
|
const existing = await users.findByEmail(normalizedEmail);
|
|
1800
1851
|
if (existing) {
|
|
1801
|
-
return
|
|
1852
|
+
return setApiResponse4(HTTP4.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
|
|
1802
1853
|
}
|
|
1854
|
+
if (await users.findArchivedByEmail(normalizedEmail)) return archivedAddressResponse();
|
|
1803
1855
|
const passwordHash = await hashPassword(password2);
|
|
1804
1856
|
const row = await users.create(
|
|
1805
1857
|
normalizedEmail,
|
|
@@ -1809,14 +1861,14 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1809
1861
|
locale
|
|
1810
1862
|
);
|
|
1811
1863
|
if (!row) {
|
|
1812
|
-
return
|
|
1864
|
+
return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
|
|
1813
1865
|
}
|
|
1814
1866
|
const pin = randomInt(1e5, 1e6).toString();
|
|
1815
1867
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
1816
1868
|
await emailVerif.create(row.id, pin, expiresAt);
|
|
1817
1869
|
const user = await users.findById(row.id);
|
|
1818
1870
|
if (!user) {
|
|
1819
|
-
return
|
|
1871
|
+
return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
|
|
1820
1872
|
}
|
|
1821
1873
|
const reqId = ctx.meta["requestId"];
|
|
1822
1874
|
const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
|
|
@@ -1874,8 +1926,9 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1874
1926
|
if (isValidPhone(phone2)) {
|
|
1875
1927
|
const existing = await users.findByPhone(normalizePhone(phone2));
|
|
1876
1928
|
if (existing) {
|
|
1877
|
-
return
|
|
1929
|
+
return setApiResponse4(HTTP4.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
|
|
1878
1930
|
}
|
|
1931
|
+
if (await users.findArchivedByPhone(normalizePhone(phone2))) return archivedAddressResponse();
|
|
1879
1932
|
const { id } = await users.findOrCreateByPhone(
|
|
1880
1933
|
normalizePhone(phone2),
|
|
1881
1934
|
firstName ?? null,
|
|
@@ -1887,7 +1940,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1887
1940
|
await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
|
|
1888
1941
|
const user = await users.findById(id);
|
|
1889
1942
|
if (!user) {
|
|
1890
|
-
return
|
|
1943
|
+
return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
|
|
1891
1944
|
}
|
|
1892
1945
|
const reqId2 = ctx.meta["requestId"];
|
|
1893
1946
|
const reqOpts2 = reqId2 !== void 0 ? { requestId: reqId2 } : void 0;
|
|
@@ -1925,8 +1978,8 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1925
1978
|
{ status: 202 }
|
|
1926
1979
|
);
|
|
1927
1980
|
}
|
|
1928
|
-
return
|
|
1929
|
-
|
|
1981
|
+
return setApiResponse4(
|
|
1982
|
+
HTTP4.UNPROCESSABLE,
|
|
1930
1983
|
"INVALID_PARAMETER",
|
|
1931
1984
|
"Provide email + password or a valid phone number"
|
|
1932
1985
|
);
|
|
@@ -1938,9 +1991,25 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1938
1991
|
const { email: rawEmail, password: password2 } = body;
|
|
1939
1992
|
const email2 = normalizeEmailSafe(rawEmail);
|
|
1940
1993
|
if (!email2) {
|
|
1941
|
-
return
|
|
1994
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
|
|
1942
1995
|
}
|
|
1943
1996
|
const user = await users.findByEmail(email2);
|
|
1997
|
+
if (!user) {
|
|
1998
|
+
const archived = await users.findArchivedByEmail(email2);
|
|
1999
|
+
if (archived?.passwordHash && archived.deletedAt) {
|
|
2000
|
+
const { valid: valid2 } = await verifyPasswordForLogin(password2, archived.passwordHash, config.legacyVerify);
|
|
2001
|
+
await loginEvents.recordSafe({
|
|
2002
|
+
userId: archived.id,
|
|
2003
|
+
emailAttempted: email2,
|
|
2004
|
+
method: "password",
|
|
2005
|
+
outcome: "failed",
|
|
2006
|
+
failureReason: valid2 ? "pending_deletion" : "bad_password",
|
|
2007
|
+
...meta
|
|
2008
|
+
});
|
|
2009
|
+
if (valid2) return pendingDeletionResponse(archived.deletedAt, config);
|
|
2010
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
1944
2013
|
if (!user || !user.passwordHash) {
|
|
1945
2014
|
await loginEvents.recordSafe({
|
|
1946
2015
|
userId: null,
|
|
@@ -1950,7 +2019,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1950
2019
|
failureReason: "unknown_email",
|
|
1951
2020
|
...meta
|
|
1952
2021
|
});
|
|
1953
|
-
return
|
|
2022
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1954
2023
|
}
|
|
1955
2024
|
const { valid, needsRehash } = await verifyPasswordForLogin(
|
|
1956
2025
|
password2,
|
|
@@ -1966,21 +2035,21 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
1966
2035
|
failureReason: "bad_password",
|
|
1967
2036
|
...meta
|
|
1968
2037
|
});
|
|
1969
|
-
return
|
|
2038
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1970
2039
|
}
|
|
1971
2040
|
if (needsRehash) {
|
|
1972
2041
|
await users.updatePassword(user.id, await hashPassword(password2));
|
|
1973
2042
|
}
|
|
1974
2043
|
if (user.suspended) {
|
|
1975
|
-
return
|
|
1976
|
-
|
|
2044
|
+
return setApiResponse4(
|
|
2045
|
+
HTTP4.FORBIDDEN,
|
|
1977
2046
|
"ACCOUNT_SUSPENDED",
|
|
1978
2047
|
"Account suspended. Please contact support."
|
|
1979
2048
|
);
|
|
1980
2049
|
}
|
|
1981
2050
|
if (user.mfaEnabled) {
|
|
1982
2051
|
const mfaToken = issueMfaPendingToken(user.id, config, "email");
|
|
1983
|
-
return
|
|
2052
|
+
return setApiResponse4(HTTP4.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
|
|
1984
2053
|
mfaToken
|
|
1985
2054
|
});
|
|
1986
2055
|
}
|
|
@@ -2017,11 +2086,11 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2017
2086
|
if (isValidPhone(phone2)) {
|
|
2018
2087
|
const user = await users.findByPhone(normalizePhone(phone2));
|
|
2019
2088
|
if (!user) {
|
|
2020
|
-
return
|
|
2089
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
2021
2090
|
}
|
|
2022
2091
|
if (user.suspended) {
|
|
2023
|
-
return
|
|
2024
|
-
|
|
2092
|
+
return setApiResponse4(
|
|
2093
|
+
HTTP4.FORBIDDEN,
|
|
2025
2094
|
"ACCOUNT_SUSPENDED",
|
|
2026
2095
|
"Account suspended. Please contact support."
|
|
2027
2096
|
);
|
|
@@ -2045,8 +2114,8 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2045
2114
|
{ status: 202 }
|
|
2046
2115
|
);
|
|
2047
2116
|
}
|
|
2048
|
-
return
|
|
2049
|
-
|
|
2117
|
+
return setApiResponse4(
|
|
2118
|
+
HTTP4.UNPROCESSABLE,
|
|
2050
2119
|
"INVALID_PARAMETER",
|
|
2051
2120
|
"Provide email + password or a valid phone number"
|
|
2052
2121
|
);
|
|
@@ -2071,35 +2140,35 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2071
2140
|
refresh: async (ctx) => {
|
|
2072
2141
|
const token = extractRefreshToken(ctx);
|
|
2073
2142
|
if (!token) {
|
|
2074
|
-
return
|
|
2143
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
|
|
2075
2144
|
}
|
|
2076
2145
|
const payload = verifyToken(token, config);
|
|
2077
2146
|
if (!payload || payload.type !== "refresh") {
|
|
2078
|
-
return
|
|
2147
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
|
|
2079
2148
|
}
|
|
2080
2149
|
const runtime = config.resolve?.(ctx);
|
|
2081
2150
|
let issued = null;
|
|
2082
2151
|
for (let attempt = 0; attempt < 3 && !issued; attempt++) {
|
|
2083
2152
|
const found = await sessions.match(token);
|
|
2084
2153
|
if (found.kind === "unknown") {
|
|
2085
|
-
return
|
|
2154
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
2086
2155
|
}
|
|
2087
2156
|
if (found.kind === "reused") {
|
|
2088
2157
|
await sessions.revokeById(found.row.id);
|
|
2089
2158
|
await background3(
|
|
2090
2159
|
bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
|
|
2091
2160
|
);
|
|
2092
|
-
return
|
|
2161
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
2093
2162
|
}
|
|
2094
2163
|
const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
|
|
2095
2164
|
const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
|
|
2096
2165
|
if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
|
|
2097
2166
|
await sessions.revokeById(found.row.id);
|
|
2098
|
-
return
|
|
2167
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
|
|
2099
2168
|
}
|
|
2100
2169
|
const user = await users.findById(found.row.userId);
|
|
2101
2170
|
if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
|
|
2102
|
-
return
|
|
2171
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
|
|
2103
2172
|
}
|
|
2104
2173
|
const pair = issueTokenPair(user.id, resolvedRefresh, {
|
|
2105
2174
|
loginMethod: payload.loginMethod ?? "email",
|
|
@@ -2112,7 +2181,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2112
2181
|
if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
|
|
2113
2182
|
}
|
|
2114
2183
|
if (!issued) {
|
|
2115
|
-
return
|
|
2184
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
2116
2185
|
}
|
|
2117
2186
|
const { accessToken, refreshToken } = issued;
|
|
2118
2187
|
return Response.json(
|
|
@@ -2131,16 +2200,16 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2131
2200
|
const body = ctx.meta["body"];
|
|
2132
2201
|
const rawEmail = body?.["email"];
|
|
2133
2202
|
if (typeof rawEmail !== "string") {
|
|
2134
|
-
return
|
|
2203
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
|
|
2135
2204
|
}
|
|
2136
2205
|
const email2 = normalizeEmailSafe(rawEmail);
|
|
2137
2206
|
if (!email2) {
|
|
2138
|
-
return
|
|
2207
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
|
|
2139
2208
|
}
|
|
2140
2209
|
const user = await users.findByEmail(email2);
|
|
2141
2210
|
if (!user) {
|
|
2142
|
-
return
|
|
2143
|
-
|
|
2211
|
+
return setApiResponse4(
|
|
2212
|
+
HTTP4.OK,
|
|
2144
2213
|
"PASSWORD_RESET_EMAIL_SENT",
|
|
2145
2214
|
"Password reset email sent (if account exists)."
|
|
2146
2215
|
);
|
|
@@ -2149,8 +2218,8 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2149
2218
|
const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
|
|
2150
2219
|
const remaining = checkCooldown(await passwordReset.findLastSentAt(user.id), cooldown);
|
|
2151
2220
|
if (remaining > 0) {
|
|
2152
|
-
return
|
|
2153
|
-
|
|
2221
|
+
return setApiResponse4(
|
|
2222
|
+
HTTP4.OK,
|
|
2154
2223
|
"PASSWORD_RESET_EMAIL_SENT",
|
|
2155
2224
|
"Password reset email sent (if account exists)."
|
|
2156
2225
|
);
|
|
@@ -2167,8 +2236,8 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2167
2236
|
recipient: { email: email2, phone: null, deviceToken: null },
|
|
2168
2237
|
data: { pin, token, resetUrl }
|
|
2169
2238
|
}));
|
|
2170
|
-
return
|
|
2171
|
-
|
|
2239
|
+
return setApiResponse4(
|
|
2240
|
+
HTTP4.OK,
|
|
2172
2241
|
"PASSWORD_RESET_EMAIL_SENT",
|
|
2173
2242
|
"Password reset email sent (if account exists)."
|
|
2174
2243
|
);
|
|
@@ -2179,11 +2248,11 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2179
2248
|
const password2 = body?.["password"];
|
|
2180
2249
|
const rawToken = body?.["token"];
|
|
2181
2250
|
if (typeof password2 !== "string") {
|
|
2182
|
-
return
|
|
2251
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
|
|
2183
2252
|
}
|
|
2184
2253
|
if (password2.length < 8) {
|
|
2185
|
-
return
|
|
2186
|
-
|
|
2254
|
+
return setApiResponse4(
|
|
2255
|
+
HTTP4.UNPROCESSABLE,
|
|
2187
2256
|
"INVALID_PARAMETER",
|
|
2188
2257
|
"password must be at least 8 characters"
|
|
2189
2258
|
);
|
|
@@ -2193,8 +2262,8 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2193
2262
|
row = await passwordReset.findByToken(rawToken.trim());
|
|
2194
2263
|
} else {
|
|
2195
2264
|
if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
|
|
2196
|
-
return
|
|
2197
|
-
|
|
2265
|
+
return setApiResponse4(
|
|
2266
|
+
HTTP4.UNPROCESSABLE,
|
|
2198
2267
|
"INVALID_PARAMETER",
|
|
2199
2268
|
"a 6-digit pin or a reset token is required"
|
|
2200
2269
|
);
|
|
@@ -2202,33 +2271,41 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2202
2271
|
row = await passwordReset.findByPin(raw.trim());
|
|
2203
2272
|
}
|
|
2204
2273
|
if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
|
|
2205
|
-
return
|
|
2206
|
-
|
|
2274
|
+
return setApiResponse4(
|
|
2275
|
+
HTTP4.BAD_REQUEST,
|
|
2207
2276
|
"PASSWORD_RESET_FAILED",
|
|
2208
2277
|
"Invalid or expired reset credentials"
|
|
2209
2278
|
);
|
|
2210
2279
|
}
|
|
2211
2280
|
const passwordHash = await hashPassword(password2);
|
|
2212
|
-
await store.
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2281
|
+
const [changed] = await store.query(
|
|
2282
|
+
`WITH changed AS (
|
|
2283
|
+
UPDATE fonderie_users SET password_hash = $1, updated_at = now()
|
|
2284
|
+
WHERE id = $2 AND deleted_at IS NULL
|
|
2285
|
+
RETURNING id
|
|
2286
|
+
), spent AS (
|
|
2287
|
+
DELETE FROM fonderie_password_resets WHERE user_id = $2
|
|
2288
|
+
), revoked AS (
|
|
2289
|
+
DELETE FROM fonderie_sessions WHERE user_id IN (SELECT id FROM changed)
|
|
2290
|
+
)
|
|
2291
|
+
SELECT id FROM changed`,
|
|
2292
|
+
[passwordHash, row.userId]
|
|
2293
|
+
);
|
|
2294
|
+
if (!changed) {
|
|
2295
|
+
return setApiResponse4(
|
|
2296
|
+
HTTP4.BAD_REQUEST,
|
|
2297
|
+
"PASSWORD_RESET_FAILED",
|
|
2298
|
+
"Invalid or expired reset credentials"
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
return setApiResponse4(HTTP4.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
|
|
2225
2302
|
},
|
|
2226
2303
|
verify: async (ctx) => {
|
|
2227
2304
|
if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
|
|
2228
|
-
return
|
|
2305
|
+
return setApiResponse4(HTTP4.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
|
|
2229
2306
|
}
|
|
2230
2307
|
if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
|
|
2231
|
-
return
|
|
2308
|
+
return setApiResponse4(HTTP4.OK, "VERIFIED", "Email verified successfully.", {
|
|
2232
2309
|
verified: true,
|
|
2233
2310
|
email: ctx.user.email
|
|
2234
2311
|
});
|
|
@@ -2236,8 +2313,8 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2236
2313
|
const body = ctx.meta["body"];
|
|
2237
2314
|
const raw = body?.["token"];
|
|
2238
2315
|
if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
|
|
2239
|
-
return
|
|
2240
|
-
|
|
2316
|
+
return setApiResponse4(
|
|
2317
|
+
HTTP4.UNPROCESSABLE,
|
|
2241
2318
|
"INVALID_PARAMETER",
|
|
2242
2319
|
"token must be a 6-digit code"
|
|
2243
2320
|
);
|
|
@@ -2255,7 +2332,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2255
2332
|
failureReason: "invalid_pin",
|
|
2256
2333
|
...phoneMeta
|
|
2257
2334
|
});
|
|
2258
|
-
return
|
|
2335
|
+
return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
|
|
2259
2336
|
}
|
|
2260
2337
|
if (/* @__PURE__ */ new Date() > record.expiresAt) {
|
|
2261
2338
|
await phoneVerif.deleteByUser(ctx.user.id);
|
|
@@ -2267,8 +2344,8 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2267
2344
|
failureReason: "expired_pin",
|
|
2268
2345
|
...phoneMeta
|
|
2269
2346
|
});
|
|
2270
|
-
return
|
|
2271
|
-
|
|
2347
|
+
return setApiResponse4(
|
|
2348
|
+
HTTP4.BAD_REQUEST,
|
|
2272
2349
|
"VERIFICATION_FAILED",
|
|
2273
2350
|
"Verification code expired"
|
|
2274
2351
|
);
|
|
@@ -2288,11 +2365,11 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2288
2365
|
});
|
|
2289
2366
|
const verifiedUser = await users.findById(ctx.user.id);
|
|
2290
2367
|
if (!verifiedUser) {
|
|
2291
|
-
return
|
|
2368
|
+
return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
2292
2369
|
}
|
|
2293
2370
|
if (verifiedUser.suspended) {
|
|
2294
|
-
return
|
|
2295
|
-
|
|
2371
|
+
return setApiResponse4(
|
|
2372
|
+
HTTP4.FORBIDDEN,
|
|
2296
2373
|
"ACCOUNT_SUSPENDED",
|
|
2297
2374
|
"Account suspended. Please contact support."
|
|
2298
2375
|
);
|
|
@@ -2314,10 +2391,10 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2314
2391
|
}
|
|
2315
2392
|
const row = await emailVerif.findByUser(ctx.user.id, pin);
|
|
2316
2393
|
if (!row) {
|
|
2317
|
-
return
|
|
2394
|
+
return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
|
|
2318
2395
|
}
|
|
2319
2396
|
if (/* @__PURE__ */ new Date() > row.expiresAt) {
|
|
2320
|
-
return
|
|
2397
|
+
return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
|
|
2321
2398
|
}
|
|
2322
2399
|
await store.transaction(async (tx) => {
|
|
2323
2400
|
await Promise.all([
|
|
@@ -2331,30 +2408,30 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2331
2408
|
])
|
|
2332
2409
|
]);
|
|
2333
2410
|
});
|
|
2334
|
-
return
|
|
2411
|
+
return setApiResponse4(HTTP4.OK, "VERIFIED", "Email verified successfully.", {
|
|
2335
2412
|
verified: true,
|
|
2336
2413
|
email: ctx.user.email
|
|
2337
2414
|
});
|
|
2338
2415
|
},
|
|
2339
2416
|
sendVerification: async (ctx) => {
|
|
2340
2417
|
if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
|
|
2341
|
-
return
|
|
2418
|
+
return setApiResponse4(HTTP4.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
|
|
2342
2419
|
}
|
|
2343
2420
|
const resolved = { ...config, ...config.resolve?.(ctx) };
|
|
2344
2421
|
const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
|
|
2345
2422
|
if (ctx.user.loginMethod === "phone") {
|
|
2346
2423
|
const phone2 = ctx.user.phone;
|
|
2347
2424
|
if (!phone2) {
|
|
2348
|
-
return
|
|
2349
|
-
|
|
2425
|
+
return setApiResponse4(
|
|
2426
|
+
HTTP4.BAD_REQUEST,
|
|
2350
2427
|
"NO_PHONE_ON_ACCOUNT",
|
|
2351
2428
|
"No phone number associated with this account"
|
|
2352
2429
|
);
|
|
2353
2430
|
}
|
|
2354
2431
|
const remaining2 = checkCooldown(await phoneVerif.findLastSentAt(ctx.user.id), cooldown);
|
|
2355
2432
|
if (remaining2 > 0) {
|
|
2356
|
-
return
|
|
2357
|
-
|
|
2433
|
+
return setApiResponse4(
|
|
2434
|
+
HTTP4.TOO_MANY_REQUESTS,
|
|
2358
2435
|
"VERIFICATION_COOLDOWN",
|
|
2359
2436
|
"Please wait before requesting a new code.",
|
|
2360
2437
|
{
|
|
@@ -2371,29 +2448,29 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2371
2448
|
data: { otp },
|
|
2372
2449
|
recipient: { email: null, phone: phone2, deviceToken: null }
|
|
2373
2450
|
}));
|
|
2374
|
-
return
|
|
2375
|
-
|
|
2451
|
+
return setApiResponse4(
|
|
2452
|
+
HTTP4.OK,
|
|
2376
2453
|
"VERIFICATION_SENT",
|
|
2377
2454
|
"A verification code has been sent to your phone."
|
|
2378
2455
|
);
|
|
2379
2456
|
}
|
|
2380
2457
|
if (!ctx.user.email) {
|
|
2381
|
-
return
|
|
2382
|
-
|
|
2458
|
+
return setApiResponse4(
|
|
2459
|
+
HTTP4.BAD_REQUEST,
|
|
2383
2460
|
"NO_EMAIL_ON_ACCOUNT",
|
|
2384
2461
|
"No email address associated with this account"
|
|
2385
2462
|
);
|
|
2386
2463
|
}
|
|
2387
2464
|
if (ctx.user.emailVerifiedAt) {
|
|
2388
|
-
return
|
|
2465
|
+
return setApiResponse4(HTTP4.OK, "EMAIL_VERIFIED", "Email already verified.", {
|
|
2389
2466
|
verified: true,
|
|
2390
2467
|
email: ctx.user.email
|
|
2391
2468
|
});
|
|
2392
2469
|
}
|
|
2393
2470
|
const remaining = checkCooldown(await emailVerif.findLastSentAt(ctx.user.id), cooldown);
|
|
2394
2471
|
if (remaining > 0) {
|
|
2395
|
-
return
|
|
2396
|
-
|
|
2472
|
+
return setApiResponse4(
|
|
2473
|
+
HTTP4.TOO_MANY_REQUESTS,
|
|
2397
2474
|
"VERIFICATION_COOLDOWN",
|
|
2398
2475
|
"Please wait before requesting a new code.",
|
|
2399
2476
|
{
|
|
@@ -2410,7 +2487,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2410
2487
|
recipient: { email: ctx.user.email, phone: null, deviceToken: null },
|
|
2411
2488
|
data: { pin }
|
|
2412
2489
|
}));
|
|
2413
|
-
return
|
|
2490
|
+
return setApiResponse4(HTTP4.OK, "VERIFICATION_SENT", "Verification email sent.", {
|
|
2414
2491
|
email: ctx.user.email
|
|
2415
2492
|
});
|
|
2416
2493
|
}
|
|
@@ -2419,7 +2496,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2419
2496
|
|
|
2420
2497
|
// src/controllers/user.controller.ts
|
|
2421
2498
|
import { randomInt as randomInt2 } from "crypto";
|
|
2422
|
-
import { setApiResponse as
|
|
2499
|
+
import { setApiResponse as setApiResponse5, HTTP as HTTP5, dateOrEmpty as dateOrEmpty2, background as background4 } from "@fonderie/core";
|
|
2423
2500
|
import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
|
|
2424
2501
|
|
|
2425
2502
|
// src/dtos/login-activity.ts
|
|
@@ -2474,9 +2551,9 @@ function userController(store, config, bus) {
|
|
|
2474
2551
|
me: async (ctx) => {
|
|
2475
2552
|
const user = await users.findById(ctx.user.id);
|
|
2476
2553
|
if (!user) {
|
|
2477
|
-
return
|
|
2554
|
+
return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
2478
2555
|
}
|
|
2479
|
-
return
|
|
2556
|
+
return setApiResponse5(HTTP5.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
|
|
2480
2557
|
user: toUserDTO(user, ctx.user.phoneVerified)
|
|
2481
2558
|
});
|
|
2482
2559
|
},
|
|
@@ -2487,14 +2564,14 @@ function userController(store, config, bus) {
|
|
|
2487
2564
|
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
|
|
2488
2565
|
const outcomeParam = params.get("outcome");
|
|
2489
2566
|
if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
|
|
2490
|
-
return
|
|
2567
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
|
|
2491
2568
|
}
|
|
2492
2569
|
const cursorParam = params.get("cursor");
|
|
2493
2570
|
let cursor = null;
|
|
2494
2571
|
if (cursorParam) {
|
|
2495
2572
|
cursor = decodeLoginCursor(cursorParam);
|
|
2496
2573
|
if (!cursor) {
|
|
2497
|
-
return
|
|
2574
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
|
|
2498
2575
|
}
|
|
2499
2576
|
}
|
|
2500
2577
|
const query = { userId: ctx.user.id, limit };
|
|
@@ -2505,8 +2582,8 @@ function userController(store, config, bus) {
|
|
|
2505
2582
|
if (from) query.from = new Date(from);
|
|
2506
2583
|
if (to) query.to = new Date(to);
|
|
2507
2584
|
const page = await loginEvents.listByUser(query);
|
|
2508
|
-
return
|
|
2509
|
-
|
|
2585
|
+
return setApiResponse5(
|
|
2586
|
+
HTTP5.OK,
|
|
2510
2587
|
"LOGIN_HISTORY_FETCHED",
|
|
2511
2588
|
"Login history retrieved.",
|
|
2512
2589
|
toLoginHistoryPageDTO(page)
|
|
@@ -2516,27 +2593,27 @@ function userController(store, config, bus) {
|
|
|
2516
2593
|
listSessions: async (ctx) => {
|
|
2517
2594
|
const rows = await sessions.listLiveByUser(ctx.user.id);
|
|
2518
2595
|
const currentSid = ctx.user.sid ?? null;
|
|
2519
|
-
return
|
|
2596
|
+
return setApiResponse5(HTTP5.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
|
|
2520
2597
|
sessions: rows.map((r) => toSessionDTO(r, currentSid))
|
|
2521
2598
|
});
|
|
2522
2599
|
},
|
|
2523
2600
|
terminateSession: async (ctx) => {
|
|
2524
2601
|
const id = ctx.meta["params"]?.["id"];
|
|
2525
2602
|
if (!id) {
|
|
2526
|
-
return
|
|
2603
|
+
return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2527
2604
|
}
|
|
2528
2605
|
const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
|
|
2529
2606
|
if (removed === void 0) {
|
|
2530
|
-
return
|
|
2607
|
+
return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2531
2608
|
}
|
|
2532
2609
|
await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
|
|
2533
|
-
return
|
|
2610
|
+
return setApiResponse5(HTTP5.OK, "SESSION_TERMINATED", "Session terminated.", { id });
|
|
2534
2611
|
},
|
|
2535
2612
|
terminateOtherSessions: async (ctx) => {
|
|
2536
2613
|
const currentSid = ctx.user.sid ?? null;
|
|
2537
2614
|
if (!currentSid) {
|
|
2538
|
-
return
|
|
2539
|
-
|
|
2615
|
+
return setApiResponse5(
|
|
2616
|
+
HTTP5.UNPROCESSABLE,
|
|
2540
2617
|
"NO_CURRENT_SESSION",
|
|
2541
2618
|
"Cannot terminate others without a current session context"
|
|
2542
2619
|
);
|
|
@@ -2544,7 +2621,7 @@ function userController(store, config, bus) {
|
|
|
2544
2621
|
const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
|
|
2545
2622
|
const count = sids.length;
|
|
2546
2623
|
if (count) await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
|
|
2547
|
-
return
|
|
2624
|
+
return setApiResponse5(HTTP5.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
|
|
2548
2625
|
},
|
|
2549
2626
|
updateProfile: async (ctx) => {
|
|
2550
2627
|
const body = ctx.meta["body"];
|
|
@@ -2554,18 +2631,18 @@ function userController(store, config, bus) {
|
|
|
2554
2631
|
if (body?.[key] !== void 0) fields[key] = body[key];
|
|
2555
2632
|
}
|
|
2556
2633
|
if (Object.keys(fields).length === 0) {
|
|
2557
|
-
return
|
|
2558
|
-
|
|
2634
|
+
return setApiResponse5(
|
|
2635
|
+
HTTP5.UNPROCESSABLE,
|
|
2559
2636
|
"INVALID_PARAMETER",
|
|
2560
2637
|
"Provide at least one of: firstName, lastName, avatarUrl"
|
|
2561
2638
|
);
|
|
2562
2639
|
}
|
|
2563
2640
|
const row = await users.update(ctx.user.id, fields);
|
|
2564
2641
|
if (!row) {
|
|
2565
|
-
return
|
|
2642
|
+
return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
2566
2643
|
}
|
|
2567
2644
|
const updated = await users.findById(ctx.user.id);
|
|
2568
|
-
return
|
|
2645
|
+
return setApiResponse5(HTTP5.OK, "PROFILE_UPDATED", "Profile updated.", {
|
|
2569
2646
|
user: toUserDTO(updated, ctx.user.phoneVerified)
|
|
2570
2647
|
});
|
|
2571
2648
|
},
|
|
@@ -2582,14 +2659,14 @@ function userController(store, config, bus) {
|
|
|
2582
2659
|
if (Object.keys(patch).length > 0) fields.patch = patch;
|
|
2583
2660
|
const row = await users.updatePreferences(ctx.user.id, fields);
|
|
2584
2661
|
if (!row) {
|
|
2585
|
-
return
|
|
2586
|
-
|
|
2662
|
+
return setApiResponse5(
|
|
2663
|
+
HTTP5.UNPROCESSABLE,
|
|
2587
2664
|
"INVALID_PARAMETER",
|
|
2588
2665
|
"Provide at least one preference field"
|
|
2589
2666
|
);
|
|
2590
2667
|
}
|
|
2591
2668
|
const updated = await users.findById(ctx.user.id);
|
|
2592
|
-
return
|
|
2669
|
+
return setApiResponse5(HTTP5.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
|
|
2593
2670
|
user: toUserDTO(updated, ctx.user.phoneVerified)
|
|
2594
2671
|
});
|
|
2595
2672
|
},
|
|
@@ -2598,23 +2675,23 @@ function userController(store, config, bus) {
|
|
|
2598
2675
|
const newEmail = body?.["email"];
|
|
2599
2676
|
const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
|
|
2600
2677
|
if (!normalised) {
|
|
2601
|
-
return
|
|
2602
|
-
|
|
2678
|
+
return setApiResponse5(
|
|
2679
|
+
HTTP5.UNPROCESSABLE,
|
|
2603
2680
|
"INVALID_PARAMETER",
|
|
2604
2681
|
"A valid email address is required"
|
|
2605
2682
|
);
|
|
2606
2683
|
}
|
|
2607
2684
|
const oldEmail = ctx.user.email;
|
|
2608
2685
|
if (normalised === oldEmail) {
|
|
2609
|
-
return
|
|
2610
|
-
|
|
2686
|
+
return setApiResponse5(
|
|
2687
|
+
HTTP5.UNPROCESSABLE,
|
|
2611
2688
|
"INVALID_PARAMETER",
|
|
2612
2689
|
"New email must differ from current email"
|
|
2613
2690
|
);
|
|
2614
2691
|
}
|
|
2615
2692
|
const existing = await users.findByEmail(normalised);
|
|
2616
2693
|
if (existing) {
|
|
2617
|
-
return
|
|
2694
|
+
return setApiResponse5(HTTP5.CONFLICT, "EMAIL_IN_USE", "Email already in use");
|
|
2618
2695
|
}
|
|
2619
2696
|
const pin = randomInt2(1e5, 1e6).toString();
|
|
2620
2697
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
@@ -2634,8 +2711,8 @@ function userController(store, config, bus) {
|
|
|
2634
2711
|
recipient: { email: oldEmail, phone: null, deviceToken: null }
|
|
2635
2712
|
}));
|
|
2636
2713
|
}
|
|
2637
|
-
return
|
|
2638
|
-
|
|
2714
|
+
return setApiResponse5(
|
|
2715
|
+
HTTP5.OK,
|
|
2639
2716
|
"EMAIL_UPDATED",
|
|
2640
2717
|
"Email updated. A verification code has been sent to your new address.",
|
|
2641
2718
|
{
|
|
@@ -2647,8 +2724,8 @@ function userController(store, config, bus) {
|
|
|
2647
2724
|
const body = ctx.meta["body"];
|
|
2648
2725
|
const newPhone = body?.["phone"];
|
|
2649
2726
|
if (!isValidPhone2(newPhone)) {
|
|
2650
|
-
return
|
|
2651
|
-
|
|
2727
|
+
return setApiResponse5(
|
|
2728
|
+
HTTP5.UNPROCESSABLE,
|
|
2652
2729
|
"INVALID_PARAMETER",
|
|
2653
2730
|
"A valid phone number is required"
|
|
2654
2731
|
);
|
|
@@ -2656,7 +2733,7 @@ function userController(store, config, bus) {
|
|
|
2656
2733
|
const normalised = normalizePhone2(newPhone);
|
|
2657
2734
|
const existing = await users.findByPhone(normalised);
|
|
2658
2735
|
if (existing) {
|
|
2659
|
-
return
|
|
2736
|
+
return setApiResponse5(HTTP5.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
|
|
2660
2737
|
}
|
|
2661
2738
|
const otp = randomInt2(1e5, 1e6).toString();
|
|
2662
2739
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
@@ -2676,8 +2753,8 @@ function userController(store, config, bus) {
|
|
|
2676
2753
|
recipient: { email: ctx.user.email, phone: null, deviceToken: null }
|
|
2677
2754
|
}));
|
|
2678
2755
|
}
|
|
2679
|
-
return
|
|
2680
|
-
|
|
2756
|
+
return setApiResponse5(
|
|
2757
|
+
HTTP5.OK,
|
|
2681
2758
|
"PHONE_UPDATED",
|
|
2682
2759
|
"Phone number updated. A verification code has been sent to your new number.",
|
|
2683
2760
|
{
|
|
@@ -2690,23 +2767,23 @@ function userController(store, config, bus) {
|
|
|
2690
2767
|
const currentPassword = body?.["currentPassword"];
|
|
2691
2768
|
const newPassword = body?.["newPassword"];
|
|
2692
2769
|
if (typeof currentPassword !== "string" || !currentPassword) {
|
|
2693
|
-
return
|
|
2770
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
|
|
2694
2771
|
}
|
|
2695
2772
|
if (typeof newPassword !== "string" || newPassword.length < 8) {
|
|
2696
|
-
return
|
|
2773
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
|
|
2697
2774
|
}
|
|
2698
2775
|
const user = await users.findById(ctx.user.id);
|
|
2699
|
-
if (!user) return
|
|
2776
|
+
if (!user) return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
2700
2777
|
const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
|
|
2701
2778
|
const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
|
|
2702
2779
|
if (!valid) {
|
|
2703
|
-
return
|
|
2780
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
|
|
2704
2781
|
}
|
|
2705
2782
|
const hash = await hashPassword2(newPassword);
|
|
2706
2783
|
await users.updatePassword(ctx.user.id, hash);
|
|
2707
2784
|
await sessions.deleteByUser(ctx.user.id);
|
|
2708
2785
|
await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
|
|
2709
|
-
return
|
|
2786
|
+
return setApiResponse5(HTTP5.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
2710
2787
|
},
|
|
2711
2788
|
/**
|
|
2712
2789
|
* Remove an OAuth provider from the account.
|
|
@@ -2725,19 +2802,19 @@ function userController(store, config, bus) {
|
|
|
2725
2802
|
const provider = String(ctx.meta["params"]?.["provider"] ?? "");
|
|
2726
2803
|
const current = await users.findById(ctx.user.id);
|
|
2727
2804
|
if (!current?.provider) {
|
|
2728
|
-
return
|
|
2805
|
+
return setApiResponse5(HTTP5.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
|
|
2729
2806
|
}
|
|
2730
2807
|
if (provider && provider.toLowerCase() !== current.provider.toLowerCase()) {
|
|
2731
|
-
return
|
|
2732
|
-
|
|
2808
|
+
return setApiResponse5(
|
|
2809
|
+
HTTP5.NOT_FOUND,
|
|
2733
2810
|
"NOT_LINKED",
|
|
2734
2811
|
`This account is not linked to ${provider}.`
|
|
2735
2812
|
);
|
|
2736
2813
|
}
|
|
2737
2814
|
const removed = await users.clearProvider(ctx.user.id);
|
|
2738
2815
|
if (!removed) {
|
|
2739
|
-
return
|
|
2740
|
-
|
|
2816
|
+
return setApiResponse5(
|
|
2817
|
+
HTTP5.CONFLICT,
|
|
2741
2818
|
"PASSWORD_REQUIRED",
|
|
2742
2819
|
"Set a password before disconnecting this provider \u2014 it is currently the only way to sign in."
|
|
2743
2820
|
);
|
|
@@ -2750,12 +2827,18 @@ function userController(store, config, bus) {
|
|
|
2750
2827
|
recipient: { email: ctx.user.email, phone: null, deviceToken: null }
|
|
2751
2828
|
}));
|
|
2752
2829
|
}
|
|
2753
|
-
return
|
|
2830
|
+
return setApiResponse5(HTTP5.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
|
|
2754
2831
|
},
|
|
2755
2832
|
deleteMe: async (ctx) => {
|
|
2756
2833
|
const userId = ctx.user.id;
|
|
2757
2834
|
await users.softDelete(userId);
|
|
2758
2835
|
await sessions.deleteByUser(userId);
|
|
2836
|
+
await store.query(
|
|
2837
|
+
`WITH resets AS (DELETE FROM fonderie_password_resets WHERE user_id = $1),
|
|
2838
|
+
emails AS (DELETE FROM fonderie_email_verifications WHERE user_id = $1)
|
|
2839
|
+
DELETE FROM fonderie_phone_verifications WHERE user_id = $1`,
|
|
2840
|
+
[userId]
|
|
2841
|
+
);
|
|
2759
2842
|
const reqId = ctx.meta["requestId"];
|
|
2760
2843
|
await background4(bus?.emit(
|
|
2761
2844
|
EVENT_KEYS.userDeleted,
|
|
@@ -2776,7 +2859,7 @@ function userController(store, config, bus) {
|
|
|
2776
2859
|
// no MFA secret, no session tokens (session metadata only).
|
|
2777
2860
|
exportMe: async (ctx) => {
|
|
2778
2861
|
const user = await users.findById(ctx.user.id);
|
|
2779
|
-
if (!user) return
|
|
2862
|
+
if (!user) return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
2780
2863
|
const sessionRows = await sessions.listByUser(ctx.user.id);
|
|
2781
2864
|
const bundle = {
|
|
2782
2865
|
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -2805,7 +2888,7 @@ function userController(store, config, bus) {
|
|
|
2805
2888
|
}
|
|
2806
2889
|
bundle["modules"] = modules;
|
|
2807
2890
|
}
|
|
2808
|
-
return
|
|
2891
|
+
return setApiResponse5(HTTP5.OK, "DATA_EXPORT", "Your account data.", bundle);
|
|
2809
2892
|
}
|
|
2810
2893
|
};
|
|
2811
2894
|
}
|
|
@@ -2813,7 +2896,7 @@ function userController(store, config, bus) {
|
|
|
2813
2896
|
// src/controllers/oauth.controller.ts
|
|
2814
2897
|
import { randomBytes as randomBytes4, createHash as createHash4 } from "crypto";
|
|
2815
2898
|
import jwt2 from "jsonwebtoken";
|
|
2816
|
-
import { setApiResponse as
|
|
2899
|
+
import { setApiResponse as setApiResponse6, HTTP as HTTP6, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
|
|
2817
2900
|
import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
|
|
2818
2901
|
|
|
2819
2902
|
// src/services/jwks.ts
|
|
@@ -3023,14 +3106,29 @@ function oauthController(store, config, bus) {
|
|
|
3023
3106
|
const method = provider === "apple" ? "oauth-apple" : "oauth-google";
|
|
3024
3107
|
const meta = requestMeta(ctx);
|
|
3025
3108
|
if (!claims.email) {
|
|
3026
|
-
return
|
|
3109
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
|
|
3027
3110
|
}
|
|
3028
3111
|
if (!claims.emailVerified) {
|
|
3029
|
-
return
|
|
3112
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
|
|
3030
3113
|
}
|
|
3031
3114
|
const normalizedEmail = normalizeEmailSafe(claims.email);
|
|
3032
3115
|
if (!normalizedEmail) {
|
|
3033
|
-
return
|
|
3116
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
|
|
3117
|
+
}
|
|
3118
|
+
const archived = await users.findArchivedByEmail(normalizedEmail);
|
|
3119
|
+
if (archived?.deletedAt) {
|
|
3120
|
+
await loginEvents.recordSafe({
|
|
3121
|
+
userId: archived.id,
|
|
3122
|
+
emailAttempted: normalizedEmail,
|
|
3123
|
+
method,
|
|
3124
|
+
outcome: "failed",
|
|
3125
|
+
failureReason: "pending_deletion",
|
|
3126
|
+
...meta
|
|
3127
|
+
});
|
|
3128
|
+
const res = pendingDeletionResponse(archived.deletedAt, config);
|
|
3129
|
+
const headers = cookieHeaders(clearCookies);
|
|
3130
|
+
headers.set("content-type", res.headers.get("content-type") ?? "application/json");
|
|
3131
|
+
return new Response(res.body, { status: res.status, headers });
|
|
3034
3132
|
}
|
|
3035
3133
|
const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
|
|
3036
3134
|
if (!upserted) {
|
|
@@ -3042,15 +3140,15 @@ function oauthController(store, config, bus) {
|
|
|
3042
3140
|
failureReason: "provider_upsert_failed",
|
|
3043
3141
|
...meta
|
|
3044
3142
|
});
|
|
3045
|
-
return
|
|
3143
|
+
return setApiResponse6(HTTP6.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
|
|
3046
3144
|
}
|
|
3047
3145
|
const fullUser = await users.findById(upserted.id);
|
|
3048
3146
|
if (!fullUser) {
|
|
3049
|
-
return
|
|
3147
|
+
return setApiResponse6(HTTP6.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
|
|
3050
3148
|
}
|
|
3051
3149
|
await announceOAuthUpsert(ctx, upserted, provider, fullUser);
|
|
3052
3150
|
if (fullUser.suspended) {
|
|
3053
|
-
return
|
|
3151
|
+
return setApiResponse6(HTTP6.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
|
|
3054
3152
|
}
|
|
3055
3153
|
if (fullUser.mfaEnabled) {
|
|
3056
3154
|
const mfaToken = issueMfaPendingToken(upserted.id, config, provider);
|
|
@@ -3093,8 +3191,8 @@ function oauthController(store, config, bus) {
|
|
|
3093
3191
|
googleInit: async (_ctx) => {
|
|
3094
3192
|
const google = config.google;
|
|
3095
3193
|
if (!google || !google.clientSecret || !google.redirectUri) {
|
|
3096
|
-
return
|
|
3097
|
-
|
|
3194
|
+
return setApiResponse6(
|
|
3195
|
+
HTTP6.NOT_IMPLEMENTED,
|
|
3098
3196
|
"NOT_CONFIGURED",
|
|
3099
3197
|
"Google OAuth not configured"
|
|
3100
3198
|
);
|
|
@@ -3128,8 +3226,8 @@ function oauthController(store, config, bus) {
|
|
|
3128
3226
|
googleCallback: async (ctx) => {
|
|
3129
3227
|
const google = config.google;
|
|
3130
3228
|
if (!google || !google.clientSecret || !google.redirectUri) {
|
|
3131
|
-
return
|
|
3132
|
-
|
|
3229
|
+
return setApiResponse6(
|
|
3230
|
+
HTTP6.NOT_IMPLEMENTED,
|
|
3133
3231
|
"NOT_CONFIGURED",
|
|
3134
3232
|
"Google OAuth not configured"
|
|
3135
3233
|
);
|
|
@@ -3137,14 +3235,14 @@ function oauthController(store, config, bus) {
|
|
|
3137
3235
|
const url = new URL(ctx.request.url);
|
|
3138
3236
|
const code = url.searchParams.get("code");
|
|
3139
3237
|
if (!code) {
|
|
3140
|
-
return
|
|
3238
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
3141
3239
|
}
|
|
3142
3240
|
const returnedState = url.searchParams.get("state") ?? "";
|
|
3143
3241
|
const cookieHeader = ctx.request.headers.get("cookie") ?? "";
|
|
3144
3242
|
const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
|
|
3145
3243
|
if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
|
|
3146
|
-
return
|
|
3147
|
-
|
|
3244
|
+
return setApiResponse6(
|
|
3245
|
+
HTTP6.BAD_REQUEST,
|
|
3148
3246
|
"GOOGLE_AUTH_FAILED",
|
|
3149
3247
|
"OAuth state mismatch \u2014 restart the sign-in flow"
|
|
3150
3248
|
);
|
|
@@ -3162,8 +3260,8 @@ function oauthController(store, config, bus) {
|
|
|
3162
3260
|
});
|
|
3163
3261
|
const tokenData = await tokenRes.json();
|
|
3164
3262
|
if (!tokenData.id_token) {
|
|
3165
|
-
return
|
|
3166
|
-
|
|
3263
|
+
return setApiResponse6(
|
|
3264
|
+
HTTP6.BAD_REQUEST,
|
|
3167
3265
|
"GOOGLE_AUTH_FAILED",
|
|
3168
3266
|
"OAuth token exchange failed"
|
|
3169
3267
|
);
|
|
@@ -3172,20 +3270,20 @@ function oauthController(store, config, bus) {
|
|
|
3172
3270
|
Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
|
|
3173
3271
|
);
|
|
3174
3272
|
if (payload.aud !== google.clientId) {
|
|
3175
|
-
return
|
|
3273
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
|
|
3176
3274
|
}
|
|
3177
3275
|
if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
|
|
3178
|
-
return
|
|
3276
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
|
|
3179
3277
|
}
|
|
3180
3278
|
if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
|
|
3181
|
-
return
|
|
3279
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
|
|
3182
3280
|
}
|
|
3183
3281
|
if (!payload.email) {
|
|
3184
|
-
return
|
|
3282
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
|
|
3185
3283
|
}
|
|
3186
3284
|
if (payload.email_verified !== true) {
|
|
3187
|
-
return
|
|
3188
|
-
|
|
3285
|
+
return setApiResponse6(
|
|
3286
|
+
HTTP6.BAD_REQUEST,
|
|
3189
3287
|
"GOOGLE_AUTH_FAILED",
|
|
3190
3288
|
"Google account email is not verified"
|
|
3191
3289
|
);
|
|
@@ -3199,7 +3297,7 @@ function oauthController(store, config, bus) {
|
|
|
3199
3297
|
appleInit: async (_ctx) => {
|
|
3200
3298
|
const apple = config.apple;
|
|
3201
3299
|
if (!apple) {
|
|
3202
|
-
return
|
|
3300
|
+
return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
3203
3301
|
}
|
|
3204
3302
|
const state = randomBytes4(16).toString("hex");
|
|
3205
3303
|
const params = new URLSearchParams({
|
|
@@ -3230,20 +3328,20 @@ function oauthController(store, config, bus) {
|
|
|
3230
3328
|
appleCallback: async (ctx) => {
|
|
3231
3329
|
const apple = config.apple;
|
|
3232
3330
|
if (!apple) {
|
|
3233
|
-
return
|
|
3331
|
+
return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
3234
3332
|
}
|
|
3235
3333
|
const pre = ctx.meta["body"];
|
|
3236
3334
|
const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
|
|
3237
3335
|
const code = form.get("code");
|
|
3238
3336
|
if (!code) {
|
|
3239
|
-
return
|
|
3337
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
3240
3338
|
}
|
|
3241
3339
|
const returnedState = form.get("state") ?? "";
|
|
3242
3340
|
const cookieHeader = ctx.request.headers.get("cookie") ?? "";
|
|
3243
3341
|
const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
|
|
3244
3342
|
if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
|
|
3245
|
-
return
|
|
3246
|
-
|
|
3343
|
+
return setApiResponse6(
|
|
3344
|
+
HTTP6.BAD_REQUEST,
|
|
3247
3345
|
"APPLE_AUTH_FAILED",
|
|
3248
3346
|
"OAuth state mismatch \u2014 restart the sign-in flow"
|
|
3249
3347
|
);
|
|
@@ -3261,11 +3359,11 @@ function oauthController(store, config, bus) {
|
|
|
3261
3359
|
});
|
|
3262
3360
|
const tokenData = await tokenRes.json();
|
|
3263
3361
|
if (!tokenData.id_token) {
|
|
3264
|
-
return
|
|
3362
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
|
|
3265
3363
|
}
|
|
3266
3364
|
const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
|
|
3267
3365
|
if (!claims) {
|
|
3268
|
-
return
|
|
3366
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
3269
3367
|
}
|
|
3270
3368
|
return completeOAuthLogin(ctx, "apple", {
|
|
3271
3369
|
email: claims.email,
|
|
@@ -3276,12 +3374,12 @@ function oauthController(store, config, bus) {
|
|
|
3276
3374
|
appleNative: async (ctx) => {
|
|
3277
3375
|
const apple = config.apple;
|
|
3278
3376
|
if (!apple) {
|
|
3279
|
-
return
|
|
3377
|
+
return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
3280
3378
|
}
|
|
3281
3379
|
const body = ctx.meta["body"];
|
|
3282
3380
|
const identityToken = body?.identityToken;
|
|
3283
3381
|
if (!identityToken) {
|
|
3284
|
-
return
|
|
3382
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
|
|
3285
3383
|
}
|
|
3286
3384
|
const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
|
|
3287
3385
|
const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
|
|
@@ -3295,7 +3393,7 @@ function oauthController(store, config, bus) {
|
|
|
3295
3393
|
failureReason: "invalid_identity_token",
|
|
3296
3394
|
...meta
|
|
3297
3395
|
});
|
|
3298
|
-
return
|
|
3396
|
+
return setApiResponse6(HTTP6.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
3299
3397
|
}
|
|
3300
3398
|
const tokenHash = createHash4("sha256").update(identityToken).digest("hex");
|
|
3301
3399
|
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
@@ -3304,14 +3402,14 @@ function oauthController(store, config, bus) {
|
|
|
3304
3402
|
const meta = requestMeta(ctx);
|
|
3305
3403
|
await loginEvents.recordSafe({
|
|
3306
3404
|
userId: null,
|
|
3307
|
-
emailAttempted: claims.email ?? null,
|
|
3405
|
+
emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
|
|
3308
3406
|
method: "oauth-apple",
|
|
3309
3407
|
outcome: "failed",
|
|
3310
3408
|
failureReason: "token_replayed",
|
|
3311
3409
|
...meta
|
|
3312
3410
|
});
|
|
3313
|
-
return
|
|
3314
|
-
|
|
3411
|
+
return setApiResponse6(
|
|
3412
|
+
HTTP6.UNAUTHORIZED,
|
|
3315
3413
|
"APPLE_AUTH_FAILED",
|
|
3316
3414
|
"This Apple identity token has already been used"
|
|
3317
3415
|
);
|
|
@@ -3328,12 +3426,12 @@ function oauthController(store, config, bus) {
|
|
|
3328
3426
|
googleNative: async (ctx) => {
|
|
3329
3427
|
const audiences = config.google?.nativeClientIds ?? [];
|
|
3330
3428
|
if (!config.google || audiences.length === 0) {
|
|
3331
|
-
return
|
|
3429
|
+
return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
|
|
3332
3430
|
}
|
|
3333
3431
|
const body = ctx.meta["body"];
|
|
3334
3432
|
const idToken = body?.idToken;
|
|
3335
3433
|
if (!idToken) {
|
|
3336
|
-
return
|
|
3434
|
+
return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
|
|
3337
3435
|
}
|
|
3338
3436
|
const claims = await verifyGoogleIdToken(idToken, { audiences, nonce: body?.nonce });
|
|
3339
3437
|
if (!claims) {
|
|
@@ -3345,20 +3443,20 @@ function oauthController(store, config, bus) {
|
|
|
3345
3443
|
failureReason: "invalid_identity_token",
|
|
3346
3444
|
...requestMeta(ctx)
|
|
3347
3445
|
});
|
|
3348
|
-
return
|
|
3446
|
+
return setApiResponse6(HTTP6.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
|
|
3349
3447
|
}
|
|
3350
3448
|
const tokenHash = createHash4("sha256").update(idToken).digest("hex");
|
|
3351
3449
|
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
3352
3450
|
if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
|
|
3353
3451
|
await loginEvents.recordSafe({
|
|
3354
3452
|
userId: null,
|
|
3355
|
-
emailAttempted: claims.email ?? null,
|
|
3453
|
+
emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
|
|
3356
3454
|
method: "oauth-google",
|
|
3357
3455
|
outcome: "failed",
|
|
3358
3456
|
failureReason: "token_replayed",
|
|
3359
3457
|
...requestMeta(ctx)
|
|
3360
3458
|
});
|
|
3361
|
-
return
|
|
3459
|
+
return setApiResponse6(HTTP6.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
|
|
3362
3460
|
}
|
|
3363
3461
|
consumedTokens.sweepExpiredSafe();
|
|
3364
3462
|
return completeOAuthLogin(ctx, "google", {
|
|
@@ -3404,7 +3502,7 @@ function buildAuthRoutes(store, config, bus, locales) {
|
|
|
3404
3502
|
"providers",
|
|
3405
3503
|
"GET",
|
|
3406
3504
|
"/auth/providers",
|
|
3407
|
-
async () =>
|
|
3505
|
+
async () => setApiResponse7(HTTP7.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
|
|
3408
3506
|
providers: [...config.providers]
|
|
3409
3507
|
})
|
|
3410
3508
|
),
|
|
@@ -3668,7 +3766,7 @@ function extractToken(request) {
|
|
|
3668
3766
|
}
|
|
3669
3767
|
|
|
3670
3768
|
// src/admin.ts
|
|
3671
|
-
import { HTTP as
|
|
3769
|
+
import { HTTP as HTTP8, encodeKeysetCursor as encodeKeysetCursor2, setApiResponse as setApiResponse8 } from "@fonderie/core";
|
|
3672
3770
|
import { background as background6 } from "@fonderie/core";
|
|
3673
3771
|
function toAdminUserDTO(user) {
|
|
3674
3772
|
return {
|
|
@@ -3685,7 +3783,7 @@ function toAdminUserPageDTO(page) {
|
|
|
3685
3783
|
}
|
|
3686
3784
|
var ADMIN_PREFIX = "/_admin";
|
|
3687
3785
|
var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
|
|
3688
|
-
var NOT_FOUND = () =>
|
|
3786
|
+
var NOT_FOUND = () => setApiResponse8(HTTP8.NOT_FOUND, "NOT_FOUND", "No such user");
|
|
3689
3787
|
function adminRouteTable(store, bus) {
|
|
3690
3788
|
const users = new UserModel(store);
|
|
3691
3789
|
const sessions = new SessionModel(store);
|
|
@@ -3694,8 +3792,8 @@ function adminRouteTable(store, bus) {
|
|
|
3694
3792
|
const ok = await users.setSuspended(idOf(ctx), suspended);
|
|
3695
3793
|
if (!ok) return NOT_FOUND();
|
|
3696
3794
|
const user = await users.findById(idOf(ctx));
|
|
3697
|
-
return user ?
|
|
3698
|
-
|
|
3795
|
+
return user ? setApiResponse8(
|
|
3796
|
+
HTTP8.OK,
|
|
3699
3797
|
suspended ? "USER_SUSPENDED" : "USER_UNSUSPENDED",
|
|
3700
3798
|
suspended ? "User suspended" : "User unsuspended",
|
|
3701
3799
|
toAdminUserDTO(user)
|
|
@@ -3710,24 +3808,25 @@ function adminRouteTable(store, bus) {
|
|
|
3710
3808
|
// see anything cannot discover who signed up this morning.
|
|
3711
3809
|
async (ctx) => {
|
|
3712
3810
|
const params = new URL(ctx.request.url).searchParams;
|
|
3713
|
-
const
|
|
3811
|
+
const raw = params.get("email")?.trim();
|
|
3812
|
+
const email2 = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : void 0;
|
|
3714
3813
|
if (email2) {
|
|
3715
3814
|
const user = await users.findByEmail(email2);
|
|
3716
|
-
return user ?
|
|
3815
|
+
return user ? setApiResponse8(HTTP8.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
|
|
3717
3816
|
}
|
|
3718
3817
|
const rawLimit = Number(params.get("limit") ?? 50);
|
|
3719
3818
|
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
|
|
3720
3819
|
const cursorParam = params.get("cursor");
|
|
3721
3820
|
const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
|
|
3722
3821
|
if (cursorParam && !cursor)
|
|
3723
|
-
return
|
|
3822
|
+
return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
|
|
3724
3823
|
const deleted = params.get("deleted") === "1" || params.get("deleted") === "true";
|
|
3725
3824
|
const page = await users.list({
|
|
3726
3825
|
limit,
|
|
3727
3826
|
...cursor ? { cursor } : {},
|
|
3728
3827
|
...deleted ? { deleted } : {}
|
|
3729
3828
|
});
|
|
3730
|
-
return
|
|
3829
|
+
return setApiResponse8(HTTP8.OK, "USERS", "Users", toAdminUserPageDTO(page));
|
|
3731
3830
|
}
|
|
3732
3831
|
],
|
|
3733
3832
|
[
|
|
@@ -3735,7 +3834,7 @@ function adminRouteTable(store, bus) {
|
|
|
3735
3834
|
"/_admin/users/:id",
|
|
3736
3835
|
async (ctx) => {
|
|
3737
3836
|
const user = await users.findByIdIncludingDeleted(idOf(ctx));
|
|
3738
|
-
return user ?
|
|
3837
|
+
return user ? setApiResponse8(HTTP8.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
|
|
3739
3838
|
}
|
|
3740
3839
|
],
|
|
3741
3840
|
[
|
|
@@ -3744,8 +3843,8 @@ function adminRouteTable(store, bus) {
|
|
|
3744
3843
|
async (ctx) => {
|
|
3745
3844
|
if (!await users.findById(idOf(ctx))) return NOT_FOUND();
|
|
3746
3845
|
const rows = await sessions.listLiveByUser(idOf(ctx));
|
|
3747
|
-
return
|
|
3748
|
-
|
|
3846
|
+
return setApiResponse8(
|
|
3847
|
+
HTTP8.OK,
|
|
3749
3848
|
"SESSIONS",
|
|
3750
3849
|
"Live sessions",
|
|
3751
3850
|
rows.map((r) => toSessionDTO(r, null))
|
|
@@ -3760,7 +3859,7 @@ function adminRouteTable(store, bus) {
|
|
|
3760
3859
|
if (!await users.findById(idOf(ctx))) return NOT_FOUND();
|
|
3761
3860
|
await sessions.deleteByUser(idOf(ctx));
|
|
3762
3861
|
await background6(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
|
|
3763
|
-
return
|
|
3862
|
+
return setApiResponse8(HTTP8.OK, "SESSIONS_REVOKED", "All sessions revoked");
|
|
3764
3863
|
}
|
|
3765
3864
|
],
|
|
3766
3865
|
[
|
|
@@ -3774,14 +3873,14 @@ function adminRouteTable(store, bus) {
|
|
|
3774
3873
|
const cursorParam = params.get("cursor");
|
|
3775
3874
|
const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
|
|
3776
3875
|
if (cursorParam && !cursor)
|
|
3777
|
-
return
|
|
3876
|
+
return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
|
|
3778
3877
|
const page = await events.listByUser({
|
|
3779
3878
|
userId: idOf(ctx),
|
|
3780
3879
|
limit,
|
|
3781
3880
|
...cursor ? { cursor } : {}
|
|
3782
3881
|
});
|
|
3783
|
-
return
|
|
3784
|
-
|
|
3882
|
+
return setApiResponse8(
|
|
3883
|
+
HTTP8.OK,
|
|
3785
3884
|
"LOGIN_HISTORY",
|
|
3786
3885
|
"Login history",
|
|
3787
3886
|
toLoginHistoryPageDTO(page)
|
|
@@ -3814,7 +3913,7 @@ var AuthModule = class {
|
|
|
3814
3913
|
name = "@fonderie/auth";
|
|
3815
3914
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3816
3915
|
// say what is actually deployed rather than 'not reported'.
|
|
3817
|
-
version = "7.
|
|
3916
|
+
version = "7.23.0";
|
|
3818
3917
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3819
3918
|
describeAdmin() {
|
|
3820
3919
|
return { routes: describeAuthAdminRoutes(this.store, this.bus) };
|
|
@@ -4670,7 +4769,9 @@ async function importUser(store, user) {
|
|
|
4670
4769
|
placeholders.push(`$${vals.length}`);
|
|
4671
4770
|
};
|
|
4672
4771
|
if (user.id !== void 0) add("id", user.id);
|
|
4673
|
-
|
|
4772
|
+
const email2 = normalizeEmailSafe(user.email);
|
|
4773
|
+
if (!email2) throw new Error(`[auth] importUser: '${user.email}' is not an email address`);
|
|
4774
|
+
add("email", email2);
|
|
4674
4775
|
if (user.passwordHash !== void 0) add("password_hash", user.passwordHash);
|
|
4675
4776
|
if (user.firstName !== void 0) add("first_name", user.firstName);
|
|
4676
4777
|
if (user.lastName !== void 0) add("last_name", user.lastName);
|