Skip to content

Commit

Permalink
feat: add Stripe webhook validator
Browse files Browse the repository at this point in the history
  • Loading branch information
Yizack committed Jun 30, 2024
1 parent d3c670c commit 6a0b494
Show file tree
Hide file tree
Showing 10 changed files with 122 additions and 1 deletion.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A simple nuxt module that works on the edge to easily validate incoming webhooks

## Features

- 4 [Webhook validators](#supported-webhook-validators)
- 5 [Webhook validators](#supported-webhook-validators)
- Works on the edge
- Exposed [Server utils](#server-utils)

Expand Down Expand Up @@ -80,6 +80,7 @@ Go to [playground/.env.example](./playground/.env.example) or [playground/nuxt.c
- Github
- Paddle
- PayPal
- Stripe
- Twitch

You can add your favorite webhook validator by creating a new file in [src/runtime/server/lib/validators/](./src/runtime/server/lib/validators/)
Expand Down
3 changes: 3 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ NUXT_WEBHOOK_PAYPAL_CLIENT_ID=
NUXT_WEBHOOK_PAYPAL_SECRET_KEY=
NUXT_WEBHOOK_PAYPAL_WEBHOOK_ID=

# Stripe Validator
NUXT_WEBHOOK_STRIPE_SECRET_KEY=

# Twitch Validator
NUXT_WEBHOOK_TWITCH_SECRET_KEY=
3 changes: 3 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export default defineNuxtConfig({
secretKey: '',
webhookId: '',
},
stripe: {
secretKey: '',
},
twitch: {
secretKey: '',
},
Expand Down
7 changes: 7 additions & 0 deletions playground/server/api/webhooks/stripe.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const isValidWebhook = await isValidStripeWebhook(event)

if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })

return { isValidWebhook }
})
4 changes: 4 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export default defineNuxtModule<ModuleOptions>({
secretKey: '',
webhookId: '',
})
// Stripe Webhook
runtimeConfig.webhook.stripe = defu(runtimeConfig.webhook.stripe, {
secretKey: '',
})
// Twitch Webhook
runtimeConfig.webhook.twitch = defu(runtimeConfig.webhook.twitch, {
secretKey: '',
Expand Down
56 changes: 56 additions & 0 deletions src/runtime/server/lib/validators/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { subtle } from 'node:crypto'
import { Buffer } from 'node:buffer'
import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
import { useRuntimeConfig } from '#imports'

const DEFAULT_TOLERANCE = 300

const extractHeaders = (header: string) => {
const parts = header.split(',')
let t = ''
let v1 = ''
for (const part of parts) {
const [key, value] = part.split('=')
if (value) {
if (key === 't') t = value
else if (key === 'v1') v1 = value
}
}
if (!(t && v1)) return null
return { t: Number.parseInt(t), v1 }
}

/**
* Validates Stripe webhooks on the Edge \
* Inspired by: https://docs.stripe.com/webhooks?verify=verify-manually
* @async
* @param event H3Event
* @returns {boolean} `true` if the webhook is valid, `false` otherwise
*/
export const isValidStripeWebhook = async (event: H3Event): Promise<boolean> => {
const headers = getRequestHeaders(event)
const body = await readRawBody(event)
const { secretKey } = useRuntimeConfig(event).webhook.stripe

const STRIPE_SIGNATURE = 'Stripe-Signature'.toLowerCase()
const stripeSignature = headers[STRIPE_SIGNATURE]

if (!body || !stripeSignature) return false

const signatureHeaders = extractHeaders(stripeSignature)
if (!signatureHeaders) return false
const { t: webhookTimestamp, v1: webhookSignature } = signatureHeaders

if ((new Date().getTime() / 1000) - webhookTimestamp > DEFAULT_TOLERANCE) return false

const payloadWithTime = `${webhookTimestamp}.${body}`
const encoder = new TextEncoder()
const algorithm = { name: 'HMAC', hash: 'SHA-256' }

const key = await subtle.importKey('raw', encoder.encode(secretKey), algorithm, false, ['sign'])
const hmac = await subtle.sign(algorithm.name, key, encoder.encode(payloadWithTime))

const computedHash = Buffer.from(hmac).toString('hex')

return computedHash === webhookSignature
}
1 change: 1 addition & 0 deletions src/runtime/server/utils/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { isValidGithubWebhook } from '../lib/validators/github'
export { isValidPaddleWebhook } from '../lib/validators/paddle'
export { isValidPaypalWebhook } from '../lib/validators/paypal'
export { isValidTwitchWebhook } from '../lib/validators/twitch'
export { isValidStripeWebhook } from '../lib/validators/stripe'
3 changes: 3 additions & 0 deletions test/fixtures/basic/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export default defineNuxtConfig({
secretKey: '',
webhookId: '',
},
stripe: {
secretKey: 'testStripeSecretKey',
},
twitch: {
secretKey: 'testTwitchSecretKey',
},
Expand Down
10 changes: 10 additions & 0 deletions test/fixtures/basic/server/api/webhooks/stripe.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineEventHandler, createError } from 'h3'
import { isValidStripeWebhook } from './../../../../../../src/runtime/server/utils/webhooks'

export default defineEventHandler(async (event) => {
const isValidWebhook = await isValidStripeWebhook(event)

if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })

return { isValidWebhook }
})
33 changes: 33 additions & 0 deletions test/stripe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { fileURLToPath } from 'node:url'
import { subtle } from 'node:crypto'
import { Buffer } from 'node:buffer'
import { describe, it, expect } from 'vitest'
import { setup, $fetch } from '@nuxt/test-utils'

describe('stripe', async () => {
await setup({
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
})

it('valid Stripe webhook', async () => {
const timestamp = Math.floor(Date.now() / 1000)
const body = 'testBody'
const secretKey = 'testStripeSecretKey'
const encoder = new TextEncoder()
const signature = await subtle.importKey('raw', encoder.encode(secretKey), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
const hmac = await subtle.sign('HMAC', signature, encoder.encode(`${timestamp}.${body}`))
const computedHash = Buffer.from(hmac).toString('hex')

const validSignature = `t=${timestamp},v1=${computedHash}`

const headers = { 'stripe-signature': validSignature }

const response = await $fetch<{ isValidWebhook: boolean }>('/api/webhooks/stripe', {
method: 'POST',
headers,
body,
})

expect(response).toStrictEqual({ isValidWebhook: true })
})
})

0 comments on commit 6a0b494

Please sign in to comment.