【Swift】Vapor で App Store Connect API の JWT を生成する

こんにちは。[kamimi](https://twitter.com/kamimi_01)です。

最近、サーバーサイド Swift のフレームワーク [Vapor](https://docs.vapor.codes/) に入門しました💧
目的は、App Store Connect API を叩くバッチを作ることです。この API は、ヘッダーに JWT を含める必要があります。

元々は GAS で実装しようとしていたのですが、 JWT を生成するときに署名で使用されている暗号化アルゴリズムが ES256 で、そのアルゴリズムをサポートしていないみたいでした・・・

https://community.auth0.com/t/looking-to-sign-the-jwt-with-ecdsa-es256-on-google-apps-script/37171

というわけで、慣れていない JavaScript で書くのも限界だし、そもそもサポートされてないしということで、以前から使ってみたかった Vapor で実装することにしました！

Vapor が jwt-kit というパッケージを提供しており、README を確認したところ、ES256 もサポート対象でした。🎉

https://github.com/vapor/jwt-kit

今回はそれを使って、App Store Connect API をリクエストするために必要な JWT を生成してみます。

ちなみに JWT については、以下の記事がとてもわかりやすかったです。

https://developer.mamezou-tech.com/blogs/2022/12/08/jwt-auth/

# 事前準備

生成するための事前準備について書きます。といっても、API Key を生成するのみです。

## API Key の取得

生成するために必要なものは以下です。

- Issuer ID
- Key ID
- Private Key

これらは、Apple Developer Program でキーを生成する時に取得することができます。（作成方法は[こちら](https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api)）
Private Key は作成する時一度しかダウンロードできないので、要注意です。 :warning: 


## JWT 生成方法

実装の前に生成方法を確認します。
Apple 公式ドキュメントに詳細が書かれています。

https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests

ざっくりいうと

**ヘッダー**

```json
{
    "alg": "ES256",
    "kid": "<Key ID>",
    "typ": "JWT"
}
```

**ペイロード**

```jsonc
{
    "iss": "<Issuer ID>",
    "iat": <現在時刻>,
    "exp": <現在時刻 + 20分>,
    "aud": "appstoreconnect-v1",  // 固定文字列
    "scope": [  // このキーだけ任意。私は今回使っていないです
        "GET /v1/apps?filter[platform]=IOS"
    ]
}
```

**署名方法**

Private Key を使って、ES256 の暗号化アルゴリズムで行う

# 実装

では生成方法もわかったところで、実装です。🚀

まず jwt のパッケージを使用するので `Package.swift` に追記する必要があります。
これをするとパッケージが取得されます。

```swift
import PackageDescription

let package = Package(
    name: "hello_vapor",
    dependencies: [
         // 他の依存ライブラリ
        .package(url: "https://github.com/vapor/jwt.git", from: "4.0.0"),  // 追加
    ],
    targets: [
        .target(name: "App", dependencies: [
            // 他の依存ライブラリ
            .product(name: "JWT", package: "jwt")  // 追加
        ]),
        // 他のターゲット
    ]
)
```

次にペイロードを実装します。

```swift
import Foundation
import JWT  // インポートが必要

struct AppStoreConnectPayload: JWTPayload {  // JWTPayload に準拠させる
    enum CodingKeys: String, CodingKey {
        case issuer = "iss"
        case issuedAtClaim = "ias"
        case expiration = "exp"
        case audience = "aud"
    }

    // それぞれ必要な xxClaim に準拠させる
    var issuer: IssuerClaim
    var issuedAtClaim: IssuedAtClaim
    var expiration: ExpirationClaim
    var audience: AudienceClaim

    // 追加の検証ロジックを実行する
    func verify(using signer: JWTSigner) throws {
        try expiration.verifyNotExpired()  // デフォルト引数が Date() で設定されている。ここは変更する必要はなかったのでこのまま使っている
    }
}
```

`JWTPayload`や必要なプロパティごとに jwt-kit が提供している構造体に準拠させます。
今回は元々用意されている構造体に準拠するプロパティしか使っていないですが、`isAdmin`を`Bool`型で追加するというようにカスタムで値を定義することも可能みたいです。

最後に`routes.swift`で残りを実装していきます。

```swift
import Vapor
import JWT

func routes(_ app: Application) throws {
    app.get("appstoreconnect") { req -> [String: String] in
        let privateKey = "<Private Key>"

        let payload = AppStoreConnectPayload(
            issuer: .init(value: "<Issuer ID>"),
            issuedAtClaim: .init(value: Date()),  // 現在時刻
            expiration: .init(value: Calendar.current.date(byAdding: .minute, value: 20, to: Date()) ?? Date()),  // 現在時刻 + 20分
            audience: .init(value: ["appstoreconnect-v1"])  // 固定文字列
        )

        let key = try ECDSAKey.private(pem: privateKey)
        try app.jwt.signers.use(.es256(key: key))  // Private Key を使って、ES256 で署名

        return try [
            "token": req.jwt.sign(payload, kid: "<Key ID>")
        ]
    }
}
```

これで完成になります！🎉

# 検証

では生成した JWT を使って問題なく App Store Connect API がリクエストできるか確認します。

まずは JWT を取得します。今回は、API で`/appstoreconnect`というエンドポイントにリクエストすると JWT を生成するように実装したのでリクエストします。

![スクリーンショット 2023-04-06 9.51.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401966/7c4d983b-2c48-d659-1616-1b980751bde8.png)

次に App Store Connect API をリクエストします。今回は[`/reviewSubmissions`](https://developer.apple.com/documentation/appstoreconnectapi/list_review_submissions_for_an_app)のエンドポイントをリクエストしてみます。
このエンドポイントは、アプリの審査結果をレスポンスします。

```shell
curl --location -g --request GET 'https://api.appstoreconnect.apple.com/v1/reviewSubmissions?filter[app]=1673161138&filter[platform]=IOS&limit=1' \
--header 'Authorization: Bearer <取得したJWT>'
```

結果はこちら。

```json
{
    "data": [
        {
            "type": "reviewSubmissions",
            "id": "66932d0f-c44f-4a5a-b955-eff39039cdd2",
            "attributes": {
                "platform": "IOS",
                "submittedDate": "2023-02-21T12:05:34.792Z",
                "state": "COMPLETE"
            },
            "relationships": {
                "items": {
                    "links": {
                        "self": "https://api.appstoreconnect.apple.com/v1/reviewSubmissions/66932d0f-c44f-4a5a-b955-eff39039cdd2/relationships/items",
                        "related": "https://api.appstoreconnect.apple.com/v1/reviewSubmissions/66932d0f-c44f-4a5a-b955-eff39039cdd2/items"
                    }
                }
            },
            "links": {
                "self": "https://api.appstoreconnect.apple.com/v1/reviewSubmissions/66932d0f-c44f-4a5a-b955-eff39039cdd2"
            }
        }
    ],
    "links": {
        "self": "https://api.appstoreconnect.apple.com/v1/reviewSubmissions?filter%5Bplatform%5D=IOS&limit=1&filter%5Bapp%5D=1673161138",
        "next": "https://api.appstoreconnect.apple.com/v1/reviewSubmissions?cursor=AQ.ZaYIyQ&filter%5Bplatform%5D=IOS&limit=1&filter%5Bapp%5D=1673161138"
    },
    "meta": {
        "paging": {
            "total": 3,
            "limit": 1
        }
    }
}
```

ということでほしいレスポンスを取得することができましたので、正しい JWT が生成されていることがわかりました。

ちなみに `IssuedAtClaim`にも実装していた通り、この JWT は 20分で有効期限切れになります。公式ページにも記載の通り、期限が切れるまでは同じ JWT を使用してリクエストすべきですが、切れたら再生成が必要です。


# おわりに

初めて Vapor を使用してみました。やっぱり普段書き慣れている言語で実装できるというのは、心理的ハードルが低くていいですね。😊

サーバーサイドの実装も好きなので、それが Swift で実装できるというのは個人的に大きなメリットです。

最終的にやりたいことは App Store Connect API を含め複数の API を使ったバッチを実装して Google Cloud Run にデプロイすることなのでまだ完成はしていないのですが、一歩ずつ頑張ります。💪🏻