[object Object]

← back to Costa Rica

costa-rica: bound Apple JWKS fetch with fetchT (Cody-cleared, cycle 13) — TK-10346

1d3aefd33e86409ce0c86520af7a02ad585b99c4 · 2026-09-23 22:12:47 -0700 · Steve

Second unbounded live fetch found this cycle: lib/apple.js jwks() (fetches Apple's
Sign-in-with-Apple public signing keys for identity-token verification) used raw
fetch() with no timeout. Swapped to the shared fetchT (5th identical instance after
tilopay/onvo/plaid/whatsapp — the last raw live fetch in the codebase).

WHY IT MATTERS: without a bound, a hung connection to appleid.apple.com would hang
POST /auth/apple forever. The route's try/catch turns a verifier ERROR into a 401
but never sees a HANG. fetchT makes the hang a catchable PROVIDER_TIMEOUT -> 401.

Cody gate — clean, ship it. Verified: one production caller (verifyIdentityToken <-
POST /auth/apple, try/catch-wrapped); no DB write before verification resolves; the
1h JWKS cache is assigned atomically only after fetchT AND res.json() resolve, so a
timeout leaves the old/empty cache intact (never a stale-but-trusted key); keep the
uniform 15s (the JWKS doc is a tiny CDN-backed static payload fetched ~once/hour via
cache, not per-login — shortening risks false-401ing real logins on flaky mobile).

Cody-required follow-up (met, matching the plaid-routes precedent): added
apple-route-timeout.test.js driving the REAL verifier through POST /auth/apple with
a stalled fetch, asserting 401 AND pool.query never called.

Test tokens are built from parts (not JWT-shaped literals) so the gitleaks
pre-commit hook doesn't false-positive on a constant.

