@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 CHANGED
@@ -9,6 +9,13 @@ downloading tarballs.
9
9
 
10
10
  ## Database tables (after all migrations)
11
11
 
12
+ ### `fonderie_consumed_tokens`
13
+
14
+ ```sql
15
+ token_hash TEXT PRIMARY KEY
16
+ expires_at TIMESTAMPTZ NOT NULL
17
+ ```
18
+
12
19
  ### `fonderie_email_verifications`
13
20
 
14
21
  ```sql
@@ -130,12 +137,12 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
130
137
  | Method | Path | Middleware chain (auth / validation / handler) |
131
138
  |---|---|---|
132
139
  | GET | `/auth/apple` | `oauth.appleInit` |
133
- | POST | `/auth/apple/callback` | `oauth.appleCallback` |
140
+ | POST | `/auth/apple/callback` | `ipLimit('login') → oauth.appleCallback` |
134
141
  | POST | `/auth/apple/native` | `ipLimit('login') → validate(appleNativeSchema) → oauth.appleNative` |
135
142
  | POST | `/auth/email/forgot` | `ipLimit('forgot') → validate(forgotPasswordSchema) → acctLimit('forgot') → auth.forgotPassword` |
136
143
  | POST | `/auth/email/reset` | `ipLimit('reset') → validate(resetPasswordSchema) → auth.resetPassword` |
137
144
  | GET | `/auth/google` | `oauth.googleInit` |
138
- | GET | `/auth/google/callback` | `oauth.googleCallback` |
145
+ | GET | `/auth/google/callback` | `ipLimit('login') → oauth.googleCallback` |
139
146
  | POST | `/auth/login` | `ipLimit('login') → validate(loginSchema) → acctLimit('login') → auth.login` |
140
147
  | GET | `/auth/login-history` | `requireAuth → user.loginHistory` |
141
148
  | POST | `/auth/logout` | `requireAuth → validate(refreshSchema) → auth.logout` |
package/dist/index.cjs CHANGED
@@ -736,7 +736,8 @@ var UserModel = class {
736
736
  `INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
737
737
  VALUES ($1, now(), $2, $3)
738
738
  ON CONFLICT (email) DO UPDATE
739
- SET provider = $2, provider_id = $3
739
+ SET provider = $2, provider_id = $3,
740
+ email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
740
741
  RETURNING id`,
741
742
  [email2, provider, providerId]
742
743
  );
@@ -2304,6 +2305,34 @@ function userController(store, config, bus) {
2304
2305
  var import_node_crypto7 = require("crypto");
2305
2306
  var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2306
2307
  var import_core8 = require("@fonderie/core");
2308
+
2309
+ // src/models/consumed-token.model.ts
2310
+ var ConsumedTokenModel = class {
2311
+ constructor(store) {
2312
+ this.store = store;
2313
+ }
2314
+ store;
2315
+ // True if the token had not been consumed before (caller may proceed); false
2316
+ // if this is a replay. `expiresAt` is the token's own exp — its TTL here.
2317
+ async consumeOnce(tokenHash, expiresAt) {
2318
+ const rows = await this.store.query(
2319
+ `INSERT INTO fonderie_consumed_tokens (token_hash, expires_at)
2320
+ VALUES ($1, $2)
2321
+ ON CONFLICT (token_hash) DO NOTHING
2322
+ RETURNING token_hash`,
2323
+ [tokenHash, expiresAt]
2324
+ );
2325
+ return rows.length > 0;
2326
+ }
2327
+ // Opportunistic, fire-and-forget cleanup of expired rows (index-backed).
2328
+ // Recording history must never block or fail a login.
2329
+ sweepExpiredSafe() {
2330
+ void this.store.query(`DELETE FROM fonderie_consumed_tokens WHERE expires_at < now()`).catch(() => {
2331
+ });
2332
+ }
2333
+ };
2334
+
2335
+ // src/controllers/oauth.controller.ts
2307
2336
  var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
2308
2337
  var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
2309
2338
  var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
@@ -2317,18 +2346,34 @@ function mintAppleClientSecret(apple) {
2317
2346
  );
2318
2347
  }
2319
2348
  var appleKeyCache = null;
2349
+ var appleKeyInflight = null;
2350
+ var appleKeyLastAttempt = 0;
2320
2351
  var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
2321
- async function getApplePublicKey(kid) {
2322
- const fresh = appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2323
- if (!fresh || !appleKeyCache.keys.some((k) => k.kid === kid)) {
2352
+ var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
2353
+ var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
2354
+ function refreshAppleKeys() {
2355
+ if (appleKeyInflight) return appleKeyInflight;
2356
+ appleKeyLastAttempt = Date.now();
2357
+ appleKeyInflight = (async () => {
2324
2358
  try {
2325
- const res = await fetch(APPLE_KEYS_URL);
2359
+ const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
2326
2360
  const data = await res.json();
2327
2361
  if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
2328
2362
  } catch {
2363
+ } finally {
2364
+ appleKeyInflight = null;
2329
2365
  }
2366
+ })();
2367
+ return appleKeyInflight;
2368
+ }
2369
+ async function getApplePublicKey(kid) {
2370
+ const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
2371
+ const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2372
+ let jwk = find();
2373
+ if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
2374
+ await refreshAppleKeys();
2375
+ jwk = find();
2330
2376
  }
2331
- const jwk = appleKeyCache?.keys.find((k) => k.kid === kid);
2332
2377
  if (!jwk) return null;
2333
2378
  try {
2334
2379
  return (0, import_node_crypto7.createPublicKey)({ key: jwk, format: "jwk" });
@@ -2364,6 +2409,7 @@ function oauthController(store, config) {
2364
2409
  const users = new UserModel(store);
2365
2410
  const sessions = new SessionModel(store);
2366
2411
  const loginEvents = new LoginEventModel(store);
2412
+ const consumedTokens = new ConsumedTokenModel(store);
2367
2413
  const completeAppleLogin = async (ctx, claims) => {
2368
2414
  const meta = requestMeta(ctx);
2369
2415
  if (!claims.email) {
@@ -2673,6 +2719,26 @@ function oauthController(store, config) {
2673
2719
  });
2674
2720
  return (0, import_core8.setApiResponse)(import_core8.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2675
2721
  }
2722
+ const tokenHash = (0, import_node_crypto7.createHash)("sha256").update(identityToken).digest("hex");
2723
+ const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
2724
+ const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
2725
+ if (!firstUse) {
2726
+ const meta = requestMeta(ctx);
2727
+ loginEvents.recordSafe({
2728
+ userId: null,
2729
+ emailAttempted: claims.email ?? null,
2730
+ method: "oauth-apple",
2731
+ outcome: "failed",
2732
+ failureReason: "token_replayed",
2733
+ ...meta
2734
+ });
2735
+ return (0, import_core8.setApiResponse)(
2736
+ import_core8.HTTP.UNAUTHORIZED,
2737
+ "APPLE_AUTH_FAILED",
2738
+ "This Apple identity token has already been used"
2739
+ );
2740
+ }
2741
+ consumedTokens.sweepExpiredSafe();
2676
2742
  return completeAppleLogin(ctx, {
2677
2743
  email: claims.email,
2678
2744
  sub: claims.sub,
@@ -2744,15 +2810,18 @@ function buildAuthRoutes(store, config, bus) {
2744
2810
  if (config.providers.includes("google")) {
2745
2811
  routes.push(
2746
2812
  ["GET", "/auth/google", oauth.googleInit],
2747
- ["GET", "/auth/google/callback", oauth.googleCallback]
2813
+ // ipLimit: an unauthenticated caller can otherwise force one outbound
2814
+ // token-exchange to Google per request (the state check doesn't gate it).
2815
+ ["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
2748
2816
  );
2749
2817
  }
2750
2818
  if (config.providers.includes("apple")) {
2751
2819
  routes.push(
2752
2820
  // Web redirect flow. The callback is a POST (response_mode=form_post);
2753
- // its urlencoded body is parsed inside the controller.
2821
+ // its urlencoded body is parsed inside the controller. ipLimit for the
2822
+ // same reason as Google's callback (bounds outbound token exchanges).
2754
2823
  ["GET", "/auth/apple", oauth.appleInit],
2755
- ["POST", "/auth/apple/callback", oauth.appleCallback],
2824
+ ["POST", "/auth/apple/callback", ipLimit("login"), oauth.appleCallback],
2756
2825
  // Native (iOS) flow: the app posts the identityToken from the Apple
2757
2826
  // sheet. ipLimit guards token brute-forcing; validate bounds the body.
2758
2827
  ["POST", "/auth/apple/native", ipLimit("login"), (0, import_middlewares.validate)(appleNativeSchema), oauth.appleNative]
@@ -2819,6 +2888,13 @@ function collectAuthConfigProblems(config) {
2819
2888
  message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
2820
2889
  });
2821
2890
  }
2891
+ if (a.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
2892
+ problems.push({
2893
+ module: MODULE,
2894
+ severity: "error",
2895
+ message: "apple.nativeClientIds must be exact bundle ids \u2014 empty or wildcard entries would accept identity tokens minted for other apps"
2896
+ });
2897
+ }
2822
2898
  }
2823
2899
  if (config.mfa && !config.mfaSecretKey) {
2824
2900
  problems.push({