@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/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
- async function getApplePublicKey(kid) {
2282
- const fresh = appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2283
- if (!fresh || !appleKeyCache.keys.some((k) => k.kid === kid)) {
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
- ["GET", "/auth/google/callback", oauth.googleCallback]
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({