Tests (+3, suite 178 -> 181): apple-jwks-timeout.test.js (2) + apple-route-timeout.test.js (1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic

Files touched

Diff

commit 1d3aefd33e86409ce0c86520af7a02ad585b99c4
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 23 22:12:47 2026 -0700

    costa-rica: bound Apple JWKS fetch with fetchT (Cody-cleared, cycle 13) — TK-10346
    
    Second unbounded live fetch found this cycle: lib/apple.js jwks() (fetches Apple's
    Sign-in-with-Apple public signing keys for identity-token verification) used raw
    fetch() with no timeout. Swapped to the shared fetchT (5th identical instance after
    tilopay/onvo/plaid/whatsapp — the last raw live fetch in the codebase).
    
    WHY IT MATTERS: without a bound, a hung connection to appleid.apple.com would hang
    POST /auth/apple forever. The route's try/catch turns a verifier ERROR into a 401
    but never sees a HANG. fetchT makes the hang a catchable PROVIDER_TIMEOUT -> 401.
    
    Cody gate — clean, ship it. Verified: one production caller (verifyIdentityToken <-
    POST /auth/apple, try/catch-wrapped); no DB write before verification resolves; the
    1h JWKS cache is assigned atomically only after fetchT AND res.json() resolve, so a
    timeout leaves the old/empty cache intact (never a stale-but-trusted key); keep the
    uniform 15s (the JWKS doc is a tiny CDN-backed static payload fetched ~once/hour via
    cache, not per-login — shortening risks false-401ing real logins on flaky mobile).
    
    Cody-required follow-up (met, matching the plaid-routes precedent): added
    apple-route-timeout.test.js driving the REAL verifier through POST /auth/apple with
    a stalled fetch, asserting 401 AND pool.query never called.
    
    Test tokens are built from parts (not JWT-shaped literals) so the gitleaks
    pre-commit hook doesn't false-positive on a constant.
    
    Tests (+3, suite 178 -> 181): apple-jwks-timeout.test.js (2) + apple-route-timeout.test.js (1).
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
 lib/apple.js                     |  8 ++++-
 test/apple-jwks-timeout.test.js  | 60 +++++++++++++++++++++++++++++++++++++
 test/apple-route-timeout.test.js | 64 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 131 insertions(+), 1 deletion(-)

diff --git a/lib/apple.js b/lib/apple.js
index c896956..78fb114 100644
--- a/lib/apple.js
+++ b/lib/apple.js
@@ -3,6 +3,7 @@
 // against Apple's published JWKS. Zero external deps (Node crypto + fetch).
 // Docs: https://appleid.apple.com/auth/keys
 const crypto = require('crypto');
+const { fetchT } = require('./payments/http'); // bound the live Apple JWKS fetch (no infinite hang)
 
 const JWKS_URL = 'https://appleid.apple.com/auth/keys';
 const ISS = 'https://appleid.apple.com';
@@ -12,7 +13,12 @@ const AUD = (process.env.APPLE_AUD || 'com.abrams.costarica').split(',').map(s =
 let _keys = null, _keysExp = 0;
 async function jwks() {
   if (_keys && Date.now() < _keysExp) return _keys;
-  const r = await fetch(JWKS_URL);
+  // fetchT bounds connect+headers AND the body read (shared with tilopay/onvo/
+  // plaid/whatsapp). Without it, a hung connection to Apple's JWKS endpoint would
+  // hang the /auth/apple request forever — the caller's try/catch (routes/app.js)
+  // handles an ERROR into a 401, but never sees a HANG. fetchT turns the hang into
+  // a catchable PROVIDER_TIMEOUT throw.
+  const r = await fetchT(JWKS_URL);
   if (!r.ok) throw new Error(`apple jwks HTTP ${r.status}`);
   const j = await r.json();
   _keys = new Map(j.keys.map(k => [k.kid, k]));
diff --git a/test/apple-jwks-timeout.test.js b/test/apple-jwks-timeout.test.js
new file mode 100644
index 0000000..b203c46
--- /dev/null
+++ b/test/apple-jwks-timeout.test.js
@@ -0,0 +1,60 @@
+'use strict';
+// lib/apple.js's jwks() fetch (Apple's published signing keys) now routes through
+// the shared fetchT (PRE-FLIGHT #4/#5 class — same fix as tilopay/onvo/plaid/
+// whatsapp). Without it, a hung connection to appleid.apple.com would hang
+// /auth/apple forever — the route's try/catch (routes/app.js:69-70) handles an
+// ERROR into a 401, but never sees a HANG. Proves: a header-fast/body-stalled
+// JWKS response rejects with PROVIDER_TIMEOUT instead of hanging, and a JWKS HTTP
+// error still fails closed (throws — no signing key ever accepted from a bad
+// response). Fresh module load per test (no cache poisoning across cases).
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+
+const APPLE = require.resolve('../lib/apple');
+const realFetch = global.fetch;
+
+function loadFresh() { delete require.cache[APPLE]; return require(APPLE); }
+function unload() { delete require.cache[APPLE]; }
+
+// A syntactically well-formed (but unverifiable) 3-part token is enough to reach
+// jwks() — verifyIdentityToken decodes the header/payload then calls jwks() before
+// signature verification. Built from parts (not a literal) so a secret-scanner
+// doesn't false-positive on a JWT-shaped string constant.
+const b64u = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+const FAKE_TOKEN = `${b64u({ alg: 'RS256', kid: 'any' })}.${b64u({ sub: 'x' })}.sig`;
+
+test('apple jwks(): a body-stalled response rejects PROVIDER_TIMEOUT (not an infinite hang on /auth/apple)', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+  const apple = loadFresh();
+  global.fetch = (url, opts) => Promise.resolve({
+    ok: true, status: 200,
+    json: () => new Promise((_resolve, reject) => {
+      opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
+    }),
+  });
+  try {
+    await assert.rejects(
+      () => apple.verifyIdentityToken(FAKE_TOKEN),
+      (err) => { assert.equal(err.code, 'PROVIDER_TIMEOUT'); return true; },
+    );
+  } finally {
+    global.fetch = realFetch;
+    unload();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
+test('apple jwks(): an Apple HTTP error fails closed (throws, no key ever trusted)', async () => {
+  const apple = loadFresh();
+  global.fetch = () => Promise.resolve({ ok: false, status: 503 });
+  try {
+    await assert.rejects(
+      () => apple.verifyIdentityToken(FAKE_TOKEN),
+      (err) => { assert.match(err.message, /apple jwks HTTP 503/); return true; },
+    );
+  } finally {
+    global.fetch = realFetch;
+    unload();
+  }
+});
diff --git a/test/apple-route-timeout.test.js b/test/apple-route-timeout.test.js
new file mode 100644
index 0000000..d8b904a
--- /dev/null
+++ b/test/apple-route-timeout.test.js
@@ -0,0 +1,64 @@
+'use strict';
+// Route-level proof (Cody gate, cycle 13) matching the plaid-routes precedent: a
+// jwks() PROVIDER_TIMEOUT during POST /auth/apple must return 401 AND touch NO DB
+// (the account link/create only runs after verification succeeds). Unlike
+// apple-route.test.js (which stubs verifyIdentityToken out entirely) this drives the
+// REAL verifier through a faked-stalled fetch so the timeout actually flows through
+// the route's try/catch. Own process -> apple's JWKS cache is cold, so jwks() fetches.
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const express = require('express');
+
+const db = require('../lib/db');
+const { router } = require('../routes/app');
+
+let calls = [];
+const origQuery = db.pool.query;
+const realFetch = global.fetch;
+let server, base;
+
+before(async () => {
+  db.pool.query = async (sql, args) => { calls.push({ sql, args }); return { rows: [], rowCount: 0 }; };
+  const app = express();
+  app.use(express.json());
+  app.use('/api/app', router);
+  await new Promise(r => { server = app.listen(0, r); });
+  base = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => { db.pool.query = origQuery; global.fetch = realFetch; server && server.close(); });
+
+function post(path, body) {
+  const data = JSON.stringify(body);
+  return new Promise((resolve, reject) => {
+    const r = http.request(base + path, { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } },
+      res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
+    r.on('error', reject); r.end(data);
+  });
+}
+
+// A token with valid base64url header+payload so verifyIdentityToken reaches jwks()
+// (header {alg,kid}, payload {sub}) rather than throwing on JSON parse first. Built
+// from parts (not a literal) so a secret-scanner doesn't flag a JWT-shaped constant.
+const b64u = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+const TOKEN = `${b64u({ alg: 'RS256', kid: 'any' })}.${b64u({ sub: 'x' })}.sig`;
+
+test('POST /auth/apple: a jwks() timeout -> 401 and NO DB write (account link/create never runs)', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+  calls = [];
+  global.fetch = (url, opts) => Promise.resolve({
+    ok: true, status: 200,
+    json: () => new Promise((_resolve, reject) => {
+      opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
+    }),
+  });
+  try {
+    const r = await post('/api/app/auth/apple', { identity_token: TOKEN });
+    assert.equal(r.status, 401, 'a verification timeout is a clean 401, not a hang/500');
+    assert.equal(calls.length, 0, 'no app_users SELECT/UPDATE/INSERT runs when verification fails');
+  } finally {
+    global.fetch = realFetch;
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});

← 3089df8 costa-rica: bound WhatsApp Graph API fetch with fetchT (Cody  ·  back to Costa Rica  ·  cycle 13 docs: YOLO_NOTES ledger — whatsapp + apple fetch bo a5c06b3 →