Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 89 additions & 12 deletions src/controllers/callbacks/opennode-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { timingSafeEqual } from 'crypto'

import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { fromOpenNodeInvoice } from '../../utils/transform'
import { createSettings } from '../../factories/settings-factory'
import { getRemoteAddress } from '../../utils/http'
import { hmacSha256 } from '../../utils/secret'
import { IController } from '../../@types/controllers'
import { IPaymentsService } from '../../@types/services'
import { opennodeCallbackBodySchema } from '../../schemas/opennode-callback-schema'
import { opennodeWebhookCallbackBodySchema } from '../../schemas/opennode-callback-schema'
import { validateSchema } from '../../utils/validation'

const debug = createLogger('opennode-callback-controller')
Expand All @@ -15,16 +19,85 @@ export class OpenNodeCallbackController implements IController {

public async handleRequest(request: Request, response: Response) {
debug('request headers: %o', request.headers)
debug('request body: %O', request.body)

const bodyValidation = validateSchema(opennodeCallbackBodySchema)(request.body)
const settings = createSettings()
const remoteAddress = getRemoteAddress(request, settings)
const paymentProcessor = settings.payments?.processor

if (paymentProcessor !== 'opennode') {
debug('denied request from %s to /callbacks/opennode which is not the current payment processor', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

const bodyValidation = validateSchema(opennodeWebhookCallbackBodySchema)(request.body)
if (bodyValidation.error) {
debug('opennode callback request rejected: invalid body %o', bodyValidation.error)
response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Malformed body')
return
}

const invoice = fromOpenNodeInvoice(request.body)
const body = bodyValidation.value
debug(
'request body metadata: hasId=%s hasHashedOrder=%s status=%s',
typeof body.id === 'string',
typeof body.hashed_order === 'string',
body.status,
)

const openNodeApiKey = process.env.OPENNODE_API_KEY
if (!openNodeApiKey) {
debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress)
response
.status(500)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Internal Server Error')
return
}

const expectedBuf = hmacSha256(openNodeApiKey, body.id)
const actualHex = body.hashed_order
const expectedHexLength = expectedBuf.length * 2

if (
actualHex.length !== expectedHexLength
|| !/^[0-9a-f]+$/i.test(actualHex)
) {
debug('invalid hashed_order format from %s to /callbacks/opennode', remoteAddress)
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Bad Request')
return
}

const actualBuf = Buffer.from(actualHex, 'hex')

if (
!timingSafeEqual(expectedBuf, actualBuf)
) {
debug('unauthorized request from %s to /callbacks/opennode: hashed_order mismatch', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

const statusMap: Record<string, InvoiceStatus> = {
expired: InvoiceStatus.EXPIRED,
refunded: InvoiceStatus.EXPIRED,
unpaid: InvoiceStatus.PENDING,
processing: InvoiceStatus.PENDING,
underpaid: InvoiceStatus.PENDING,
paid: InvoiceStatus.COMPLETED,
}

const invoice: Pick<Invoice, 'id' | 'status'> = {
id: body.id,
status: statusMap[body.status],
}

debug('invoice', invoice)

Expand All @@ -37,21 +110,25 @@ export class OpenNodeCallbackController implements IController {
throw error
}

if (updatedInvoice.status !== InvoiceStatus.COMPLETED && !updatedInvoice.confirmedAt) {
response.status(200).send()
if (updatedInvoice.status !== InvoiceStatus.COMPLETED) {
response
.status(200)
.send()

return
}

invoice.amountPaid = invoice.amountRequested
updatedInvoice.amountPaid = invoice.amountRequested
if (!updatedInvoice.confirmedAt) {
updatedInvoice.confirmedAt = new Date()
}
updatedInvoice.amountPaid = updatedInvoice.amountRequested

try {
await this.paymentsService.confirmInvoice({
id: invoice.id,
pubkey: invoice.pubkey,
id: updatedInvoice.id,
pubkey: updatedInvoice.pubkey,
status: updatedInvoice.status,
amountPaid: updatedInvoice.amountRequested,
amountPaid: updatedInvoice.amountPaid,
confirmedAt: updatedInvoice.confirmedAt,
})
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
Expand Down
4 changes: 2 additions & 2 deletions src/routes/callbacks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { json, Router } from 'express'
import { json, Router, urlencoded } from 'express'

import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory'
import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory'
Expand All @@ -20,6 +20,6 @@ router
}),
withController(createNodelessCallbackController),
)
.post('/opennode', json(), withController(createOpenNodeCallbackController))
.post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController))

export default router
10 changes: 10 additions & 0 deletions src/schemas/opennode-callback-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { pubkeySchema } from './base-schema'
import { z } from 'zod'

const openNodeCallbackStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid'] as const

export const opennodeWebhookCallbackBodySchema = z
.object({
id: z.string(),
hashed_order: z.string(),
status: z.enum(openNodeCallbackStatuses),
})
.passthrough()

export const opennodeCallbackBodySchema = z
.object({
id: z.string(),
Expand Down
28 changes: 28 additions & 0 deletions test/integration/features/callbacks/opennode-callback.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
@opennode-callback
Feature: OpenNode callback endpoint
Scenario: rejects malformed callback body
Given OpenNode callback processing is enabled
When I post a malformed OpenNode callback
Then the OpenNode callback response status is 400
And the OpenNode callback response body is "Malformed body"

Scenario: rejects callback with invalid signature
Given OpenNode callback processing is enabled
When I post an OpenNode callback with an invalid signature
Then the OpenNode callback response status is 403
And the OpenNode callback response body is "Forbidden"

Scenario: accepts valid signed callback for pending invoice
Given OpenNode callback processing is enabled
And a pending OpenNode invoice exists
When I post a signed OpenNode callback with status "processing"
Then the OpenNode callback response status is 200
And the OpenNode callback response body is empty

Scenario: completes a pending invoice on paid callback
Given OpenNode callback processing is enabled
And a pending OpenNode invoice exists
When I post a signed OpenNode callback with status "paid"
Then the OpenNode callback response status is 200
And the OpenNode callback response body is "OK"
And the OpenNode invoice is marked completed
147 changes: 147 additions & 0 deletions test/integration/features/callbacks/opennode-callback.feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { After, Given, Then, When } from '@cucumber/cucumber'
import axios, { AxiosResponse } from 'axios'
import { expect } from 'chai'
import { randomUUID } from 'crypto'

import { getMasterDbClient } from '../../../../src/database/client'
import { hmacSha256 } from '../../../../src/utils/secret'
import { SettingsStatic } from '../../../../src/utils/settings'

const CALLBACK_URL = 'http://localhost:18808/callbacks/opennode'
const OPENNODE_TEST_API_KEY = 'integration-opennode-api-key'
const TEST_PUBKEY = 'a'.repeat(64)

const postOpenNodeCallback = async (body: Record<string, string>) => {
const encodedBody = new URLSearchParams(body).toString()

return axios.post(
CALLBACK_URL,
encodedBody,
{
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
validateStatus: () => true,
},
)
}

Given('OpenNode callback processing is enabled', function () {
const settings = SettingsStatic._settings as any

this.parameters.previousOpenNodeCallbackSettings = settings
this.parameters.previousOpenNodeApiKey = process.env.OPENNODE_API_KEY

SettingsStatic._settings = {
...settings,
payments: {
...(settings?.payments ?? {}),
processor: 'opennode',
},
}

process.env.OPENNODE_API_KEY = OPENNODE_TEST_API_KEY
})

Given('a pending OpenNode invoice exists', async function () {
const dbClient = getMasterDbClient()
const invoiceId = `integration-opennode-${randomUUID()}`

await dbClient('invoices').insert({
id: invoiceId,
pubkey: Buffer.from(TEST_PUBKEY, 'hex'),
bolt11: 'lnbc210n1integration',
amount_requested: '21000',
unit: 'sats',
status: 'pending',
description: 'open node integration callback test',
expires_at: new Date(Date.now() + 15 * 60 * 1000),
updated_at: new Date(),
created_at: new Date(),
})

this.parameters.openNodeInvoiceId = invoiceId
this.parameters.openNodeInvoiceIds = [
...(this.parameters.openNodeInvoiceIds ?? []),
invoiceId,
]
})

When('I post a malformed OpenNode callback', async function () {
this.parameters.openNodeResponse = await postOpenNodeCallback({
id: 'missing-required-fields',
})
})

When('I post an OpenNode callback with an invalid signature', async function () {
this.parameters.openNodeResponse = await postOpenNodeCallback({
hashed_order: '0'.repeat(64),
id: `integration-opennode-${randomUUID()}`,
status: 'paid',
})
})

When('I post a signed OpenNode callback with status {string}', async function (status: string) {
const id = this.parameters.openNodeInvoiceId
const hashedOrder = hmacSha256(OPENNODE_TEST_API_KEY, id).toString('hex')

this.parameters.openNodeResponse = await postOpenNodeCallback({
hashed_order: hashedOrder,
id,
status,
})
})

Then('the OpenNode callback response status is {int}', function (statusCode: number) {
const response = this.parameters.openNodeResponse as AxiosResponse

expect(response.status).to.equal(statusCode)
})

Then('the OpenNode callback response body is {string}', function (expectedBody: string) {
const response = this.parameters.openNodeResponse as AxiosResponse

expect(response.data).to.equal(expectedBody)
})

Then('the OpenNode callback response body is empty', function () {
const response = this.parameters.openNodeResponse as AxiosResponse

expect(['', undefined, null]).to.include(response.data)
})

Then('the OpenNode invoice is marked completed', async function () {
const dbClient = getMasterDbClient()
const invoiceId = this.parameters.openNodeInvoiceId

const invoice = await dbClient('invoices')
.where('id', invoiceId)
.first('status', 'confirmed_at', 'amount_paid')

expect(invoice).to.exist
expect(invoice.status).to.equal('completed')
expect(invoice.confirmed_at).to.not.equal(null)
expect(invoice.amount_paid).to.equal('21000')
})

After({ tags: '@opennode-callback' }, async function () {
SettingsStatic._settings = this.parameters.previousOpenNodeCallbackSettings

if (typeof this.parameters.previousOpenNodeApiKey === 'undefined') {
delete process.env.OPENNODE_API_KEY
} else {
process.env.OPENNODE_API_KEY = this.parameters.previousOpenNodeApiKey
}

const invoiceIds = this.parameters.openNodeInvoiceIds ?? []
if (invoiceIds.length > 0) {
const dbClient = getMasterDbClient()
await dbClient('invoices').whereIn('id', invoiceIds).delete()
}

this.parameters.openNodeInvoiceId = undefined
this.parameters.openNodeInvoiceIds = []
this.parameters.openNodeResponse = undefined
this.parameters.previousOpenNodeApiKey = undefined
this.parameters.previousOpenNodeCallbackSettings = undefined
})
Loading
Loading