← back to Costa Rica

lib/apple.js

89 lines

'use strict';
// Sign in with Apple — verify the identity token (a JWT signed by Apple, RS256)
// 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';
// 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());

// 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());

// Returns { sub, email, email_verified } on success; throws on any invalid token.
async function verifyIdentityToken(idToken) {
  if (!idToken || idToken.split('.').length !== 3) throw new Error('malformed identity token');
  const [h, p, sig] = idToken.split('.');
  const header = b64uJson(h);
  const payload = b64uJson(p);

  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'));
  if (!ok) throw new Error('apple signature invalid');

  if (payload.iss !== ISS) throw new Error('bad iss');
  if (!AUD.includes(payload.aud)) throw new Error(`bad aud ${payload.aud}`);
  // 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 };
}

module.exports = { verifyIdentityToken };