|
| 1 | +// APNS token-based sender over HTTP/2. Signs a provider-authentication JWT |
| 2 | +// (ES256, from the `.p8` key) with Node's built-in crypto — no third-party JWT |
| 3 | +// dependency — and sends a silent `content-available` background push. Only wire |
| 4 | +// this when the APNS_* env is configured — see the notifications config. |
| 5 | + |
| 6 | +import http2 from 'node:http2' |
| 7 | +import { createPrivateKey, sign as cryptoSign, type KeyObject } from 'node:crypto' |
| 8 | +import { logger } from '../logger' |
| 9 | +import type { ApnsSender } from './types' |
| 10 | + |
| 11 | +/** |
| 12 | + * How long a provider JWT is reused before re-signing. Apple requires refreshing |
| 13 | + * no more than once every 20 min and at least once every 60 min; 30 min sits |
| 14 | + * safely inside that window. |
| 15 | + */ |
| 16 | +const APNS_JWT_TTL_SECONDS = 30 * 60 |
| 17 | + |
| 18 | +const HOST_PRODUCTION = 'https://api.push.apple.com' |
| 19 | +const HOST_SANDBOX = 'https://api.sandbox.push.apple.com' |
| 20 | + |
| 21 | +export interface ApnsConfig { |
| 22 | + /** Contents of the AuthKey `.p8` (PKCS#8 PEM). */ |
| 23 | + p8: string |
| 24 | + /** The key's 10-character Key ID. */ |
| 25 | + keyId: string |
| 26 | + /** The Apple Developer Team ID. */ |
| 27 | + teamId: string |
| 28 | + /** App bundle id, sent as `apns-topic`. */ |
| 29 | + bundleId: string |
| 30 | + /** true → api.push.apple.com; false → the sandbox host. */ |
| 31 | + production: boolean |
| 32 | +} |
| 33 | + |
| 34 | +function base64url(input: string | Buffer): string { |
| 35 | + return Buffer.from(input).toString('base64url') |
| 36 | +} |
| 37 | + |
| 38 | +/** Sign the APNS provider-authentication JWT (ES256) for the given issue time. */ |
| 39 | +function signProviderJwt(privateKey: KeyObject, keyId: string, teamId: string, iat: number): string { |
| 40 | + const header = base64url(JSON.stringify({ alg: 'ES256', kid: keyId })) |
| 41 | + const claims = base64url(JSON.stringify({ iss: teamId, iat })) |
| 42 | + const signingInput = `${header}.${claims}` |
| 43 | + // ieee-p1363 yields the raw R||S signature JWS ES256 requires (not DER). |
| 44 | + const signature = cryptoSign('sha256', Buffer.from(signingInput), { |
| 45 | + key: privateKey, |
| 46 | + dsaEncoding: 'ieee-p1363', |
| 47 | + }) |
| 48 | + return `${signingInput}.${base64url(signature)}` |
| 49 | +} |
| 50 | + |
| 51 | +/** |
| 52 | + * Build an {@link ApnsSender}. Maintains one persistent HTTP/2 session (lazily |
| 53 | + * reconnected) and a cached provider JWT. A push resolves `{ deadToken: true }` |
| 54 | + * on 410 Unregistered / 400 BadDeviceToken; any other non-200 throws (transient). |
| 55 | + */ |
| 56 | +export function createApnsSender(config: ApnsConfig): ApnsSender { |
| 57 | + const host = config.production ? HOST_PRODUCTION : HOST_SANDBOX |
| 58 | + const privateKey = createPrivateKey(config.p8) |
| 59 | + |
| 60 | + let session: http2.ClientHttp2Session | null = null |
| 61 | + let cachedJwt: { token: string; issuedAt: number } | null = null |
| 62 | + |
| 63 | + function getSession(): http2.ClientHttp2Session { |
| 64 | + if (session && !session.closed && !session.destroyed) { |
| 65 | + return session |
| 66 | + } |
| 67 | + const next = http2.connect(host) |
| 68 | + next.on('error', (error) => { |
| 69 | + logger.error('[push] APNS HTTP/2 session error', error) |
| 70 | + if (session === next) { |
| 71 | + session = null |
| 72 | + } |
| 73 | + }) |
| 74 | + session = next |
| 75 | + return next |
| 76 | + } |
| 77 | + |
| 78 | + function getJwt(): string { |
| 79 | + const now = Math.floor(Date.now() / 1000) |
| 80 | + if (cachedJwt && now - cachedJwt.issuedAt < APNS_JWT_TTL_SECONDS) { |
| 81 | + return cachedJwt.token |
| 82 | + } |
| 83 | + const token = signProviderJwt(privateKey, config.keyId, config.teamId, now) |
| 84 | + cachedJwt = { token, issuedAt: now } |
| 85 | + return token |
| 86 | + } |
| 87 | + |
| 88 | + return (deviceToken, payload) => |
| 89 | + new Promise((resolve, reject) => { |
| 90 | + const request = getSession().request({ |
| 91 | + ':method': 'POST', |
| 92 | + ':path': `/3/device/${deviceToken}`, |
| 93 | + authorization: `bearer ${getJwt()}`, |
| 94 | + 'apns-topic': config.bundleId, |
| 95 | + 'apns-push-type': 'background', |
| 96 | + 'apns-priority': '5', |
| 97 | + 'content-type': 'application/json', |
| 98 | + }) |
| 99 | + |
| 100 | + let status = 0 |
| 101 | + let body = '' |
| 102 | + request.setEncoding('utf8') |
| 103 | + request.on('response', (headers) => { |
| 104 | + status = Number(headers[':status'] ?? 0) |
| 105 | + }) |
| 106 | + request.on('data', (chunk: string) => { |
| 107 | + body += chunk |
| 108 | + }) |
| 109 | + request.on('end', () => { |
| 110 | + if (status === 200) { |
| 111 | + resolve({ deadToken: false }) |
| 112 | + return |
| 113 | + } |
| 114 | + if (status === 410 || (status === 400 && body.includes('BadDeviceToken'))) { |
| 115 | + logger.debug(`[push] APNS reports dead token (${status})`) |
| 116 | + resolve({ deadToken: true }) |
| 117 | + return |
| 118 | + } |
| 119 | + reject(new Error(`APNS send failed: ${status} ${body}`)) |
| 120 | + }) |
| 121 | + request.on('error', reject) |
| 122 | + request.end(JSON.stringify(payload)) |
| 123 | + }) |
| 124 | +} |
0 commit comments