@fonderie/auth 7.2.0 → 7.3.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 +9 -2
- package/dist/index.cjs +85 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +86 -10
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +2 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.js +2 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/016_consumed_tokens.sql +13 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -696,7 +696,8 @@ var UserModel = class {
|
|
|
696
696
|
`INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
|
|
697
697
|
VALUES ($1, now(), $2, $3)
|
|
698
698
|
ON CONFLICT (email) DO UPDATE
|
|
699
|
-
SET provider = $2, provider_id = $3
|
|
699
|
+
SET provider = $2, provider_id = $3,
|
|
700
|
+
email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
|
|
700
701
|
RETURNING id`,
|
|
701
702
|
[email2, provider, providerId]
|
|
702
703
|
);
|
|
@@ -2261,9 +2262,37 @@ function userController(store, config, bus) {
|
|
|
2261
2262
|
}
|
|
2262
2263
|
|
|
2263
2264
|
// src/controllers/oauth.controller.ts
|
|
2264
|
-
import { randomBytes as randomBytes4, createPublicKey } from "crypto";
|
|
2265
|
+
import { randomBytes as randomBytes4, createPublicKey, createHash as createHash2 } from "crypto";
|
|
2265
2266
|
import jwt2 from "jsonwebtoken";
|
|
2266
2267
|
import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2 } from "@fonderie/core";
|
|
2268
|
+
|
|
2269
|
+
// src/models/consumed-token.model.ts
|
|
2270
|
+
var ConsumedTokenModel = class {
|
|
2271
|
+
constructor(store) {
|
|
2272
|
+
this.store = store;
|
|
2273
|
+
}
|
|
2274
|
+
store;
|
|
2275
|
+
// True if the token had not been consumed before (caller may proceed); false
|
|
2276
|
+
// if this is a replay. `expiresAt` is the token's own exp — its TTL here.
|
|
2277
|
+
async consumeOnce(tokenHash, expiresAt) {
|
|
2278
|
+
const rows = await this.store.query(
|
|
2279
|
+
`INSERT INTO fonderie_consumed_tokens (token_hash, expires_at)
|
|
2280
|
+
VALUES ($1, $2)
|
|
2281
|
+
ON CONFLICT (token_hash) DO NOTHING
|
|
2282
|
+
RETURNING token_hash`,
|
|
2283
|
+
[tokenHash, expiresAt]
|
|
2284
|
+
);
|
|
2285
|
+
return rows.length > 0;
|
|
2286
|
+
}
|
|
2287
|
+
// Opportunistic, fire-and-forget cleanup of expired rows (index-backed).
|
|
2288
|
+
// Recording history must never block or fail a login.
|
|
2289
|
+
sweepExpiredSafe() {
|
|
2290
|
+
void this.store.query(`DELETE FROM fonderie_consumed_tokens WHERE expires_at < now()`).catch(() => {
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
|
|
2295
|
+
// src/controllers/oauth.controller.ts
|
|
2267
2296
|
var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
|
|
2268
2297
|
var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
|
|
2269
2298
|
var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
|
|
@@ -2277,18 +2306,34 @@ function mintAppleClientSecret(apple) {
|
|
|
2277
2306
|
);
|
|
2278
2307
|
}
|
|
2279
2308
|
var appleKeyCache = null;
|
|
2309
|
+
var appleKeyInflight = null;
|
|
2310
|
+
var appleKeyLastAttempt = 0;
|
|
2280
2311
|
var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2312
|
+
var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
|
|
2313
|
+
var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
|
|
2314
|
+
function refreshAppleKeys() {
|
|
2315
|
+
if (appleKeyInflight) return appleKeyInflight;
|
|
2316
|
+
appleKeyLastAttempt = Date.now();
|
|
2317
|
+
appleKeyInflight = (async () => {
|
|
2284
2318
|
try {
|
|
2285
|
-
const res = await fetch(APPLE_KEYS_URL);
|
|
2319
|
+
const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
|
|
2286
2320
|
const data = await res.json();
|
|
2287
2321
|
if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
|
|
2288
2322
|
} catch {
|
|
2323
|
+
} finally {
|
|
2324
|
+
appleKeyInflight = null;
|
|
2289
2325
|
}
|
|
2326
|
+
})();
|
|
2327
|
+
return appleKeyInflight;
|
|
2328
|
+
}
|
|
2329
|
+
async function getApplePublicKey(kid) {
|
|
2330
|
+
const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
|
|
2331
|
+
const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
|
|
2332
|
+
let jwk = find();
|
|
2333
|
+
if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
|
|
2334
|
+
await refreshAppleKeys();
|
|
2335
|
+
jwk = find();
|
|
2290
2336
|
}
|
|
2291
|
-
const jwk = appleKeyCache?.keys.find((k) => k.kid === kid);
|
|
2292
2337
|
if (!jwk) return null;
|
|
2293
2338
|
try {
|
|
2294
2339
|
return createPublicKey({ key: jwk, format: "jwk" });
|
|
@@ -2324,6 +2369,7 @@ function oauthController(store, config) {
|
|
|
2324
2369
|
const users = new UserModel(store);
|
|
2325
2370
|
const sessions = new SessionModel(store);
|
|
2326
2371
|
const loginEvents = new LoginEventModel(store);
|
|
2372
|
+
const consumedTokens = new ConsumedTokenModel(store);
|
|
2327
2373
|
const completeAppleLogin = async (ctx, claims) => {
|
|
2328
2374
|
const meta = requestMeta(ctx);
|
|
2329
2375
|
if (!claims.email) {
|
|
@@ -2633,6 +2679,26 @@ function oauthController(store, config) {
|
|
|
2633
2679
|
});
|
|
2634
2680
|
return setApiResponse5(HTTP5.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
2635
2681
|
}
|
|
2682
|
+
const tokenHash = createHash2("sha256").update(identityToken).digest("hex");
|
|
2683
|
+
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
2684
|
+
const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
|
|
2685
|
+
if (!firstUse) {
|
|
2686
|
+
const meta = requestMeta(ctx);
|
|
2687
|
+
loginEvents.recordSafe({
|
|
2688
|
+
userId: null,
|
|
2689
|
+
emailAttempted: claims.email ?? null,
|
|
2690
|
+
method: "oauth-apple",
|
|
2691
|
+
outcome: "failed",
|
|
2692
|
+
failureReason: "token_replayed",
|
|
2693
|
+
...meta
|
|
2694
|
+
});
|
|
2695
|
+
return setApiResponse5(
|
|
2696
|
+
HTTP5.UNAUTHORIZED,
|
|
2697
|
+
"APPLE_AUTH_FAILED",
|
|
2698
|
+
"This Apple identity token has already been used"
|
|
2699
|
+
);
|
|
2700
|
+
}
|
|
2701
|
+
consumedTokens.sweepExpiredSafe();
|
|
2636
2702
|
return completeAppleLogin(ctx, {
|
|
2637
2703
|
email: claims.email,
|
|
2638
2704
|
sub: claims.sub,
|
|
@@ -2704,15 +2770,18 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2704
2770
|
if (config.providers.includes("google")) {
|
|
2705
2771
|
routes.push(
|
|
2706
2772
|
["GET", "/auth/google", oauth.googleInit],
|
|
2707
|
-
|
|
2773
|
+
// ipLimit: an unauthenticated caller can otherwise force one outbound
|
|
2774
|
+
// token-exchange to Google per request (the state check doesn't gate it).
|
|
2775
|
+
["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
|
|
2708
2776
|
);
|
|
2709
2777
|
}
|
|
2710
2778
|
if (config.providers.includes("apple")) {
|
|
2711
2779
|
routes.push(
|
|
2712
2780
|
// Web redirect flow. The callback is a POST (response_mode=form_post);
|
|
2713
|
-
// its urlencoded body is parsed inside the controller.
|
|
2781
|
+
// its urlencoded body is parsed inside the controller. ipLimit for the
|
|
2782
|
+
// same reason as Google's callback (bounds outbound token exchanges).
|
|
2714
2783
|
["GET", "/auth/apple", oauth.appleInit],
|
|
2715
|
-
["POST", "/auth/apple/callback", oauth.appleCallback],
|
|
2784
|
+
["POST", "/auth/apple/callback", ipLimit("login"), oauth.appleCallback],
|
|
2716
2785
|
// Native (iOS) flow: the app posts the identityToken from the Apple
|
|
2717
2786
|
// sheet. ipLimit guards token brute-forcing; validate bounds the body.
|
|
2718
2787
|
["POST", "/auth/apple/native", ipLimit("login"), validate(appleNativeSchema), oauth.appleNative]
|
|
@@ -2779,6 +2848,13 @@ function collectAuthConfigProblems(config) {
|
|
|
2779
2848
|
message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
|
|
2780
2849
|
});
|
|
2781
2850
|
}
|
|
2851
|
+
if (a.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
|
|
2852
|
+
problems.push({
|
|
2853
|
+
module: MODULE,
|
|
2854
|
+
severity: "error",
|
|
2855
|
+
message: "apple.nativeClientIds must be exact bundle ids \u2014 empty or wildcard entries would accept identity tokens minted for other apps"
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2782
2858
|
}
|
|
2783
2859
|
if (config.mfa && !config.mfaSecretKey) {
|
|
2784
2860
|
problems.push({
|