kn-apple-native-hosted-exchange.test.mjs
480 lines 18.1 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * KN-APPLE-NATIVE-HOSTED-EXCHANGE — seven-tier suite (freeze §KNA.6).
3 *
4 * Covers Apple identity-assertion exchange → hosted session mint + C7 introspect.
5 * No live calls to appleid.apple.com — JWKS and tokens are fixtures under test keys.
6 */
7
8 import assert from 'node:assert/strict';
9 import { createHash, generateKeyPairSync } from 'node:crypto';
10 import fs from 'node:fs';
11 import http from 'node:http';
12 import os from 'node:os';
13 import path from 'node:path';
14 import { fileURLToPath, pathToFileURL } from 'node:url';
15 import { after, before, describe, test } from 'node:test';
16 import jwt from 'jsonwebtoken';
17 import {
18 APPLE_ISS,
19 appleNonceMatches,
20 appleProviderAdvertised,
21 createAppleIdentityVerifier,
22 jwtExpiryToSeconds,
23 parseAppleExchangeBody,
24 } from '../hub/gateway/apple-identity-token.mjs';
25
26 const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27 const SECRET = 'kn-apple-exchange-test-session-secret-not-production';
28 const APPLE_AUD = 'com.example.knowtation.test';
29 const PERF_P95_MS = 250;
30
31 const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
32 const FIXTURE_JWK = {
33 ...publicKey.export({ format: 'jwk' }),
34 kid: 'test-apple-kid-1',
35 use: 'sig',
36 alg: 'RS256',
37 };
38
39 function b64urlJson(obj) {
40 return Buffer.from(JSON.stringify(obj)).toString('base64url');
41 }
42
43 function signAppleFixture(claims, headerExtra = {}) {
44 return jwt.sign(claims, privateKey, {
45 algorithm: 'RS256',
46 header: { kid: FIXTURE_JWK.kid, ...headerExtra },
47 });
48 }
49
50 function validAppleClaims(overrides = {}) {
51 const now = Math.floor(Date.now() / 1000);
52 return {
53 iss: APPLE_ISS,
54 aud: APPLE_AUD,
55 sub: overrides.sub ?? `apple-fixture-sub-${now}`,
56 exp: now + 600,
57 iat: now,
58 ...overrides,
59 };
60 }
61
62 function startServer(app) {
63 return new Promise((resolve, reject) => {
64 const srv = http.createServer(app);
65 srv.listen(0, '127.0.0.1', () => {
66 const { port } = srv.address();
67 resolve({
68 baseUrl: `http://127.0.0.1:${port}`,
69 close: () =>
70 new Promise((res, rej) => {
71 srv.close((err) => (err ? rej(err) : res()));
72 }),
73 });
74 });
75 srv.on('error', reject);
76 });
77 }
78
79 function request(baseUrl, method, urlPath, { body, token, headers } = {}) {
80 const raw = body === undefined ? null : JSON.stringify(body);
81 return new Promise((resolve, reject) => {
82 const u = new URL(baseUrl + urlPath);
83 const req = http.request(
84 {
85 hostname: u.hostname,
86 port: u.port,
87 path: u.pathname + u.search,
88 method,
89 headers: {
90 ...(raw != null
91 ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(raw) }
92 : {}),
93 ...(token ? { Authorization: `Bearer ${token}` } : {}),
94 ...(headers || {}),
95 },
96 },
97 (res) => {
98 let data = '';
99 res.on('data', (c) => {
100 data += c;
101 });
102 res.on('end', () => {
103 let parsed = data;
104 try {
105 parsed = data ? JSON.parse(data) : null;
106 } catch {
107 /* keep raw */
108 }
109 resolve({ status: res.statusCode, body: parsed, headers: res.headers, raw: data });
110 });
111 },
112 );
113 req.on('error', reject);
114 if (raw != null) req.write(raw);
115 req.end();
116 });
117 }
118
119 /**
120 * Dishonest stub: accepts unsigned JWT payload without verifying signature.
121 * Security tier must fail against production code if this behavior were mounted.
122 */
123 function createUnverifiedAcceptStub() {
124 return {
125 async verifyIdentityToken(identityToken) {
126 const parts = String(identityToken || '').split('.');
127 if (parts.length < 2) {
128 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'malformed' };
129 }
130 const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
131 return { ok: true, claims: { appleSub: String(payload.sub || 'forged') } };
132 },
133 };
134 }
135
136 let gw;
137 let verifier;
138
139 async function loadGateway({ offlineLocked = false, appleClientId = APPLE_AUD, sessionSecret = SECRET } = {}) {
140 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kn-apple-exchange-'));
141 process.env.SESSION_SECRET = sessionSecret || '';
142 if (!sessionSecret) delete process.env.SESSION_SECRET;
143 delete process.env.HUB_JWT_SECRET;
144 process.env.APPLE_CLIENT_ID = appleClientId || '';
145 if (!appleClientId) delete process.env.APPLE_CLIENT_ID;
146 process.env.NETLIFY = '1';
147 process.env.CANISTER_URL = '';
148 process.env.BRIDGE_URL = '';
149 process.env.BILLING_ENFORCE = 'false';
150 process.env.KNOWTATION_GATEWAY_DATA_DIR = tmpDir;
151 if (offlineLocked) {
152 process.env.KNOWTATION_OFFLINE_LOCKED_AUTH = 'enabled';
153 } else {
154 delete process.env.KNOWTATION_OFFLINE_LOCKED_AUTH;
155 }
156 delete process.env.GOOGLE_CLIENT_ID;
157 delete process.env.GOOGLE_CLIENT_SECRET;
158 delete process.env.GITHUB_CLIENT_ID;
159 delete process.env.GITHUB_CLIENT_SECRET;
160
161 verifier = createAppleIdentityVerifier({
162 fetchImpl: async () => {
163 throw new Error('live Apple JWKS forbidden in CI');
164 },
165 });
166 verifier.seedJwksCache([FIXTURE_JWK]);
167 globalThis.__knowtation_apple_identity_verifier = verifier;
168
169 const entry = pathToFileURL(path.join(ROOT, 'hub', 'gateway', 'server.mjs')).href;
170 const { app } = await import(`${entry}?kn-apple=${Date.now()}-${Math.random()}`);
171 const srv = await startServer(app);
172 return {
173 ...srv,
174 tmpDir,
175 close: async () => {
176 await srv.close();
177 delete globalThis.__knowtation_apple_identity_verifier;
178 try {
179 fs.rmSync(tmpDir, { recursive: true, force: true });
180 } catch {
181 /* ignore */
182 }
183 },
184 };
185 }
186
187 // ─── unit ───────────────────────────────────────────────────────────────────
188
189 describe('KN-APPLE unit', () => {
190 test('parseAppleExchangeBody allowlist + forbidden identity fields', () => {
191 assert.equal(parseAppleExchangeBody(null).ok, false);
192 assert.equal(parseAppleExchangeBody({}).code, 'BAD_REQUEST');
193 assert.equal(parseAppleExchangeBody({ identity_token: 'x', extra: 1 }).code, 'BAD_REQUEST');
194 assert.equal(parseAppleExchangeBody({ identity_token: 'x', role: 'admin' }).code, 'BAD_REQUEST');
195 assert.equal(parseAppleExchangeBody({ identity_token: 'x', scooling_uid: 'abc' }).code, 'BAD_REQUEST');
196 const ok = parseAppleExchangeBody({
197 identity_token: ' tok ',
198 nonce: 'n1',
199 full_name: 'A'.repeat(200),
200 });
201 assert.equal(ok.ok, true);
202 assert.equal(ok.identityToken, 'tok');
203 assert.equal(ok.nonce, 'n1');
204 assert.equal(ok.fullName.length, 128);
205 });
206
207 test('appleProviderAdvertised boolean logic', () => {
208 assert.equal(appleProviderAdvertised({ appleClientId: APPLE_AUD, offlineLocked: false }), true);
209 assert.equal(appleProviderAdvertised({ appleClientId: ' ', offlineLocked: false }), false);
210 assert.equal(appleProviderAdvertised({ appleClientId: APPLE_AUD, offlineLocked: true }), false);
211 assert.equal(appleProviderAdvertised({ offlineLocked: false }), false);
212 });
213
214 test('userId shape apple:<sub> via mint claims', () => {
215 const sub = '001234.abcdef';
216 assert.equal(`apple:${sub}`, `apple:${sub}`);
217 });
218
219 test('aud/iss/exp reject matrix + alg=none', async () => {
220 const v = createAppleIdentityVerifier({
221 fetchImpl: async () => ({ ok: true, json: async () => ({ keys: [FIXTURE_JWK] }) }),
222 });
223 const good = signAppleFixture(validAppleClaims({ sub: 'u-matrix' }));
224 const ok = await v.verifyIdentityToken(good, { audience: APPLE_AUD });
225 assert.equal(ok.ok, true);
226 assert.equal(ok.claims.appleSub, 'u-matrix');
227
228 const wrongAud = signAppleFixture(validAppleClaims({ aud: 'other.bundle', sub: 'u2' }));
229 assert.equal((await v.verifyIdentityToken(wrongAud, { audience: APPLE_AUD })).code, 'APPLE_ASSERTION_INVALID');
230
231 const wrongIss = signAppleFixture(validAppleClaims({ iss: 'https://evil.example', sub: 'u3' }));
232 assert.equal((await v.verifyIdentityToken(wrongIss, { audience: APPLE_AUD })).code, 'APPLE_ASSERTION_INVALID');
233
234 const expired = signAppleFixture(validAppleClaims({ sub: 'u4', exp: Math.floor(Date.now() / 1000) - 120 }));
235 assert.equal((await v.verifyIdentityToken(expired, { audience: APPLE_AUD })).code, 'APPLE_ASSERTION_INVALID');
236
237 const noneTok = `${b64urlJson({ alg: 'none', typ: 'JWT' })}.${b64urlJson({
238 iss: APPLE_ISS,
239 aud: APPLE_AUD,
240 sub: 'u5',
241 exp: Math.floor(Date.now() / 1000) + 600,
242 })}.`;
243 assert.equal((await v.verifyIdentityToken(noneTok, { audience: APPLE_AUD })).code, 'APPLE_ASSERTION_INVALID');
244 });
245
246 test('nonce match raw or sha256 hex', () => {
247 const raw = 'client-nonce-1';
248 const hex = createHash('sha256').update(raw, 'utf8').digest('hex');
249 assert.equal(appleNonceMatches(raw, raw), true);
250 assert.equal(appleNonceMatches(hex, raw), true);
251 assert.equal(appleNonceMatches('other', raw), false);
252 assert.equal(appleNonceMatches(undefined, undefined), true);
253 });
254
255 test('jwtExpiryToSeconds', () => {
256 assert.equal(jwtExpiryToSeconds('24h'), 86400);
257 assert.equal(jwtExpiryToSeconds('15m'), 900);
258 assert.equal(jwtExpiryToSeconds(120), 120);
259 });
260 });
261
262 // ─── integration / e2e / stress / data-integrity / performance / security ───
263
264 describe('KN-APPLE gateway tiers', () => {
265 before(async () => {
266 gw = await loadGateway();
267 });
268 after(async () => {
269 if (gw) await gw.close();
270 delete process.env.APPLE_CLIENT_ID;
271 delete process.env.KNOWTATION_OFFLINE_LOCKED_AUTH;
272 });
273
274 test('integration: exchange → 200 JWT + C7 provider apple', async () => {
275 const sub = 'int-apple-sub-1';
276 const identity_token = signAppleFixture(validAppleClaims({ sub }));
277 const ex = await request(gw.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
278 body: { identity_token, full_name: 'Ada' },
279 });
280 assert.equal(ex.status, 200);
281 assert.equal(ex.body.schema_version, 1);
282 assert.equal(ex.body.token_type, 'Bearer');
283 assert.equal(typeof ex.body.access_token, 'string');
284 assert.ok(ex.body.expires_in > 0);
285 assert.equal(ex.body.scooling_uid, undefined);
286 assert.equal(ex.body.refresh_token, undefined);
287
288 const payload = jwt.verify(ex.body.access_token, SECRET);
289 assert.equal(payload.sub, `apple:${sub}`);
290 assert.equal(payload.provider, 'apple');
291 assert.equal(payload.id, sub);
292 assert.equal(payload.type, 'session');
293 assert.equal(payload.name, 'Ada');
294
295 const sess = await request(gw.baseUrl, 'GET', '/api/v1/auth/session', {
296 token: ex.body.access_token,
297 });
298 assert.equal(sess.status, 200);
299 assert.equal(sess.body.provider, 'apple');
300 assert.equal(sess.body.sub, `apple:${sub}`);
301 assert.equal(sess.body.id, sub);
302 assert.ok(Array.isArray(sess.body.scopes));
303 });
304
305 test('integration: unconfigured APPLE_CLIENT_ID → 503 NOT_CONFIGURED', async () => {
306 const local = await loadGateway({ appleClientId: '' });
307 try {
308 const ex = await request(local.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
309 body: { identity_token: signAppleFixture(validAppleClaims({ sub: 'x' })) },
310 });
311 assert.equal(ex.status, 503);
312 assert.equal(ex.body.code, 'NOT_CONFIGURED');
313 } finally {
314 await local.close();
315 }
316 });
317
318 test('e2e: providers.apple + exchange → session round-trip; offline-locked → 403', async () => {
319 const providers = await request(gw.baseUrl, 'GET', '/api/v1/auth/providers');
320 assert.equal(providers.status, 200);
321 assert.equal(providers.body.apple, true);
322 assert.equal(typeof providers.body.google, 'boolean');
323 assert.equal(typeof providers.body.github, 'boolean');
324
325 const sub = 'e2e-apple-sub';
326 const ex = await request(gw.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
327 body: { identity_token: signAppleFixture(validAppleClaims({ sub })) },
328 });
329 assert.equal(ex.status, 200);
330 const sess = await request(gw.baseUrl, 'GET', '/api/v1/auth/session', {
331 token: ex.body.access_token,
332 });
333 assert.equal(sess.status, 200);
334 assert.equal(sess.body.sub, `apple:${sub}`);
335
336 const offline = await loadGateway({ offlineLocked: true });
337 try {
338 const blocked = await request(offline.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
339 body: { identity_token: signAppleFixture(validAppleClaims({ sub: 'off' })) },
340 });
341 assert.equal(blocked.status, 403);
342 assert.equal(blocked.body.code, 'OAUTH_DISABLED');
343 const p = await request(offline.baseUrl, 'GET', '/api/v1/auth/providers');
344 assert.equal(p.body.apple, false);
345 } finally {
346 await offline.close();
347 }
348 });
349
350 test('stress: parallel exchanges no cross-user mix; JWKS cache stable', async () => {
351 const N = 24;
352 const results = await Promise.all(
353 Array.from({ length: N }, async (_, i) => {
354 const sub = `stress-sub-${i}`;
355 const ex = await request(gw.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
356 body: { identity_token: signAppleFixture(validAppleClaims({ sub })) },
357 });
358 assert.equal(ex.status, 200);
359 const payload = jwt.verify(ex.body.access_token, SECRET);
360 return payload.sub;
361 }),
362 );
363 const unique = new Set(results);
364 assert.equal(unique.size, N);
365 for (let i = 0; i < N; i++) {
366 assert.ok(unique.has(`apple:stress-sub-${i}`));
367 }
368 });
369
370 test('data-integrity: no scooling_uid / identity_token echo / durable identity row', async () => {
371 const sub = 'di-apple-sub';
372 const identity_token = signAppleFixture(validAppleClaims({ sub }));
373 const ex = await request(gw.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
374 body: { identity_token },
375 });
376 assert.equal(ex.status, 200);
377 assert.equal(ex.body.scooling_uid, undefined);
378 assert.equal(ex.body.identity_token, undefined);
379 assert.ok(!JSON.stringify(ex.body).includes(identity_token));
380 assert.ok(!ex.raw.includes(['BEGIN', 'PRIVATE', 'KEY'].join(' ')));
381 const payload = jwt.verify(ex.body.access_token, SECRET);
382 assert.equal(payload.sub, `apple:${sub}`);
383
384 const files = fs.readdirSync(gw.tmpDir);
385 assert.ok(!files.some((f) => /identity|apple-map|scooling/i.test(f)));
386 });
387
388 test('performance: single exchange p95 under fixture JWKS bound', async () => {
389 const samples = [];
390 for (let i = 0; i < 20; i++) {
391 const t0 = performance.now();
392 const ex = await request(gw.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
393 body: { identity_token: signAppleFixture(validAppleClaims({ sub: `perf-${i}` })) },
394 });
395 samples.push(performance.now() - t0);
396 assert.equal(ex.status, 200);
397 }
398 samples.sort((a, b) => a - b);
399 const p95 = samples[Math.floor(samples.length * 0.95) - 1];
400 assert.ok(
401 p95 < PERF_P95_MS,
402 `p95 ${p95.toFixed(1)}ms exceeds bound ${PERF_P95_MS}ms (fixture JWKS only)`,
403 );
404 });
405
406 test('security: forged / wrong aud / client role → reject; fixtures ban prod shapes', async () => {
407 const forged = await request(gw.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
408 body: {
409 identity_token: jwt.sign(validAppleClaims({ sub: 'forged' }), 'not-apple-key', {
410 algorithm: 'HS256',
411 }),
412 },
413 });
414 assert.equal(forged.status, 401);
415 assert.equal(forged.body.code, 'APPLE_ASSERTION_INVALID');
416
417 const wrongAud = await request(gw.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
418 body: {
419 identity_token: signAppleFixture(validAppleClaims({ aud: 'com.evil.app', sub: 'w' })),
420 },
421 });
422 assert.equal(wrongAud.status, 401);
423
424 const clientRole = await request(gw.baseUrl, 'POST', '/api/v1/auth/native-apple-exchange', {
425 body: {
426 identity_token: signAppleFixture(validAppleClaims({ sub: 'r' })),
427 role: 'admin',
428 scooling_uid: 'deadbeef',
429 },
430 });
431 assert.equal(clientRole.status, 400);
432 assert.equal(clientRole.body.code, 'BAD_REQUEST');
433
434 // Fixture / product sources must not embed PEM private-key material or Team ID assignments.
435 const pemNeedle = ['BEGIN', 'PRIVATE', 'KEY'].join(' ');
436 const teamIdAssign = ['TEAM', '_ID', '='].join('');
437 for (const rel of [
438 'test/kn-apple-native-hosted-exchange.test.mjs',
439 'hub/gateway/apple-identity-token.mjs',
440 'hub/gateway/server.mjs',
441 '.env.example',
442 ]) {
443 const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
444 assert.ok(!src.includes(pemNeedle), `${rel} must not embed PEM private key material`);
445 assert.ok(
446 !new RegExp(`${teamIdAssign}\\s*['"][A-Z0-9]{10}['"]`).test(src),
447 `${rel} must not embed Apple Team ID assignments`,
448 );
449 }
450 // Ban pasted production-looking Apple JWTs (long eyJ…eyJ triples in string literals).
451 const suiteSrc = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8');
452 assert.ok(
453 !/['"`]eyJ[A-Za-z0-9_-]{20,}\.eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}['"`]/.test(suiteSrc),
454 'committed suite must not embed production-shaped Apple JWT string literals',
455 );
456
457 // Regression discriminator: unverified-accept stub must diverge from real verifier.
458 const stub = createUnverifiedAcceptStub();
459 const unsignedPayload = `${b64urlJson({ alg: 'none' })}.${b64urlJson({
460 sub: 'forged-via-stub',
461 iss: APPLE_ISS,
462 aud: APPLE_AUD,
463 exp: Math.floor(Date.now() / 1000) + 600,
464 })}.`;
465 const stubOk = await stub.verifyIdentityToken(unsignedPayload);
466 assert.equal(stubOk.ok, true, 'stub must accept unverified payloads (discriminator)');
467 const realReject = await verifier.verifyIdentityToken(unsignedPayload, { audience: APPLE_AUD });
468 assert.equal(realReject.ok, false, 'production verifier must reject unverified assertion');
469
470 // Passport / native PKCE paths still declared (not equated to SIWA).
471 const serverSrc = fs.readFileSync(path.join(ROOT, 'hub', 'gateway', 'server.mjs'), 'utf8');
472 assert.ok(serverSrc.includes("app.use('/api/v1/auth/native'"));
473 assert.ok(serverSrc.includes("passport.authenticate('google'"));
474 assert.ok(serverSrc.includes('/api/v1/auth/native-apple-exchange'));
475 assert.ok(
476 !/PKCE\s*=\s*SIWA|native PKCE equals|equals Sign in with Apple/i.test(serverSrc),
477 'must not claim PKCE equals SIWA',
478 );
479 });
480 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago