// server/supabase/functions/stripe-webhook/index.ts // Stripe Webhook 핸들러 — checkout 완료, 구독 갱신/취소 등 이벤트 처리. // Supabase 대시보드 → Functions → stripe-webhook 의 verify_jwt를 false로 설정해야 함 // (Stripe는 JWT 없이 호출, 대신 signature로 검증). // // 환경변수: // STRIPE_SECRET_KEY // STRIPE_WEBHOOK_SECRET (Stripe 대시보드에서 발급) import { createServiceRoleClient } from '../_shared/quota.ts' interface StripeEvent { id: string type: string data: { object: Record } } /** * Stripe webhook signature 검증 — Web Crypto API 기반 HMAC-SHA256. * * Stripe-Signature 헤더 형식: "t=TIMESTAMP,v1=SIG,v1=SIG2,..." * 검증 방식: HMAC_SHA256(secret, `${timestamp}.${payload}`) 를 16진수로 인코딩하여 * v1 시그니처 중 하나와 일치하면 valid. * * 또한 timestamp가 tolerance(5분) 이상 벗어나면 reject (replay 공격 방지). * * 참고: https://stripe.com/docs/webhooks/signatures */ async function verifyStripeSignature( payload: string, signatureHeader: string, secret: string, toleranceSec = 300 ): Promise { if (!signatureHeader || !secret || !payload) return false // 헤더 파싱 const parts = signatureHeader.split(',').map((p) => p.trim()) const timestampEntry = parts.find((p) => p.startsWith('t=')) const v1Signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3)) if (!timestampEntry || v1Signatures.length === 0) return false const timestamp = Number(timestampEntry.slice(2)) if (!Number.isFinite(timestamp)) return false // Replay 방지: 5분 이상 오래된 요청 거부 const nowSec = Math.floor(Date.now() / 1000) if (Math.abs(nowSec - timestamp) > toleranceSec) { return false } // HMAC-SHA256 계산 const signedPayload = `${timestamp}.${payload}` const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const sigBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload)) const expectedHex = Array.from(new Uint8Array(sigBuffer)) .map((b) => b.toString(16).padStart(2, '0')) .join('') // 타이밍 공격 방지: constant-time 비교 for (const v1 of v1Signatures) { if (constantTimeEqual(v1, expectedHex)) { return true } } return false } function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false let mismatch = 0 for (let i = 0; i < a.length; i++) { mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i) } return mismatch === 0 } // @ts-expect-error — Deno 런타임 전역 Deno.serve(async (req: Request) => { if (req.method !== 'POST') { return new Response('Method not allowed', { status: 405 }) } // @ts-expect-error — Deno.env const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '' if (!webhookSecret) { return new Response('Webhook secret not configured', { status: 503 }) } const signature = req.headers.get('stripe-signature') ?? '' const payload = await req.text() const valid = await verifyStripeSignature(payload, signature, webhookSecret) if (!valid) { return new Response('Invalid signature', { status: 400 }) } let event: StripeEvent try { event = JSON.parse(payload) as StripeEvent } catch { return new Response('Invalid JSON', { status: 400 }) } const serviceClient = createServiceRoleClient() try { switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as { customer: string subscription: string metadata?: { user_id?: string; tier?: string } } const userId = session.metadata?.user_id const tier = session.metadata?.tier if (userId && tier) { await serviceClient.from('subscriptions').upsert({ user_id: userId, stripe_customer_id: session.customer, stripe_subscription_id: session.subscription, tier, status: 'active' }) } break } case 'customer.subscription.updated': case 'customer.subscription.created': { const sub = event.data.object as { id: string customer: string status: string current_period_start?: number current_period_end?: number cancel_at?: number | null } await serviceClient .from('subscriptions') .update({ status: sub.status, current_period_start: sub.current_period_start ? new Date(sub.current_period_start * 1000).toISOString() : null, current_period_end: sub.current_period_end ? new Date(sub.current_period_end * 1000).toISOString() : null, cancel_at: sub.cancel_at ? new Date(sub.cancel_at * 1000).toISOString() : null }) .eq('stripe_subscription_id', sub.id) break } case 'customer.subscription.deleted': { const sub = event.data.object as { id: string } await serviceClient .from('subscriptions') .update({ tier: 'free', status: 'canceled' }) .eq('stripe_subscription_id', sub.id) break } default: // 기타 이벤트는 무시 break } return new Response(JSON.stringify({ received: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }) } })