oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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.
10package apns
11
12import (
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.
33const (
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.
42const 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.
46const maxBody = 8 * 1024
47
48type 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
62type 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.
71type Verdict int
72
73const (
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
84func 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
100func 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.
125func (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
152func 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.
160func (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.
212func 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