Skip to content

Commit a64afea

Browse files
authored
App Store Connect API client improvements (#256)
* Use jwt.RegisteredClaims instead of custom claims and set IssuedAt claim * Add app store connect api client integration test * Update integration tests * Use check step from upgrade-golangci-lint branch * Fix lint issues * Use check step from master branch * Log warning for too many requests response * Update comment * Add rate limit info to warning messages
1 parent e6b7808 commit a64afea

File tree

12 files changed

+486
-53
lines changed

12 files changed

+486
-53
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ _tmp/
22
.vscode/*
33
.idea/*
44
**/.idea/*
5-
.DS_Store
5+
.DS_Store
6+
.env
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package appstoreconnect_tests
2+
3+
import (
4+
"io"
5+
"os"
6+
"testing"
7+
8+
"github.com/bitrise-io/go-utils/v2/log"
9+
"github.com/bitrise-io/go-utils/v2/retryhttp"
10+
"github.com/bitrise-io/go-xcode/v2/_integration_tests"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func getAPIKey(t *testing.T) (string, string, []byte, bool) {
15+
if os.Getenv("TEST_API_KEY") != "" {
16+
return getLocalAPIKey(t)
17+
}
18+
return getRemoteAPIKey(t)
19+
}
20+
21+
func getLocalAPIKey(t *testing.T) (string, string, []byte, bool) {
22+
keyID := os.Getenv("TEST_API_KEY_ID")
23+
require.NotEmpty(t, keyID)
24+
issuerID := os.Getenv("TEST_API_KEY_ISSUER_ID")
25+
require.NotEmpty(t, issuerID)
26+
privateKey := os.Getenv("TEST_API_KEY")
27+
require.NotEmpty(t, privateKey)
28+
isEnterpriseAPIKey := os.Getenv("TEST_API_KEY_IS_ENTERPRISE") == "true"
29+
30+
return keyID, issuerID, []byte(privateKey), isEnterpriseAPIKey
31+
}
32+
33+
func getRemoteAPIKey(t *testing.T) (string, string, []byte, bool) {
34+
serviceAccountJSON := os.Getenv("GCS_SERVICE_ACCOUNT_JSON")
35+
require.NotEmpty(t, serviceAccountJSON)
36+
projectID := os.Getenv("GCS_PROJECT_ID")
37+
require.NotEmpty(t, projectID)
38+
bucketName := os.Getenv("GCS_BUCKET_NAME")
39+
require.NotEmpty(t, bucketName)
40+
41+
secretAccessor, err := _integration_tests.NewSecretAccessor(serviceAccountJSON, projectID)
42+
require.NoError(t, err)
43+
44+
bucketAccessor, err := _integration_tests.NewBucketAccessor(serviceAccountJSON, bucketName)
45+
require.NoError(t, err)
46+
47+
keyID, err := secretAccessor.GetSecret("BITRISE_APPSTORECONNECT_API_KEY_ID")
48+
require.NoError(t, err)
49+
50+
issuerID, err := secretAccessor.GetSecret("BITRISE_APPSTORECONNECT_API_KEY_ISSUER_ID")
51+
require.NoError(t, err)
52+
53+
keyURL, err := secretAccessor.GetSecret("BITRISE_APPSTORECONNECT_API_KEY_URL")
54+
require.NoError(t, err)
55+
56+
keyDownloadURL, err := bucketAccessor.GetExpiringURL(keyURL)
57+
require.NoError(t, err)
58+
59+
logger := log.NewLogger()
60+
logger.EnableDebugLog(false)
61+
client := retryhttp.NewClient(logger)
62+
resp, err := client.Get(keyDownloadURL)
63+
require.NoError(t, err)
64+
65+
privateKey, err := io.ReadAll(resp.Body)
66+
require.NoError(t, err)
67+
68+
return keyID, issuerID, privateKey, false
69+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package appstoreconnect_tests
2+
3+
import (
4+
"testing"
5+
6+
"github.com/bitrise-io/go-xcode/v2/autocodesign/devportalclient/appstoreconnect"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestListBundleIDs(t *testing.T) {
11+
keyID, issuerID, privateKey, enterpriseAccount := getAPIKey(t)
12+
13+
client := appstoreconnect.NewClient(appstoreconnect.NewRetryableHTTPClient(), keyID, issuerID, []byte(privateKey), enterpriseAccount)
14+
15+
response, err := client.Provisioning.ListBundleIDs(&appstoreconnect.ListBundleIDsOptions{})
16+
require.NoError(t, err)
17+
require.True(t, len(response.Data) > 0)
18+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package _integration_tests
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strings"
7+
"time"
8+
9+
"cloud.google.com/go/storage"
10+
"golang.org/x/oauth2/google"
11+
"golang.org/x/oauth2/jwt"
12+
)
13+
14+
// BucketAccessor ...
15+
type BucketAccessor struct {
16+
jwtConfig *jwt.Config
17+
bucket string
18+
objectExpiry time.Duration
19+
}
20+
21+
// NewBucketAccessor ...
22+
func NewBucketAccessor(serviceAccountJSONContent, bucket string) (*BucketAccessor, error) {
23+
conf, err := google.JWTConfigFromJSON([]byte(serviceAccountJSONContent))
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
return &BucketAccessor{
29+
jwtConfig: conf,
30+
bucket: bucket,
31+
objectExpiry: 1 * time.Hour,
32+
}, nil
33+
}
34+
35+
// GetExpiringURL ...
36+
func (a BucketAccessor) GetExpiringURL(originalURL string) (string, error) {
37+
artifactPath := strings.TrimPrefix(strings.TrimPrefix(originalURL, fmt.Sprintf("https://storage.googleapis.com/%s/", a.bucket)), fmt.Sprintf("https://storage.cloud.google.com/%s/", a.bucket))
38+
opts := &storage.SignedURLOptions{
39+
Method: http.MethodGet,
40+
GoogleAccessID: a.jwtConfig.Email,
41+
PrivateKey: a.jwtConfig.PrivateKey,
42+
Expires: time.Now().Add(a.objectExpiry),
43+
}
44+
45+
return storage.SignedURL(a.bucket, artifactPath, opts)
46+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package _integration_tests
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
secretmanager "cloud.google.com/go/secretmanager/apiv1"
9+
secretmanagerpb "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
10+
"github.com/bitrise-io/go-utils/log"
11+
"github.com/bitrise-io/go-utils/retry"
12+
"google.golang.org/api/option"
13+
)
14+
15+
// SecretAccessor ...
16+
type SecretAccessor struct {
17+
ctx context.Context
18+
client *secretmanager.Client
19+
projectID string
20+
}
21+
22+
// NewSecretAccessor ...
23+
func NewSecretAccessor(serviceAccountJSONContent, projectID string) (*SecretAccessor, error) {
24+
ctx := context.Background()
25+
client, err := secretmanager.NewClient(ctx, option.WithCredentialsJSON([]byte(serviceAccountJSONContent)))
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
return &SecretAccessor{
31+
ctx: ctx,
32+
client: client,
33+
projectID: projectID,
34+
}, nil
35+
}
36+
37+
// GetSecret ...
38+
func (m SecretAccessor) GetSecret(key string) (string, error) {
39+
secretValue := ""
40+
if err := retry.Times(3).Wait(30 * time.Second).Try(func(attempt uint) error {
41+
if attempt > 0 {
42+
log.Warnf("%d attempt failed", attempt)
43+
}
44+
45+
name := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", m.projectID, key)
46+
req := &secretmanagerpb.AccessSecretVersionRequest{
47+
Name: name,
48+
}
49+
result, err := m.client.AccessSecretVersion(m.ctx, req)
50+
if err != nil {
51+
log.Warnf("%s", err)
52+
return err
53+
}
54+
55+
secretValue = string(result.Payload.Data)
56+
return nil
57+
}); err != nil {
58+
return "", err
59+
}
60+
61+
return secretValue, nil
62+
}

autocodesign/devportalclient/appstoreconnect/appstoreconnect.go

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,8 @@ var (
3737
// A given token can be reused for up to 20 minutes:
3838
// https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests
3939
//
40-
// Using 19 minutes to make sure time inaccuracies at token validation does not cause issues.
41-
jwtDuration = 19 * time.Minute
42-
jwtReserveTime = 2 * time.Minute
40+
// We use 18 minutes to make sure time inaccuracies at token validation does not cause issues.
41+
jwtDuration = 18 * time.Minute
4342
)
4443

4544
// HTTPClient ...
@@ -79,6 +78,23 @@ func NewRetryableHTTPClient() *http.Client {
7978
return true, nil
8079
}
8180

81+
if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
82+
message := "Received HTTP 429 Too Many Requests"
83+
if rateLimit := resp.Header.Get("X-Rate-Limit"); rateLimit != "" {
84+
message += " (" + rateLimit + ")"
85+
}
86+
87+
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
88+
message += ", retrying the request in " + retryAfter + " seconds..."
89+
} else {
90+
message += ", retrying the request..."
91+
}
92+
93+
log.Warnf(message)
94+
95+
return true, nil
96+
}
97+
8298
shouldRetry, err := retryablehttp.DefaultRetryPolicy(ctx, resp, err)
8399
if shouldRetry && resp != nil {
84100
log.Debugf("Retry network error: %d", resp.StatusCode)
@@ -122,21 +138,12 @@ func NewClient(httpClient HTTPClient, keyID, issuerID string, privateKey []byte,
122138
// and return a signed key
123139
func (c *Client) ensureSignedToken() (string, error) {
124140
if c.token != nil {
125-
claim, ok := c.token.Claims.(claims)
126-
if !ok {
127-
return "", fmt.Errorf("failed to cast claim for token")
128-
}
129-
expiration := time.Unix(int64(claim.Expiration), 0)
130-
131-
// A given token can be reused for up to 20 minutes:
132-
// https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests
133-
//
134-
// The step generates a new token 2 minutes before the expiry.
135-
if time.Until(expiration) > jwtReserveTime {
141+
err := c.token.Claims.Valid()
142+
if err == nil {
136143
return c.signedToken, nil
137144
}
138145

139-
log.Debugf("JWT token expired, regenerating")
146+
log.Debugf("JWT token is invalid: %s, regenerating...", err)
140147
} else {
141148
log.Debugf("Generating JWT token")
142149
}

autocodesign/devportalclient/appstoreconnect/jwt.go

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,29 +32,22 @@ func signToken(token *jwt.Token, privateKeyContent []byte) (string, error) {
3232

3333
// createToken creates a jwt.Token for the Apple API
3434
func createToken(keyID string, issuerID string, audience string) *jwt.Token {
35-
payload := claims{
36-
IssuerID: issuerID,
37-
Expiration: time.Now().Add(jwtDuration).Unix(),
38-
Audience: audience,
35+
issuedAt := time.Now()
36+
expirationTime := time.Now().Add(jwtDuration)
37+
38+
claims := jwt.RegisteredClaims{
39+
Issuer: issuerID,
40+
IssuedAt: jwt.NewNumericDate(issuedAt),
41+
ExpiresAt: jwt.NewNumericDate(expirationTime),
42+
Audience: jwt.ClaimStrings{audience},
3943
}
4044

4145
// registers headers: alg = ES256 and typ = JWT
42-
token := jwt.NewWithClaims(jwt.SigningMethodES256, payload)
46+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
4347

4448
header := token.Header
4549
header["kid"] = keyID
50+
token.Header = header
4651

4752
return token
4853
}
49-
50-
// claims represents the JWT payload for the Apple API
51-
type claims struct {
52-
IssuerID string `json:"iss"`
53-
Expiration int64 `json:"exp"`
54-
Audience string `json:"aud"`
55-
}
56-
57-
// Valid implements the jwt.Claims interface
58-
func (c claims) Valid() error {
59-
return nil
60-
}

codesign/codesign_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ func TestSelectConnectionCredentials(t *testing.T) {
217217
localKeyPath := filepath.Join(t.TempDir(), "key.p8")
218218
err := os.WriteFile(localKeyPath, []byte("private key contents"), 0700)
219219
if err != nil {
220-
t.Fatalf(err.Error())
220+
t.Fatal(err.Error())
221221
}
222222
testInputs := ConnectionOverrideInputs{
223223
APIKeyPath: stepconf.Secret(localKeyPath),

codesign/inputparse_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ func Test_ParseConnectionOverrideConfig(t *testing.T) {
203203
fileContent := "this is a private key"
204204
err := os.WriteFile(path, []byte(fileContent), 0666)
205205
if err != nil {
206-
t.Errorf(err.Error())
206+
t.Error(err.Error())
207207
}
208208

209209
keyID := " ABC123 "
@@ -212,7 +212,7 @@ func Test_ParseConnectionOverrideConfig(t *testing.T) {
212212
// When
213213
connection, err := parseConnectionOverrideConfig(stepconf.Secret(path), keyID, keyIssuerID, true, log.NewLogger())
214214
if err != nil {
215-
t.Errorf(err.Error())
215+
t.Error(err.Error())
216216
}
217217

218218
// Then

0 commit comments

Comments
 (0)