Honoのmiddlewareを使って、Stripe Webhookの認証処理を作ってみた

この記事は、「Hono Advent Calendar 2023」の2日目の記事で、Honoを使用してStripe Webhookの認証処理を行う方法について試しています。記事では、Honoのmiddlewareを使ってAPIの動作確認を行い、StripeのWebhookのAPIも作成しています。実装にはいくつかの問題があり、解決策としてクラスを使用してイベントデータをハンドルする方法を提案しています。ただし、この実装にはまだ時間がかかるため、他の方法も試してみる予定です。

広告ここから
広告ここまで

目次

    この記事は「Hono Advent Calendar 2023」2日目の記事です。

    最近ちょっとしたAPIの動作確認を行う際に、Honoを使うことが多くなってきました。仕事柄、Stripe API / Webhookを試すことが多いため、今回は「Stripe Webhookの認証処理を、Honoのmiddlewareで実装するとどうなるか?」を試してみたいと思います。

    Honoのmiddlewareを簡単に追加してみる

    まずはmiddlewareの作り方をおさらいします。app.useで適用するルートの条件を設定し、第二引数にmiddlewareの処理を定義する形で実装します。例えば下のコードでは、日本からのリクエストの時は、middleware側でAPIのレスポンスを返すようにしています。

    import { Hono} from "hono";
    
    const app = new Hono();
    
    app.use('/webhook', async (c, next) => {
        const header = c.req.header()
        if (header['cf-ipcountry'] === 'JP') {
            return c.text("こんにちは!")
        }
        next();
    })
    app.get('/webhook', async c => {
        return c.text("hello")
    })
    
    export default app
    

    cf-ipcountryヘッダーをJPにしているかどうかを確認する処理があるだけですが、とてもシンプルに複数のAPIルートに影響を与える処理を書けることがわかります。

    StripeのWebhook認証処理をmiddlewareで実装する

    それではStripe WebhookのAPIを作ってみましょう。StripeのAPIドキュメントなどを参考にすると、だいたいこんな感じになるはずです。

    import { Hono } from "hono";
    import Stripe from 'stripe';
    
    type Bindings = {
        STRIPE_WEBHOOK_SECRET: string;
        STRIPE_SECRET_KEY: string;
    };
    const app = new Hono<{ Bindings: Bindings }>();
    
    app.use('/webhook', async (c, next) => {
        const stripe = new Stripe(c.env.STRIPE_SECRET_KEY, {
            httpClient: Stripe.createFetchHttpClient(), 
        })
        const header = c.req.header();
        const signature = header["stripe-signature"];
        if (!signature) {
            return new Response("Bad request", {
                status: 401
            });
        }
        try {
            const body = await c.req.text();
            await stripe.webhooks.constructEventAsync(
                body,
                signature,
                c.env.STRIPE_WEBHOOK_SECRET,
                undefined,
                Stripe.createSubtleCryptoProvider()
            )
    
            next();
        } catch (err) {
            const errorMessage = `⚠️  Webhook signature verification failed. ${err instanceof Error ? err.message : 'Internal server error'}`
            return new Response(errorMessage, {
                status: 401
            });
        }
    })

    署名検証処理は、適切に環境変数でAPIシークレットや署名シークレットが渡されていれば動作するはずです。

    問題点: constructした後のデータが取れない

    ただしこの実装には問題が1つあります。それはWebhookのリクエスト本体が、stripe.webhooks.constructEventAsync処理の戻り値にあることです。このままではPOSTのAPI側でも同様の処理を行う必要があり、middlewareをいれるメリットが薄れます。

    クラスを使って、イベントデータを使いまわせるようにしてみた

    とりあえずの解決案として、クラスの中でイベントオブジェクトをハンドルするものを作ってみました。

    type StripeWebhookMiddlewareEnv = {
        stripeAPISecret: string;
        stripeWebhookSecret: string;
    }
    type StripeWebhookMiddlewareRuntime = 'node' | 'edge'
    type StripeWebhookAPIFunction = (c: any, event: Stripe.Event) => Promise<Response> 
    class StripeWebhookMiddleware {
        public event: Stripe.Event | null = null
        private runtime: StripeWebhookMiddlewareRuntime
        private readonly webhookFunction: StripeWebhookAPIFunction
        constructor(callback: StripeWebhookAPIFunction, runtime: StripeWebhookMiddlewareRuntime = 'node') {
            this.webhookFunction = callback
            this.runtime = runtime
        }
    
        async constructEvent(request: HonoRequest, env: StripeWebhookMiddlewareEnv): Promise<Stripe.Event> {
            const stripe = new Stripe(env.stripeAPISecret, {
                httpClient: this.runtime === 'edge' ? Stripe.createFetchHttpClient(): undefined, 
            })
            const signature = request.headers.get("stripe-signature");
            try {
                if (!signature) {
                    throw new Error("Invalid signature")
                }
                const body = await request.text();
                switch (this.runtime) {
                    case 'edge': {
                        return await stripe.webhooks.constructEventAsync(
                            body,
                            signature,
                            env.stripeWebhookSecret,
                            undefined,
                            Stripe.createSubtleCryptoProvider()
                        )
                        break
                    }
                    case "node": {
                        return await stripe.webhooks.constructEvent(
                            body,
                            signature,
                            env.stripeWebhookSecret
                        )
                        break
                    }
                    default: {
                        throw new Error(`Unsupported runtime: ${this.runtime}`)
                    }
                }
              } catch (err) {
                const errorMessage = `⚠️  Webhook signature verification failed. ${err instanceof Error ? err.message : 'Internal server error'}`
                throw new Error(errorMessage)
              }
        }
    
        apply() {
            return async (c: any, next: any) => {
                try {
                    const event = await stripeMiddleware.constructEvent(c.req, {
                        stripeAPISecret: c.env.STRIPE_SECRET_KEY,
                        stripeWebhookSecret: c.env.STRIPE_WEBHOOK_SECRET
                    })
                    return this.webhookFunction(c, event)
                } catch (e) {
                    return c.json({
                        errorMessage: (e as Error).message
                    }, 400)
                }
            }
        }
    }

    使い方はこうなります。クラスのコンストラクタに実装したいWebhookの処理を書く形にしてみました。

    const stripeMiddleware: StripeWebhookMiddleware = new StripeWebhookMiddleware(async (c, event) => {
       if (event.type !== 'payment_intent.created') {
         return c.text('ok')
       }
       return c.json({
           message: 'hello'
       })
    },'edge')
    
    app.use('/webhook', stripeMiddleware.apply())

    Cloudflare Workersとそれ以外の環境で、少し認証処理が変わる部分があります。そのため、第二引数のedgenodeに変えることで、AWS LambdaやDockerなどで使えるように分岐させています。

    作ってみて

    本当はこんな感じの実装にしたかったのですが、そこまでは時間が取れませんでした・・・

    const middleware = new StripeWebhookMiddleware();
    middleware.event(“payment_intents.created”, async (c, event) => { … })

    また時間があれば、他の方法にもチャレンジしてみます。

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark