apple-identity-token.mjs
280 lines 9.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Apple Sign in with Apple identity-token verifier (KN-APPLE-NATIVE-HOSTED-EXCHANGE).
3 *
4 * Verifies Apple `identityToken` JWTs against Apple JWKS. Does not mint sessions,
5 * does not touch Layer-2 `scooling_uid`, and never logs the raw assertion.
6 *
7 * @module hub/gateway/apple-identity-token
8 */
9
10 import { createHash, createPublicKey } from 'node:crypto';
11 import jwt from 'jsonwebtoken';
12
13 /** @typedef {{ appleSub: string, email?: string }} AppleIdentityClaims */
14
15 export const APPLE_ISS = 'https://appleid.apple.com';
16 export const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
17
18 /** Allowed request body keys for POST api/v1/auth/native-apple-exchange. */
19 export const APPLE_EXCHANGE_ALLOWED_FIELDS = Object.freeze(['identity_token', 'nonce', 'full_name']);
20
21 /** Client-supplied identity / authority fields — presence → BAD_REQUEST. */
22 export const APPLE_EXCHANGE_FORBIDDEN_FIELDS = Object.freeze([
23 'sub',
24 'provider',
25 'id',
26 'role',
27 'scopes',
28 'scooling_uid',
29 'scoolingUid',
30 'kid',
31 'access_token',
32 'refresh_token',
33 'client_secret',
34 'team_id',
35 'authorization',
36 ]);
37
38 const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
39 const CLOCK_SKEW_SECONDS = 60;
40
41 /**
42 * Advertise `providers.apple` per freeze §KNA.3.7.
43 * @param {{ appleClientId?: string|null, offlineLocked?: boolean }} opts
44 * @returns {boolean}
45 */
46 export function appleProviderAdvertised(opts = {}) {
47 if (opts.offlineLocked) return false;
48 const id = opts.appleClientId;
49 return typeof id === 'string' && id.trim().length > 0;
50 }
51
52 /**
53 * Parse / allowlist the native-apple-exchange JSON body.
54 * @param {unknown} body
55 * @returns {{ ok: true, identityToken: string, nonce?: string, fullName: string }
56 * | { ok: false, code: 'BAD_REQUEST', error: string }}
57 */
58 export function parseAppleExchangeBody(body) {
59 if (body == null || typeof body !== 'object' || Array.isArray(body)) {
60 return { ok: false, code: 'BAD_REQUEST', error: 'Request body must be a JSON object' };
61 }
62 const keys = Object.keys(body);
63 for (const key of keys) {
64 if (APPLE_EXCHANGE_FORBIDDEN_FIELDS.includes(key)) {
65 return { ok: false, code: 'BAD_REQUEST', error: `Forbidden field: ${key}` };
66 }
67 if (!APPLE_EXCHANGE_ALLOWED_FIELDS.includes(key)) {
68 return { ok: false, code: 'BAD_REQUEST', error: `Unknown field: ${key}` };
69 }
70 }
71 const identityToken = body.identity_token;
72 if (typeof identityToken !== 'string' || identityToken.trim().length === 0) {
73 return { ok: false, code: 'BAD_REQUEST', error: 'identity_token is required' };
74 }
75 let nonce;
76 if (Object.prototype.hasOwnProperty.call(body, 'nonce')) {
77 if (typeof body.nonce !== 'string' || body.nonce.trim().length === 0) {
78 return { ok: false, code: 'BAD_REQUEST', error: 'nonce must be a non-empty string when present' };
79 }
80 nonce = body.nonce;
81 }
82 let fullName = '';
83 if (Object.prototype.hasOwnProperty.call(body, 'full_name')) {
84 if (typeof body.full_name !== 'string') {
85 return { ok: false, code: 'BAD_REQUEST', error: 'full_name must be a string when present' };
86 }
87 fullName = body.full_name.slice(0, 128);
88 }
89 return { ok: true, identityToken: identityToken.trim(), nonce, fullName };
90 }
91
92 /**
93 * Convert gateway JWT expiry strings (`24h`, `15m`, …) to seconds for `expires_in`.
94 * @param {string|number} expiry
95 * @returns {number}
96 */
97 export function jwtExpiryToSeconds(expiry) {
98 if (typeof expiry === 'number' && Number.isFinite(expiry) && expiry > 0) {
99 return Math.floor(expiry);
100 }
101 const m = String(expiry || '').trim().match(/^(\d+)\s*([smhd])$/i);
102 if (!m) return 86400;
103 const n = Number(m[1]);
104 const u = m[2].toLowerCase();
105 if (u === 's') return n;
106 if (u === 'm') return n * 60;
107 if (u === 'h') return n * 3600;
108 return n * 86400;
109 }
110
111 /**
112 * Apple nonce match: claim equals raw nonce OR SHA-256 hex of the raw nonce.
113 * @param {string|undefined} claim
114 * @param {string|undefined} presented
115 * @returns {boolean}
116 */
117 export function appleNonceMatches(claim, presented) {
118 if (presented == null || presented === '') return true;
119 if (typeof claim !== 'string' || claim.length === 0) return false;
120 if (claim === presented) return true;
121 const hex = createHash('sha256').update(presented, 'utf8').digest('hex');
122 return claim === hex;
123 }
124
125 /**
126 * Create a verifier with in-process JWKS cache (TTL ≤ 24h).
127 * @param {{
128 * fetchImpl?: typeof fetch,
129 * jwksUrl?: string,
130 * cacheTtlMs?: number,
131 * nowSeconds?: () => number,
132 * }} [opts]
133 */
134 export function createAppleIdentityVerifier(opts = {}) {
135 const fetchImpl = opts.fetchImpl || globalThis.fetch.bind(globalThis);
136 const jwksUrl = opts.jwksUrl || APPLE_JWKS_URL;
137 const cacheTtlMs = Math.min(
138 typeof opts.cacheTtlMs === 'number' ? opts.cacheTtlMs : DEFAULT_CACHE_TTL_MS,
139 DEFAULT_CACHE_TTL_MS,
140 );
141 const nowSeconds = opts.nowSeconds || (() => Math.floor(Date.now() / 1000));
142
143 /** @type {{ keys: object[], fetchedAt: number } | null} */
144 let cache = null;
145
146 /**
147 * @returns {Promise<{ ok: true, keys: object[] } | { ok: false, code: 'APPLE_JWKS_UNAVAILABLE', error: string }>}
148 */
149 async function loadJwks() {
150 const now = Date.now();
151 if (cache && now - cache.fetchedAt < cacheTtlMs && Array.isArray(cache.keys) && cache.keys.length) {
152 return { ok: true, keys: cache.keys };
153 }
154 try {
155 const res = await fetchImpl(jwksUrl, {
156 method: 'GET',
157 headers: { Accept: 'application/json' },
158 });
159 if (!res || !res.ok) {
160 if (cache?.keys?.length) return { ok: true, keys: cache.keys };
161 return { ok: false, code: 'APPLE_JWKS_UNAVAILABLE', error: 'Apple JWKS fetch failed' };
162 }
163 const body = await res.json();
164 const keys = Array.isArray(body?.keys) ? body.keys : [];
165 if (!keys.length) {
166 if (cache?.keys?.length) return { ok: true, keys: cache.keys };
167 return { ok: false, code: 'APPLE_JWKS_UNAVAILABLE', error: 'Apple JWKS empty' };
168 }
169 cache = { keys, fetchedAt: now };
170 return { ok: true, keys };
171 } catch {
172 if (cache?.keys?.length) return { ok: true, keys: cache.keys };
173 return { ok: false, code: 'APPLE_JWKS_UNAVAILABLE', error: 'Apple JWKS fetch failed' };
174 }
175 }
176
177 /**
178 * @param {string} identityToken
179 * @param {{ audience: string, nonce?: string }} verifyOpts
180 * @returns {Promise<
181 * | { ok: true, claims: AppleIdentityClaims }
182 * | { ok: false, code: 'APPLE_ASSERTION_INVALID'|'APPLE_JWKS_UNAVAILABLE', error: string }
183 * >}
184 */
185 async function verifyIdentityToken(identityToken, verifyOpts) {
186 const audience = verifyOpts?.audience;
187 if (typeof audience !== 'string' || !audience.trim()) {
188 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'audience not configured' };
189 }
190 if (typeof identityToken !== 'string' || !identityToken.trim()) {
191 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'malformed identity token' };
192 }
193
194 let header;
195 try {
196 const parts = identityToken.split('.');
197 if (parts.length !== 3) {
198 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'malformed identity token' };
199 }
200 header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
201 } catch {
202 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'malformed identity token' };
203 }
204
205 const alg = header?.alg;
206 if (!alg || alg === 'none' || alg !== 'RS256') {
207 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'unsupported JWT alg' };
208 }
209 const kid = header?.kid;
210 if (typeof kid !== 'string' || !kid) {
211 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'missing kid' };
212 }
213
214 const jwks = await loadJwks();
215 if (!jwks.ok) return jwks;
216
217 const jwk = jwks.keys.find((k) => k && k.kid === kid && k.kty === 'RSA');
218 if (!jwk) {
219 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'unknown kid' };
220 }
221
222 let key;
223 try {
224 key = createPublicKey({ key: jwk, format: 'jwk' });
225 } catch {
226 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'invalid JWK' };
227 }
228
229 let payload;
230 try {
231 payload = jwt.verify(identityToken, key, {
232 algorithms: ['RS256'],
233 issuer: APPLE_ISS,
234 audience: audience.trim(),
235 clockTolerance: CLOCK_SKEW_SECONDS,
236 clockTimestamp: nowSeconds(),
237 });
238 } catch {
239 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'identity token verification failed' };
240 }
241
242 if (!payload || typeof payload !== 'object') {
243 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'identity token verification failed' };
244 }
245 const appleSub = payload.sub;
246 if (typeof appleSub !== 'string' || !appleSub.trim()) {
247 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'missing sub' };
248 }
249 if (!appleNonceMatches(payload.nonce, verifyOpts.nonce)) {
250 return { ok: false, code: 'APPLE_ASSERTION_INVALID', error: 'nonce mismatch' };
251 }
252
253 /** @type {AppleIdentityClaims} */
254 const claims = { appleSub: appleSub.trim() };
255 if (typeof payload.email === 'string' && payload.email.trim()) {
256 claims.email = payload.email.trim();
257 }
258 return { ok: true, claims };
259 }
260
261 /** Test helper: seed JWKS cache without network. */
262 function seedJwksCache(keys) {
263 cache = { keys: Array.isArray(keys) ? keys : [], fetchedAt: Date.now() };
264 }
265
266 /** Test helper: clear JWKS cache. */
267 function clearJwksCache() {
268 cache = null;
269 }
270
271 return {
272 verifyIdentityToken,
273 loadJwks,
274 seedJwksCache,
275 clearJwksCache,
276 };
277 }
278
279 /** Default process-wide verifier (real Apple JWKS URL). */
280 export const defaultAppleIdentityVerifier = createAppleIdentityVerifier();
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago