← 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}`);
});