| 1 | // Package apns sends one kind of notification to Apple: someone is |
| 2 | // waiting to be let into a meeting. It is deliberately small — no |
| 3 | // badges, no collapse identifiers, no background pushes — because the |
| 4 | // only thing this app has ever needed to say to a closed app is that |
| 5 | // somebody is at the door. |
| 6 | // |
| 7 | // It is cgo-free, like the rest of the server. The provider token is |
| 8 | // ES256 over the stdlib's crypto/ecdsa, and HTTP/2 comes from net/http |
| 9 | // over TLS without help, which is the whole of what APNs requires. |
| 10 | package apns |
| 11 | |
| 12 | import ( |
| 13 | "bytes" |
| 14 | "context" |
| 15 | "crypto/ecdsa" |
| 16 | "crypto/rand" |
| 17 | "crypto/sha256" |
| 18 | "crypto/x509" |
| 19 | "encoding/base64" |
| 20 | "encoding/json" |
| 21 | "encoding/pem" |
| 22 | "errors" |
| 23 | "fmt" |
| 24 | "io" |
| 25 | "net/http" |
| 26 | "sync" |
| 27 | "time" |
| 28 | ) |
| 29 | |
| 30 | // Hosts Apple serves. Production tokens are refused by the sandbox and |
| 31 | // the other way round, with an error that names neither, so the choice |
| 32 | // is configuration rather than something to guess at. |
| 33 | const ( |
| 34 | HostProduction = "https://api.push.apple.com" |
| 35 | HostSandbox = "https://api.sandbox.push.apple.com" |
| 36 | ) |
| 37 | |
| 38 | // providerTokenLife is how long one JWT is used for. Apple refuses a |
| 39 | // token older than an hour and refuses a client that mints them more |
| 40 | // often than every twenty minutes, so anything in between is correct and |
| 41 | // fifty minutes leaves room for a slow clock on either side. |
| 42 | const providerTokenLife = 50 * time.Minute |
| 43 | |
| 44 | // maxBody caps what is read from an error response. Apple's are a few |
| 45 | // dozen bytes of JSON; anything larger is not Apple. |
| 46 | const maxBody = 8 * 1024 |
| 47 | |
| 48 | type Config struct { |
| 49 | KeyID string |
| 50 | TeamID string |
| 51 | Key *ecdsa.PrivateKey |
| 52 | // Topic per platform — the bundle identifier, which differs between |
| 53 | // the Mac and iPhone apps, so a token is useless without knowing |
| 54 | // which app issued it. |
| 55 | Topics map[string]string |
| 56 | Host string |
| 57 | // Now and HTTP exist for the tests, which run a fake Apple. |
| 58 | Now func() time.Time |
| 59 | HTTP *http.Client |
| 60 | } |
| 61 | |
| 62 | type Client struct { |
| 63 | cfg Config |
| 64 | mu sync.Mutex |
| 65 | jwt string |
| 66 | made time.Time |
| 67 | } |
| 68 | |
| 69 | // Verdict is what the caller must do about a token, which is the only |
| 70 | // part of an APNs response worth carrying upwards. |
| 71 | type Verdict int |
| 72 | |
| 73 | const ( |
| 74 | // Delivered: Apple accepted it. It says nothing about the device. |
| 75 | Delivered Verdict = iota |
| 76 | // Gone: this token is dead and must be deleted. The app was removed, |
| 77 | // or the token was reissued to someone else. Keeping it means |
| 78 | // pushing at nobody for ever. |
| 79 | Gone |
| 80 | // Retry: transient, or ours to fix. The token stays. |
| 81 | Retry |
| 82 | ) |
| 83 | |
| 84 | func ParseKey(data []byte) (*ecdsa.PrivateKey, error) { |
| 85 | block, _ := pem.Decode(data) |
| 86 | if block == nil { |
| 87 | return nil, errors.New("apns: key is not PEM; expected the .p8 Apple issued") |
| 88 | } |
| 89 | parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) |
| 90 | if err != nil { |
| 91 | return nil, errors.New("apns: key is not a PKCS#8 private key") |
| 92 | } |
| 93 | key, ok := parsed.(*ecdsa.PrivateKey) |
| 94 | if !ok { |
| 95 | return nil, errors.New("apns: key is not an ECDSA key; APNs keys are P-256") |
| 96 | } |
| 97 | return key, nil |
| 98 | } |
| 99 | |
| 100 | func New(cfg Config) (*Client, error) { |
| 101 | if cfg.KeyID == "" || cfg.TeamID == "" || cfg.Key == nil { |
| 102 | return nil, errors.New("apns: key id, team id and key are all required") |
| 103 | } |
| 104 | if len(cfg.Topics) == 0 { |
| 105 | return nil, errors.New("apns: at least one platform topic is required") |
| 106 | } |
| 107 | if cfg.Host == "" { |
| 108 | cfg.Host = HostProduction |
| 109 | } |
| 110 | if cfg.Now == nil { |
| 111 | cfg.Now = time.Now |
| 112 | } |
| 113 | if cfg.HTTP == nil { |
| 114 | // No Timeout here: the deadline rides on the request context, so |
| 115 | // one slow push cannot outlive the caller that asked for it. |
| 116 | cfg.HTTP = &http.Client{} |
| 117 | } |
| 118 | return &Client{cfg: cfg}, nil |
| 119 | } |
| 120 | |
| 121 | // providerToken mints or reuses the ES256 JWT. Held under the mutex for |
| 122 | // the whole mint: two goroutines racing here would each make one, and |
| 123 | // Apple counts that against a rate limit measured in tokens per |
| 124 | // connection rather than tokens per need. |
| 125 | func (c *Client) providerToken() (string, error) { |
| 126 | c.mu.Lock() |
| 127 | defer c.mu.Unlock() |
| 128 | now := c.cfg.Now() |
| 129 | if c.jwt != "" && now.Sub(c.made) < providerTokenLife { |
| 130 | return c.jwt, nil |
| 131 | } |
| 132 | header := encodeSegment(map[string]any{"alg": "ES256", "kid": c.cfg.KeyID, "typ": "JWT"}) |
| 133 | claims := encodeSegment(map[string]any{"iss": c.cfg.TeamID, "iat": now.Unix()}) |
| 134 | signing := header + "." + claims |
| 135 | digest := sha256.Sum256([]byte(signing)) |
| 136 | r, s, err := ecdsa.Sign(rand.Reader, c.cfg.Key, digest[:]) |
| 137 | if err != nil { |
| 138 | return "", errors.New("apns: cannot sign the provider token") |
| 139 | } |
| 140 | // JWS wants the raw r||s pair, each left-padded to the curve size — |
| 141 | // not the ASN.1 form ecdsa.SignASN1 would give, which Apple rejects |
| 142 | // with a message about the token being malformed and no more. |
| 143 | size := (c.cfg.Key.Curve.Params().BitSize + 7) / 8 |
| 144 | signature := make([]byte, 2*size) |
| 145 | r.FillBytes(signature[:size]) |
| 146 | s.FillBytes(signature[size:]) |
| 147 | c.jwt = signing + "." + base64.RawURLEncoding.EncodeToString(signature) |
| 148 | c.made = now |
| 149 | return c.jwt, nil |
| 150 | } |
| 151 | |
| 152 | func encodeSegment(value map[string]any) string { |
| 153 | b, _ := json.Marshal(value) |
| 154 | return base64.RawURLEncoding.EncodeToString(b) |
| 155 | } |
| 156 | |
| 157 | // Push tells one device that someone is waiting. `name` is the waiting |
| 158 | // person's own text, from the server's roster, so it is carried as a |
| 159 | // JSON value and never built into one. |
| 160 | func (c *Client) Push(ctx context.Context, token, platform, name string) (Verdict, error) { |
| 161 | topic, ok := c.cfg.Topics[platform] |
| 162 | if !ok { |
| 163 | return Retry, fmt.Errorf("apns: no topic configured for platform %q", platform) |
| 164 | } |
| 165 | jwt, err := c.providerToken() |
| 166 | if err != nil { |
| 167 | return Retry, err |
| 168 | } |
| 169 | body, err := json.Marshal(map[string]any{ |
| 170 | "aps": map[string]any{ |
| 171 | "alert": map[string]any{ |
| 172 | "title": "Someone is waiting to join", |
| 173 | "body": name, |
| 174 | }, |
| 175 | "sound": "default", |
| 176 | }, |
| 177 | }) |
| 178 | if err != nil { |
| 179 | return Retry, errors.New("apns: cannot build the payload") |
| 180 | } |
| 181 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Host+"/3/device/"+token, bytes.NewReader(body)) |
| 182 | if err != nil { |
| 183 | return Retry, errors.New("apns: cannot build the request") |
| 184 | } |
| 185 | req.Header.Set("authorization", "bearer "+jwt) |
| 186 | req.Header.Set("apns-topic", topic) |
| 187 | req.Header.Set("apns-push-type", "alert") |
| 188 | // 10 is "deliver now", which is the only priority that makes sense |
| 189 | // for a person standing at a door. |
| 190 | req.Header.Set("apns-priority", "10") |
| 191 | req.Header.Set("content-type", "application/json") |
| 192 | res, err := c.cfg.HTTP.Do(req) |
| 193 | if err != nil { |
| 194 | return Retry, errors.New("apns: cannot reach Apple") |
| 195 | } |
| 196 | defer res.Body.Close() |
| 197 | if res.StatusCode == http.StatusOK { |
| 198 | return Delivered, nil |
| 199 | } |
| 200 | var out struct { |
| 201 | Reason string `json:"reason"` |
| 202 | } |
| 203 | raw, _ := io.ReadAll(io.LimitReader(res.Body, maxBody)) |
| 204 | _ = json.Unmarshal(raw, &out) |
| 205 | return classify(res.StatusCode, out.Reason) |
| 206 | } |
| 207 | |
| 208 | // classify turns a refusal into the one decision the caller has to make: |
| 209 | // keep this token or delete it. Pure, so the table of Apple's reasons is |
| 210 | // a thing a test can read rather than a thing a fake server has to act |
| 211 | // out one status at a time. |
| 212 | func classify(status int, reason string) (Verdict, error) { |
| 213 | switch reason { |
| 214 | case "BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic": |
| 215 | // The token is wrong for this app or belongs to an app that is |
| 216 | // gone. No amount of retrying changes that. |
| 217 | return Gone, fmt.Errorf("apns: token rejected (%s)", reason) |
| 218 | case "ExpiredProviderToken", "InvalidProviderToken", "MissingProviderToken": |
| 219 | // Ours, not the device's. The token stays; the next push mints a |
| 220 | // new JWT. |
| 221 | return Retry, fmt.Errorf("apns: provider token refused (%s)", reason) |
| 222 | } |
| 223 | if status == http.StatusGone { |
| 224 | return Gone, errors.New("apns: token is no longer registered") |
| 225 | } |
| 226 | if reason == "" { |
| 227 | return Retry, fmt.Errorf("apns: refused with status %d", status) |
| 228 | } |
| 229 | return Retry, fmt.Errorf("apns: refused (%s)", reason) |
| 230 | } |
| 231 | |