Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(backend): add Open Payments client service #595

Merged
merged 6 commits into from
Sep 15, 2022
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
2 changes: 1 addition & 1 deletion docs/transaction-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ The payment must be created with `quoteId`.
- `FIXED_SEND`: Fixed source amount.
- `FIXED_DELIVERY`: Incoming payment, fixed delivery amount.

### `Incoming Payment`
### `IncomingPayment`

| Name | Optional | Type | Description |
| :-------------------------- | :------- | :--------------------- | :---------------------------------------------------------------------------------------- |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ exports.up = function (knex) {
table.bigInteger('incomingAmountValue').nullable()
table.string('state').notNullable()
table.string('externalRef').nullable()
table.uuid('connectionId').notNullable()
table.uuid('connectionId').nullable()

table.uuid('assetId').notNullable()
table.foreign('assetId').references('assets.id')
Expand Down
5 changes: 2 additions & 3 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"apollo-server": "^3.10.1",
"auth": "workspace:../auth",
"cross-fetch": "^3.1.4",
"ilp-packet": "^3.1.2",
"ilp-protocol-ildcp": "^2.2.2",
"ilp-protocol-stream": "^2.7.0",
"jest-openapi": "^0.14.2",
Expand Down Expand Up @@ -59,15 +58,15 @@
"add": "^2.0.6",
"ajv": "^8.11.0",
"apollo-server-koa": "^3.10.1",
"axios": "^0.27.2",
"axios": "0.26.1",
"base64url": "^3.0.1",
"bcrypt": "^5.0.1",
"extensible-error": "^1.0.2",
"fishery": "^2.2.2",
"graphql": "^16.6.0",
"graphql-scalars": "^1.18.0",
"graphql-tools": "^8.3.3",
"ilp-packet": "^3.1.2",
"ilp-packet": "3.1.4-alpha.1",
"ilp-protocol-ccp": "^1.2.2",
"ilp-protocol-ildcp": "^2.2.2",
"ioredis": "^5.2.2",
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const Config = {
nonceRedisKey: envString('NONCE_REDIS_KEY', 'nonceToProject'),
adminKey: envString('ADMIN_KEY', 'qwertyuiop1234567890'),
sessionLength: envInt('SESSION_LENGTH', 30), // in minutes
devAccessToken: envString('DEV_ACCESS_TOKEN', 'dev-access-token'),

Check failure

Code scanning / CodeQL

Hard-coded credentials

The hard-coded value "dev-access-token" is used as [authorization header](1).

ilpAddress: envString('ILP_ADDRESS', 'test.rafiki'),
streamSecret: process.env.STREAM_SECRET
Expand Down Expand Up @@ -102,7 +103,7 @@ export const Config = {

openPaymentsSpec: envString(
'OPEN_PAYMENTS_SPEC',
'https://raw.githubusercontent.com/interledger/open-payments/6a410518c2fe054286bb0a55b0964d80596fea5a/open-api-spec.yaml'
'https://raw.githubusercontent.com/interledger/open-payments/bc90cb63e99e56b85abe25f2018393c7b21f6648/open-api-spec.yaml'
),
authServerSpec: envString(
'AUTH_SERVER_SPEC',
Expand Down
19 changes: 15 additions & 4 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createAssetService } from './asset/service'
import { createAccountingService } from './accounting/service'
import { createPeerService } from './peer/service'
import { createAuthService } from './open_payments/auth/service'
import { createOpenPaymentsClientService } from './open_payments/client/service'
import { createPaymentPointerService } from './open_payments/payment_pointer/service'
import { createSPSPRoutes } from './spsp/routes'
import { createClientKeysRoutes } from './clientKeys/routes'
Expand Down Expand Up @@ -185,6 +186,15 @@ export function initIocContainer(
logger: await deps.use('logger')
})
})
container.singleton('openPaymentsClientService', async (deps) => {
const config = await deps.use('config')
return await createOpenPaymentsClientService({
logger: await deps.use('logger'),
// TODO: https://github.com/interledger/rafiki/issues/583
accessToken: config.devAccessToken,
openApi: await deps.use('openApi')
})
})
container.singleton('incomingPaymentService', async (deps) => {
return await createIncomingPaymentService({
logger: await deps.use('logger'),
Expand Down Expand Up @@ -213,14 +223,15 @@ export function initIocContainer(
})
})
container.singleton('connectionService', async (deps) => {
const config = await deps.use('config')
return await createConnectionService({
logger: await deps.use('logger'),
openPaymentsUrl: config.openPaymentsUrl,
streamServer: await deps.use('streamServer')
})
})
container.singleton('connectionRoutes', async (deps) => {
return createConnectionRoutes({
config: await deps.use('config'),
logger: await deps.use('logger'),
incomingPaymentService: await deps.use('incomingPaymentService'),
connectionService: await deps.use('connectionService')
Expand Down Expand Up @@ -272,6 +283,7 @@ export function initIocContainer(
logger: await deps.use('logger'),
knex: await deps.use('knex'),
makeIlpPlugin: await deps.use('makeIlpPlugin'),
clientService: await deps.use('openPaymentsClientService'),
paymentPointerService: await deps.use('paymentPointerService'),
ratesService: await deps.use('ratesService')
})
Expand All @@ -284,14 +296,13 @@ export function initIocContainer(
})
})
container.singleton('outgoingPaymentService', async (deps) => {
const config = await deps.use('config')
return await createOutgoingPaymentService({
logger: await deps.use('logger'),
knex: await deps.use('knex'),
accountingService: await deps.use('accountingService'),
clientService: await deps.use('openPaymentsClientService'),
makeIlpPlugin: await deps.use('makeIlpPlugin'),
peerService: await deps.use('peerService'),
publicHost: config.publicHost
peerService: await deps.use('peerService')
})
})
container.singleton('outgoingPaymentRoutes', async (deps) => {
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/open_payments/auth/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,12 @@ describe('Auth Middleware', (): void => {
scope.isDone()
}
)
test('bypasses token introspection for configured DEV_ACCESS_TOKEN', async (): Promise<void> => {
ctx.headers.authorization = `GNAP ${Config.devAccessToken}`
const authService = await deps.use('authService')
const introspectSpy = jest.spyOn(authService, 'introspect')
await expect(middleware(ctx, next)).resolves.toBeUndefined()
expect(introspectSpy).not.toHaveBeenCalled()
expect(next).toHaveBeenCalled()
})
})
7 changes: 7 additions & 0 deletions packages/backend/src/open_payments/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export function createAuthMiddleware({
ctx.throw(401, 'Unauthorized')
}
const token = parts[1]
if (
process.env.NODE_ENV !== 'production' &&
token === config.devAccessToken
) {
await next()
return
}
const authService = await ctx.container.use('authService')
const grant = await authService.introspect(token)
if (!grant || !grant.active) {
Expand Down
109 changes: 109 additions & 0 deletions packages/backend/src/open_payments/client/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { IocContract } from '@adonisjs/fold'
import { faker } from '@faker-js/faker'
import { Knex } from 'knex'
import nock from 'nock'
import { URL } from 'url'
import { v4 as uuid } from 'uuid'

import { OpenPaymentsClientService } from './service'
import { createTestApp, TestContainer } from '../../tests/app'
import { Config } from '../../config/app'
import { initIocContainer } from '../..'
import { AppServices } from '../../app'
import { createIncomingPayment } from '../../tests/incomingPayment'
import { createPaymentPointer } from '../../tests/paymentPointer'
import { truncateTables } from '../../tests/tableManager'

describe('Open Payments Client Service', (): void => {
let deps: IocContract<AppServices>
let appContainer: TestContainer
let clientService: OpenPaymentsClientService
let knex: Knex

beforeAll(async (): Promise<void> => {
deps = await initIocContainer(Config)
appContainer = await createTestApp(deps)
clientService = await deps.use('openPaymentsClientService')
knex = await deps.use('knex')
})

afterEach(async (): Promise<void> => {
jest.useRealTimers()
await truncateTables(knex)
})

afterAll(async (): Promise<void> => {
await appContainer.shutdown()
})
describe('incomingPayment.get', (): void => {
test.each`
incomingAmount | description | externalRef
${undefined} | ${undefined} | ${undefined}
${BigInt(123)} | ${'Test'} | ${'#123'}
`(
'resolves incoming payment from Open Payments server',
async ({ incomingAmount, description, externalRef }): Promise<void> => {
const paymentPointer = await createPaymentPointer(deps, {
mockServerPort: appContainer.openPaymentsPort
})
const incomingPayment = await createIncomingPayment(deps, {
paymentPointerId: paymentPointer.id,
description,
incomingAmount: incomingAmount && {
value: incomingAmount,
assetCode: paymentPointer.asset.code,
assetScale: paymentPointer.asset.scale
},
externalRef
})
const resolvedPayment = await clientService.incomingPayment.get(
incomingPayment.url
)
paymentPointer.scope.isDone()
expect(resolvedPayment).toEqual({
...incomingPayment.toJSON(),
id: incomingPayment.url,
paymentPointer: incomingPayment.paymentPointer.url,
ilpStreamConnection: {
id: `${Config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`,
ilpAddress: expect.any(String),
sharedSecret: expect.any(String)
}
})
}
)
test.each`
statusCode
${404}
${500}
`(
'returns undefined for unsuccessful request ($statusCode)',
async ({ statusCode }): Promise<void> => {
const incomingPaymentUrl = new URL(
`${faker.internet.url()}/incoming-payments/${uuid()}`
)
const scope = nock(incomingPaymentUrl.host)
.get(incomingPaymentUrl.pathname)
.reply(statusCode)
scope.isDone()
await expect(
clientService.incomingPayment.get(incomingPaymentUrl.href)
).resolves.toBeUndefined()
}
)
test('returns undefined for invalid incoming payment response', async (): Promise<void> => {
const incomingPaymentUrl = new URL(
`${faker.internet.url()}/incoming-payments/${uuid()}`
)
const scope = nock(incomingPaymentUrl.host)
.get(incomingPaymentUrl.pathname)
.reply(200, () => ({
validPayment: 0
}))
scope.isDone()
await expect(
clientService.incomingPayment.get(incomingPaymentUrl.href)
).resolves.toBeUndefined()
})
})
})
70 changes: 70 additions & 0 deletions packages/backend/src/open_payments/client/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import axios from 'axios'
import { OpenAPI, HttpMethod, ValidateFunction } from 'openapi'

import { BaseService } from '../../shared/baseService'
import { IncomingPaymentJSON } from '../payment/incoming/model'

const REQUEST_TIMEOUT = 5_000 // millseconds

export interface OpenPaymentsClientService {
incomingPayment: {
get(url: string): Promise<IncomingPaymentJSON | undefined>
}
}

export interface ServiceDependencies extends BaseService {
accessToken: string
openApi: OpenAPI
validateResponse: ValidateFunction<IncomingPaymentJSON>
}

export async function createOpenPaymentsClientService(
deps_: Omit<ServiceDependencies, 'validateResponse'>
): Promise<OpenPaymentsClientService> {
const log = deps_.logger.child({
service: 'OpenPaymentsClientService'
})
const validateResponse =
deps_.openApi.createResponseValidator<IncomingPaymentJSON>({
path: '/incoming-payments/{incomingPaymentId}',
method: HttpMethod.GET
})
Comment on lines +27 to +31
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does that have to be overwritten?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateResponse isn't included on deps_.
Or are you asking why define a response validator that should already exist in a validator middleware?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the latter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔
Nothing is being overwritten.
Both this and the auth middleware (only for the get incoming payment route) will each create the same response validator once.
However, the auth middleware does not create response validators when running in production:
https://github.com/interledger/rafiki/blob/main/packages/openapi/src/middleware.ts#L11-L12

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never mind, I misread.

const deps: ServiceDependencies = {
...deps_,
logger: log,
validateResponse
}
return {
incomingPayment: {
get: (url) => getIncomingPayment(deps, url)
}
}
}

export async function getIncomingPayment(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we check whether the incoming payment is a resource on the same Rafiki instance and if so, we just use the service to get it instead of the axios call + all the validation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially tried that but didn't like having two code paths, one dealing with IncomingPayments model and the other dealing with IncomingPaymentJSON, the former of which duplicated a lot of functionality in middleware and incoming payments routes.ts, including the solution for #598

Copy link
Member

@sabineschaller sabineschaller Sep 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, we keep it simple then.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks up local incoming payments / connections and keeps it simpler by calling the route methods:

deps: ServiceDependencies,
url: string
): Promise<IncomingPaymentJSON | undefined> {
const requestHeaders = {
Authorization: `GNAP ${deps.accessToken}`,
'Content-Type': 'application/json'
}
try {
const { status, data } = await axios.get(url, {
headers: requestHeaders,
timeout: REQUEST_TIMEOUT,
validateStatus: (status) => status === 200
})
if (
!deps.validateResponse({
status,
body: data
})
) {
throw new Error('unreachable')
}
return data
} catch (_) {
return undefined
}
}
36 changes: 33 additions & 3 deletions packages/backend/src/open_payments/connection/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { initIocContainer } from '../../'
import { ConnectionRoutes } from './routes'
import { createContext } from '../../tests/context'
import { PaymentPointer } from '../payment_pointer/model'
import { IncomingPayment } from '../payment/incoming/model'
import {
IncomingPayment,
IncomingPaymentState
} from '../payment/incoming/model'
import { createIncomingPayment } from '../../tests/incomingPayment'
import { createPaymentPointer } from '../../tests/paymentPointer'
import base64url from 'base64url'
Expand All @@ -25,7 +28,6 @@ describe('Connection Routes', (): void => {

beforeAll(async (): Promise<void> => {
config = Config
config.publicHost = 'https://wallet.example'
config.authServerGrantUrl = 'https://auth.wallet.example/authorize'
deps = await initIocContainer(config)
appContainer = await createTestApp(deps)
Expand Down Expand Up @@ -82,6 +84,34 @@ describe('Connection Routes', (): void => {
)
})

test.each`
state
${IncomingPaymentState.Completed}
${IncomingPaymentState.Expired}
`(
`returns 404 for $state incoming payment`,
async ({ state }): Promise<void> => {
await incomingPayment.$query(knex).patch({
state,
expiresAt:
state === IncomingPaymentState.Expired ? new Date() : undefined
})
const ctx = createContext<ReadContext>(
{
headers: { Accept: 'application/json' },
url: `/connections/${incomingPayment.connectionId}`
},
{
connectionId: incomingPayment.connectionId
}
)
await expect(connectionRoutes.get(ctx)).rejects.toHaveProperty(
'status',
404
)
}
)

test('returns 200 for correct connection id', async (): Promise<void> => {
const ctx = createContext<ReadContext>(
{
Expand All @@ -100,7 +130,7 @@ describe('Connection Routes', (): void => {
]

expect(ctx.body).toEqual({
id: `${config.publicHost}/connections/${incomingPayment.connectionId}`,
id: `${config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`,
ilpAddress: expect.stringMatching(/^test\.rafiki\.[a-zA-Z0-9_-]{95}$/),
sharedSecret
})
Expand Down
Loading