krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

spikes/apns-worker/src/apns.ts

78 lines · 2982 bytes

 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}