← 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
M lib/apple.jsA test/apple-jwks-timeout.test.jsA test/apple-route-timeout.test.js
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 →