#development #golang #mac

Apple has an option to access their App Store using an API as described in their documentation. Let's see how we can use this API using Golang. We're assuming you already have an Apple developer account setup.

The API is a REST API using a JSON Web Token (JWT) for authentication.

Before we can do anything, we need to create the keys needed to be able to connect. You can do this under "Users and Access" in the App Store Connect website:

  1. Browse to the App Store Connect website
  2. Login to your developer account
  3. Select "Users and Access" and go the tab "Keys"
  4. In the left column, ensure you have "App Store Connect API" selected
  5. Create a new key by clicking on the plus sign
  6. Give the key a name and select the access level (I selected "Admin")

One you did this, you'll end up with 3 pieces of information:

  • Issuer ID: Identifies the issuer who created the authentication token
  • Key ID: the unique ID of the key which was issued
  • Key (a .p8 file): the actual API key you created

You then download the .p8 file and store it in a safe location. Remember you can download this file only once. If you loose it, you'll need to generate a new API key.

We'll now start a new empty Go project to have a look at how to connect:

$ mkdir appstoreconnectapi
$ cd appstoreconnectapi
$ go mod init github.com/pieterclaerhout/appstoreconnectapi

To make it easier, I'll put a copy of the .p8 in that same folder. We also create a main.go file which is the main entry point for our sample app. In there, let's start with defining the connection details:

1package main
2
3const issuerID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
4const keyID = "XXXXXXXXXX"
5const keyFile = "AuthKey_XXXXXXXXXX.p8"
6
7func main() {
8}

We'll then add the parsing of the .p8 file as we need it to create the connection:

 1package main
 2
 3import (
 4    "crypto/ecdsa"
 5    "crypto/x509"
 6    "encoding/pem"
 7    "errors"
 8    "io/ioutil"
 9)
10
11const issuerID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
12const keyID = "XXXXXXXXXX"
13const keyFile = "AuthKey_XXXXXXXXXX.p8"
14
15func main() {
16
17    privateKey, err := privateKeyFromFile()
18    if err != nil {
19        log.Fatal(err)
20    }
21
22}
23
24func privateKeyFromFile() (*ecdsa.PrivateKey, error) {
25
26    bytes, err := ioutil.ReadFile(keyFile)
27    if err != nil {
28        return nil, err
29    }
30
31    block, _ := pem.Decode(bytes)
32    if block == nil {
33        return nil, errors.New("AuthKey must be a valid .p8 PEM file")
34    }
35    key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
36    if err != nil {
37        return nil, err
38    }
39
40    switch pk := key.(type) {
41    case *ecdsa.PrivateKey:
42        return pk, nil
43    default:
44        return nil, errors.New("AuthKey must be of type ecdsa.PrivateKey")
45    }
46
47}

The next step is adding the code used to generate the authentication token. We'll use the jwt-go library to generate the authentication key.

 1package main
 2
 3import (
 4    "crypto/ecdsa"
 5    "crypto/x509"
 6    "encoding/pem"
 7    "errors"
 8    "io/ioutil"
 9    "log"
10    "time"
11
12    "github.com/dgrijalva/jwt-go"
13)
14
15const issuerID = "69a6de7f-828a-47e3-e053-5b8c7c11a4d1"
16const keyID = "KB22YXSRT2"
17const keyFile = "AuthKey_KB22YXSRT2.p8"
18
19func main() {
20
21    privateKey, err := privateKeyFromFile()
22    if err != nil {
23        log.Fatal(err)
24    }
25
26    authToken, err := generateAuthToken(privateKey)
27    if err != nil {
28        log.Fatal(err)
29    }
30
31}
32
33func privateKeyFromFile() (*ecdsa.PrivateKey, error) {
34
35    bytes, err := ioutil.ReadFile(keyFile)
36    if err != nil {
37        return nil, err
38    }
39
40    block, _ := pem.Decode(bytes)
41    if block == nil {
42        return nil, errors.New("AuthKey must be a valid .p8 PEM file")
43    }
44    key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
45    if err != nil {
46        return nil, err
47    }
48
49    switch pk := key.(type) {
50    case *ecdsa.PrivateKey:
51        return pk, nil
52    default:
53        return nil, errors.New("AuthKey must be of type ecdsa.PrivateKey")
54    }
55
56}
57
58func generateAuthToken(privateKey *ecdsa.PrivateKey) (string, error) {
59
60    expirationTimestamp := time.Now().Add(15 * time.Minute)
61
62    token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
63        "iss": issuerID,
64        "exp": expirationTimestamp.Unix(),
65        "aud": "appstoreconnect-v1",
66    })
67
68    token.Header["kid"] = keyID
69
70    tokenString, err := token.SignedString(privateKey)
71    if err != nil {
72        return "", err
73    }
74
75    return tokenString, nil
76
77}

