@fonderie/auth 5.3.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/brain/outcomes.md +2 -2
- package/brain/signatures.md +1 -1
- package/dist/index.cjs +76 -32
- 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 +77 -33
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/{session-jITwnc3m.d.cts → session-Db6KZca3.d.cts} +1 -1
- package/dist/{session-jITwnc3m.d.ts → session-Db6KZca3.d.ts} +1 -1
- package/package.json +4 -4
package/brain/outcomes.md
CHANGED
|
@@ -141,11 +141,11 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
|
|
|
141
141
|
| POST | `/auth/mfa/verify` | `ipLimit('mfaVerify') → requireAnyAuth → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.verify` |
|
|
142
142
|
| POST | `/auth/refresh` | `validate(refreshSchema) → auth.refresh` |
|
|
143
143
|
| POST | `/auth/register` | `ipLimit('register') → validate(registerSchema) → auth.register` |
|
|
144
|
-
| GET | `/auth/send-verification` | `
|
|
144
|
+
| GET | `/auth/send-verification` | `requireAnyAuth → auth.sendVerification` |
|
|
145
145
|
| GET | `/auth/sessions` | `requireAuth → user.listSessions` |
|
|
146
146
|
| DELETE | `/auth/sessions/:id` | `requireAuth → user.terminateSession` |
|
|
147
147
|
| DELETE | `/auth/sessions/others` | `requireAuth → user.terminateOtherSessions` |
|
|
148
|
-
| POST | `/auth/verify` | `
|
|
148
|
+
| POST | `/auth/verify` | `ipLimit('verify') → requireAnyAuth → validate(verifySchema) → auth.verify` |
|
|
149
149
|
| DELETE | `/users` | `requireAuth → verifyGate → user.deleteMe` |
|
|
150
150
|
| GET | `/users` | `requireAuth → user.me` |
|
|
151
151
|
| PUT | `/users/email` | `requireAuth → verifyGate → validate(updateEmailSchema) → user.updateEmail` |
|
package/brain/signatures.md
CHANGED
|
@@ -203,5 +203,5 @@ interface IAuthRateLimitConfig {
|
|
|
203
203
|
rules?: Partial<Record<AuthLimitedRoute, IRateLimitRule | false>>;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
type AuthLimitedRoute = 'login' | 'register' | 'forgot' | 'reset' | 'mfaVerify';
|
|
206
|
+
type AuthLimitedRoute = 'login' | 'register' | 'forgot' | 'reset' | 'verify' | 'mfaVerify';
|
|
207
207
|
```
|
package/dist/index.cjs
CHANGED
|
@@ -132,6 +132,9 @@ var DEFAULTS = {
|
|
|
132
132
|
// so this IP bucket is the guard. (Moving reset to a high-entropy opaque
|
|
133
133
|
// token is tracked as a follow-up hardening.)
|
|
134
134
|
reset: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
|
|
135
|
+
// verify: 10/15min per IP — /auth/verify checks a 6-digit email/phone OTP,
|
|
136
|
+
// and in the phone flow that OTP is the login credential itself.
|
|
137
|
+
verify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
|
|
135
138
|
// mfaVerify: 10/15min per IP — TOTP brute-force.
|
|
136
139
|
mfaVerify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } }
|
|
137
140
|
};
|
|
@@ -1464,23 +1467,17 @@ function authController(store, config, bus) {
|
|
|
1464
1467
|
reqOpts2
|
|
1465
1468
|
).catch(() => {
|
|
1466
1469
|
});
|
|
1467
|
-
const
|
|
1468
|
-
loginMethod: "phone"
|
|
1469
|
-
});
|
|
1470
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1470
|
+
const otpToken = issueMfaPendingToken(user.id, config, "phone");
|
|
1471
1471
|
return Response.json(
|
|
1472
1472
|
{
|
|
1473
1473
|
reason: "USER_PHONE_REGISTERED",
|
|
1474
1474
|
explanation: "Account created. A verification code has been sent to your phone.",
|
|
1475
1475
|
result: {
|
|
1476
|
-
|
|
1476
|
+
otpToken,
|
|
1477
1477
|
user: toUserDTO(user)
|
|
1478
1478
|
}
|
|
1479
1479
|
},
|
|
1480
|
-
{
|
|
1481
|
-
status: 202,
|
|
1482
|
-
headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
|
|
1483
|
-
}
|
|
1480
|
+
{ status: 202 }
|
|
1484
1481
|
);
|
|
1485
1482
|
}
|
|
1486
1483
|
return (0, import_core5.setApiResponse)(
|
|
@@ -1594,23 +1591,14 @@ function authController(store, config, bus) {
|
|
|
1594
1591
|
recipient: { email: null, phone: normalizePhone(phone2), deviceToken: null }
|
|
1595
1592
|
}).catch(() => {
|
|
1596
1593
|
});
|
|
1597
|
-
const
|
|
1598
|
-
loginMethod: "phone"
|
|
1599
|
-
});
|
|
1600
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1594
|
+
const otpToken = issueMfaPendingToken(user.id, config, "phone");
|
|
1601
1595
|
return Response.json(
|
|
1602
1596
|
{
|
|
1603
1597
|
reason: "USER_PHONE_OTP_SENT",
|
|
1604
1598
|
explanation: "A verification code has been sent to your phone.",
|
|
1605
|
-
result: {
|
|
1606
|
-
tokens: { access: accessToken, refresh: refreshToken },
|
|
1607
|
-
user: toUserDTO(user, false)
|
|
1608
|
-
}
|
|
1599
|
+
result: { otpToken }
|
|
1609
1600
|
},
|
|
1610
|
-
{
|
|
1611
|
-
status: 202,
|
|
1612
|
-
headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
|
|
1613
|
-
}
|
|
1601
|
+
{ status: 202 }
|
|
1614
1602
|
);
|
|
1615
1603
|
}
|
|
1616
1604
|
return (0, import_core5.setApiResponse)(
|
|
@@ -1756,12 +1744,18 @@ function authController(store, config, bus) {
|
|
|
1756
1744
|
passwordHash,
|
|
1757
1745
|
row.userId
|
|
1758
1746
|
]),
|
|
1759
|
-
tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId])
|
|
1747
|
+
tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId]),
|
|
1748
|
+
// Revoke every session: the reset exists because the account may
|
|
1749
|
+
// be compromised — a stolen session must not survive it.
|
|
1750
|
+
tx.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [row.userId])
|
|
1760
1751
|
]);
|
|
1761
1752
|
});
|
|
1762
1753
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
|
|
1763
1754
|
},
|
|
1764
1755
|
verify: async (ctx) => {
|
|
1756
|
+
if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
|
|
1757
|
+
return (0, import_core5.setApiResponse)(import_core5.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
|
|
1758
|
+
}
|
|
1765
1759
|
if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
|
|
1766
1760
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "VERIFIED", "Email verified successfully.", {
|
|
1767
1761
|
verified: true,
|
|
@@ -1848,6 +1842,9 @@ function authController(store, config, bus) {
|
|
|
1848
1842
|
});
|
|
1849
1843
|
},
|
|
1850
1844
|
sendVerification: async (ctx) => {
|
|
1845
|
+
if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
|
|
1846
|
+
return (0, import_core5.setApiResponse)(import_core5.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
|
|
1847
|
+
}
|
|
1851
1848
|
const resolved = { ...config, ...config.resolve?.(ctx) };
|
|
1852
1849
|
const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
|
|
1853
1850
|
if (ctx.user.loginMethod === "phone") {
|
|
@@ -2271,6 +2268,7 @@ function userController(store, config, bus) {
|
|
|
2271
2268
|
}
|
|
2272
2269
|
|
|
2273
2270
|
// src/controllers/oauth.controller.ts
|
|
2271
|
+
var import_node_crypto6 = require("crypto");
|
|
2274
2272
|
var import_core8 = require("@fonderie/core");
|
|
2275
2273
|
function oauthController(store, config) {
|
|
2276
2274
|
const users = new UserModel(store);
|
|
@@ -2286,18 +2284,30 @@ function oauthController(store, config) {
|
|
|
2286
2284
|
"Google OAuth not configured"
|
|
2287
2285
|
);
|
|
2288
2286
|
}
|
|
2287
|
+
const state = (0, import_node_crypto6.randomBytes)(16).toString("hex");
|
|
2289
2288
|
const params = new URLSearchParams({
|
|
2290
2289
|
client_id: google.clientId,
|
|
2291
2290
|
redirect_uri: google.redirectUri,
|
|
2292
2291
|
response_type: "code",
|
|
2293
|
-
scope: "openid email profile"
|
|
2292
|
+
scope: "openid email profile",
|
|
2293
|
+
state
|
|
2294
2294
|
});
|
|
2295
2295
|
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2296
|
+
const secure = config.secureCookies ?? process.env["NODE_ENV"] === "production" ? "; Secure" : "";
|
|
2297
|
+
return Response.json(
|
|
2298
|
+
{
|
|
2299
|
+
reason: "GOOGLE_AUTH_URL",
|
|
2300
|
+
explanation: "Redirect the user to the returned URL to begin Google OAuth.",
|
|
2301
|
+
result: { url }
|
|
2302
|
+
},
|
|
2303
|
+
{
|
|
2304
|
+
status: 200,
|
|
2305
|
+
// SameSite=Lax (not Strict): the cookie must be sent on the
|
|
2306
|
+
// top-level cross-site redirect back from Google.
|
|
2307
|
+
headers: cookieHeaders([
|
|
2308
|
+
`oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600${secure}`
|
|
2309
|
+
])
|
|
2310
|
+
}
|
|
2301
2311
|
);
|
|
2302
2312
|
},
|
|
2303
2313
|
googleCallback: async (ctx) => {
|
|
@@ -2314,6 +2324,16 @@ function oauthController(store, config) {
|
|
|
2314
2324
|
if (!code) {
|
|
2315
2325
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
2316
2326
|
}
|
|
2327
|
+
const returnedState = url.searchParams.get("state") ?? "";
|
|
2328
|
+
const cookieHeader = ctx.request.headers.get("cookie") ?? "";
|
|
2329
|
+
const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
|
|
2330
|
+
if (!returnedState || !expectedState || !(0, import_core8.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
|
|
2331
|
+
return (0, import_core8.setApiResponse)(
|
|
2332
|
+
import_core8.HTTP.BAD_REQUEST,
|
|
2333
|
+
"GOOGLE_AUTH_FAILED",
|
|
2334
|
+
"OAuth state mismatch \u2014 restart the sign-in flow"
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2317
2337
|
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
2318
2338
|
method: "POST",
|
|
2319
2339
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
@@ -2336,9 +2356,25 @@ function oauthController(store, config) {
|
|
|
2336
2356
|
const payload = JSON.parse(
|
|
2337
2357
|
Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
|
|
2338
2358
|
);
|
|
2359
|
+
if (payload.aud !== google.clientId) {
|
|
2360
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
|
|
2361
|
+
}
|
|
2362
|
+
if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
|
|
2363
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
|
|
2364
|
+
}
|
|
2365
|
+
if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
|
|
2366
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
|
|
2367
|
+
}
|
|
2339
2368
|
if (!payload.email) {
|
|
2340
2369
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
|
|
2341
2370
|
}
|
|
2371
|
+
if (payload.email_verified !== true) {
|
|
2372
|
+
return (0, import_core8.setApiResponse)(
|
|
2373
|
+
import_core8.HTTP.BAD_REQUEST,
|
|
2374
|
+
"GOOGLE_AUTH_FAILED",
|
|
2375
|
+
"Google account email is not verified"
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2342
2378
|
const normalizedEmail = normalizeEmailSafe(payload.email);
|
|
2343
2379
|
if (!normalizedEmail) {
|
|
2344
2380
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
|
|
@@ -2382,7 +2418,11 @@ function oauthController(store, config) {
|
|
|
2382
2418
|
},
|
|
2383
2419
|
{
|
|
2384
2420
|
status: 200,
|
|
2385
|
-
headers: cookieHeaders(
|
|
2421
|
+
headers: cookieHeaders([
|
|
2422
|
+
...tokenPairCookies(accessToken, refreshToken, config),
|
|
2423
|
+
// One-time value — clear it once the flow completes.
|
|
2424
|
+
"oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
|
|
2425
|
+
])
|
|
2386
2426
|
}
|
|
2387
2427
|
);
|
|
2388
2428
|
}
|
|
@@ -2414,9 +2454,13 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2414
2454
|
// Email — Password Recovery (Public)
|
|
2415
2455
|
R("forgotPassword", "POST", "/auth/email/forgot", ipLimit("forgot"), (0, import_middlewares.validate)(forgotPasswordSchema), acctLimit("forgot"), auth.forgotPassword),
|
|
2416
2456
|
R("resetPassword", "POST", "/auth/email/reset", ipLimit("reset"), (0, import_middlewares.validate)(resetPasswordSchema), auth.resetPassword),
|
|
2417
|
-
// Verification (
|
|
2418
|
-
|
|
2419
|
-
|
|
2457
|
+
// Verification (email or phone, determined by loginMethod). requireAnyAuth,
|
|
2458
|
+
// not requireAuth: the phone-OTP flow reaches these with a short-lived
|
|
2459
|
+
// pending token (login/register no longer issue real tokens before the OTP
|
|
2460
|
+
// round-trip); the controllers reject pending tokens for any other flow.
|
|
2461
|
+
// ipLimit guards the 6-digit OTP against brute force (like mfaVerify).
|
|
2462
|
+
R("verifyEmail", "POST", "/auth/verify", ipLimit("verify"), import_middlewares2.requireAnyAuth, (0, import_middlewares.validate)(verifySchema), auth.verify),
|
|
2463
|
+
R("sendVerification", "GET", "/auth/send-verification", import_middlewares2.requireAnyAuth, auth.sendVerification),
|
|
2420
2464
|
// Account Management (Protected)
|
|
2421
2465
|
R("logout", "POST", "/auth/logout", import_middlewares2.requireAuth, (0, import_middlewares.validate)(refreshSchema), auth.logout),
|
|
2422
2466
|
// User Profile (Protected; writes also gate on requireVerification)
|