使用Golang计算App Store Connect API jwt token

golang 19-06-20 22:36 7481  

### 一、背景 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)