← 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
M lib/apple.jsM routes/app.jsA test/apple-verify.test.js
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 →