Motivation
最近流行の決済サービスであるStripeであるが、決済処理時のエンドポイントに送信するWebhookイベントのヘッダに署名を含めることができる。これによりそれがサードパーティでなくStripeによって送信されたものであるかどうかを検証することができる。
そこで、本記事ではこの署名検証処理をローカルで手軽にテストできるようにすることを目的とする。
Prerequisite
テスト環境
OS: macOS
node: v14.17.3
Firebase Cloud Functions
プロジェクトの追加やCLIでのログインは省略
詳しくはこちら:https://firebase.google.com/docs/functions/get-started
Cloud Functionsの実装
import * as express from "express";
import * as functions from "firebase-functions";
import { https } from "firebase-functions";
import Stripe from "stripe";
// 環境変数
const SECRET_KEY = "sk_xxxx"; // APIキー
const ENDPOINT_SECRET = "whsec_XXXX"; // 署名シークレット。後述のStripe CLIによるサーバー起動時に表示されるシークレットを指定する。
const app = express();
// webhook
app.post("/webhook-stripe/", async (req, res) => {
const signature = req.headers["stripe-signature"];// ここに署名が入っている
if (!signature) {
throw new Error("no avalilable signature header");
}
try {
const stripe = new Stripe(SECRET_KEY, {
apiVersion: "2020-08-27",
});
// 署名検証
const firebaseRequest = req as https.Request;
const event = stripe.webhooks.constructEvent(firebaseRequest.rawBody, signature, ENDPOINT_SECRET);
res.status(200).send();
} catch (e) {
console.log(e);
res.status(500).send(`Verification Error: ${e.message}`);
}
});
export const verifySignature = functions.https.onRequest(app);
ハマったポイント
stripe.webhooks.constructEvent()の第一引数に渡すpayload
は、stripeからのraw request body(Buffer)を渡さなければいけないが、通常の方法だとなぜか上手くいかず「No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?」のエラーが出た。
原因:Cloud Functions + Expressの環境では、requestがデフォルトでjsonパースされているみたいで、どうしてもraw bodyが取得できなかった
解決法:
import { https } from "firebase-functions";
...
const firebaseRequest = req as https.Request;
// -> firebaseRequest.rawBodyをstripe.webhooks.constructEventのpayloadで渡すようにする
ローカルでエミュレート
$ firebase emulators:start
これにより、http://localhost:5001/{projectId}/{region}/{functionName}/{functionPath}/
で署名検証用のCloud Functionsが立ち上がる。このURLはwebhook URLとして、後述のStripe CLIでサーバーを起動するときに指定する。
ここで、functionName
はverifySignature
、functionPath
はwebhook-stripe/
である。
region
, projectId
はご自身のFirebaseに作成したプロジェクトのリージョン及びプロジェクトIDを参照すること。
Stripe
アカウント作成
省略
CLIのインストール
$ brew install stripe/stripe-cli/stripe
ログイン
$ stripe login
# ブラウザに遷移するので、画面に従い認証を行う
CLIによるサーバー起動
$ stripe listen --forward-to http://localhost:5001/{projectId}/us-central1/verifySignature/webhook-stripe/
# 署名シークレットが表示されるため、これで index.ts 内で指定するものを更新し、再度firebase emulators:start で起動し直す
Test & Result
リクエストの形式
$ stripe trigger <EVENT_NAME>
に入れるべきものは、以下で確認可能
$ stripe trigger --help
...
Supported events:
account.updated
balance.available
charge.captured
...
実際にstripeに何らかのリクエストを投げる
$ stripe trigger balance.available
...
functions: Beginning execution of "us-central1-verifySignature"
{
id: 'evt_1JGOSGFZkFlFEY6jnM5qUSGL',
object: 'event',
api_version: '2020-08-27',
created: 1627047019,
data: {
object: {
id: 'pi_1JGOSEFZkFlFEY6jH4vzdVHS',
object: 'payment_intent',
amount: 2000,
amount_capturable: 0,
amount_received: 2000,
application: null,
application_fee_amount: null,
canceled_at: null,
cancellation_reason: null,
capture_method: 'automatic',
charges: [Object],
client_secret: 'pi_1JGOSEFZkFlFEY6jH4vzdVHS_secret_oIsYsVEmk91lO3D8RERkUU0JO',
confirmation_method: 'automatic',
created: 1627047018,
currency: 'usd',
customer: null,
description: '(created by Stripe CLI)',
invoice: null,
last_payment_error: null,
livemode: false,
metadata: {},
next_action: null,
on_behalf_of: null,
payment_method: 'pm_1JGOSEFZkFlFEY6jre53Hhu9',
payment_method_options: [Object],
payment_method_types: [Array],
receipt_email: null,
review: null,
setup_future_usage: null,
shipping: [Object],
source: null,
statement_descriptor: null,
statement_descriptor_suffix: null,
status: 'succeeded',
transfer_data: null,
transfer_group: null
}
},
livemode: false,
pending_webhooks: 3,
request: { id: 'req_f1Qn79RYEzu0Lm', idempotency_key: null },
type: 'payment_intent.succeeded'
}
i functions: Beginning execution of "us-central1-verifySignature"
i functions: Beginning execution of "us-central1-verifySignature"
{
id: 'evt_1JGOSGFZkFlFEY6j57JY0AMQ',
object: 'event',
api_version: '2020-08-27',
created: 1627047019,
data: {
object: {
id: 'ch_1JGOSFFZkFlFEY6jjreb2LGJ',
object: 'charge',
amount: 2000,
amount_captured: 2000,
amount_refunded: 0,
application: null,
application_fee: null,
application_fee_amount: null,
balance_transaction: 'txn_1JGOSFFZkFlFEY6jXw83a3Wy',
billing_details: [Object],
calculated_statement_descriptor: 'Stripe',
captured: true,
created: 1627047019,
currency: 'usd',
customer: null,
description: '(created by Stripe CLI)',
destination: null,
dispute: null,
disputed: false,
failure_code: null,
failure_message: null,
fraud_details: {},
invoice: null,
livemode: false,
metadata: {},
on_behalf_of: null,
order: null,
outcome: [Object],
paid: true,
payment_intent: 'pi_1JGOSEFZkFlFEY6jH4vzdVHS',
payment_method: 'pm_1JGOSEFZkFlFEY6jre53Hhu9',
payment_method_details: [Object],
receipt_email: null,
receipt_number: null,
receipt_url: 'https://pay.stripe.com/receipts/acct_1JExhyFZkFlFEY6j/ch_1JGOSFFZkFlFEY6jjreb2LGJ/rcpt_JuCrnUA8LSppUxCxBQ58XX5CbdP0igP',
refunded: false,
refunds: [Object],
review: null,
shipping: [Object],
source: null,
source_transfer: null,
statement_descriptor: null,
statement_descriptor_suffix: null,
status: 'succeeded',
transfer_data: null,
transfer_group: null
}
},
livemode: false,
pending_webhooks: 3,
request: { id: 'req_f1Qn79RYEzu0Lm', idempotency_key: null },
type: 'charge.succeeded'
}
{
id: 'evt_1JGOSGFZkFlFEY6jXdUgwE4B',
object: 'event',
api_version: '2020-08-27',
created: 1627047018,
data: {
object: {
id: 'pi_1JGOSEFZkFlFEY6jH4vzdVHS',
object: 'payment_intent',
amount: 2000,
amount_capturable: 0,
amount_received: 0,
application: null,
application_fee_amount: null,
canceled_at: null,
cancellation_reason: null,
capture_method: 'automatic',
charges: [Object],
client_secret: 'pi_1JGOSEFZkFlFEY6jH4vzdVHS_secret_oIsYsVEmk91lO3D8RERkUU0JO',
confirmation_method: 'automatic',
created: 1627047018,
currency: 'usd',
customer: null,
description: '(created by Stripe CLI)',
invoice: null,
last_payment_error: null,
livemode: false,
metadata: {},
next_action: null,
on_behalf_of: null,
payment_method: null,
payment_method_options: [Object],
payment_method_types: [Array],
receipt_email: null,
review: null,
setup_future_usage: null,
shipping: [Object],
source: null,
statement_descriptor: null,
statement_descriptor_suffix: null,
status: 'requires_payment_method',
transfer_data: null,
transfer_group: null
}
},
livemode: false,
pending_webhooks: 3,
request: { id: 'req_f1Qn79RYEzu0Lm', idempotency_key: null },
type: 'payment_intent.created'
}
i functions: Finished "us-central1-verifySignature" in ~1s
i functions: Finished "us-central1-verifySignature" in ~1s
i functions: Finished "us-central1-verifySignature" in ~1s
i functions: Beginning execution of "us-central1-verifySignature"
{
id: 'evt_1JGOSGFZkFlFEY6jPyo2vocQ',
object: 'event',
api_version: '2020-08-27',
created: 1627047020,
data: {
object: {
object: 'balance',
available: [Array],
livemode: false,
pending: [Array]
}
},
livemode: false,
pending_webhooks: 3,
request: { id: null, idempotency_key: null },
type: 'balance.available'
}
すなわち、署名検証を行なった結果、Stripeからのリクエストであることが証明された。
Couclusion
Firebase Cloud Functionsのローカルエミュレーションを用いて、Stripeの署名検証テストを行うことができた。