spikes/apns-worker/src/apns.ts
78 lines · 2982 bytes
9 symbols in this file
1// APNs provider-token auth and sending, using only Web APIs (Workers, Node).
2
3export interface ApnsConfig {
4 keyId: string;
5 teamId: string;
6 /** Contents of the .p8 key file (PKCS#8 PEM). */
7 privateKey: string;
8 /** App bundle ID. */
9 topic: string;
10}
11
12export interface ApnsResult {
13 status: number;
14 apnsId: string | null;
15 reason: string | null;
16}
17
18const encoder = new TextEncoder();
19
20function base64url(bytes: Uint8Array): string {
21 let s = "";
22 for (const b of bytes) s += String.fromCharCode(b);
23 return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
24}
25
26function pemToDer(pem: string): Uint8Array {
27 const body = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
28 return Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
29}
30
31export async function importKey(pem: string): Promise<CryptoKey> {
32 return crypto.subtle.importKey("pkcs8", pemToDer(pem), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
33}
34
35/** ES256 provider token. WebCrypto returns the raw r||s signature JWS expects. */
36export async function providerToken(keyId: string, teamId: string, key: CryptoKey, issuedAt = Math.floor(Date.now() / 1000)): Promise<string> {
37 const header = base64url(encoder.encode(JSON.stringify({ alg: "ES256", kid: keyId })));
38 const claims = base64url(encoder.encode(JSON.stringify({ iss: teamId, iat: issuedAt })));
39 const input = `${header}.${claims}`;
40 const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, encoder.encode(input));
41 return `${input}.${base64url(new Uint8Array(sig))}`;
42}
43
44/** Apple accepts a token for up to 60 minutes and rejects refreshes more often than every 20. */
45let cached: { token: string; issuedAt: number; keyId: string } | null = null;
46
47async function token(config: ApnsConfig): Promise<string> {
48 const now = Math.floor(Date.now() / 1000);
49 if (cached && cached.keyId === config.keyId && now - cached.issuedAt < 50 * 60) return cached.token;
50 const t = await providerToken(config.keyId, config.teamId, await importKey(config.privateKey), now);
51 cached = { token: t, issuedAt: now, keyId: config.keyId };
52 return t;
53}
54
55export async function send(config: ApnsConfig, deviceToken: string, payload: unknown, sandbox: boolean): Promise<ApnsResult> {
56 const host = sandbox ? "api.sandbox.push.apple.com" : "api.push.apple.com";
57 const res = await fetch(`https://${host}/3/device/${deviceToken}`, {
58 method: "POST",
59 headers: {
60 authorization: `bearer ${await token(config)}`,
61 "apns-topic": config.topic,
62 "apns-push-type": "alert",
63 "apns-priority": "10",
64 "content-type": "application/json",
65 },
66 body: JSON.stringify(payload),
67 });
68 let reason: string | null = null;
69 if (!res.ok) {
70 const text = await res.text();
71 try {
72 reason = (JSON.parse(text) as { reason?: string }).reason ?? text;
73 } catch {
74 reason = text;
75 }
76 }
77 return { status: res.status, apnsId: res.headers.get("apns-id"), reason };
78}