こんにちは。kamimiです。
最近、サーバーサイド Swift のフレームワーク Vapor に入門しました💧
目的は、App Store Connect API を叩くバッチを作ることです。この API は、ヘッダーに JWT を含める必要があります。
元々は GAS で実装しようとしていたのですが、 JWT を生成するときに署名で使用されている暗号化アルゴリズムが ES256 で、そのアルゴリズムをサポートしていないみたいでした・・・
というわけで、慣れていない JavaScript で書くのも限界だし、そもそもサポートされてないしということで、以前から使ってみたかった Vapor で実装することにしました!
Vapor が jwt-kit というパッケージを提供しており、README を確認したところ、ES256 もサポート対象でした。🎉
今回はそれを使って、App Store Connect API をリクエストするために必要な JWT を生成してみます。
ちなみに JWT については、以下の記事がとてもわかりやすかったです。
事前準備
生成するための事前準備について書きます。といっても、API Key を生成するのみです。
API Key の取得
生成するために必要なものは以下です。
- Issuer ID
- Key ID
- Private Key
これらは、Apple Developer Program でキーを生成する時に取得することができます。(作成方法はこちら)
Private Key は作成する時一度しかダウンロードできないので、要注意です。
JWT 生成方法
実装の前に生成方法を確認します。
Apple 公式ドキュメントに詳細が書かれています。
ざっくりいうと
ヘッダー
{
"alg": "ES256",
"kid": "<Key ID>",
"typ": "JWT"
}
ペイロード
{
"iss": "<Issuer ID>",
"iat": <現在時刻>,
"exp": <現在時刻 + 20分>,
"aud": "appstoreconnect-v1", // 固定文字列
"scope": [ // このキーだけ任意。私は今回使っていないです
"GET /v1/apps?filter[platform]=IOS"
]
}
署名方法
Private Key を使って、ES256 の暗号化アルゴリズムで行う
実装
では生成方法もわかったところで、実装です。🚀
まず jwt のパッケージを使用するので Package.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") // 追加
]),
// 他のターゲット
]
)
次にペイロードを実装します。
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
で残りを実装していきます。
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 を生成するように実装したのでリクエストします。
次に App Store Connect API をリクエストします。今回は/reviewSubmissions
のエンドポイントをリクエストしてみます。
このエンドポイントは、アプリの審査結果をレスポンスします。
curl --location -g --request GET 'https://api.appstoreconnect.apple.com/v1/reviewSubmissions?filter[app]=1673161138&filter[platform]=IOS&limit=1' \
--header 'Authorization: Bearer <取得したJWT>'
結果はこちら。
{
"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 にデプロイすることなのでまだ完成はしていないのですが、一歩ずつ頑張ります。💪🏻