← back to Costa Rica
test/apple-verify.test.js
121 lines
'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}`);
});