// Package apns sends one kind of notification to Apple: someone is // waiting to be let into a meeting. It is deliberately small — no // badges, no collapse identifiers, no background pushes — because the // only thing this app has ever needed to say to a closed app is that // somebody is at the door. // // It is cgo-free, like the rest of the server. The provider token is // ES256 over the stdlib's crypto/ecdsa, and HTTP/2 comes from net/http // over TLS without help, which is the whole of what APNs requires. package apns import ( "bytes" "context" "crypto/ecdsa" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "io" "net/http" "sync" "time" ) // Hosts Apple serves. Production tokens are refused by the sandbox and // the other way round, with an error that names neither, so the choice // is configuration rather than something to guess at. const ( HostProduction = "https://api.push.apple.com" HostSandbox = "https://api.sandbox.push.apple.com" ) // providerTokenLife is how long one JWT is used for. Apple refuses a // token older than an hour and refuses a client that mints them more // often than every twenty minutes, so anything in between is correct and // fifty minutes leaves room for a slow clock on either side. const providerTokenLife = 50 * time.Minute // maxBody caps what is read from an error response. Apple's are a few // dozen bytes of JSON; anything larger is not Apple. const maxBody = 8 * 1024 type Config struct { KeyID string TeamID string Key *ecdsa.PrivateKey // Topic per platform — the bundle identifier, which differs between // the Mac and iPhone apps, so a token is useless without knowing // which app issued it. Topics map[string]string Host string // Now and HTTP exist for the tests, which run a fake Apple. Now func() time.Time HTTP *http.Client } type Client struct { cfg Config mu sync.Mutex jwt string made time.Time } // Verdict is what the caller must do about a token, which is the only // part of an APNs response worth carrying upwards. type Verdict int const ( // Delivered: Apple accepted it. It says nothing about the device. Delivered Verdict = iota // Gone: this token is dead and must be deleted. The app was removed, // or the token was reissued to someone else. Keeping it means // pushing at nobody for ever. Gone // Retry: transient, or ours to fix. The token stays. Retry ) func ParseKey(data []byte) (*ecdsa.PrivateKey, error) { block, _ := pem.Decode(data) if block == nil { return nil, errors.New("apns: key is not PEM; expected the .p8 Apple issued") } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, errors.New("apns: key is not a PKCS#8 private key") } key, ok := parsed.(*ecdsa.PrivateKey) if !ok { return nil, errors.New("apns: key is not an ECDSA key; APNs keys are P-256") } return key, nil } func New(cfg Config) (*Client, error) { if cfg.KeyID == "" || cfg.TeamID == "" || cfg.Key == nil { return nil, errors.New("apns: key id, team id and key are all required") } if len(cfg.Topics) == 0 { return nil, errors.New("apns: at least one platform topic is required") } if cfg.Host == "" { cfg.Host = HostProduction } if cfg.Now == nil { cfg.Now = time.Now } if cfg.HTTP == nil { // No Timeout here: the deadline rides on the request context, so // one slow push cannot outlive the caller that asked for it. cfg.HTTP = &http.Client{} } return &Client{cfg: cfg}, nil } // providerToken mints or reuses the ES256 JWT. Held under the mutex for // the whole mint: two goroutines racing here would each make one, and // Apple counts that against a rate limit measured in tokens per // connection rather than tokens per need. func (c *Client) providerToken() (string, error) { c.mu.Lock() defer c.mu.Unlock() now := c.cfg.Now() if c.jwt != "" && now.Sub(c.made) < providerTokenLife { return c.jwt, nil } header := encodeSegment(map[string]any{"alg": "ES256", "kid": c.cfg.KeyID, "typ": "JWT"}) claims := encodeSegment(map[string]any{"iss": c.cfg.TeamID, "iat": now.Unix()}) signing := header + "." + claims digest := sha256.Sum256([]byte(signing)) r, s, err := ecdsa.Sign(rand.Reader, c.cfg.Key, digest[:]) if err != nil { return "", errors.New("apns: cannot sign the provider token") } // JWS wants the raw r||s pair, each left-padded to the curve size — // not the ASN.1 form ecdsa.SignASN1 would give, which Apple rejects // with a message about the token being malformed and no more. size := (c.cfg.Key.Curve.Params().BitSize + 7) / 8 signature := make([]byte, 2*size) r.FillBytes(signature[:size]) s.FillBytes(signature[size:]) c.jwt = signing + "." + base64.RawURLEncoding.EncodeToString(signature) c.made = now return c.jwt, nil } func encodeSegment(value map[string]any) string { b, _ := json.Marshal(value) return base64.RawURLEncoding.EncodeToString(b) } // Push tells one device that someone is waiting. `name` is the waiting // person's own text, from the server's roster, so it is carried as a // JSON value and never built into one. func (c *Client) Push(ctx context.Context, token, platform, name string) (Verdict, error) { topic, ok := c.cfg.Topics[platform] if !ok { return Retry, fmt.Errorf("apns: no topic configured for platform %q", platform) } jwt, err := c.providerToken() if err != nil { return Retry, err } body, err := json.Marshal(map[string]any{ "aps": map[string]any{ "alert": map[string]any{ "title": "Someone is waiting to join", "body": name, }, "sound": "default", }, }) if err != nil { return Retry, errors.New("apns: cannot build the payload") } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Host+"/3/device/"+token, bytes.NewReader(body)) if err != nil { return Retry, errors.New("apns: cannot build the request") } req.Header.Set("authorization", "bearer "+jwt) req.Header.Set("apns-topic", topic) req.Header.Set("apns-push-type", "alert") // 10 is "deliver now", which is the only priority that makes sense // for a person standing at a door. req.Header.Set("apns-priority", "10") req.Header.Set("content-type", "application/json") res, err := c.cfg.HTTP.Do(req) if err != nil { return Retry, errors.New("apns: cannot reach Apple") } defer res.Body.Close() if res.StatusCode == http.StatusOK { return Delivered, nil } var out struct { Reason string `json:"reason"` } raw, _ := io.ReadAll(io.LimitReader(res.Body, maxBody)) _ = json.Unmarshal(raw, &out) return classify(res.StatusCode, out.Reason) } // classify turns a refusal into the one decision the caller has to make: // keep this token or delete it. Pure, so the table of Apple's reasons is // a thing a test can read rather than a thing a fake server has to act // out one status at a time. func classify(status int, reason string) (Verdict, error) { switch reason { case "BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic": // The token is wrong for this app or belongs to an app that is // gone. No amount of retrying changes that. return Gone, fmt.Errorf("apns: token rejected (%s)", reason) case "ExpiredProviderToken", "InvalidProviderToken", "MissingProviderToken": // Ours, not the device's. The token stays; the next push mints a // new JWT. return Retry, fmt.Errorf("apns: provider token refused (%s)", reason) } if status == http.StatusGone { return Gone, errors.New("apns: token is no longer registered") } if reason == "" { return Retry, fmt.Errorf("apns: refused with status %d", status) } return Retry, fmt.Errorf("apns: refused (%s)", reason) }