Now we have all the bits and pieces we need to perform a request. In this example, we request the latest 10 builds from our account using the v1/builds endpoint.

  1package main
  2
  3import (
  4    "crypto/ecdsa"
  5    "crypto/x509"
  6    "encoding/pem"
  7    "errors"
  8    "io/ioutil"
  9    "log"
 10    "net/http"
 11    "net/url"
 12    "time"
 13
 14    "github.com/dgrijalva/jwt-go"
 15)
 16
 17const issuerID = "69a6de7f-828a-47e3-e053-5b8c7c11a4d1"
 18const keyID = "KB22YXSRT2"
 19const keyFile = "AuthKey_KB22YXSRT2.p8"
 20
 21func main() {
 22
 23    privateKey, err := privateKeyFromFile()
 24    if err != nil {
 25        log.Fatal(err)
 26    }
 27
 28    authToken, err := generateAuthToken(privateKey)
 29    if err != nil {
 30        log.Fatal(err)
 31    }
 32
 33    client := &http.Client{}
 34
 35    qs := url.Values{}
 36    qs.Set("sort", "-uploadedDate")
 37    qs.Set("limit", "10")
 38
 39    req, err := http.NewRequest(
 40        http.MethodGet,
 41        "https://api.appstoreconnect.apple.com/v1/builds?"+qs.Encode(),
 42        nil,
 43    )
 44    if err != nil {
 45        log.Fatal(err)
 46    }
 47
 48    req.Header.Set("Authorization", "Bearer "+authToken)
 49    req.Header.Set("User-Agent", "App Store Connect Client")
 50
 51    resp, err := client.Do(req)
 52    if err != nil {
 53        log.Fatal(err)
 54    }
 55    defer resp.Body.Close()
 56
 57    bytes, err := ioutil.ReadAll(resp.Body)
 58    if err != nil {
 59        log.Fatal(err)
 60    }
 61
 62    log.Println(string(bytes))
 63
 64}
 65
 66func privateKeyFromFile() (*ecdsa.PrivateKey, error) {
 67
 68    bytes, err := ioutil.ReadFile(keyFile)
 69    if err != nil {
 70        return nil, err
 71    }
 72
 73    block, _ := pem.Decode(bytes)
 74    if block == nil {
 75        return nil, errors.New("AuthKey must be a valid .p8 PEM file")
 76    }
 77    key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
 78    if err != nil {
 79        return nil, err
 80    }
 81
 82    switch pk := key.(type) {
 83    case *ecdsa.PrivateKey:
 84        return pk, nil
 85    default:
 86        return nil, errors.New("AuthKey must be of type ecdsa.PrivateKey")
 87    }
 88
 89}
 90
 91func generateAuthToken(privateKey *ecdsa.PrivateKey) (string, error) {
 92
 93    expirationTimestamp := time.Now().Add(15 * time.Minute)
 94
 95    token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
 96        "iss": issuerID,
 97        "exp": expirationTimestamp.Unix(),
 98        "aud": "appstoreconnect-v1",
 99    })
100
101    token.Header["kid"] = keyID
102
103    tokenString, err := token.SignedString(privateKey)
104    if err != nil {
105        return "", err
106    }
107
108    return tokenString, nil
109
110}

Next time, we can look at how to do error handling and how to properly interpret the errors.