個人開発アプリの freemium サブスク(Plus)を、RevenueCat を使わず Supabase の Deno Edge Function で検証した記録です。鍵は「証明書チェーンを検証しない」こと。
制約:Deno では x5c チェーン検証ができない
前提として、別記事に書いた壁があります——Supabase の Deno Edge ランタイムでは Apple の x5c 証明書チェーン検証が動きません(node:crypto の X509Certificate、@peculiar/x509、pkijs がいずれも boot か実行時に死ぬ)。サブスク検証で Apple の JWS 署名チェーンを検証しようとすると、まっすぐこの壁に戻ります。
チェーン検証なしで「検証済みの権利状態」を得る道は2つ:
- RevenueCat(マネージド。Apple のチェーン検証を肩代わり。ただしベンダーと売上1%が増える)
- 自前の Edge Function で App Store Server API を叩き、TLS を信頼する
(2) を第一候補にしました。Deno に要求するのは EC 署名と base64 デコードだけで、どちらもランタイム内で確実に動くからです。
自分で署名する「リクエスト」JWT
検証するのではなく、こちらから Apple を呼ぶための JWT を ES256 で署名します。
import * as jose from "npm:jose@5";
const key = await jose.importPKCS8(p8, "ES256"); // In-App Purchase キーの .p8
const jwt = await new jose.SignJWT({ bid: bundleId })
.setProtectedHeader({ alg: "ES256", kid: keyId, typ: "JWT" })
.setIssuer(issuerId)
.setIssuedAt()
.setExpirationTime("50m") // ≤60分。1トークンを使い回してよい
.setAudience("appstoreconnect-v1")
.setJti(nonce)
.sign(key);
ハマりどころ:鍵は In-App Purchase キー の .p8 を使うこと(通常の App Store Connect API キーだと NOT_AUTHORIZED が返る)。
肝:TLS を信頼して、チェーンは検証しない
GET /inApps/v1/subscriptions/{transactionId} を TLS で叩くと、signedTransactionInfo という JWS(x5c ヘッダ付き)が返ります。これを——payload セグメントだけデコードして、チェーン検証はスキップします。
const info = jose.decodeJwt(signedTransactionInfo); // 署名チェーン検証なし
根拠:Apple の既知ホストへ こちらから TLS を張った ので、チャネルが発信元を認証している。x5c チェーンの再検証は、まさに Deno で動かない作業であり、すでにセキュリティ上クリティカルでないデータ(権利状態。端末側の StoreKit 2 とサーバの per-user 行が backstop)への二重防御にすぎません。トレードオフとして受け入れました。
Server Notifications は信用しない
通知の push ペイロードは、URL を知った者なら誰でも偽造できます。なので通知は無視するか、「いま再ポーリングしろ」の合図としてだけ扱い、真実は常に TLS 越しのポーリングです。
実際に動いた(スパイク)
ライブの Deno Edge ランタイムで端から端まで確認:jose が .p8 を読み ES256 で署名 → サンドボックスの App Store Server API を TLS で呼ぶ → 存在しない transaction id に 4040010 Transaction id not found(不正な形式の id なら 4000006。いずれも認証は通って、リクエスト検証まで到達したというお決まりの成功シグナル)。x5c チェーン検証はどこでも不要でした。
すべての consumer は1つの resolveEntitlement() シームから権利状態を読むので、もし将来詰まっても RevenueCat への切り替えは「バックエンドの差し替え」であって書き直しではありません。
アプリ本体(iPhone・基本無料):APP STORE