使用Golang计算App Store Connect API jwt token
### 一、背景
QQ群里一位兄弟问到生成苹果开发平台API token的方法,我一看jwt我知道啊,不就是header、payload再用算法、秘钥签个名拼起来不就完了?
### 二、碰壁
1. 先阅读[官方文档](https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests),得知计算jwt的步骤
- 创建header,包含alg(签名算法)、typ(token类型)、kid(苹果后台获取到的私钥id),示例:
```json
{
"alg": "ES256", // 签名算法
"kid": "2X9R4HXF34", // 苹果后台获取到的私钥id
"typ": "JWT" // token类型
}
```
- 创建payload,包含iss(issuer ID)、exp(过期时间,unix时间戳,以秒为单位,)、aud
```json
{
"iss": "57246542-96fe-1a63-e053-0824d011072a",// issuer ID
"exp": 1528408800, // token过期时间,unix时间戳,秒为单位,必须在20分钟以内
"aud": "appstoreconnect-v1" // jwt接收方
}
```
- 计算签名
使用ES256算法和苹果后台提供的私钥计算签名
2. 根据文档提示,使用[JWT.io](https://jwt.io/)进行网页版签名计算,根据要求输入参数,但一直提示非法签名
![file](https://i.loli.net/2019/06/21/5d0c42574e53538830.png)
3. 使用[jwt-go](https://github.com/dgrijalva/jwt-go)计算签名
- 尝试一,提示传入的可以是非法类型
```go
package main
import (
"fmt"
"github.com/dgrijalva/jwt-go"
"time"
)
func main(){
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": "57246542-96fe-1a63-e053-0824d011072a",
"exp": time.Now().Unix(),
"aud": "appstoreconnect-v1",
})
token.Header["kid"] = "2X9R4HXF34"
tokenString, err := token.SignedString("<privateKey>")
fmt.Println(tokenString, err)
}
---------
key is of invalid type
```
- 尝试二,通过阅读源代码发现需要传入*ecdsa.PrivateKey类型的key
```go
package main
import (
"crypto/ecdsa"
"fmt"
"github.com/dgrijalva/jwt-go"
"log"
"time"
)
func main(){
var (
ecdsaKey *ecdsa.PrivateKey
err error
)
ecdsaKey,err = jwt.ParseECPrivateKeyFromPEM([]byte("<privatekey>"))
if err != nil{
log.Fatal(err)
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": "57246542-96fe-1a63-e053-0824d011072a",
"exp": time.Now().Unix(),
"aud": "appstoreconnect-v1",
})
token.Header["kid"] = "2X9R4HXF34"
tokenString, err := token.SignedString(ecdsaKey)
fmt.Println(tokenString, err)
}
------------
x509: failed to parse EC private key: asn1: structure error: tags don't match (4 vs {class:0 tag:16 length:19 isCompound:true}) {optional:false explicit:false application:false private:false defaultValue:<nil> tag:<nil> stringType:0 timeType:0 set:false omitEmpty:false} @5
```
![file](https://i.loli.net/2019/06/21/5d0c428775f2853145.png)
- 尝试三,仔细观察下载到的privatekey文件,是以p8结尾的。在网上找到了[解析p8文件的方法](https://github.com/sideshow/apns2/blob/master/token/token.go#L50-L67)
```go
package main
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"github.com/dgrijalva/jwt-go"
"log"
"time"
)
var (
ErrAuthKeyNotPem = errors.New("token: AuthKey must be a valid .p8 PEM file")
ErrAuthKeyNotECDSA = errors.New("token: AuthKey must be of type ecdsa.PrivateKey")
ErrAuthKeyNil = errors.New("token: AuthKey was nil")
)
func AuthKeyFromBytes(bytes []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(bytes)
if block == nil {
return nil, ErrAuthKeyNotPem
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
switch pk := key.(type) {
case *ecdsa.PrivateKey:
return pk, nil
default:
return nil, ErrAuthKeyNotECDSA
}
}
func main() {
var (
ecdsaKey *ecdsa.PrivateKey
err error
)
ecdsaKey, err = AuthKeyFromBytes([]byte("<privatekey>"))
if err != nil {
log.Fatal(err)
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": "57246542-96fe-1a63-e053-0824d011072a",
"exp": time.Now().Unix(),
"aud": "appstoreconnect-v1",
})
token.Header["kid"] = "2X9R4HXF34"
tokenString, err := token.SignedString(ecdsaKey)
fmt.Println(tokenString, err)
}
-------
eyJhbGciOiJFUzI1NiIsImtpZCI6IjJYOVI0SFhGMzQiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJhcHBzdG9yZWNvbm5lY3QtdjEiLCJleHAiOjE1NjEwODM1OTgsImlzcyI6IjU3MjQ2NTQyLTk2ZmUtMWE2My1lMDUzLTA4MjRkMDExMDcyYSJ9.E_wRsV0M7kM2aonqFBb6--Nagn5cU1mBqMOpIqJyOE34wPmcCg2-O2Ee02QbrbWHQ02rildvdiDMW8KeIWlemg <nil>
```
- 尝试四,签名终于计算成功了!不过还不能高兴的太早,要请求下服务器试下。请求返回401 NOT_AUTHORIZED,想死的心都有了!!!
```shell
$ curl -v -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6IjJYOVI0SFhGMzQiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJhcHBzdG9yZWNvbm5lY3QtdjEiLCJleHAiOjE1NjEwODM1OTgsImlzcyI6IjU3MjQ2NTQyLTk2ZmUtMWE2My1lMDUzLTA4MjRkMDExMDcyYSJ9.E_wRsV0M7kM2aonqFBb6--Nagn5cU1mBqMOpIqJyOE34wPmcCg2-O2Ee02QbrbWHQ02rildvdiDMW8KeIWlemg' "https://api.appstoreconnect.apple.com/v1/apps"
```
![file](https://i.loli.net/2019/06/21/5d0c427490d4111341.png)
### 三、柳暗花明
经过分析代码,原来是payload里面的exp值传错了,文档中提到过期时间在未来的20分钟之内,而我传的是当前时间。token传到服务器,服务器一看,过期了,当然拒绝了。所以最后的代码:
```go
package main
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"github.com/dgrijalva/jwt-go"
"log"
"time"
)
var (
ErrAuthKeyNotPem = errors.New("token: AuthKey must be a valid .p8 PEM file")
ErrAuthKeyNotECDSA = errors.New("token: AuthKey must be of type ecdsa.PrivateKey")
ErrAuthKeyNil = errors.New("token: AuthKey was nil")
)
func AuthKeyFromBytes(bytes []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(bytes)
if block == nil {
return nil, ErrAuthKeyNotPem
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
switch pk := key.(type) {
case *ecdsa.PrivateKey:
return pk, nil
default:
return nil, ErrAuthKeyNotECDSA
}
}
func main() {
var (
ecdsaKey *ecdsa.PrivateKey
err error
)
ecdsaKey, err = AuthKeyFromBytes([]byte("<privatekey>"))
if err != nil {
log.Fatal(err)
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": "57246542-96fe-1a63-e053-0824d011072a",
"exp": time.Now().Add(20*time.Minute).Unix(),
"aud": "appstoreconnect-v1",
})
token.Header["kid"] = "2X9R4HXF34"
tokenString, err := token.SignedString(ecdsaKey)
fmt.Println(tokenString, err)
}
```
### 四、参考资料
- [Generating Tokens for API Requests](https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests)
- [https://godoc.org/github.com/dgrijalva/jwt-go#example-New--Hmac](https://godoc.org/github.com/dgrijalva/jwt-go#example-New--Hmac)
- [https://github.com/dgrijalva/jwt-go/blob/master/ecdsa_test.go](https://github.com/dgrijalva/jwt-go/blob/master/ecdsa_test.go#L80-L86)
- [https://github.com/sideshow/apns2/blob/master/token/token.go#L50-L67](https://github.com/sideshow/apns2/blob/master/token/token.go#L50-L67)