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