apple-identity-token.mjs
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