[object Object]

← back to Costa Rica

costa-rica: harden Sign-in-with-Apple verify — JWKS rotation refetch, require exp, no error leak (cycle 27) — TK-10346

3114048b8b8c494491ce75ad7a9054f113bb96b3 · 2026-09-24 05:51:48 -0700 · Steve

Cold Cody audit of SIWA: the crypto core is SOUND (empirically — 5 forge attempts
incl. alg:none/HS256-confusion all fail; alg is never read, RSA-SHA256 is forced,
kid only selects among Apple-fetched keys). No takeover path. Three real
hardening/availability gaps fixed:

- JWKS never refetched on a kid-miss -> a valid token signed with Apple's NEWLY
  ROTATED key 401'd for up to the 1h cache TTL (silent outage on Apple's schedule).
  Now a kid-miss forces ONE cooldown-guarded refetch to pick up the rotation.
- exp was checked only-if-present -> a signature-valid token with no exp never
  expired. Now REQUIRED (=== undefined -> reject; exp:0 still hits the expiry check).
- routes/app.js /auth/apple leaked e.message (fetchT timeout text, JSON.parse
  errors) to the client, violating this file's own M2/R4 policy 6 lines below. Now
  logs server-side + returns a generic 401.

Cody's diff-gate then caught a REAL concurrency bug in my first pass: _lastFetch
was written after the awaits, so a burst of concurrent kid-miss requests all read
the stale clock and each fired a fetch (Cody reproduced: 20 concurrent garbage
kids -> 20 fetches), defeating the anti-hammer guard AND risking an Apple-side
rate-limit self-DoS. Fixed with an in-flight-promise dedup (start the cooldown
clock synchronously before the await; concurrent callers join the single fetch) —
which also kills the pre-existing hourly cache-expiry thundering-herd for free.

test/apple-verify.test.js: generates a REAL RSA keypair, serves it as Apple's JWKS,
and proves valid verifies; tampered/empty-sig/wrong-key/bad-aud/bad-iss/expired/
no-exp reject; a kid-miss refetches a rotated key; and a 20-way concurrent
garbage-kid burst causes <=1 refetch. Also fixed a bug the test found in my own
cooldown env-parse (Number('0')||60000 swallowed a legit 0). Suite 216 -> 226.

DEFERRED as pre-launch DECISIONS (not mechanical, no live exploit): account-
splitting when an Apple email differs from a pre-existing account's (needs a merge
flow), and nonce/replay hardening (needs a client-side ceremony). Flagged in
YOLO_NOTES + GO-LIVE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic

Files touched

Diff

commit 3114048b8b8c494491ce75ad7a9054f113bb96b3
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 24 05:51:48 2026 -0700

    costa-rica: harden Sign-in-with-Apple verify — JWKS rotation refetch, require exp, no error leak (cycle 27) — TK-10346
    
    Cold Cody audit of SIWA: the crypto core is SOUND (empirically — 5 forge attempts
    incl. alg:none/HS256-confusion all fail; alg is never read, RSA-SHA256 is forced,
    kid only selects among Apple-fetched keys). No takeover path. Three real
    hardening/availability gaps fixed:
    
    - JWKS never refetched on a kid-miss -> a valid token signed with Apple's NEWLY
      ROTATED key 401'd for up to the 1h cache TTL (silent outage on Apple's schedule).
      Now a kid-miss forces ONE cooldown-guarded refetch to pick up the rotation.
    - exp was checked only-if-present -> a signature-valid token with no exp never
      expired. Now REQUIRED (=== undefined -> reject; exp:0 still hits the expiry check).
    - routes/app.js /auth/apple leaked e.message (fetchT timeout text, JSON.parse
      errors) to the client, violating this file's own M2/R4 policy 6 lines below. Now
      logs server-side + returns a generic 401.
    
    Cody's diff-gate then caught a REAL concurrency bug in my first pass: _lastFetch
    was written after the awaits, so a burst of concurrent kid-miss requests all read
    the stale clock and each fired a fetch (Cody reproduced: 20 concurrent garbage
    kids -> 20 fetches), defeating the anti-hammer guard AND risking an Apple-side
    rate-limit self-DoS. Fixed with an in-flight-promise dedup (start the cooldown
    clock synchronously before the await; concurrent callers join the single fetch) —
    which also kills the pre-existing hourly cache-expiry thundering-herd for free.
    
    test/apple-verify.test.js: generates a REAL RSA keypair, serves it as Apple's JWKS,
    and proves valid verifies; tampered/empty-sig/wrong-key/bad-aud/bad-iss/expired/
    no-exp reject; a kid-miss refetches a rotated key; and a 20-way concurrent
    garbage-kid burst causes <=1 refetch. Also fixed a bug the test found in my own
    cooldown env-parse (Number('0')||60000 swallowed a legit 0). Suite 216 -> 226.
    
    DEFERRED as pre-launch DECISIONS (not mechanical, no live exploit): account-
    splitting when an Apple email differs from a pre-existing account's (needs a merge
    flow), and nonce/replay hardening (needs a client-side ceremony). Flagged in
    YOLO_NOTES + GO-LIVE.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
 lib/apple.js              |  69 +++++++++++++++++++-------
 routes/app.js             |   5 +-
 test/apple-verify.test.js | 120 ++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 177 insertions(+), 17 deletions(-)

