Skip to content

Commit

Permalink
invoice: update GET /invoice API to not require LSAT auth (#19)
Browse files Browse the repository at this point in the history
* no longer require lsat to get invoice status

* update documentation

* fix create hodl invoice error: pass payment hash to lnservice call

* update get invoice tests
  • Loading branch information
bucko13 authored Feb 28, 2020
1 parent ae6625c commit 60b597b
Show file tree
Hide file tree
Showing 7 changed files with 90 additions and 97 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ Once the server is running, you can test the API:
4. `POST http://localhost:5000/invoice` with the following JSON body to get a new invoice (there
is no relation to any lsat and so cannot be used for authentication): `{ "amount": 30 }`

5. `GET http://localhost:5000/invoice` with the appropriate LSAT in Authorization header (even with missing
payment hash) will return the status of the associated invoice
5. `GET http://localhost:5000/invoice?id=[payment hash]` returns information for invoice with given
payment hash including payment status and payreq.

Read more about the REST API in the [documentation](#documentation).

Expand Down
14 changes: 6 additions & 8 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,16 @@ paths:
get:
summary: get status of a given invoice based on request LSAT
description: |
Given an LSAT and invoice id, get the status of an invoice (i.e. is it paid or not)
Given an invoice id or a valid LSAT, get the status of an invoice (i.e. is it paid or not)
as well as payment information like payment request and id.
operationId: getInvoice
parameters:
- name: id
in: header
description: |
Hex encoded string of invoice id. Can be optional if request has a cookie that contains a root macaroon attached which should include data referencing an invoice id.
required: false
style: simple
explode: false
- in: query
name: id
schema:
type: string
required: true
description: hex encoded string of payment hash for looking up invoice
responses:
"200":
description: Status of invoice and payreq string for reference
Expand Down
22 changes: 15 additions & 7 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,21 +282,29 @@ This means payer can pay whatever they want for access.'
}
if (lnd) {
let invoiceFunction = lnService.createInvoice
if (boltwallConfig && boltwallConfig.hodl)
const options = {
lnd: lnd,
description: _description,
expires_at: expiresAt,
tokens,
id: undefined,
}

if (boltwallConfig && boltwallConfig.hodl) {
const paymentHash = query.paymentHash || body.paymentHash
if (!paymentHash)
throw new Error('Require paymentHash to create HODL invoice')
invoiceFunction = lnService.createHodlInvoice
options.id = paymentHash
}

const {
request: payreq,
id,
description = _description,
created_at: createdAt,
tokens: amount,
} = await invoiceFunction({
lnd: lnd,
description: _description,
expires_at: expiresAt,
tokens,
})
} = await invoiceFunction(options)
invoice = { payreq, id, description, createdAt, amount }
} else if (opennode) {
const {
Expand Down
66 changes: 30 additions & 36 deletions src/routes/invoice.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,64 @@
import { Response, Request, Router, NextFunction } from 'express'
import { Lsat } from 'lsat-js'

import { InvoiceResponse } from '../typings'
import { Lsat } from 'lsat-js'
import { createInvoice, checkInvoiceStatus } from '../helpers'
import { validateLsat } from '.'
import { createInvoice, checkInvoiceStatus, isHex } from '../helpers'
import validateLsat from './validate'
const router: Router = Router()

/**
* ## Route: GET /invoice
* @description Get information about an invoice including status and secret. Request must be
* authenticated with a macaroon. The handler will check for an LSAT and reject requests
* without one since this is where the invoice id is extracted from.
* authenticated with an LSAT if requesting secret. Supports requesting for invoice
* based on hash in query parameter "id" OR from an attached LSAT.
*/
async function getInvoice(
req: Request,
res: Response,
next: NextFunction
): Promise<void | Response> {
const { headers } = req

if (!headers.authorization || !headers.authorization.includes('LSAT')) {
req.logger.info(
`Unauthorized request made without macaroon for ${req.originalUrl} from ${req.hostname}`
)
res.status(400)
return next({
message: 'Bad Request: Missing LSAT authorization header',
})
let { id } = req.query
let lsat
if (req.headers.authorization) {
try {
lsat = Lsat.fromToken(req.headers.authorization)
if (lsat.paymentHash && !id) id = lsat.paymentHash
} catch (e) {
req.logger.warning(
'Failed to create an LSAT from authorization header: %s',
e.message || e
)
}
}

// get the lsat from the auth header
const lsat = Lsat.fromToken(headers.authorization)

if (lsat.isExpired()) {
req.logger.debug(
`Request made with expired macaroon for ${req.originalUrl} from ${req.hostname}`
)
res.status(401)
if (!id || id.length !== 64 || !isHex(id)) {
res.status(400)
return next({
message: 'Unauthorized: LSAT expired',
message:
'Bad Request: Missing valid payment hash in required query parameter "id" for looking up invoice',
})
}

// validation happens in validateLsat middleware
// all this route has to do is confirm that the invoice exists
let invoice
try {
invoice = await checkInvoiceStatus(
lsat.paymentHash,
req.lnd,
req.opennode,
true
)
// if we have an LSAT included then it will have been validated already
// in an earlier middleware and we can include the secret (which will only
// be shown if it has been paid as well)
const includeSecret = lsat ? true : false
invoice = await checkInvoiceStatus(id, req.lnd, req.opennode, includeSecret)
} catch (e) {
// handle ln-service errors
if (Array.isArray(e)) {
req.logger.error(`Problem looking up invoice:`, ...e)
if (e[0] === 503) {
res.status(404)
return res.send({
error: { message: 'Unable to find invoice with that id' },
return next({
message: 'Unable to find invoice with that id',
})
} else {
res.status(500)
return res.send({
error: { message: 'Unknown error when looking up invoice' },
return next({
message: 'Unknown error when looking up invoice',
})
}
}
Expand Down
1 change: 1 addition & 0 deletions src/routes/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default async function validateLsat(
next: NextFunction
): Promise<void> {
const { headers } = req

// if hodl is enabled and there is not already an auth header
// then we need to check if there is a paymentHash in the request body
if (
Expand Down
8 changes: 6 additions & 2 deletions tests/helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ describe('helper functions', () => {
is_confirmed: true,
id: request.id,
secret: invoice.secret,
tokens: 30,
tokens: request.tokens,
created_at: '2016-08-29T09:12:33.001Z',
description: request.description,
}
Expand All @@ -257,7 +257,11 @@ describe('helper functions', () => {
})

it('should create an invoice using the amount provided in a request query', async () => {
const request = { lnd: {}, query: { amount: 50 }, body: {} }
const request = {
lnd: {},
query: { amount: invoiceResponse.tokens },
body: {},
}

createInvStub
.withArgs({
Expand Down
72 changes: 30 additions & 42 deletions tests/invoice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { expect } from 'chai'
import sinon from 'sinon'
import { Application } from 'express'
import { parsePaymentRequest } from 'ln-service'
import { MacaroonsBuilder } from 'macaroons.js'
import { Lsat, expirationSatisfier } from 'lsat-js'

import getApp from './mockApp'
Expand Down Expand Up @@ -37,7 +36,9 @@ describe('/invoice', () => {
invoiceResponse: InvoiceResponseStub,
sessionSecret: string,
builder: BuilderInterface,
app: Application
app: Application,
basePath: string,
validPath: string

beforeEach(() => {
// boltwall sets up authenticated client when it boots up
Expand All @@ -60,7 +61,8 @@ describe('/invoice', () => {
created_at: '2016-08-29T09:12:33.001Z',
description: request.description,
}

basePath = `/invoice`
validPath = `${basePath}?id=${invoiceResponse.id}`
builder = getTestBuilder(sessionSecret)

getInvStub = getLnStub('getInvoice', invoiceResponse)
Expand All @@ -80,24 +82,21 @@ describe('/invoice', () => {
})

describe('GET', () => {
it('should return 400 Bad Request when no lsat to check', async () => {
const response1: request.Response = await request
.agent(app)
.get('/invoice')
it('should return 400 Bad Request when missing id in query parameter', async () => {
const response1: request.Response = await request.agent(app).get(basePath)
const response2: request.Response = await request
.agent(app)
.get('/invoice')
.set('Authorization', 'Basic')
.get(`${basePath}?id=12345`)

for (const resp of [response1, response2]) {
expect(resp.status).to.equal(400)
expect(resp).to.have.nested.property('body.error.message')
expect(resp.body.error.message).to.match(/Bad Request/g)
expect(resp.body.error.message).to.match(/LSAT/g)
expect(resp.body.error.message).to.match(/payment hash/g)
}
})

it('should return 401 if macaroon is expired', async () => {
it('should return 401 if sent with expired LSAT', async () => {
const expirationCaveat = getExpirationCaveat(-100)

const macaroon = builder
Expand All @@ -109,7 +108,7 @@ describe('/invoice', () => {

const response: request.Response = await request
.agent(app)
.get('/invoice')
.get(basePath)
.set('Authorization', lsat.toToken())

expect(response.status).to.equal(401)
Expand All @@ -118,40 +117,21 @@ describe('/invoice', () => {
expect(response.body.error.message).to.match(/expired/g)
})

it('should return 401 if macaroon has invalid signature', async () => {
it('should return 401 if sent with LSAT that has invalid signature', async () => {
const macaroon = getTestBuilder('another secret')
.getMacaroon()
.serialize()

const response: request.Response = await request
.agent(app)
.get('/invoice')
.get(basePath)
.set('Authorization', `LSAT ${macaroon}:`)

expect(response.status).to.equal(401)
expect(response).to.have.nested.property('body.error.message')
})

it('should return 400 if no invoice id in the macaroon', async () => {
const macaroon = new MacaroonsBuilder('location', 'secret', 'identifier')
.getMacaroon()
.serialize()
const response: request.Response = await request
.agent(app)
.get('/invoice')
.set('Authorization', `LSAT ${macaroon}:`)

expect(response.status).to.equal(400)
expect(response).to.have.nested.property('body.error.message')
// confirm it gives an error message about a missing invoice
expect(response.body.error.message).to.match(/malformed/i)
})

it('should return 404 if requested invoice does not exist', async () => {
// create a macaroon that has an invoice attached to it but our getInvoice request
// should return a fake error that the invoice wasn't found
const macaroon = builder.getMacaroon().serialize()

// Setup response from getInvoice with response that it could not be found
getInvStub.restore()

Expand All @@ -162,24 +142,21 @@ describe('/invoice', () => {
{ details: 'unable to locate invoice' },
])

const response: request.Response = await request
.agent(app)
.get('/invoice')
.set('Authorization', `LSAT ${macaroon}:`)
const response: request.Response = await request.agent(app).get(validPath)

expect(response.status).to.equal(404)
expect(response).to.have.nested.property('body.error.message')

// expect some kind of message that tells us the invoice is missing
expect(response.body.error.message).to.match(/invoice/g)
})

it('should return invoice information w/ status for a request w/ valid LSAT macaroon', async () => {
it('should return invoice information w/ status for valid requests', async () => {
const response: InvoiceResponse = {
id: invoiceResponse.id,
payreq: invoiceResponse.request,
createdAt: invoiceResponse.created_at,
amount: invoiceResponse.tokens,
secret: invoiceResponse.secret,
status: 'paid',
description: invoiceResponse.description,
}
Expand All @@ -190,15 +167,25 @@ describe('/invoice', () => {
const macaroon = builder.getMacaroon().serialize()
app = getApp({ caveatSatisfiers: expirationSatisfier })

const supertestResp: request.Response = await request
// first test just with the invoice id in the request query parameter
let supertestResp: request.Response = await request
.agent(app)
.get('/invoice')
.get(validPath)

expect(supertestResp.body).to.eql(response)

// test next with a paid invoice and LSAT sent in the request
// this should include the secret
response.secret = invoiceResponse.secret
supertestResp = await request
.agent(app)
.get(basePath)
.set('Authorization', `LSAT ${macaroon}:`)

expect(supertestResp.body).to.eql(response)
})

it('should not return the secret if invoice is unpaid', async () => {
it('should not return the secret if invoice is unpaid or LSAT is invalid', async () => {
const macaroon = builder.getMacaroon().serialize()

// Setup response from getInvoice w/ unconfirmed invoice
Expand All @@ -213,6 +200,7 @@ describe('/invoice', () => {
.get('/invoice')
.set('Authorization', `LSAT ${macaroon}:`)

expect(response.body).to.not.have.property('error')
expect(response.body).to.not.have.property('secret')
})
})
Expand Down

0 comments on commit 60b597b

Please sign in to comment.