From ce5c135d94e33bfa26a9cb054102b0b4e74fbaf1 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Tue, 25 Oct 2022 17:36:06 -0500 Subject: [PATCH 01/10] feat(backend): request grant to query incoming payment receiver Add services for managing grants. --- ...0221005181411_create_auth_servers_table.js | 12 + .../20221012013413_create_grants_table.js | 23 ++ ... => 20221012205150_create_quotes_table.js} | 0 ...2205244_create_outgoing_payments_table.js} | 0 packages/backend/src/index.ts | 22 +- .../backend/src/open_payments/auth/grant.ts | 1 + .../src/open_payments/authServer/model.ts | 9 + .../open_payments/authServer/service.test.ts | 50 ++++ .../src/open_payments/authServer/service.ts | 40 +++ .../backend/src/open_payments/grant/model.ts | 34 +++ .../src/open_payments/grant/service.test.ts | 110 ++++++++ .../src/open_payments/grant/service.ts | 61 +++++ .../grantReference/service.test.ts | 4 +- .../open_payments/grantReference/service.ts | 8 +- .../open_payments/payment/outgoing/service.ts | 5 +- .../open_payments/payment_pointer/model.ts | 15 + .../open_payments/payment_pointer/routes.ts | 13 +- .../open_payments/receiver/service.test.ts | 257 +++++++++++++----- .../src/open_payments/receiver/service.ts | 61 ++++- packages/open-payments/src/index.ts | 1 + 20 files changed, 640 insertions(+), 86 deletions(-) create mode 100644 packages/backend/migrations/20221005181411_create_auth_servers_table.js create mode 100644 packages/backend/migrations/20221012013413_create_grants_table.js rename packages/backend/migrations/{20220819162331_create_quotes_table.js => 20221012205150_create_quotes_table.js} (100%) rename packages/backend/migrations/{20220908085854_create_outgoing_payments_table.js => 20221012205244_create_outgoing_payments_table.js} (100%) create mode 100644 packages/backend/src/open_payments/authServer/model.ts create mode 100644 packages/backend/src/open_payments/authServer/service.test.ts create mode 100644 packages/backend/src/open_payments/authServer/service.ts create mode 100644 packages/backend/src/open_payments/grant/model.ts create mode 100644 packages/backend/src/open_payments/grant/service.test.ts create mode 100644 packages/backend/src/open_payments/grant/service.ts diff --git a/packages/backend/migrations/20221005181411_create_auth_servers_table.js b/packages/backend/migrations/20221005181411_create_auth_servers_table.js new file mode 100644 index 0000000000..d3825f9e07 --- /dev/null +++ b/packages/backend/migrations/20221005181411_create_auth_servers_table.js @@ -0,0 +1,12 @@ +exports.up = function (knex) { + return knex.schema.createTable('authServers', function (table) { + table.uuid('id').notNullable().primary() + table.string('url').notNullable().unique() + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('authServers') +} diff --git a/packages/backend/migrations/20221012013413_create_grants_table.js b/packages/backend/migrations/20221012013413_create_grants_table.js new file mode 100644 index 0000000000..d539cdf0b2 --- /dev/null +++ b/packages/backend/migrations/20221012013413_create_grants_table.js @@ -0,0 +1,23 @@ +exports.up = function (knex) { + return knex.schema.createTable('grants', function (table) { + table.uuid('id').notNullable().primary() + table.uuid('authServerId').notNullable() + table.foreign('authServerId').references('authServers.id') + table.string('continueId').nullable() + table.string('continueToken').nullable() + table.string('accessToken').nullable().unique() + table.string('accessType').notNullable() + table.specificType('accessActions', 'text[]') + + table.timestamp('expiresAt').nullable() + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + + table.unique(['authServerId', 'accessType', 'accessActions']) + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('grants') +} diff --git a/packages/backend/migrations/20220819162331_create_quotes_table.js b/packages/backend/migrations/20221012205150_create_quotes_table.js similarity index 100% rename from packages/backend/migrations/20220819162331_create_quotes_table.js rename to packages/backend/migrations/20221012205150_create_quotes_table.js diff --git a/packages/backend/migrations/20220908085854_create_outgoing_payments_table.js b/packages/backend/migrations/20221012205244_create_outgoing_payments_table.js similarity index 100% rename from packages/backend/migrations/20220908085854_create_outgoing_payments_table.js rename to packages/backend/migrations/20221012205244_create_outgoing_payments_table.js diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 887db7b9bd..6ebcee8cb6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -24,7 +24,8 @@ import { createAssetService } from './asset/service' import { createAccountingService } from './accounting/service' import { createPeerService } from './peer/service' import { createAuthService } from './open_payments/auth/service' - +import { createAuthServerService } from './open_payments/authServer/service' +import { createGrantService } from './open_payments/grant/service' import { createPaymentPointerService } from './open_payments/payment_pointer/service' import { createSPSPRoutes } from './spsp/routes' import { createPaymentPointerKeyRoutes } from './paymentPointerKey/routes' @@ -176,6 +177,19 @@ export function initIocContainer( authOpenApi: await deps.use('authOpenApi') }) }) + container.singleton('authServerService', async (deps) => { + return await createAuthServerService({ + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + }) + container.singleton('grantService', async (deps) => { + return await createGrantService({ + authServerService: await deps.use('authServerService'), + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + }) container.singleton('paymentPointerService', async (deps) => { const logger = await deps.use('logger') const assetService = await deps.use('assetService') @@ -218,8 +232,9 @@ export function initIocContainer( }) }) container.singleton('paymentPointerRoutes', async (deps) => { + const config = await deps.use('config') return createPaymentPointerRoutes({ - config: await deps.use('config') + authServer: config.authServerGrantUrl }) }) container.singleton('paymentPointerKeyRoutes', async (deps) => { @@ -248,9 +263,8 @@ export function initIocContainer( const config = await deps.use('config') return await createReceiverService({ logger: await deps.use('logger'), - // TODO: https://github.com/interledger/rafiki/issues/583 - accessToken: config.devAccessToken, connectionService: await deps.use('connectionService'), + grantService: await deps.use('grantService'), incomingPaymentService: await deps.use('incomingPaymentService'), openPaymentsUrl: config.openPaymentsUrl, paymentPointerService: await deps.use('paymentPointerService'), diff --git a/packages/backend/src/open_payments/auth/grant.ts b/packages/backend/src/open_payments/auth/grant.ts index 494567b776..4124913f1d 100644 --- a/packages/backend/src/open_payments/auth/grant.ts +++ b/packages/backend/src/open_payments/auth/grant.ts @@ -11,6 +11,7 @@ interface AmountJSON { assetScale: number } +// TODO: replace with open-payments generated types export enum AccessType { IncomingPayment = 'incoming-payment', OutgoingPayment = 'outgoing-payment', diff --git a/packages/backend/src/open_payments/authServer/model.ts b/packages/backend/src/open_payments/authServer/model.ts new file mode 100644 index 0000000000..633ec38780 --- /dev/null +++ b/packages/backend/src/open_payments/authServer/model.ts @@ -0,0 +1,9 @@ +import { BaseModel } from '../../shared/baseModel' + +export class AuthServer extends BaseModel { + public static get tableName(): string { + return 'authServers' + } + + public url!: string +} diff --git a/packages/backend/src/open_payments/authServer/service.test.ts b/packages/backend/src/open_payments/authServer/service.test.ts new file mode 100644 index 0000000000..35f9b17d20 --- /dev/null +++ b/packages/backend/src/open_payments/authServer/service.test.ts @@ -0,0 +1,50 @@ +import { IocContract } from '@adonisjs/fold' +import { faker } from '@faker-js/faker' +import { Knex } from 'knex' + +import { AuthServer } from './model' +import { AuthServerService } from './service' +import { initIocContainer } from '../../' +import { AppServices } from '../../app' +import { Config } from '../../config/app' +import { createTestApp, TestContainer } from '../../tests/app' +import { truncateTables } from '../../tests/tableManager' + +describe('Auth Server Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let authServerService: AuthServerService + let knex: Knex + + beforeAll(async (): Promise => { + deps = await initIocContainer(Config) + appContainer = await createTestApp(deps) + knex = await deps.use('knex') + authServerService = await deps.use('authServerService') + }) + + afterEach(async (): Promise => { + await truncateTables(knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('getOrCreate', (): void => { + test('Auth server can be created or fetched', async (): Promise => { + const url = faker.internet.url() + await expect( + AuthServer.query(knex).findOne({ url }) + ).resolves.toBeUndefined() + const authServer = await authServerService.getOrCreate(url) + await expect(authServer).toMatchObject({ url }) + await expect(AuthServer.query(knex).findOne({ url })).resolves.toEqual( + authServer + ) + await expect(authServerService.getOrCreate(url)).resolves.toEqual( + authServer + ) + }) + }) +}) diff --git a/packages/backend/src/open_payments/authServer/service.ts b/packages/backend/src/open_payments/authServer/service.ts new file mode 100644 index 0000000000..36daff1553 --- /dev/null +++ b/packages/backend/src/open_payments/authServer/service.ts @@ -0,0 +1,40 @@ +import { UniqueViolationError } from 'objection' + +import { AuthServer } from './model' +import { BaseService } from '../../shared/baseService' + +export interface AuthServerService { + getOrCreate(url: string): Promise +} + +type ServiceDependencies = BaseService + +export async function createAuthServerService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ + service: 'AuthServerService' + }) + } + return { + getOrCreate: (url) => getOrCreateAuthServer(deps, url) + } +} + +async function getOrCreateAuthServer( + deps: ServiceDependencies, + url: string +): Promise { + try { + return await AuthServer.query(deps.knex).insertAndFetch({ + url + }) + } catch (err) { + if (err instanceof UniqueViolationError) { + return await AuthServer.query(deps.knex).findOne({ url }) + } + throw err + } +} diff --git a/packages/backend/src/open_payments/grant/model.ts b/packages/backend/src/open_payments/grant/model.ts new file mode 100644 index 0000000000..e46600d21a --- /dev/null +++ b/packages/backend/src/open_payments/grant/model.ts @@ -0,0 +1,34 @@ +import { Model, QueryContext } from 'objection' + +import { AccessType, AccessAction } from '../auth/grant' +import { AuthServer } from '../authServer/model' +import { BaseModel } from '../../shared/baseModel' + +export class Grant extends BaseModel { + public static get tableName(): string { + return 'grants' + } + + static relationMappings = { + authServer: { + relation: Model.BelongsToOneRelation, + modelClass: AuthServer, + join: { + from: 'grants.authServerId', + to: 'authServers.id' + } + } + } + + public authServerId!: string + public continueId?: string + public continueToken?: string + public accessToken?: string + public accessType!: AccessType + public accessActions!: AccessAction[] + + $afterFind(queryContext: QueryContext): void { + super.$afterFind(queryContext) + delete this['authServer'] + } +} diff --git a/packages/backend/src/open_payments/grant/service.test.ts b/packages/backend/src/open_payments/grant/service.test.ts new file mode 100644 index 0000000000..9790c24719 --- /dev/null +++ b/packages/backend/src/open_payments/grant/service.test.ts @@ -0,0 +1,110 @@ +import { IocContract } from '@adonisjs/fold' +import { faker } from '@faker-js/faker' +import { Knex } from 'knex' + +import { GrantOptions, GrantService } from './service' +import { AccessType, AccessAction } from '../auth/grant' +import { AuthServer } from '../authServer/model' +import { initIocContainer } from '../..' +import { AppServices } from '../../app' +import { Config } from '../../config/app' +import { createTestApp, TestContainer } from '../../tests/app' +import { truncateTables } from '../../tests/tableManager' + +describe('Grant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let grantService: GrantService + let knex: Knex + + beforeAll(async (): Promise => { + deps = await initIocContainer(Config) + appContainer = await createTestApp(deps) + knex = await deps.use('knex') + }) + + beforeEach(async (): Promise => { + grantService = await deps.use('grantService') + }) + + afterEach(async (): Promise => { + await truncateTables(knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('Create and Get Grant', (): void => { + test.each` + newAuthServer + ${false} + ${true} + `( + 'Grant can be created and fetched (new auth server: $newAuthServer)', + async ({ newAuthServer }): Promise => { + const authServerService = await deps.use('authServerService') + let authServerId: string | undefined + const authServerUrl = faker.internet.url() + if (newAuthServer) { + await expect( + AuthServer.query(knex).findOne({ + url: authServerUrl + }) + ).resolves.toBeUndefined() + } else { + authServerId = (await authServerService.getOrCreate(authServerUrl)).id + } + const options: GrantOptions = { + authServer: authServerUrl, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + const grant = await grantService.create(options) + expect(grant).toMatchObject({ + accessType: options.accessType, + accessActions: options.accessActions + }) + if (newAuthServer) { + await expect( + AuthServer.query(knex).findOne({ + url: authServerUrl + }) + ).resolves.toMatchObject({ + id: grant.authServerId + }) + } else { + expect(grant.authServerId).toEqual(authServerId) + } + await expect(grantService.get(options)).resolves.toEqual(grant) + } + ) + + test('cannot fetch non-existing grant', async (): Promise => { + const options: GrantOptions = { + authServer: faker.internet.url(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + await grantService.create(options) + await expect( + grantService.get({ + ...options, + authServer: faker.internet.url() + }) + ).resolves.toBeUndefined() + await expect( + grantService.get({ + ...options, + accessType: AccessType.Quote + }) + ).resolves.toBeUndefined() + await expect( + grantService.get({ + ...options, + accessActions: [AccessAction.Read] + }) + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/packages/backend/src/open_payments/grant/service.ts b/packages/backend/src/open_payments/grant/service.ts new file mode 100644 index 0000000000..b956986ece --- /dev/null +++ b/packages/backend/src/open_payments/grant/service.ts @@ -0,0 +1,61 @@ +import { Grant } from './model' +import { AccessType, AccessAction } from '../auth/grant' +import { AuthServerService } from '../authServer/service' +import { BaseService } from '../../shared/baseService' + +export interface GrantService { + create(options: CreateOptions): Promise + get(options: GrantOptions): Promise +} + +export interface ServiceDependencies extends BaseService { + authServerService: AuthServerService +} + +export async function createGrantService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ + service: 'GrantService' + }) + } + + return { + get: (options) => getGrant(deps, options), + create: (options) => createGrant(deps, options) + } +} + +export interface GrantOptions { + authServer: string + accessType: AccessType + accessActions: AccessAction[] +} + +export interface CreateOptions extends GrantOptions { + accessToken?: string +} + +async function createGrant(deps: ServiceDependencies, options: CreateOptions) { + const { id: authServerId } = await deps.authServerService.getOrCreate( + options.authServer + ) + return Grant.query(deps.knex).insertAndFetch({ + accessType: options.accessType, + accessActions: options.accessActions, + accessToken: options.accessToken, + authServerId + }) +} + +async function getGrant(deps: ServiceDependencies, options: GrantOptions) { + return Grant.query(deps.knex) + .findOne({ + accessType: options.accessType, + accessActions: options.accessActions + }) + .withGraphJoined('authServer') + .where('authServer.url', options.authServer) +} diff --git a/packages/backend/src/open_payments/grantReference/service.test.ts b/packages/backend/src/open_payments/grantReference/service.test.ts index 711f176776..5981282efc 100644 --- a/packages/backend/src/open_payments/grantReference/service.test.ts +++ b/packages/backend/src/open_payments/grantReference/service.test.ts @@ -49,9 +49,7 @@ describe('Grant Reference Service', (): void => { const retrievedRef = await grantReferenceService.get(id, trx) expect(grantRef).toEqual(retrievedRef) await trx.rollback() - await expect( - await grantReferenceService.get(id) - ).resolves.toBeUndefined() + await expect(grantReferenceService.get(id)).resolves.toBeUndefined() }) }) diff --git a/packages/backend/src/open_payments/grantReference/service.ts b/packages/backend/src/open_payments/grantReference/service.ts index 74a453597c..f652b513b8 100644 --- a/packages/backend/src/open_payments/grantReference/service.ts +++ b/packages/backend/src/open_payments/grantReference/service.ts @@ -1,13 +1,13 @@ -import { Transaction, TransactionOrKnex } from 'objection' +import { Transaction } from 'objection' import { GrantReference } from './model' export interface GrantReferenceService { - get(grantId: string, trx?: Transaction): Promise + get(grantId: string, trx?: Transaction): Promise create( options: CreateGrantReferenceOptions, trx?: Transaction ): Promise - lock(grantId: string, trx: TransactionOrKnex): Promise + lock(grantId: string, trx: Transaction): Promise } export async function createGrantReferenceService(): Promise { @@ -34,7 +34,7 @@ async function createGrantReference( return await GrantReference.query(trx).insertAndFetch(options) } -async function lockGrantReference(grantId: string, trx: TransactionOrKnex) { +async function lockGrantReference(grantId: string, trx: Transaction) { // TODO: update to use objection once it supports forNoKeyUpdate await trx('grantReferences') .select() diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 86acdfdadb..ab73aa00de 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -2,7 +2,8 @@ import assert from 'assert' import { ForeignKeyViolationError, TransactionOrKnex, - UniqueViolationError + UniqueViolationError, + Transaction } from 'objection' import { BaseService } from '../../../shared/baseService' @@ -253,7 +254,7 @@ async function validateGrant( } //lock grant - await deps.grantReferenceService.lock(grant.grant, deps.knex) + await deps.grantReferenceService.lock(grant.grant, deps.knex as Transaction) if (callback) await new Promise(callback) diff --git a/packages/backend/src/open_payments/payment_pointer/model.ts b/packages/backend/src/open_payments/payment_pointer/model.ts index 229a999b56..6606daccbf 100644 --- a/packages/backend/src/open_payments/payment_pointer/model.ts +++ b/packages/backend/src/open_payments/payment_pointer/model.ts @@ -1,4 +1,5 @@ import { Model, Page } from 'objection' +import { PaymentPointer as OpenPaymentsPaymentPointer } from 'open-payments' import { GrantReference } from '../grantReference/model' import { LiquidityAccount, OnCreditOptions } from '../../accounting/service' import { ConnectorAccount } from '../../connector/core/rafiki' @@ -84,6 +85,20 @@ export class PaymentPointer } } } + + public toOpenPaymentsType({ + authServer + }: { + authServer: string + }): OpenPaymentsPaymentPointer { + return { + id: this.url, + publicName: this.publicName, + assetCode: this.asset.code, + assetScale: this.asset.scale, + authServer + } + } } export enum PaymentPointerEventType { diff --git a/packages/backend/src/open_payments/payment_pointer/routes.ts b/packages/backend/src/open_payments/payment_pointer/routes.ts index 64b6ced9bb..3f216f3971 100644 --- a/packages/backend/src/open_payments/payment_pointer/routes.ts +++ b/packages/backend/src/open_payments/payment_pointer/routes.ts @@ -1,14 +1,13 @@ import { PaymentPointerSubresource } from './model' import { PaymentPointerSubresourceService } from './service' import { PaymentPointerContext, ListContext } from '../../app' -import { IAppConfig } from '../../config/app' import { getPageInfo, parsePaginationQueryParameters } from '../../shared/pagination' interface ServiceDependencies { - config: IAppConfig + authServer: string } export interface PaymentPointerRoutes { @@ -32,13 +31,9 @@ export async function getPaymentPointer( return ctx.throw(404) } - ctx.body = { - id: ctx.paymentPointer.url, - publicName: ctx.paymentPointer.publicName ?? undefined, - assetCode: ctx.paymentPointer.asset.code, - assetScale: ctx.paymentPointer.asset.scale, - authServer: deps.config.authServerGrantUrl - } + ctx.body = ctx.paymentPointer.toOpenPaymentsType({ + authServer: deps.authServer + }) } interface ListSubresourceOptions { diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index 3cdb2922fa..a8ba298320 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -1,5 +1,11 @@ import { IocContract } from '@adonisjs/fold' +import { faker } from '@faker-js/faker' import { Knex } from 'knex' +import { + AuthenticatedClient, + GrantRequest, + NonInteractiveGrant +} from 'open-payments' import { URL } from 'url' import { v4 as uuid } from 'uuid' @@ -11,8 +17,11 @@ import { AppServices } from '../../app' import { createIncomingPayment } from '../../tests/incomingPayment' import { createPaymentPointer } from '../../tests/paymentPointer' import { truncateTables } from '../../tests/tableManager' +import { AccessAction, AccessType } from '../auth/grant' import { ConnectionService } from '../connection/service' -import { AuthenticatedClient } from 'open-payments' +import { GrantService } from '../grant/service' +import { IncomingPayment } from '../payment/incoming/model' +import { PaymentPointer } from '../payment_pointer/model' import { PaymentPointerService } from '../payment_pointer/service' describe('Receiver Service', (): void => { @@ -23,6 +32,7 @@ describe('Receiver Service', (): void => { let knex: Knex let connectionService: ConnectionService let paymentPointerService: PaymentPointerService + let grantService: GrantService beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -31,11 +41,12 @@ describe('Receiver Service', (): void => { openPaymentsClient = await deps.use('openPaymentsClient') connectionService = await deps.use('connectionService') paymentPointerService = await deps.use('paymentPointerService') + grantService = await deps.use('grantService') knex = await deps.use('knex') }) afterEach(async (): Promise => { - jest.useRealTimers() + jest.restoreAllMocks() await truncateTables(knex) }) @@ -161,8 +172,6 @@ describe('Receiver Service', (): void => { }) describe('incoming payments', () => { - const INCOMING_PAYMENT_PATH = 'incoming-payments' - test('resolves local incoming payment', async () => { const paymentPointer = await createPaymentPointer(deps, { mockServerPort: Config.openPaymentsPort @@ -195,31 +204,96 @@ describe('Receiver Service', (): void => { expect(clientGetIncomingPaymentSpy).not.toHaveBeenCalled() }) - test('resolves remote incoming payment', async () => { - const paymentPointer = await createPaymentPointer(deps) - const incomingPayment = await createIncomingPayment(deps, { - paymentPointerId: paymentPointer.id, - incomingAmount: { - value: BigInt(5), - assetCode: paymentPointer.asset.code, - assetScale: paymentPointer.asset.scale + describe.each` + existingGrant | description + ${false} | ${'no grant'} + ${true} | ${'existing grant'} + `('remote ($description)', ({ existingGrant }): void => { + let paymentPointer: PaymentPointer + let incomingPayment: IncomingPayment + const authServer = faker.internet.url() + const INCOMING_PAYMENT_PATH = 'incoming-payments' + const grantOptions = { + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll], + accessToken: 'OZB8CDFONP219RP1LT0OS9M2PMHKUR64TB8N6BW7' + } + const grantRequest: GrantRequest = { + access_token: { + access: [ + { + type: grantOptions.accessType, + actions: grantOptions.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } as GrantRequest + const grant: NonInteractiveGrant = { + access_token: { + value: grantOptions.accessToken, + manage: `${authServer}/token/8f69de01-5bf9-4603-91ed-eeca101081f1`, + expires_in: 3600, + access: grantRequest.access_token.access + }, + continue: { + access_token: { + value: '33OMUKMKSKU80UPRY5NM' + }, + uri: `${authServer}/continue/4CF492MLVMSW9MKMXKHQ`, + wait: 30 } + } + + beforeEach(async (): Promise => { + paymentPointer = await createPaymentPointer(deps) + incomingPayment = await createIncomingPayment(deps, { + paymentPointerId: paymentPointer.id, + incomingAmount: { + value: BigInt(5), + assetCode: paymentPointer.asset.code, + assetScale: paymentPointer.asset.scale + } + }) + if (existingGrant) { + await expect( + grantService.create({ + ...grantOptions, + authServer + }) + ).resolves.toMatchObject(grantOptions) + } + jest + .spyOn(paymentPointerService, 'getByUrl') + .mockResolvedValueOnce(undefined) }) - const clientGetIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'get') - .mockImplementationOnce(async () => - incomingPayment.toOpenPaymentsType({ - ilpStreamConnection: connectionService.get(incomingPayment) - }) - ) - - jest - .spyOn(paymentPointerService, 'getByUrl') - .mockResolvedValueOnce(undefined) - - await expect(receiverService.get(incomingPayment.url)).resolves.toEqual( - { + test('resolves incoming payment', async () => { + const clientGetPaymentPointerSpy = jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockResolvedValueOnce( + paymentPointer.toOpenPaymentsType({ + authServer + }) + ) + + const clientRequestGrantSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(grant) + + const clientGetIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'get') + .mockResolvedValueOnce( + incomingPayment.toOpenPaymentsType({ + ilpStreamConnection: connectionService.get(incomingPayment) + }) + ) + + await expect( + receiverService.get(incomingPayment.url) + ).resolves.toEqual({ assetCode: paymentPointer.asset.code, assetScale: paymentPointer.asset.scale, incomingAmountValue: incomingPayment.incomingAmount.value, @@ -227,48 +301,109 @@ describe('Receiver Service', (): void => { ilpAddress: expect.any(String), sharedSecret: expect.any(Buffer), expiresAt: expect.any(Date) + }) + expect(clientGetPaymentPointerSpy).toHaveBeenCalledWith({ + url: paymentPointer.url + }) + if (!existingGrant) { + expect(clientRequestGrantSpy).toHaveBeenCalledWith({ + url: authServer, + request: grantRequest + }) } - ) - expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ - url: incomingPayment.url, - accessToken: expect.any(String) - }) - }) - - test('returns undefined for unknown incoming payment', async (): Promise => { - const paymentPointer = await createPaymentPointer(deps, { - mockServerPort: Config.openPaymentsPort + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ + url: incomingPayment.url, + accessToken: grantOptions.accessToken + }) }) - await expect( - receiverService.get( - `${paymentPointer.url}/${INCOMING_PAYMENT_PATH}/${uuid()}` - ) - ).resolves.toBeUndefined() - }) - - test('returns undefined when fetching remote incoming payment throws', async (): Promise => { - const paymentPointer = await createPaymentPointer(deps) - const incomingPayment = await createIncomingPayment(deps, { - paymentPointerId: paymentPointer.id + test('returns undefined for invalid remote incoming payment payment pointer', async (): Promise => { + const clientGetPaymentPointerSpy = jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockRejectedValueOnce(new Error('Could not get payment pointer')) + + await expect( + receiverService.get( + `${paymentPointer.url}/${INCOMING_PAYMENT_PATH}/${uuid()}` + ) + ).resolves.toBeUndefined() + expect(clientGetPaymentPointerSpy).toHaveBeenCalledWith({ + url: paymentPointer.url + }) }) - const clientGetIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'get') - .mockImplementationOnce(async () => { - throw new Error('Could not get incoming payment') + if (!existingGrant) { + test('returns undefined for invalid grant', async (): Promise => { + jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockResolvedValueOnce( + paymentPointer.toOpenPaymentsType({ + authServer + }) + ) + const clientRequestGrantSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockRejectedValueOnce(new Error('Could not request grant')) + + await expect( + receiverService.get(incomingPayment.url) + ).resolves.toBeUndefined() + expect(clientRequestGrantSpy).toHaveBeenCalledWith({ + url: authServer, + request: grantRequest + }) }) - jest - .spyOn(paymentPointerService, 'getByUrl') - .mockResolvedValueOnce(undefined) - - await expect( - receiverService.get(incomingPayment.url) - ).resolves.toBeUndefined() - expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ - url: incomingPayment.url, - accessToken: expect.any(String) + test('returns undefined for interactive grant', async (): Promise => { + jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockResolvedValueOnce( + paymentPointer.toOpenPaymentsType({ + authServer + }) + ) + const clientRequestGrantSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce({ + continue: grant.continue, + interact: { + redirect: `${authServer}/4CF492MLVMSW9MKMXKHQ`, + finish: 'MBDOFXG4Y5CVJCX821LH' + } + }) + + await expect( + receiverService.get(incomingPayment.url) + ).resolves.toBeUndefined() + expect(clientRequestGrantSpy).toHaveBeenCalledWith({ + url: authServer, + request: grantRequest + }) + }) + } + + test('returns undefined when fetching remote incoming payment throws', async (): Promise => { + jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockResolvedValueOnce( + paymentPointer.toOpenPaymentsType({ + authServer + }) + ) + jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(grant) + const clientGetIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'get') + .mockRejectedValueOnce(new Error('Could not get incoming payment')) + + await expect( + receiverService.get(incomingPayment.url) + ).resolves.toBeUndefined() + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ + url: incomingPayment.url, + accessToken: expect.any(String) + }) }) }) }) diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index 99fb9dfff4..15eca7ebd0 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -1,10 +1,15 @@ import { AuthenticatedClient, + GrantRequest, IncomingPayment as OpenPaymentsIncomingPayment, - ILPStreamConnection as OpenPaymentsConnection + ILPStreamConnection as OpenPaymentsConnection, + isNonInteractiveGrant } from 'open-payments' +import { AccessAction, AccessType } from '../auth/grant' import { ConnectionService } from '../connection/service' +import { Grant } from '../grant/model' +import { GrantService } from '../grant/service' import { PaymentPointerService } from '../payment_pointer/service' import { BaseService } from '../../shared/baseService' import { IncomingPaymentService } from '../payment/incoming/service' @@ -17,8 +22,8 @@ export interface ReceiverService { } interface ServiceDependencies extends BaseService { - accessToken: string connectionService: ConnectionService + grantService: GrantService incomingPaymentService: IncomingPaymentService openPaymentsUrl: string paymentPointerService: PaymentPointerService @@ -140,9 +145,13 @@ async function getIncomingPayment( }) } + const grant = await getIncomingPaymentGrant( + deps, + urlParseResult.paymentPointerUrl + ) return await deps.openPaymentsClient.incomingPayment.get({ url, - accessToken: deps.accessToken + accessToken: grant.accessToken }) } catch (error) { deps.logger.error( @@ -179,3 +188,49 @@ async function getLocalIncomingPayment({ return incomingPayment.toOpenPaymentsType({ ilpStreamConnection: connection }) } + +async function getIncomingPaymentGrant( + deps: ServiceDependencies, + paymentPointerUrl: string +): Promise { + const paymentPointer = await deps.openPaymentsClient.paymentPointer.get({ + url: paymentPointerUrl + }) + if (!paymentPointer) { + return undefined + } + const grantOptions = { + authServer: paymentPointer.authServer, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + const existingGrant = await deps.grantService.get(grantOptions) + if (existingGrant) { + return existingGrant + } + + const grant = await deps.openPaymentsClient.grant.request({ + url: paymentPointer.authServer, + request: { + access_token: { + access: [ + { + type: grantOptions.accessType, + actions: grantOptions.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } as GrantRequest + }) + if (isNonInteractiveGrant(grant)) { + return await deps.grantService.create({ + ...grantOptions, + accessToken: grant.access_token.value + }) + } + return undefined +} diff --git a/packages/open-payments/src/index.ts b/packages/open-payments/src/index.ts index 78b8b06253..d7262c5295 100644 --- a/packages/open-payments/src/index.ts +++ b/packages/open-payments/src/index.ts @@ -1,4 +1,5 @@ export { + GrantRequest, IncomingPayment, ILPStreamConnection, InteractiveGrant, From 6f7b31f5af2dcf1262d9b179a57092890f95a18a Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Mon, 21 Nov 2022 18:04:30 -0600 Subject: [PATCH 02/10] fix(localenv): connect Docker network Update environment variables. --- infrastructure/local/docker-compose.yml | 1 + infrastructure/local/peer-docker-compose.yml | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/infrastructure/local/docker-compose.yml b/infrastructure/local/docker-compose.yml index 7a7325cea2..c6cb915ad9 100644 --- a/infrastructure/local/docker-compose.yml +++ b/infrastructure/local/docker-compose.yml @@ -71,6 +71,7 @@ services: REDIS_URL: redis://redis:6379/0 QUOTE_URL: http://fynbos/quotes BYPASS_SIGNATURE_VALIDATION: "true" + PAYMENT_POINTER_URL: https://backend/.well-known/pay depends_on: - tigerbeetle - database diff --git a/infrastructure/local/peer-docker-compose.yml b/infrastructure/local/peer-docker-compose.yml index 8909ba904f..a11a85b7d0 100644 --- a/infrastructure/local/peer-docker-compose.yml +++ b/infrastructure/local/peer-docker-compose.yml @@ -7,7 +7,7 @@ services: dockerfile: ./packages/auth/Dockerfile restart: always networks: - rafiki: + local_rafiki: ports: - "4006:3006" environment: @@ -27,7 +27,7 @@ services: - "4000:80" - "4001:3001" networks: - rafiki: + local_rafiki: environment: NODE_ENV: development LOG_LEVEL: debug @@ -51,13 +51,14 @@ services: REDIS_URL: redis://redis:6379/1 QUOTE_URL: http://local-bank/quote BYPASS_SIGNATURE_VALIDATION: "true" + PAYMENT_POINTER_URL: https://peer-backend/.well-known/pay local-bank: build: context: ../.. dockerfile: ./packages/mock-account-provider/Dockerfile restart: always networks: - rafiki: + local_rafiki: ports: - '3031:80' environment: @@ -69,3 +70,6 @@ services: - ./seed.peer.yml:/workspace/seed.peer.yml depends_on: - peer-backend +networks: + local_rafiki: + external: true From 2988e547b6459ac09188282aac7b799fe94138ff Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Tue, 22 Nov 2022 19:29:17 -0600 Subject: [PATCH 03/10] fix(auth): add grant access actions Remove unused locations and interval fields and account access. --- packages/auth/src/access/types.ts | 22 ++-------------------- packages/auth/src/grant/service.test.ts | 1 - 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/auth/src/access/types.ts b/packages/auth/src/access/types.ts index fd18a0466a..378d07d224 100644 --- a/packages/auth/src/access/types.ts +++ b/packages/auth/src/access/types.ts @@ -1,5 +1,4 @@ export enum AccessType { - Account = 'account', IncomingPayment = 'incoming-payment', OutgoingPayment = 'outgoing-payment', Quote = 'quote' @@ -8,15 +7,15 @@ export enum AccessType { export enum Action { Create = 'create', Read = 'read', + ReadAll = 'read-all', List = 'list', + ListAll = 'list-all', Complete = 'complete' } interface BaseAccessRequest { actions: Action[] - locations?: string[] identifier?: string - interval?: string } export interface IncomingPaymentRequest extends BaseAccessRequest { @@ -29,11 +28,6 @@ interface OutgoingPaymentRequest extends BaseAccessRequest { limits?: OutgoingPaymentLimit } -interface AccountRequest extends BaseAccessRequest { - type: AccessType.Account - limits?: never -} - interface QuoteRequest extends BaseAccessRequest { type: AccessType.Quote limits?: never @@ -42,7 +36,6 @@ interface QuoteRequest extends BaseAccessRequest { export type AccessRequest = | IncomingPaymentRequest | OutgoingPaymentRequest - | AccountRequest | QuoteRequest export function isAccessType(accessType: AccessType): accessType is AccessType { @@ -78,16 +71,6 @@ function isOutgoingPaymentAccessRequest( ) } -function isAccountAccessRequest( - accessRequest: AccountRequest -): accessRequest is AccountRequest { - return ( - accessRequest.type === AccessType.Account && - isAction(accessRequest.actions) && - !accessRequest.limits - ) -} - function isQuoteAccessRequest( accessRequest: QuoteRequest ): accessRequest is QuoteRequest { @@ -104,7 +87,6 @@ export function isAccessRequest( return ( isIncomingPaymentAccessRequest(accessRequest as IncomingPaymentRequest) || isOutgoingPaymentAccessRequest(accessRequest as OutgoingPaymentRequest) || - isAccountAccessRequest(accessRequest as AccountRequest) || isQuoteAccessRequest(accessRequest as QuoteRequest) ) } diff --git a/packages/auth/src/grant/service.test.ts b/packages/auth/src/grant/service.test.ts index fa7dcbdc6a..4a59b2740f 100644 --- a/packages/auth/src/grant/service.test.ts +++ b/packages/auth/src/grant/service.test.ts @@ -71,7 +71,6 @@ describe('Grant Service', (): void => { const BASE_GRANT_ACCESS = { actions: [Action.Create, Action.Read, Action.List], - locations: ['https://example.com'], identifier: `https://example.com/${v4()}` } From 3763cd6bf17424af0d1bfd75e4f62ea2a2c2945f Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Tue, 22 Nov 2022 19:32:34 -0600 Subject: [PATCH 04/10] fix(backend): properly export public key jwk --- packages/backend/src/paymentPointerKey/routes.test.ts | 3 ++- packages/backend/src/paymentPointerKey/routes.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/paymentPointerKey/routes.test.ts b/packages/backend/src/paymentPointerKey/routes.test.ts index 6d57bce0fb..6af6ad0795 100644 --- a/packages/backend/src/paymentPointerKey/routes.test.ts +++ b/packages/backend/src/paymentPointerKey/routes.test.ts @@ -1,3 +1,4 @@ +import { createPublicKey } from 'crypto' import jestOpenAPI from 'jest-openapi' import { Knex } from 'knex' import { v4 as uuid } from 'uuid' @@ -101,7 +102,7 @@ describe('Payment Pointer Keys Routes', (): void => { test('returns 200 with backend key', async (): Promise => { const config = await deps.use('config') const jwk = { - ...config.privateKey.export({ format: 'jwk' }), + ...createPublicKey(config.privateKey).export({ format: 'jwk' }), kid: config.keyId, alg: 'EdDSA' } diff --git a/packages/backend/src/paymentPointerKey/routes.ts b/packages/backend/src/paymentPointerKey/routes.ts index 63598275ed..0ce049f5ba 100644 --- a/packages/backend/src/paymentPointerKey/routes.ts +++ b/packages/backend/src/paymentPointerKey/routes.ts @@ -1,3 +1,4 @@ +import { createPublicKey } from 'crypto' import { JWK } from 'open-payments' import { PaymentPointerContext } from '../app' @@ -22,7 +23,7 @@ export function createPaymentPointerKeyRoutes( const deps = { ...deps_, jwk: { - ...deps_.config.privateKey.export({ format: 'jwk' }), + ...createPublicKey(deps_.config.privateKey).export({ format: 'jwk' }), kid: deps_.config.keyId, alg: 'EdDSA' } as JWK From da07feac80b670245a5c2581a715ef710e4acf48 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Tue, 22 Nov 2022 19:34:41 -0600 Subject: [PATCH 05/10] fix(open-payments): properly construct grant request body --- packages/open-payments/src/client/grant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/open-payments/src/client/grant.ts b/packages/open-payments/src/client/grant.ts index 737201f1e9..1c27ac76c3 100644 --- a/packages/open-payments/src/client/grant.ts +++ b/packages/open-payments/src/client/grant.ts @@ -37,7 +37,7 @@ export const createGrantRoutes = (deps: GrantRouteDeps): GrantRoutes => { { url: args.url, body: { - ...args, + ...args.request, client: deps.client } }, From 4a453a92c584856bd076f661102385aaf7f05c97 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Mon, 28 Nov 2022 12:53:20 -0600 Subject: [PATCH 06/10] chore(backend): import open-payments generateJwk --- .../backend/src/paymentPointerKey/routes.test.ts | 11 +++++------ packages/backend/src/paymentPointerKey/routes.ts | 12 +++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/paymentPointerKey/routes.test.ts b/packages/backend/src/paymentPointerKey/routes.test.ts index 6af6ad0795..c0841cb4a9 100644 --- a/packages/backend/src/paymentPointerKey/routes.test.ts +++ b/packages/backend/src/paymentPointerKey/routes.test.ts @@ -1,6 +1,6 @@ -import { createPublicKey } from 'crypto' import jestOpenAPI from 'jest-openapi' import { Knex } from 'knex' +import { generateJwk } from 'open-payments' import { v4 as uuid } from 'uuid' import { createContext } from '../tests/context' @@ -101,11 +101,10 @@ describe('Payment Pointer Keys Routes', (): void => { test('returns 200 with backend key', async (): Promise => { const config = await deps.use('config') - const jwk = { - ...createPublicKey(config.privateKey).export({ format: 'jwk' }), - kid: config.keyId, - alg: 'EdDSA' - } + const jwk = generateJwk({ + privateKey: config.privateKey, + keyId: config.keyId + }) const ctx = createContext({ headers: { Accept: 'application/json' }, diff --git a/packages/backend/src/paymentPointerKey/routes.ts b/packages/backend/src/paymentPointerKey/routes.ts index 0ce049f5ba..54eb3c232f 100644 --- a/packages/backend/src/paymentPointerKey/routes.ts +++ b/packages/backend/src/paymentPointerKey/routes.ts @@ -1,5 +1,4 @@ -import { createPublicKey } from 'crypto' -import { JWK } from 'open-payments' +import { generateJwk, JWK } from 'open-payments' import { PaymentPointerContext } from '../app' import { IAppConfig } from '../config/app' @@ -22,11 +21,10 @@ export function createPaymentPointerKeyRoutes( ): PaymentPointerKeyRoutes { const deps = { ...deps_, - jwk: { - ...createPublicKey(deps_.config.privateKey).export({ format: 'jwk' }), - kid: deps_.config.keyId, - alg: 'EdDSA' - } as JWK + jwk: generateJwk({ + privateKey: deps_.config.privateKey, + keyId: deps_.config.keyId + }) } return { From aa571e11bd87a36dfbb24472541c827f07d72b66 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Mon, 28 Nov 2022 14:03:32 -0600 Subject: [PATCH 07/10] chore(backend): store and check grant expiresAt --- .../backend/src/open_payments/grant/model.ts | 9 ++ .../src/open_payments/grant/service.test.ts | 84 ++++++++++++------- .../src/open_payments/grant/service.ts | 6 +- .../open_payments/receiver/service.test.ts | 26 +++++- .../src/open_payments/receiver/service.ts | 10 ++- 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/packages/backend/src/open_payments/grant/model.ts b/packages/backend/src/open_payments/grant/model.ts index e46600d21a..80b2d17ac6 100644 --- a/packages/backend/src/open_payments/grant/model.ts +++ b/packages/backend/src/open_payments/grant/model.ts @@ -9,6 +9,10 @@ export class Grant extends BaseModel { return 'grants' } + static get virtualAttributes(): string[] { + return ['expired'] + } + static relationMappings = { authServer: { relation: Model.BelongsToOneRelation, @@ -26,6 +30,11 @@ export class Grant extends BaseModel { public accessToken?: string public accessType!: AccessType public accessActions!: AccessAction[] + public expiresAt?: Date + + public get expired(): boolean { + return !!this.expiresAt && this.expiresAt <= new Date() + } $afterFind(queryContext: QueryContext): void { super.$afterFind(queryContext) diff --git a/packages/backend/src/open_payments/grant/service.test.ts b/packages/backend/src/open_payments/grant/service.test.ts index 9790c24719..8290b59fbe 100644 --- a/packages/backend/src/open_payments/grant/service.test.ts +++ b/packages/backend/src/open_payments/grant/service.test.ts @@ -2,6 +2,7 @@ import { IocContract } from '@adonisjs/fold' import { faker } from '@faker-js/faker' import { Knex } from 'knex' +import { Grant } from './model' import { GrantOptions, GrantService } from './service' import { AccessType, AccessAction } from '../auth/grant' import { AuthServer } from '../authServer/model' @@ -36,36 +37,36 @@ describe('Grant Service', (): void => { }) describe('Create and Get Grant', (): void => { - test.each` - newAuthServer - ${false} - ${true} - `( - 'Grant can be created and fetched (new auth server: $newAuthServer)', - async ({ newAuthServer }): Promise => { - const authServerService = await deps.use('authServerService') - let authServerId: string | undefined - const authServerUrl = faker.internet.url() - if (newAuthServer) { + describe.each` + existingAuthServer | description + ${false} | ${'new auth server'} + ${true} | ${'existing auth server'} + `('$description', ({ existingAuthServer }): void => { + let authServerId: string | undefined + let grant: Grant | undefined + const authServerUrl = faker.internet.url() + + beforeEach(async (): Promise => { + if (existingAuthServer) { + const authServerService = await deps.use('authServerService') + authServerId = (await authServerService.getOrCreate(authServerUrl)).id + } else { await expect( AuthServer.query(knex).findOne({ url: authServerUrl }) ).resolves.toBeUndefined() - } else { - authServerId = (await authServerService.getOrCreate(authServerUrl)).id + authServerId = undefined } - const options: GrantOptions = { - authServer: authServerUrl, - accessType: AccessType.IncomingPayment, - accessActions: [AccessAction.ReadAll] - } - const grant = await grantService.create(options) - expect(grant).toMatchObject({ - accessType: options.accessType, - accessActions: options.accessActions - }) - if (newAuthServer) { + jest.useFakeTimers() + jest.setSystemTime(Date.now()) + }) + + afterEach(async (): Promise => { + jest.useRealTimers() + if (existingAuthServer) { + expect(grant.authServerId).toEqual(authServerId) + } else { await expect( AuthServer.query(knex).findOne({ url: authServerUrl @@ -73,12 +74,37 @@ describe('Grant Service', (): void => { ).resolves.toMatchObject({ id: grant.authServerId }) - } else { - expect(grant.authServerId).toEqual(authServerId) } - await expect(grantService.get(options)).resolves.toEqual(grant) - } - ) + }) + + test.each` + expiresIn | description + ${undefined} | ${'without expiresIn'} + ${600} | ${'with expiresIn'} + `( + 'Grant can be created and fetched ($description)', + async ({ expiresIn }): Promise => { + const options: GrantOptions = { + authServer: authServerUrl, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + grant = await grantService.create({ + ...options, + expiresIn + }) + expect(grant).toMatchObject({ + accessType: options.accessType, + accessActions: options.accessActions, + expiresAt: expiresIn + ? new Date(Date.now() + expiresIn * 1000) + : null + }) + expect(grant.expired).toBe(false) + await expect(grantService.get(options)).resolves.toEqual(grant) + } + ) + }) test('cannot fetch non-existing grant', async (): Promise => { const options: GrantOptions = { diff --git a/packages/backend/src/open_payments/grant/service.ts b/packages/backend/src/open_payments/grant/service.ts index b956986ece..aa42180512 100644 --- a/packages/backend/src/open_payments/grant/service.ts +++ b/packages/backend/src/open_payments/grant/service.ts @@ -36,6 +36,7 @@ export interface GrantOptions { export interface CreateOptions extends GrantOptions { accessToken?: string + expiresIn?: number } async function createGrant(deps: ServiceDependencies, options: CreateOptions) { @@ -46,7 +47,10 @@ async function createGrant(deps: ServiceDependencies, options: CreateOptions) { accessType: options.accessType, accessActions: options.accessActions, accessToken: options.accessToken, - authServerId + authServerId, + expiresAt: options.expiresIn + ? new Date(Date.now() + options.expiresIn * 1000) + : undefined }) } diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index a8ba298320..3717ff253c 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -332,7 +332,31 @@ describe('Receiver Service', (): void => { }) }) - if (!existingGrant) { + if (existingGrant) { + test('returns undefined for expired grant', async (): Promise => { + const grant = await grantService.get({ + ...grantOptions, + authServer + }) + await grant.$query(knex).patch({ expiresAt: new Date() }) + jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockResolvedValueOnce( + paymentPointer.toOpenPaymentsType({ + authServer + }) + ) + const clientRequestGrantSpy = jest.spyOn( + openPaymentsClient.grant, + 'request' + ) + + await expect( + receiverService.get(incomingPayment.url) + ).resolves.toBeUndefined() + expect(clientRequestGrantSpy).not.toHaveBeenCalled() + }) + } else { test('returns undefined for invalid grant', async (): Promise => { jest .spyOn(openPaymentsClient.paymentPointer, 'get') diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index 15eca7ebd0..05b975eab7 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -207,6 +207,12 @@ async function getIncomingPaymentGrant( const existingGrant = await deps.grantService.get(grantOptions) if (existingGrant) { + if (existingGrant.expired) { + // TODO + // https://github.com/interledger/rafiki/issues/795 + deps.logger.warn({ grantOptions }, 'Grant access token expired') + return undefined + } return existingGrant } @@ -229,8 +235,10 @@ async function getIncomingPaymentGrant( if (isNonInteractiveGrant(grant)) { return await deps.grantService.create({ ...grantOptions, - accessToken: grant.access_token.value + accessToken: grant.access_token.value, + expiresIn: grant.access_token.expires_in }) } + deps.logger.warn({ grantOptions }, 'Grant request required interaction') return undefined } From 19fe8287d466874d444a4e0ccb3792404e2f7b56 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Mon, 28 Nov 2022 22:27:58 -0600 Subject: [PATCH 08/10] chore(auth): add interval to OutgoingPaymentLimit --- .../auth/migrations/20220504163024_create_accesses_table.js | 2 -- packages/auth/src/access/model.ts | 2 -- packages/auth/src/access/types.ts | 1 + 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/auth/migrations/20220504163024_create_accesses_table.js b/packages/auth/migrations/20220504163024_create_accesses_table.js index d161ba30da..8c8b260c80 100644 --- a/packages/auth/migrations/20220504163024_create_accesses_table.js +++ b/packages/auth/migrations/20220504163024_create_accesses_table.js @@ -4,8 +4,6 @@ exports.up = function (knex) { table.string('type').notNullable() table.specificType('actions', 'text[]').notNullable() table.string('identifier') - table.specificType('locations', 'text[]') - table.integer('interval') table.jsonb('limits') table.uuid('grantId').notNullable() table.foreign('grantId').references('grants.id').onDelete('CASCADE') diff --git a/packages/auth/src/access/model.ts b/packages/auth/src/access/model.ts index c60422014b..15fefe67de 100644 --- a/packages/auth/src/access/model.ts +++ b/packages/auth/src/access/model.ts @@ -25,7 +25,5 @@ export class Access extends BaseModel { public type!: AccessType public actions!: Action[] public identifier?: string - public locations?: string[] - public interval?: string public limits?: LimitData } diff --git a/packages/auth/src/access/types.ts b/packages/auth/src/access/types.ts index 378d07d224..a47c37f5ed 100644 --- a/packages/auth/src/access/types.ts +++ b/packages/auth/src/access/types.ts @@ -103,6 +103,7 @@ export type OutgoingPaymentLimit = { receiver: string sendAmount?: PaymentAmount receiveAmount?: PaymentAmount + interval?: string } export type LimitData = OutgoingPaymentLimit From 291ec8cf7446808750242fdb411e7448d9f5f80c Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Wed, 30 Nov 2022 07:29:42 -0600 Subject: [PATCH 09/10] chore(localenv): re-format networks --- infrastructure/local/docker-compose.yml | 10 +++++----- infrastructure/local/peer-docker-compose.yml | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/infrastructure/local/docker-compose.yml b/infrastructure/local/docker-compose.yml index c6cb915ad9..f0302abf36 100644 --- a/infrastructure/local/docker-compose.yml +++ b/infrastructure/local/docker-compose.yml @@ -7,7 +7,7 @@ services: dockerfile: ./packages/auth/Dockerfile restart: always networks: - rafiki: + - rafiki ports: - '3006:3006' environment: @@ -24,7 +24,7 @@ services: dockerfile: ./packages/mock-account-provider/Dockerfile restart: always networks: - rafiki: + - rafiki ports: - '3030:80' environment: @@ -47,7 +47,7 @@ services: - '3000:80' - '3001:3001' networks: - rafiki: + - rafiki environment: NODE_ENV: development LOG_LEVEL: debug @@ -80,7 +80,7 @@ services: image: 'postgres:15' # use latest official postgres version restart: unless-stopped networks: - rafiki: + - rafiki volumes: - database-data:/var/lib/postgresql/data/ # persist data even if container shuts down - ./dbinit.sql:/docker-entrypoint-initdb.d/init.sql @@ -120,7 +120,7 @@ services: image: 'redis:7' restart: unless-stopped networks: - rafiki: + - rafiki volumes: database-data: # named volumes can be managed easier using docker-compose tigerbeetle-data: # named volumes can be managed easier using docker-compose diff --git a/infrastructure/local/peer-docker-compose.yml b/infrastructure/local/peer-docker-compose.yml index a11a85b7d0..5e41b41714 100644 --- a/infrastructure/local/peer-docker-compose.yml +++ b/infrastructure/local/peer-docker-compose.yml @@ -7,7 +7,7 @@ services: dockerfile: ./packages/auth/Dockerfile restart: always networks: - local_rafiki: + - local_rafiki ports: - "4006:3006" environment: @@ -27,7 +27,7 @@ services: - "4000:80" - "4001:3001" networks: - local_rafiki: + - local_rafiki environment: NODE_ENV: development LOG_LEVEL: debug @@ -58,7 +58,7 @@ services: dockerfile: ./packages/mock-account-provider/Dockerfile restart: always networks: - local_rafiki: + - local_rafiki ports: - '3031:80' environment: From 2e2a3a9462016f465c220bcf27caa2b4b6388124 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Fri, 2 Dec 2022 11:34:53 -0600 Subject: [PATCH 10/10] chore(backend): don't cast GrantRequest --- packages/backend/src/open_payments/receiver/service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index 05b975eab7..f9067fa9e8 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -1,6 +1,5 @@ import { AuthenticatedClient, - GrantRequest, IncomingPayment as OpenPaymentsIncomingPayment, ILPStreamConnection as OpenPaymentsConnection, isNonInteractiveGrant @@ -222,7 +221,7 @@ async function getIncomingPaymentGrant( access_token: { access: [ { - type: grantOptions.accessType, + type: grantOptions.accessType as 'incoming-payment', actions: grantOptions.accessActions } ] @@ -230,7 +229,7 @@ async function getIncomingPaymentGrant( interact: { start: ['redirect'] } - } as GrantRequest + } }) if (isNonInteractiveGrant(grant)) { return await deps.grantService.create({