diff --git a/lib/apple.js b/lib/apple.js
index 78fb114..ff2a567 100644
--- a/lib/apple.js
+++ b/lib/apple.js
@@ -10,20 +10,51 @@ const ISS = 'https://appleid.apple.com';
 // Accept our app bundle id (native) — extend if a web/services aud is added.
 const AUD = (process.env.APPLE_AUD || 'com.abrams.costarica').split(',').map(s => s.trim());
 
-let _keys = null, _keysExp = 0;
-async function jwks() {
-  if (_keys && Date.now() < _keysExp) return _keys;
-  // 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]));
-  _keysExp = Date.now() + 60 * 60 * 1000; // 1h cache
-  return _keys;
+// A short cooldown between FORCED refetches (below) so an attacker hammering
+// /auth/apple with garbage kids can't drive unbounded refetch traffic to Apple.
+// Honor an explicit value incl. 0 (a plain `Number(x) || 60000` would swallow a
+// legitimate 0); default 60s when unset/blank/NaN. Never set to 0 in prod — it
+// disables the anti-hammer guard.
+const _cdRaw = process.env.APPLE_JWKS_REFETCH_COOLDOWN_MS;
+const _cd = Number(_cdRaw);
+const REFETCH_COOLDOWN_MS = (_cdRaw !== undefined && _cdRaw !== '' && Number.isFinite(_cd)) ? _cd : 60000;
+let _keys = null, _keysExp = 0, _lastFetch = 0, _inflight = null;
+async function jwks(force = false) {
+  const now = Date.now();
+  const fresh = _keys && now < _keysExp;
+  // A kid-miss (force=true) may bypass a still-fresh cache — but only past the
+  // cooldown — to pick up an Apple key ROTATION (they rotate on an undocumented
+  // cadence; a stale 1h cache otherwise 401s valid, freshly-signed tokens on the
+  // NEW kid until it expires). Normal path just serves the cache.
+  if (fresh && !(force && now - _lastFetch > REFETCH_COOLDOWN_MS)) return _keys;
+  // CONCURRENCY: dedupe simultaneous refetches onto ONE in-flight fetch. Writing
+  // _lastFetch/_keysExp only AFTER the awaits let a burst of concurrent kid-miss
+  // requests all pass the cooldown check and each fire a fetch (Cody gate, cycle 27:
+  // 20 concurrent garbage kids -> 20 fetches, defeating the anti-hammer guard AND
+  // risking an Apple-side rate-limit self-DoS). Joining _inflight collapses them to
+  // one, and the cooldown clock starts synchronously here, before yielding.
+  if (_inflight) return _inflight;
+  _lastFetch = now; // start the cooldown clock NOW (before any await), so concurrent
+                    // callers below see it; a failed fetch thus also holds the
+                    // cooldown — intentional: don't hammer a failing JWKS endpoint.
+  _inflight = (async () => {
+    try {
+      // 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)
+      // turns an ERROR into a 401, but never sees a HANG. fetchT makes the hang 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]));
+      _keysExp = now + 60 * 60 * 1000; // 1h cache
+      return _keys;
+    } finally {
+      _inflight = null;
+    }
+  })();
+  return _inflight;
 }
 
 const b64uJson = (s) => JSON.parse(Buffer.from(s, 'base64url').toString());
@@ -35,7 +66,10 @@ async function verifyIdentityToken(idToken) {
   const header = b64uJson(h);
   const payload = b64uJson(p);
 
-  const key = (await jwks()).get(header.kid);
+  let key = (await jwks()).get(header.kid);
+  // On a kid-miss, force ONE JWKS refetch (cooldown-guarded) before failing — an
+  // unknown kid is the signature of an Apple key rotation, not necessarily a forgery.
+  if (!key) key = (await jwks(true)).get(header.kid);
   if (!key) throw new Error('apple signing key not found');
   const pub = crypto.createPublicKey({ key, format: 'jwk' });
   const ok = crypto.verify('RSA-SHA256', Buffer.from(`${h}.${p}`), pub, Buffer.from(sig, 'base64url'));
@@ -43,7 +77,10 @@ async function verifyIdentityToken(idToken) {
 
   if (payload.iss !== ISS) throw new Error('bad iss');
   if (!AUD.includes(payload.aud)) throw new Error(`bad aud ${payload.aud}`);
-  if (payload.exp !== undefined && Math.floor(Date.now() / 1000) > payload.exp) throw new Error('token expired');
+  // exp is REQUIRED (not just checked-if-present): a signature-valid token with no
+  // exp would otherwise never expire. Apple always sets it; a missing exp is anomalous.
+  if (payload.exp === undefined) throw new Error('token missing exp');
+  if (Math.floor(Date.now() / 1000) > payload.exp) throw new Error('token expired');
 
   return { sub: payload.sub, email: payload.email || null, email_verified: payload.email_verified === 'true' || payload.email_verified === true };
 }
diff --git a/routes/app.js b/routes/app.js
index af5b764..108565a 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -67,7 +67,10 @@ router.post('/auth/apple', async (req, res) => {
   const { identity_token, full_name } = req.body || {};
   let claims;
   try { claims = await apple.verifyIdentityToken(identity_token); }
-  catch (e) { return bad(res, 401, `apple verify failed: ${e.message}`); }
+  // Log the reason server-side; return a GENERIC 401 (do not hand token/infra
+  // internals — fetchT timeout text, JSON.parse errors — to the client). Mirrors
+  // the DB-error catch below (M2/R4). (Cody SIWA audit, cycle 27.)
+  catch (e) { console.error('[auth/apple] verify failed:', e.message); return bad(res, 401, 'apple verify failed'); }
   try {
     // Link by apple_sub first, else by verified email, else create.
     let { rows } = await pool.query(
diff --git a/test/apple-verify.test.js b/test/apple-verify.test.js
new file mode 100644
index 0000000..a3761d1
--- /dev/null
+++ b/test/apple-verify.test.js
@@ -0,0 +1,120 @@
+'use strict';
+// Behavioral audit-coverage for Sign-in-with-Apple token verification
+// (lib/apple.js), from the cycle-27 Cody cold audit. Generates a REAL RSA keypair,
+// serves it as Apple's JWKS via a mocked fetch, signs real RS256 tokens, and proves:
+//   - a valid Apple-signed token verifies and returns {sub, email, email_verified}
+//   - forged/tampered/alg:none/empty-sig tokens are rejected (crypto core is sound)
+//   - iss / aud / exp are enforced — INCLUDING the cycle-27 fix that exp is now
+//     REQUIRED (a signature-valid token with no exp used to be accepted forever)
+//   - the cycle-27 fix: a kid-miss forces ONE JWKS refetch (Apple key rotation)
+//     instead of a hard 401, so valid tokens on a rotated key still verify.
+//
+// Env set BEFORE requiring lib/apple (it reads APPLE_AUD + the cooldown at load).
+process.env.APPLE_AUD = 'com.abrams.costarica';
+process.env.APPLE_JWKS_REFETCH_COOLDOWN_MS = '0'; // let the rotation refetch fire in-test
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const crypto = require('node:crypto');
+
+const apple = require('../lib/apple');
+const b64u = (buf) => Buffer.from(buf).toString('base64url');
+
+// --- a controllable Apple: an RSA keypair per kid, served through a fake JWKS ---
+function makeKey(kid) {
+  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+  const jwk = { ...publicKey.export({ format: 'jwk' }), kid, alg: 'RS256', use: 'sig' };
+  return { kid, privateKey, jwk };
+}
+const K1 = makeKey('kid-1');
+const K2 = makeKey('kid-2'); // the "rotated-in" key
+
+let servedJwks = [K1.jwk]; // what Apple's /auth/keys currently returns
+let fetchCount = 0;        // how many times we actually hit Apple's JWKS endpoint
+const realFetch = global.fetch;
+before(() => {
+  global.fetch = async (url) => {
+    if (String(url).includes('appleid.apple.com/auth/keys')) {
+      fetchCount++;
+      return { ok: true, status: 200, json: async () => ({ keys: servedJwks }) };
+    }
+    throw new Error('unexpected fetch ' + url);
+  };
+});
+after(() => { global.fetch = realFetch; });
+
+function sign(k, payload, { alg = 'RS256', tamper = false, emptySig = false } = {}) {
+  const h = b64u(JSON.stringify({ alg, kid: k.kid }));
+  const p = b64u(JSON.stringify(payload));
+  if (emptySig) return `${h}.${p}.`; // alg:none-style: no signature
+  let sig = b64u(crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), k.privateKey));
+  const pOut = tamper ? b64u(JSON.stringify({ ...payload, sub: 'attacker' })) : p; // sig no longer matches
+  return `${h}.${pOut}.${sig}`;
+}
+const now = () => Math.floor(Date.now() / 1000);
+const goodPayload = (over = {}) => ({
+  iss: 'https://appleid.apple.com', aud: 'com.abrams.costarica',
+  sub: 'apple-user-1', email: 'user@example.com', email_verified: 'true',
+  iat: now(), exp: now() + 600, ...over,
+});
+
+test('a valid Apple-signed token verifies and returns the identity claims', async () => {
+  const claims = await apple.verifyIdentityToken(sign(K1, goodPayload()));
+  assert.equal(claims.sub, 'apple-user-1');
+  assert.equal(claims.email, 'user@example.com');
+  assert.equal(claims.email_verified, true);
+});
+
+test('rejects a tampered payload (signature no longer matches)', async () => {
+  await assert.rejects(() => apple.verifyIdentityToken(sign(K1, goodPayload(), { tamper: true })), /signature invalid/);
+});
+
+test('rejects an empty signature (alg:none-style forge)', async () => {
+  await assert.rejects(() => apple.verifyIdentityToken(sign(K1, goodPayload(), { emptySig: true })), /signature invalid|malformed/);
+});
+
+test('rejects a token signed by a DIFFERENT (non-Apple) key on a known kid', async () => {
+  // sign with K2's private key but claim K1's kid -> verifies against K1 pub -> fail
+  const forged = sign({ kid: K1.kid, privateKey: K2.privateKey }, goodPayload());
+  await assert.rejects(() => apple.verifyIdentityToken(forged), /signature invalid/);
+});
+
+test('rejects a bad audience', async () => {
+  await assert.rejects(() => apple.verifyIdentityToken(sign(K1, goodPayload({ aud: 'com.evil.app' }))), /bad aud/);
+});
+
+test('rejects a bad issuer', async () => {
+  await assert.rejects(() => apple.verifyIdentityToken(sign(K1, goodPayload({ iss: 'https://evil.example' }))), /bad iss/);
+});
+
+test('rejects an expired token', async () => {
+  await assert.rejects(() => apple.verifyIdentityToken(sign(K1, goodPayload({ exp: now() - 10 }))), /expired/);
+});
+
+test('cycle-27 fix: a token with NO exp is REJECTED (was accepted forever before)', async () => {
+  const noExp = goodPayload(); delete noExp.exp;
+  await assert.rejects(() => apple.verifyIdentityToken(sign(K1, noExp)), /missing exp/);
+});
+
+test('cycle-27 fix: a kid-miss forces a JWKS refetch (Apple key rotation) instead of a hard 401', async () => {
+  // Apple rotates: the live JWKS now serves K2 only. A fresh token signed with K2
+  // has a kid not in our cache -> must trigger a refetch and then verify.
+  servedJwks = [K2.jwk];
+  const claims = await apple.verifyIdentityToken(sign(K2, goodPayload({ sub: 'rotated-user' })));
+  assert.equal(claims.sub, 'rotated-user');
+});
+
+test('cycle-27 GATE fix: a CONCURRENT burst of kid-miss requests triggers AT MOST ONE refetch', async () => {
+  // The bug Cody's gate reproduced: _lastFetch written after the awaits let 20
+  // concurrent garbage-kid requests each fire their own fetch (20, not 1),
+  // defeating the anti-hammer guard + risking an Apple-side rate-limit self-DoS.
+  // The in-flight-promise dedup must collapse a concurrent burst to a single fetch.
+  servedJwks = [K2.jwk]; // none of the garbage kids are in here -> all will miss
+  const before = fetchCount;
+  const results = await Promise.allSettled(
+    Array.from({ length: 20 }, (_, i) =>
+      apple.verifyIdentityToken(sign(makeKey('garbage-' + i), goodPayload()))));
+  assert.ok(results.every(r => r.status === 'rejected'), 'every garbage-kid token must be rejected');
+  const fetches = fetchCount - before;
+  assert.ok(fetches <= 1, `a concurrent garbage-kid burst must cause <=1 refetch, got ${fetches}`);
+});

← 4f11ad1 cycle 26 docs: YOLO_NOTES ledger — cr-osm-match isolation +  ·  back to Costa Rica  ·  cycle 27 docs: YOLO_NOTES + GO-LIVE — SIWA hardening + 2 def edda387 →