@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/dist/index.d.cts
CHANGED
|
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.cjs';
|
|
|
2
2
|
import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
|
|
3
3
|
import { IStoreAdapter } from '@fonderie/store';
|
|
4
4
|
import { EventBus } from '@fonderie/events';
|
|
5
|
-
import { I as IAuthConfig } from './session-
|
|
6
|
-
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-
|
|
5
|
+
import { I as IAuthConfig } from './session-Db6KZca3.cjs';
|
|
6
|
+
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-Db6KZca3.cjs';
|
|
7
7
|
export { IUserDTO, toUserDTO } from './dtos/user.cjs';
|
|
8
8
|
export { requireAuth, validate } from '@fonderie/core/middlewares';
|
|
9
9
|
import { z } from 'zod';
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.js';
|
|
|
2
2
|
import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
|
|
3
3
|
import { IStoreAdapter } from '@fonderie/store';
|
|
4
4
|
import { EventBus } from '@fonderie/events';
|
|
5
|
-
import { I as IAuthConfig } from './session-
|
|
6
|
-
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-
|
|
5
|
+
import { I as IAuthConfig } from './session-Db6KZca3.js';
|
|
6
|
+
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-Db6KZca3.js';
|
|
7
7
|
export { IUserDTO, toUserDTO } from './dtos/user.js';
|
|
8
8
|
export { requireAuth, validate } from '@fonderie/core/middlewares';
|
|
9
9
|
import { z } from 'zod';
|
package/dist/index.js
CHANGED
|
@@ -92,6 +92,9 @@ var DEFAULTS = {
|
|
|
92
92
|
// so this IP bucket is the guard. (Moving reset to a high-entropy opaque
|
|
93
93
|
// token is tracked as a follow-up hardening.)
|
|
94
94
|
reset: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
|
|
95
|
+
// verify: 10/15min per IP — /auth/verify checks a 6-digit email/phone OTP,
|
|
96
|
+
// and in the phone flow that OTP is the login credential itself.
|
|
97
|
+
verify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
|
|
95
98
|
// mfaVerify: 10/15min per IP — TOTP brute-force.
|
|
96
99
|
mfaVerify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } }
|
|
97
100
|
};
|
|
@@ -1424,23 +1427,17 @@ function authController(store, config, bus) {
|
|
|
1424
1427
|
reqOpts2
|
|
1425
1428
|
).catch(() => {
|
|
1426
1429
|
});
|
|
1427
|
-
const
|
|
1428
|
-
loginMethod: "phone"
|
|
1429
|
-
});
|
|
1430
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1430
|
+
const otpToken = issueMfaPendingToken(user.id, config, "phone");
|
|
1431
1431
|
return Response.json(
|
|
1432
1432
|
{
|
|
1433
1433
|
reason: "USER_PHONE_REGISTERED",
|
|
1434
1434
|
explanation: "Account created. A verification code has been sent to your phone.",
|
|
1435
1435
|
result: {
|
|
1436
|
-
|
|
1436
|
+
otpToken,
|
|
1437
1437
|
user: toUserDTO(user)
|
|
1438
1438
|
}
|
|
1439
1439
|
},
|
|
1440
|
-
{
|
|
1441
|
-
status: 202,
|
|
1442
|
-
headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
|
|
1443
|
-
}
|
|
1440
|
+
{ status: 202 }
|
|
1444
1441
|
);
|
|
1445
1442
|
}
|
|
1446
1443
|
return setApiResponse3(
|
|
@@ -1554,23 +1551,14 @@ function authController(store, config, bus) {
|
|
|
1554
1551
|
recipient: { email: null, phone: normalizePhone(phone2), deviceToken: null }
|
|
1555
1552
|
}).catch(() => {
|
|
1556
1553
|
});
|
|
1557
|
-
const
|
|
1558
|
-
loginMethod: "phone"
|
|
1559
|
-
});
|
|
1560
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1554
|
+
const otpToken = issueMfaPendingToken(user.id, config, "phone");
|
|
1561
1555
|
return Response.json(
|
|
1562
1556
|
{
|
|
1563
1557
|
reason: "USER_PHONE_OTP_SENT",
|
|
1564
1558
|
explanation: "A verification code has been sent to your phone.",
|
|
1565
|
-
result: {
|
|
1566
|
-
tokens: { access: accessToken, refresh: refreshToken },
|
|
1567
|
-
user: toUserDTO(user, false)
|
|
1568
|
-
}
|
|
1559
|
+
result: { otpToken }
|
|
1569
1560
|
},
|
|
1570
|
-
{
|
|
1571
|
-
status: 202,
|
|
1572
|
-
headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
|
|
1573
|
-
}
|
|
1561
|
+
{ status: 202 }
|
|
1574
1562
|
);
|
|
1575
1563
|
}
|
|
1576
1564
|
return setApiResponse3(
|
|
@@ -1716,12 +1704,18 @@ function authController(store, config, bus) {
|
|
|
1716
1704
|
passwordHash,
|
|
1717
1705
|
row.userId
|
|
1718
1706
|
]),
|
|
1719
|
-
tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId])
|
|
1707
|
+
tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId]),
|
|
1708
|
+
// Revoke every session: the reset exists because the account may
|
|
1709
|
+
// be compromised — a stolen session must not survive it.
|
|
1710
|
+
tx.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [row.userId])
|
|
1720
1711
|
]);
|
|
1721
1712
|
});
|
|
1722
1713
|
return setApiResponse3(HTTP3.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
|
|
1723
1714
|
},
|
|
1724
1715
|
verify: async (ctx) => {
|
|
1716
|
+
if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
|
|
1717
|
+
return setApiResponse3(HTTP3.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
|
|
1718
|
+
}
|
|
1725
1719
|
if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
|
|
1726
1720
|
return setApiResponse3(HTTP3.OK, "VERIFIED", "Email verified successfully.", {
|
|
1727
1721
|
verified: true,
|
|
@@ -1808,6 +1802,9 @@ function authController(store, config, bus) {
|
|
|
1808
1802
|
});
|
|
1809
1803
|
},
|
|
1810
1804
|
sendVerification: async (ctx) => {
|
|
1805
|
+
if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
|
|
1806
|
+
return setApiResponse3(HTTP3.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
|
|
1807
|
+
}
|
|
1811
1808
|
const resolved = { ...config, ...config.resolve?.(ctx) };
|
|
1812
1809
|
const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
|
|
1813
1810
|
if (ctx.user.loginMethod === "phone") {
|
|
@@ -2231,7 +2228,8 @@ function userController(store, config, bus) {
|
|
|
2231
2228
|
}
|
|
2232
2229
|
|
|
2233
2230
|
// src/controllers/oauth.controller.ts
|
|
2234
|
-
import {
|
|
2231
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
2232
|
+
import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2 } from "@fonderie/core";
|
|
2235
2233
|
function oauthController(store, config) {
|
|
2236
2234
|
const users = new UserModel(store);
|
|
2237
2235
|
const sessions = new SessionModel(store);
|
|
@@ -2246,18 +2244,30 @@ function oauthController(store, config) {
|
|
|
2246
2244
|
"Google OAuth not configured"
|
|
2247
2245
|
);
|
|
2248
2246
|
}
|
|
2247
|
+
const state = randomBytes3(16).toString("hex");
|
|
2249
2248
|
const params = new URLSearchParams({
|
|
2250
2249
|
client_id: google.clientId,
|
|
2251
2250
|
redirect_uri: google.redirectUri,
|
|
2252
2251
|
response_type: "code",
|
|
2253
|
-
scope: "openid email profile"
|
|
2252
|
+
scope: "openid email profile",
|
|
2253
|
+
state
|
|
2254
2254
|
});
|
|
2255
2255
|
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2256
|
+
const secure = config.secureCookies ?? process.env["NODE_ENV"] === "production" ? "; Secure" : "";
|
|
2257
|
+
return Response.json(
|
|
2258
|
+
{
|
|
2259
|
+
reason: "GOOGLE_AUTH_URL",
|
|
2260
|
+
explanation: "Redirect the user to the returned URL to begin Google OAuth.",
|
|
2261
|
+
result: { url }
|
|
2262
|
+
},
|
|
2263
|
+
{
|
|
2264
|
+
status: 200,
|
|
2265
|
+
// SameSite=Lax (not Strict): the cookie must be sent on the
|
|
2266
|
+
// top-level cross-site redirect back from Google.
|
|
2267
|
+
headers: cookieHeaders([
|
|
2268
|
+
`oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600${secure}`
|
|
2269
|
+
])
|
|
2270
|
+
}
|
|
2261
2271
|
);
|
|
2262
2272
|
},
|
|
2263
2273
|
googleCallback: async (ctx) => {
|
|
@@ -2274,6 +2284,16 @@ function oauthController(store, config) {
|
|
|
2274
2284
|
if (!code) {
|
|
2275
2285
|
return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
2276
2286
|
}
|
|
2287
|
+
const returnedState = url.searchParams.get("state") ?? "";
|
|
2288
|
+
const cookieHeader = ctx.request.headers.get("cookie") ?? "";
|
|
2289
|
+
const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
|
|
2290
|
+
if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
|
|
2291
|
+
return setApiResponse5(
|
|
2292
|
+
HTTP5.BAD_REQUEST,
|
|
2293
|
+
"GOOGLE_AUTH_FAILED",
|
|
2294
|
+
"OAuth state mismatch \u2014 restart the sign-in flow"
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2277
2297
|
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
2278
2298
|
method: "POST",
|
|
2279
2299
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
@@ -2296,9 +2316,25 @@ function oauthController(store, config) {
|
|
|
2296
2316
|
const payload = JSON.parse(
|
|
2297
2317
|
Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
|
|
2298
2318
|
);
|
|
2319
|
+
if (payload.aud !== google.clientId) {
|
|
2320
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
|
|
2321
|
+
}
|
|
2322
|
+
if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
|
|
2323
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
|
|
2324
|
+
}
|
|
2325
|
+
if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
|
|
2326
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
|
|
2327
|
+
}
|
|
2299
2328
|
if (!payload.email) {
|
|
2300
2329
|
return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
|
|
2301
2330
|
}
|
|
2331
|
+
if (payload.email_verified !== true) {
|
|
2332
|
+
return setApiResponse5(
|
|
2333
|
+
HTTP5.BAD_REQUEST,
|
|
2334
|
+
"GOOGLE_AUTH_FAILED",
|
|
2335
|
+
"Google account email is not verified"
|
|
2336
|
+
);
|
|
2337
|
+
}
|
|
2302
2338
|
const normalizedEmail = normalizeEmailSafe(payload.email);
|
|
2303
2339
|
if (!normalizedEmail) {
|
|
2304
2340
|
return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
|
|
@@ -2342,7 +2378,11 @@ function oauthController(store, config) {
|
|
|
2342
2378
|
},
|
|
2343
2379
|
{
|
|
2344
2380
|
status: 200,
|
|
2345
|
-
headers: cookieHeaders(
|
|
2381
|
+
headers: cookieHeaders([
|
|
2382
|
+
...tokenPairCookies(accessToken, refreshToken, config),
|
|
2383
|
+
// One-time value — clear it once the flow completes.
|
|
2384
|
+
"oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
|
|
2385
|
+
])
|
|
2346
2386
|
}
|
|
2347
2387
|
);
|
|
2348
2388
|
}
|
|
@@ -2374,9 +2414,13 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2374
2414
|
// Email — Password Recovery (Public)
|
|
2375
2415
|
R("forgotPassword", "POST", "/auth/email/forgot", ipLimit("forgot"), validate(forgotPasswordSchema), acctLimit("forgot"), auth.forgotPassword),
|
|
2376
2416
|
R("resetPassword", "POST", "/auth/email/reset", ipLimit("reset"), validate(resetPasswordSchema), auth.resetPassword),
|
|
2377
|
-
// Verification (
|
|
2378
|
-
|
|
2379
|
-
|
|
2417
|
+
// Verification (email or phone, determined by loginMethod). requireAnyAuth,
|
|
2418
|
+
// not requireAuth: the phone-OTP flow reaches these with a short-lived
|
|
2419
|
+
// pending token (login/register no longer issue real tokens before the OTP
|
|
2420
|
+
// round-trip); the controllers reject pending tokens for any other flow.
|
|
2421
|
+
// ipLimit guards the 6-digit OTP against brute force (like mfaVerify).
|
|
2422
|
+
R("verifyEmail", "POST", "/auth/verify", ipLimit("verify"), requireAnyAuth, validate(verifySchema), auth.verify),
|
|
2423
|
+
R("sendVerification", "GET", "/auth/send-verification", requireAnyAuth, auth.sendVerification),
|
|
2380
2424
|
// Account Management (Protected)
|
|
2381
2425
|
R("logout", "POST", "/auth/logout", requireAuth, validate(refreshSchema), auth.logout),
|
|
2382
2426
|
// User Profile (Protected; writes also gate on requireVerification)
|