← back to Costa Rica
test/apple.test.js
97 lines
'use strict';
// Offline tests (node:test) for the Sign in with Apple identity-token verifier.
// No network: we generate our own RSA keypair, stub Apple's JWKS endpoint with it,
// and sign our own tokens. Guards the c2 account-takeover fix — email_verified must
// be a real boolean, never a truthy string smuggled in.
// Run: node --test
const { test, before } = require('node:test');
const assert = require('node:assert');
const crypto = require('crypto');
// aud must match apple.js's default (process.env.APPLE_AUD || 'com.abrams.costarica')
const AUD = 'com.abrams.costarica';
const ISS = 'https://appleid.apple.com';
const KID = 'test-key-1';
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = { ...publicKey.export({ format: 'jwk' }), kid: KID, alg: 'RS256', use: 'sig' };
const b64u = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
function signToken(payload, { kid = KID } = {}) {
const h = b64u({ alg: 'RS256', kid });
const p = b64u({ iss: ISS, aud: AUD, exp: Math.floor(Date.now() / 1000) + 600, ...payload });
const sig = crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), privateKey).toString('base64url');
return `${h}.${p}.${sig}`;
}
let verifyIdentityToken;
before(() => {
// Stub Apple's JWKS endpoint with our test key BEFORE requiring the module.
global.fetch = async () => ({ ok: true, json: async () => ({ keys: [jwk] }) });
({ verifyIdentityToken } = require('../lib/apple'));
});
test('valid token returns sub + email + email_verified', async () => {
const claims = await verifyIdentityToken(signToken({ sub: 'apple-001', email: 'a@b.com', email_verified: 'true' }));
assert.equal(claims.sub, 'apple-001');
assert.equal(claims.email, 'a@b.com');
assert.equal(claims.email_verified, true);
});
test('email_verified normalizes both the string "true" and boolean true → true', async () => {
assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: 'true' }))).email_verified, true);
assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: true }))).email_verified, true);
});
// Verifier-layer half of the c2 guard: the boolean the route gate depends on is
// only ever true for a genuine Apple-verified email. (The route gate itself is
// exercised in apple-route.test.js.)
test('email_verified is strictly true only for Apple "true"/true — false/missing/truthy-junk → false', async () => {
assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: 'false' }))).email_verified, false);
assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: false }))).email_verified, false);
assert.equal((await verifyIdentityToken(signToken({ sub: 's' }))).email_verified, false); // absent
assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: 1 }))).email_verified, false); // strict: truthy 1 is NOT verified
assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: 'TRUE' }))).email_verified, false); // strict: wrong case
});
test('rejects a tampered signature', async () => {
const tok = signToken({ sub: 's' });
const bad = tok.slice(0, -3) + (tok.slice(-3) === 'AAA' ? 'BBB' : 'AAA');
await assert.rejects(() => verifyIdentityToken(bad), /signature invalid/);
});
test('rejects a wrong audience', async () => {
const h = b64u({ alg: 'RS256', kid: KID });
const p = b64u({ iss: ISS, aud: 'com.someone.else', exp: Math.floor(Date.now() / 1000) + 600, sub: 's' });
const sig = crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), privateKey).toString('base64url');
await assert.rejects(() => verifyIdentityToken(`${h}.${p}.${sig}`), /bad aud/);
});
test('rejects a wrong issuer', async () => {
const h = b64u({ alg: 'RS256', kid: KID });
const p = b64u({ iss: 'https://evil.example', aud: AUD, exp: Math.floor(Date.now() / 1000) + 600, sub: 's' });
const sig = crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), privateKey).toString('base64url');
await assert.rejects(() => verifyIdentityToken(`${h}.${p}.${sig}`), /bad iss/);
});
async function signRaw(payload) {
const h = b64u({ alg: 'RS256', kid: KID });
const p = b64u(payload);
const sig = crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), privateKey).toString('base64url');
return `${h}.${p}.${sig}`;
}
test('rejects an expired token (incl. the exp:0 falsy-zero edge)', async () => {
await assert.rejects(() => verifyIdentityToken(signToken({ sub: 's', exp: Math.floor(Date.now() / 1000) - 60 })), /expired/);
// exp:0 (Unix epoch) is clearly expired — must NOT be accepted via a `payload.exp &&` short-circuit.
const epochTok = await signRaw({ iss: ISS, aud: AUD, exp: 0, sub: 's' });
await assert.rejects(() => verifyIdentityToken(epochTok), /expired/);
});
test('rejects a malformed token and an unknown signing key', async () => {
await assert.rejects(() => verifyIdentityToken('not.a.jwt.at.all'), /malformed/);
await assert.rejects(() => verifyIdentityToken('abc'), /malformed/);
await assert.rejects(() => verifyIdentityToken(signToken({ sub: 's' }, { kid: 'unknown-kid' })), /signing key not found/);
});