Astro × Kinde × Stripe 月額サブスクリプション実装ガイド

Page content

Astro × Kinde × Stripe 月額サブスクリプション実装ガイド

対象スタック: Astro (SSRモード) + Cloudflare Pages/Workers + Kinde + Stripe


全体アーキテクチャ

ブラウザ
  │
  ├─ /pricing          → Kindeの Pricing Table ウィジェットを埋め込み
  ├─ /api/auth/*       → Kinde SDK が処理(ログイン・コールバック・ログアウト)
  ├─ /dashboard        → 認証+サブスク状態チェック後に表示
  └─ /api/portal       → Stripe カスタマーポータルへリダイレクト
         │
  Cloudflare Workers (Astro SSR)
         │
  ├─ Kinde Auth  ←→  Kinde管理画面(ユーザー管理・プラン管理)
  └─ Stripe API  ←→  Stripe(決済・Webhook)

STEP 0: 前提パッケージとプロジェクト初期状態の確認

# Node.js 18以上、npm 9以上が必要
node -v
npm -v

# 既存プロジェクトの確認
cat astro.config.mjs
cat package.json

astro.config.mjs が以下になっていることを確認します。output: 'server'必須です:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server',           // ← SSRモード(必須)
  adapter: cloudflare({
    mode: 'directory',        // Cloudflare Pages向け推奨設定
  }),
});

まだ @astrojs/cloudflare を追加していない場合:

npx astro add cloudflare
# プロンプトに "Yes" で答えると自動設定される

STEP 1: 依存パッケージのインストール

npm install @kinde-oss/kinde-typescript-sdk stripe
パッケージ 用途
@kinde-oss/kinde-typescript-sdk Kinde認証・プラン管理
stripe Stripe APIクライアント(Webhookの署名検証に使用)

STEP 2: Kinde管理画面の設定

2-1. アプリケーションの作成

  1. app.kinde.com にログイン
  2. [Settings] → [Applications] → [Add application]
  3. 種類: Back-end web を選択
  4. 作成後、以下をメモ:
    • Domain (例: https://yourapp.kinde.com)
    • Client ID
    • Client Secret

2-2. コールバックURLの登録

同じアプリケーション設定画面で:

  • Allowed callback URLs: https://your-app.pages.dev/api/auth/kinde_callback
  • Allowed logout redirect URLs: https://your-app.pages.dev/

ローカル開発用に http://localhost:4321/api/auth/kinde_callback も追加しておくと便利です。

2-3. Stripeの接続

  1. [Settings] → [Billing] → [Connect Stripe]
  2. Stripeアカウントと連携(OAuth認証フロー)
  3. 連携後、Stripe側で作成した「月額商品」をKindeのプランとして登録

2-4. フィーチャーフラグの設定

Kinde管理画面で:

  1. [Feature flags] → [Add feature flag]
  2. Key: is_pro、Type: boolean、Default value: false
  3. 「Proプラン」のユーザーにはこのフラグが自動で true になるよう、[Billing] → [Plans] でプランにフラグを紐付け

2-5. Pricing Tableの有効化

  1. [Design] → [Widgets] → [Pricing table] を有効化
  2. 生成されたHTMLスニペットをメモ(STEP 5で使用)

STEP 3: Stripe管理画面の設定

3-1. 月額商品の作成

  1. dashboard.stripe.com → テストモードで作業
  2. [製品カタログ] → [製品を追加]
  3. 料金体系: 定期 → 月次を選択
  4. 作成後、price_XXXXX 形式の Price ID をメモ

3-2. Webhookの設定

Kinde経由の課金状態変更を受け取るため:

  1. [開発者] → [Webhook] → [エンドポイントを追加]
  2. URL: https://your-app.pages.dev/api/stripe/webhook
  3. 購読するイベント:
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • checkout.session.completed
  4. Webhook署名シークレット (whsec_XXXXX) をメモ

STEP 4: 環境変数の設定

ローカル(.envファイル)

# .env(Gitにコミットしない!)

# Kinde
KINDE_DOMAIN=https://yourapp.kinde.com
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret
KINDE_REDIRECT_URL=http://localhost:4321/api/auth/kinde_callback
KINDE_LOGOUT_REDIRECT_URL=http://localhost:4321/

# Stripe
STRIPE_SECRET_KEY=sk_test_XXXXX
STRIPE_WEBHOOK_SECRET=whsec_XXXXX

.gitignore に追加されていることを確認:

.env
.env.local

Cloudflare Pages(本番)

Cloudflareダッシュボードの [設定] → [環境変数] で同じキーを本番値で設定します。KINDE_REDIRECT_URLhttps://your-app.pages.dev/api/auth/kinde_callback に変更することを忘れずに。


STEP 5: ファイル構成

src/
├── lib/
│   └── kinde.ts          # Kindeクライアントのシングルトン
├── middleware.ts           # 認証状態をリクエスト全体に伝播
└── pages/
    ├── api/
    │   └── auth/
    │       └── [...kindeAuth].ts   # 認証エンドポイント群
    │   └── stripe/
    │       └── webhook.ts          # StripeのWebhook受信
    │       └── portal.ts           # カスタマーポータルへのリダイレクト
    ├── index.astro           # トップページ
    ├── pricing.astro         # 料金ページ
    └── dashboard.astro       # 有料会員専用ページ

STEP 6: Kindeクライアントの初期化(src/lib/kinde.ts

// src/lib/kinde.ts
import {
  createKindeServerClient,
  GrantType,
  type SessionManager,
} from "@kinde-oss/kinde-typescript-sdk";

// Cloudflare Workers環境ではprocess.envではなくimport.meta.envを使用
export function createKindeClient() {
  return createKindeServerClient(GrantType.AUTHORIZATION_CODE, {
    authDomain: import.meta.env.KINDE_DOMAIN,
    clientId: import.meta.env.KINDE_CLIENT_ID,
    clientSecret: import.meta.env.KINDE_CLIENT_SECRET,
    redirectURL: import.meta.env.KINDE_REDIRECT_URL,
    logoutRedirectURL: import.meta.env.KINDE_LOGOUT_REDIRECT_URL,
  });
}

// AstroのAPIルート用セッションマネージャー
// KindeはデフォルトでCookieベースのセッション管理を行う
export function getSessionManager(
  request: Request,
  responseHeaders: Headers
): SessionManager {
  // @kinde-oss/kinde-typescript-sdk のCookieSessionManagerを使用
  // 詳細はKindeの公式ドキュメントを参照
  return {
    async getSessionItem(key: string) {
      // Cookieからセッション情報を取得するロジック
      const cookieHeader = request.headers.get("cookie") ?? "";
      const cookies = Object.fromEntries(
        cookieHeader.split("; ").map((c) => c.split("=").map(decodeURIComponent))
      );
      return cookies[key] ?? null;
    },
    async setSessionItem(key: string, value: unknown) {
      responseHeaders.append(
        "Set-Cookie",
        `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}; Path=/; HttpOnly; SameSite=Lax; Secure`
      );
    },
    async removeSessionItem(key: string) {
      responseHeaders.append(
        "Set-Cookie",
        `${encodeURIComponent(key)}=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0`
      );
    },
    async destroySession() {
      // 全セッションCookieの削除ロジック(本番実装では要調整)
    },
  };
}

注意: Kinde SDKのバージョンによってSessionManagerの実装が異なります。公式ドキュメント kinde.com/docs で最新のCookie実装を確認してください。


STEP 7: 認証エンドポイント(src/pages/api/auth/[...kindeAuth].ts

// src/pages/api/auth/[...kindeAuth].ts
import type { APIRoute } from "astro";
import { createKindeClient, getSessionManager } from "../../../lib/kinde";

export const GET: APIRoute = async ({ request, params, redirect }) => {
  const kindeClient = createKindeClient();
  const url = new URL(request.url);
  const action = params.kindeAuth; // "login" | "logout" | "kinde_callback" | "register"

  const responseHeaders = new Headers();
  const sessionManager = getSessionManager(request, responseHeaders);

  try {
    switch (action) {
      case "login": {
        // Kindeのログインページへリダイレクト
        const loginUrl = await kindeClient.login(sessionManager);
        return redirect(loginUrl.toString());
      }

      case "register": {
        // 新規登録ページへ
        const registerUrl = await kindeClient.register(sessionManager);
        return redirect(registerUrl.toString());
      }

      case "kinde_callback": {
        // 認証後のコールバック処理
        await kindeClient.handleRedirectToApp(sessionManager, url);
        // 認証成功後、ダッシュボードへ
        return new Response(null, {
          status: 302,
          headers: { ...Object.fromEntries(responseHeaders), Location: "/dashboard" },
        });
      }

      case "logout": {
        // ログアウト処理
        const logoutUrl = await kindeClient.logout(sessionManager);
        return new Response(null, {
          status: 302,
          headers: { ...Object.fromEntries(responseHeaders), Location: logoutUrl.toString() },
        });
      }

      default:
        return new Response("Not Found", { status: 404 });
    }
  } catch (error) {
    console.error("Kinde auth error:", error);
    return new Response("Authentication error", { status: 500 });
  }
};

これにより以下のURLが自動的に機能します:

URL 機能
/api/auth/login ログインページへリダイレクト
/api/auth/register 新規登録ページへリダイレクト
/api/auth/kinde_callback 認証後のコールバック処理
/api/auth/logout ログアウト処理

STEP 8: 保護されたページの実装(src/pages/dashboard.astro

---
// src/pages/dashboard.astro
import { createKindeClient, getSessionManager } from "../lib/kinde";
import Layout from "../layouts/Layout.astro";

const responseHeaders = new Headers();
const sessionManager = getSessionManager(Astro.request, responseHeaders);
const kindeClient = createKindeClient();

// ① ログイン状態の確認
const isAuthenticated = await kindeClient.isAuthenticated(sessionManager);
if (!isAuthenticated) {
  // 未ログインはログインページへ
  return Astro.redirect("/api/auth/login");
}

// ② ユーザー情報の取得
const user = await kindeClient.getUser(sessionManager);

// ③ サブスクリプション状態の確認
// Kindeの管理画面でProプランに "is_pro" フラグを紐付けておく
let isPro = false;
try {
  const flag = await kindeClient.getFlag(sessionManager, "is_pro");
  isPro = flag.value === true;
} catch (e) {
  // フラグが存在しない場合はfalseのまま
  isPro = false;
}

// ④ 未課金ユーザーは料金ページへ
if (!isPro) {
  return Astro.redirect("/pricing");
}
---

<Layout title="ダッシュボード">
  <main>
    <h1>ようこそ、{user?.given_name}さん!</h1>
    <p>月額プランをご利用いただきありがとうございます。</p>

    <section>
      <h2>プレミアム機能</h2>
      <!-- 有料会員専用コンテンツ -->
    </section>

    <div>
      <a href="/api/portal">サブスクリプションの管理(解約・カード変更)</a>
    </div>
    <div>
      <a href="/api/auth/logout">ログアウト</a>
    </div>
  </main>
</Layout>

STEP 9: 料金ページ(src/pages/pricing.astro

---
// src/pages/pricing.astro
import Layout from "../layouts/Layout.astro";

// Kinde Pricing TableのスニペットはKinde管理画面から取得
// [Design] → [Widgets] → [Pricing table] → スニペットをコピー
---

<Layout title="料金プラン">
  <main>
    <h1>料金プラン</h1>

    <!-- KindeのPricing Tableウィジェットをここに貼り付け -->
    <!-- 例(実際の値はKinde管理画面から取得してください): -->
    <!--
    <div
      id="kinde-pricing-table"
      data-kinde-pricing-table="true"
      data-kinde-domain="https://yourapp.kinde.com"
      data-kinde-plan-id="plan_XXXXX"
    ></div>
    <script src="https://kinde.com/widgets/pricing-table.js" async></script>
    -->

    <!-- ログイン済みの場合はダッシュボードへ誘導 -->
    <p>
      すでに会員の方は <a href="/dashboard">こちら</a>
    </p>
  </main>
</Layout>

STEP 10: Stripeカスタマーポータル(src/pages/api/portal.ts

ユーザーが自分でサブスクを管理(解約・プラン変更・支払い情報更新)できるページへのリダイレクトを実装します。

// src/pages/api/portal.ts
import type { APIRoute } from "astro";
import Stripe from "stripe";
import { createKindeClient, getSessionManager } from "../../lib/kinde";

export const GET: APIRoute = async ({ request, redirect }) => {
  const responseHeaders = new Headers();
  const sessionManager = getSessionManager(request, responseHeaders);
  const kindeClient = createKindeClient();

  // 認証確認
  const isAuthenticated = await kindeClient.isAuthenticated(sessionManager);
  if (!isAuthenticated) {
    return redirect("/api/auth/login");
  }

  // KindeのユーザーIDからStripeのカスタマーIDを取得
  // ※ KindeとStripeを連携している場合、Kindeユーザーには
  //   Stripeのカスタマー情報が紐付く
  const user = await kindeClient.getUser(sessionManager);

  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY, {
    apiVersion: "2024-11-20.acacia",
  });

  try {
    // KindeのユーザーメールでStripeカスタマーを検索
    const customers = await stripe.customers.list({
      email: user?.email ?? "",
      limit: 1,
    });

    if (customers.data.length === 0) {
      return redirect("/pricing");
    }

    const customerId = customers.data[0].id;

    // カスタマーポータルセッションを作成
    const portalSession = await stripe.billingPortal.sessions.create({
      customer: customerId,
      return_url: `${new URL(request.url).origin}/dashboard`,
    });

    return redirect(portalSession.url);
  } catch (error) {
    console.error("Stripe portal error:", error);
    return new Response("Error creating portal session", { status: 500 });
  }
};

前提: Stripeダッシュボードの [設定] → [顧客ポータル] でポータルを有効化しておく必要があります。


STEP 11: Stripe Webhookの受信(src/pages/api/stripe/webhook.ts

KindeとStripeを連携している場合、Kindeが自動的にフラグ更新まで行いますが、独自の課金ロジック(例: 使用量の記録、メール送信など)を実装したい場合はWebhookを使います。

// src/pages/api/stripe/webhook.ts
import type { APIRoute } from "astro";
import Stripe from "stripe";

export const POST: APIRoute = async ({ request }) => {
  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY, {
    apiVersion: "2024-11-20.acacia",
  });

  const body = await request.text();
  const signature = request.headers.get("stripe-signature") ?? "";

  let event: Stripe.Event;

  try {
    // Webhook署名の検証(必須:なりすまし防止)
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      import.meta.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return new Response("Invalid signature", { status: 400 });
  }

  // イベントの種類に応じた処理
  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      console.log(
        `Subscription ${event.type}:`,
        subscription.id,
        subscription.status
      );
      // 必要に応じてDBの更新、メール送信などを実装
      break;
    }

    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      console.log("Subscription cancelled:", subscription.id);
      // 解約時の処理(例:farewell emailの送信)
      break;
    }

    case "checkout.session.completed": {
      const session = event.data.object as Stripe.CheckoutSession;
      console.log("Checkout completed:", session.id);
      // 初回購入完了時の処理(例:ウェルカムメール)
      break;
    }
  }

  // Stripeには必ず200を返す(さもなくばリトライされる)
  return new Response(JSON.stringify({ received: true }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
};

STEP 12: ローカル開発とデプロイ

ローカル開発

# Cloudflare Workers環境をローカルで再現
npm run dev
# または
npx wrangler pages dev -- npm run dev

Stripeのローカルテスト用にStripe CLIを使います:

# Stripe CLIのインストール(macOS)
brew install stripe/stripe-cli/stripe

# ローカルサーバーへWebhookを転送
stripe listen --forward-to localhost:4321/api/stripe/webhook

# テスト用の課金イベントを発生させる
stripe trigger customer.subscription.created

Cloudflare Pagesへのデプロイ

# ビルド
npm run build

# Wranglerでデプロイ
npx wrangler pages deploy dist/

STEP 13: セキュリティチェックリスト

デプロイ前に以下を確認してください:

  • .env ファイルが .gitignore に含まれている
  • STRIPE_WEBHOOK_SECRET が設定されており、Webhook署名検証を実装している
  • ダッシュボード等の保護ページで必ず isAuthenticated チェックをしている
  • is_pro フラグのチェックをサーバーサイドのみで行っている(クライアントサイドのみの実装は危険)
  • Cloudflare Pagesの環境変数が本番値で設定されている
  • KINDE_REDIRECT_URL が本番URLになっている

よくあるハマりポイント

process.env が使えない

Cloudflare Workers環境では Node.js の process.env が使えません。import.meta.env を使います。

// ❌
const secret = process.env.STRIPE_SECRET_KEY;

// ✅
const secret = import.meta.env.STRIPE_SECRET_KEY;

Cookieのセキュア属性

本番環境(HTTPS)では Cookie に Secure 属性が必要ですが、ローカル開発(HTTP)では不要です。

const isProduction = import.meta.env.PROD;
const cookieOptions = isProduction ? "; Secure; HttpOnly; SameSite=Lax" : "; HttpOnly; SameSite=Lax";

Cloudflare Pagesのビルドコマンド設定

Cloudflareダッシュボードで:

  • ビルドコマンド: npm run build
  • ビルド出力ディレクトリ: dist
  • Node.jsバージョン: 18 以上を指定

Kindeコールバックの404

[...kindeAuth].ts のファイル名が正確であることを確認。Astroの動的ルーティングは [...param] 形式(... が重要)。


参考リンク