From 8eb71fc1db6f01afef59273a53d4f85c501ee0a2 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 17 Dec 2024 15:01:18 -0800 Subject: [PATCH 1/2] feat(auth): tenanted grants --- .../20241206232423_add_tenant_to_grant.js | 20 ++ packages/auth/src/access/service.test.ts | 24 +-- packages/auth/src/access/utils.test.ts | 10 +- packages/auth/src/accessToken/routes.test.ts | 20 +- packages/auth/src/accessToken/service.test.ts | 13 +- packages/auth/src/app.ts | 6 +- packages/auth/src/grant/model.ts | 21 ++ packages/auth/src/grant/routes.test.ts | 194 +++++++++++++++--- packages/auth/src/grant/routes.ts | 29 ++- packages/auth/src/grant/service.test.ts | 28 ++- packages/auth/src/grant/service.ts | 33 ++- .../auth/src/graphql/resolvers/grant.test.ts | 27 ++- packages/auth/src/index.ts | 22 +- packages/auth/src/interaction/routes.test.ts | 119 ++++++++--- packages/auth/src/interaction/routes.ts | 173 +++++++++------- packages/auth/src/interaction/service.test.ts | 11 +- packages/auth/src/interaction/service.ts | 9 +- .../auth/src/signature/middleware.test.ts | 15 +- packages/auth/src/tests/grant.ts | 31 +-- packages/auth/src/tests/tenant.ts | 11 + 20 files changed, 599 insertions(+), 217 deletions(-) create mode 100644 packages/auth/migrations/20241206232423_add_tenant_to_grant.js create mode 100644 packages/auth/src/tests/tenant.ts diff --git a/packages/auth/migrations/20241206232423_add_tenant_to_grant.js b/packages/auth/migrations/20241206232423_add_tenant_to_grant.js new file mode 100644 index 0000000000..f38353ab83 --- /dev/null +++ b/packages/auth/migrations/20241206232423_add_tenant_to_grant.js @@ -0,0 +1,20 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('grants', function (table) { + table.uuid('tenantId').notNullable() + table.foreign('tenantId').references('tenants.id') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('grants', function (table) { + table.dropColumn('tenantId') + }) +} diff --git a/packages/auth/src/access/service.test.ts b/packages/auth/src/access/service.test.ts index ff7a32229b..3aba7b787d 100644 --- a/packages/auth/src/access/service.test.ts +++ b/packages/auth/src/access/service.test.ts @@ -1,7 +1,5 @@ -import { faker } from '@faker-js/faker' import nock from 'nock' import { Knex } from 'knex' -import { v4 } from 'uuid' import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' import { Config } from '../config/app' @@ -9,11 +7,13 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' import { AccessService } from './service' -import { Grant, GrantState, StartMethod, FinishMethod } from '../grant/model' +import { Grant } from '../grant/model' import { IncomingPaymentRequest, OutgoingPaymentRequest } from './types' -import { generateNonce, generateToken } from '../shared/utils' +import { generateBaseGrant } from '../tests/grant' import { AccessType, AccessAction } from '@interledger/open-payments' import { Access } from './model' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access Service', (): void => { let deps: IocContract @@ -22,19 +22,11 @@ describe('Access Service', (): void => { let trx: Knex.Transaction let grant: Grant - const generateBaseGrant = () => ({ - state: GrantState.Pending, - startMethod: [StartMethod.Redirect], - continueToken: generateToken(), - continueId: v4(), - finishMethod: FinishMethod.Redirect, - finishUri: 'https://example.com/finish', - clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) - }) - beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(generateBaseGrant()) + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ tenantId: tenant.id }) + ) }) beforeAll(async (): Promise => { diff --git a/packages/auth/src/access/utils.test.ts b/packages/auth/src/access/utils.test.ts index 351a535cf1..0da2531a18 100644 --- a/packages/auth/src/access/utils.test.ts +++ b/packages/auth/src/access/utils.test.ts @@ -17,6 +17,8 @@ import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' import { generateToken, generateNonce } from '../shared/utils' import { compareRequestAndGrantAccessItems } from './utils' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access utilities', (): void => { let deps: IocContract @@ -25,6 +27,7 @@ describe('Access utilities', (): void => { let identifier: string let grant: Grant let grantAccessItem: Access + let tenant: Tenant const receiver: string = 'https://wallet.com/alice/incoming-payments/12341234-1234-1234-1234-123412341234' @@ -36,6 +39,7 @@ describe('Access utilities', (): void => { beforeEach(async (): Promise => { identifier = `https://example.com/${v4()}` + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -44,7 +48,8 @@ describe('Access utilities', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com/finish', clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) + client: faker.internet.url({ appendSlash: false }), + tenantId: tenant.id }) grantAccessItem = await Access.query(trx).insertAndFetch({ @@ -241,7 +246,8 @@ describe('Access utilities', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com/finish', clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) + client: faker.internet.url({ appendSlash: false }), + tenantId: tenant.id }) const grantAccessItem = await Access.query(trx).insertAndFetch({ diff --git a/packages/auth/src/accessToken/routes.test.ts b/packages/auth/src/accessToken/routes.test.ts index ac9fb14964..fd4fb58066 100644 --- a/packages/auth/src/accessToken/routes.test.ts +++ b/packages/auth/src/accessToken/routes.test.ts @@ -24,6 +24,8 @@ import { import { GrantService } from '../grant/service' import { AccessTokenService } from './service' import { GNAPErrorCode } from '../shared/gnapErrors' +import { generateTenant } from '../tests/tenant' +import { Tenant } from '../tenant/model' describe('Access Token Routes', (): void => { let deps: IocContract @@ -96,7 +98,11 @@ describe('Access Token Routes', (): void => { const method = 'POST' beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) access = await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS @@ -367,7 +373,11 @@ describe('Access Token Routes', (): void => { let token: AccessToken beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) token = await AccessToken.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_TOKEN @@ -406,7 +416,11 @@ describe('Access Token Routes', (): void => { let token: AccessToken beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) access = await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS diff --git a/packages/auth/src/accessToken/service.test.ts b/packages/auth/src/accessToken/service.test.ts index ba7029ae18..b1438f0a8a 100644 --- a/packages/auth/src/accessToken/service.test.ts +++ b/packages/auth/src/accessToken/service.test.ts @@ -20,6 +20,8 @@ import { AccessItem } from '@interledger/open-payments' import { generateBaseGrant } from '../tests/grant' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access Token Service', (): void => { let deps: IocContract @@ -63,8 +65,9 @@ describe('Access Token Service', (): void => { let grant: Grant beforeEach(async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( - generateBaseGrant({ state: GrantState.Approved }) + generateBaseGrant({ state: GrantState.Approved, tenantId: tenant.id }) ) grant.access = [ await Access.query(trx).insertAndFetch({ @@ -186,8 +189,9 @@ describe('Access Token Service', (): void => { }) test('Introspection only returns requested access', async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) const grantWithTwoAccesses = await Grant.query(trx).insertAndFetch( - generateBaseGrant({ state: GrantState.Approved }) + generateBaseGrant({ state: GrantState.Approved, tenantId: tenant.id }) ) grantWithTwoAccesses.access = [ await Access.query(trx).insertAndFetch({ @@ -247,11 +251,14 @@ describe('Access Token Service', (): void => { }) describe('Revoke', (): void => { + let tenant: Tenant let grant: Grant let token: AccessToken beforeEach(async (): Promise => { + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) @@ -352,8 +359,10 @@ describe('Access Token Service', (): void => { let token: AccessToken let originalTokenValue: string beforeEach(async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index dba8efebda..8aabae2830 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -267,7 +267,7 @@ export class App { /* Back-channel GNAP Routes */ // Grant Initiation router.post( - '/', + '/:tenantId/', createValidatorMiddleware(openApi.authServerSpec, { path: '/', method: HttpMethod.POST @@ -278,7 +278,7 @@ export class App { // Grant Continue router.post( - '/continue/:id', + '/:tenantId/continue/:id', createValidatorMiddleware(openApi.authServerSpec, { path: '/continue/{id}', method: HttpMethod.POST @@ -289,7 +289,7 @@ export class App { // Grant Cancel router.delete( - '/continue/:id', + '/:tenantId/continue/:id', createValidatorMiddleware(openApi.authServerSpec, { path: '/continue/{id}', method: HttpMethod.DELETE diff --git a/packages/auth/src/grant/model.ts b/packages/auth/src/grant/model.ts index 54cfb39201..4fb8f7dce1 100644 --- a/packages/auth/src/grant/model.ts +++ b/packages/auth/src/grant/model.ts @@ -9,6 +9,7 @@ import { } from '@interledger/open-payments' import { AccessToken, toOpenPaymentsAccessToken } from '../accessToken/model' import { Interaction } from '../interaction/model' +import { Tenant } from '../tenant/model' export enum StartMethod { Redirect = 'redirect' @@ -61,6 +62,14 @@ export class Grant extends BaseModel { from: 'grants.id', to: 'interactions.grantId' } + }, + tenant: { + relation: Model.HasOneRelation, + modelClass: join(__dirname, '../tenant/model'), + join: { + from: 'grants.tenantId', + to: 'tenants.id' + } } }) public access!: Access[] @@ -79,6 +88,10 @@ export class Grant extends BaseModel { public lastContinuedAt!: Date + public tenantId!: string + + public tenant?: Tenant + public $beforeInsert(context: QueryContext): void { super.$beforeInsert(context) this.lastContinuedAt = new Date() @@ -192,3 +205,11 @@ export function isRevokedGrant(grant: Grant): boolean { grant.finalizationReason === GrantFinalization.Revoked ) } + +export interface GrantWithTenant extends Grant { + tenant: NonNullable +} + +export function isGrantWithTenant(grant: Grant): grant is GrantWithTenant { + return !!grant.tenant +} diff --git a/packages/auth/src/grant/routes.test.ts b/packages/auth/src/grant/routes.test.ts index 5175fb02c0..c0544fd909 100644 --- a/packages/auth/src/grant/routes.test.ts +++ b/packages/auth/src/grant/routes.test.ts @@ -36,6 +36,8 @@ import { AccessAction, AccessType } from '@interledger/open-payments' import { generateBaseGrant } from '../tests/grant' import { generateBaseInteraction } from '../tests/interaction' import { GNAPErrorCode } from '../shared/gnapErrors' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' export const TEST_CLIENT_DISPLAY = { name: 'Test Client', @@ -80,10 +82,14 @@ describe('Grant Routes', (): void => { let clientService: ClientService let interactionService: InteractionService + let tenant: Tenant let grant: Grant beforeEach(async (): Promise => { - grant = await Grant.query().insert(generateBaseGrant()) + tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -173,7 +179,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const body = { access_token: { @@ -252,7 +260,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -291,7 +301,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const body = { access_token: { @@ -327,7 +339,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = { ...BASE_GRANT_REQUEST, interact: undefined } @@ -364,7 +378,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -386,7 +402,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const grantRequest = { @@ -423,7 +441,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -434,6 +454,52 @@ describe('Grant Routes', (): void => { message: "missing required request field 'client'" }) }) + + test('Fails to initiate a grant without providing a tenant id', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + url, + method + }, + {} + ) + + ctx.request.body = BASE_GRANT_REQUEST + + await expect(grantRoutes.create(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidRequest, + message: 'Not Found' + }) + }) + + test('Fails to initiate a grant if the provided tenant does not exist', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + url, + method + }, + { + tenantId: v4() + } + ) + + ctx.request.body = BASE_GRANT_REQUEST + + await expect(grantRoutes.create(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidRequest, + message: 'Not Found' + }) + }) }) describe('/continue', (): void => { @@ -443,6 +509,7 @@ describe('Grant Routes', (): void => { beforeEach(async (): Promise => { grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Approved }) ) @@ -476,7 +543,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -527,7 +595,8 @@ describe('Grant Routes', (): void => { } }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) @@ -545,6 +614,7 @@ describe('Grant Routes', (): void => { test('Cannot issue access token if grant has not been granted', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Pending }) ) @@ -571,7 +641,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -589,6 +660,7 @@ describe('Grant Routes', (): void => { test('Cannot issue access token if grant has been revoked', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -611,7 +683,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -635,7 +708,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -659,7 +733,9 @@ describe('Grant Routes', (): void => { Authorization: `GNAP ${grant.continueToken}` } }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = { @@ -683,7 +759,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -698,8 +775,61 @@ describe('Grant Routes', (): void => { }) }) + test('Cannot continue if tenant id is not provided', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `GNAP ${grant.continueToken}` + } + }, + { + id: grant.continueId + } + ) + + ctx.request.body = { + interact_ref: interaction.ref + } + + await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidContinuation, + message: 'grant not found' + }) + }) + + test('Cannot continue if tenant does not exist', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `GNAP ${grant.continueToken}` + } + }, + { + id: grant.continueId, + tenantId: v4() + } + ) + + ctx.request.body = { + interact_ref: interaction.ref + } + + await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidContinuation, + message: 'grant not found' + }) + }) + test('Honors wait value when continuing too early', async (): Promise => { - const grantWithWait = await Grant.query().insert(generateBaseGrant()) + const grantWithWait = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -721,7 +851,8 @@ describe('Grant Routes', (): void => { } }, { - id: grantWithWait.continueId + id: grantWithWait.continueId, + tenantId: tenant.id } ) @@ -746,6 +877,7 @@ describe('Grant Routes', (): void => { async ({ state }): Promise => { const polledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state, noFinishMethod: true }) @@ -779,7 +911,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: polledGrant.continueId + id: polledGrant.continueId, + tenantId: tenant.id } ) @@ -842,6 +975,7 @@ describe('Grant Routes', (): void => { test('Cannot poll a finalized grant', async (): Promise => { const finalizedPolledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, noFinishMethod: true }) @@ -885,7 +1019,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -903,6 +1038,7 @@ describe('Grant Routes', (): void => { test('Cannot poll a grant faster than its wait method', async (): Promise => { const polledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }) ) @@ -929,7 +1065,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: polledGrant.continueId + id: polledGrant.continueId, + tenantId: tenant.id } ) @@ -952,7 +1089,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).resolves.toBeUndefined() @@ -963,6 +1101,7 @@ describe('Grant Routes', (): void => { test('Can revoke an existing grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) @@ -976,7 +1115,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).resolves.toBeUndefined() @@ -987,6 +1127,7 @@ describe('Grant Routes', (): void => { test('Cannot revoke an already revoked grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -1000,7 +1141,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject({ @@ -1020,7 +1162,8 @@ describe('Grant Routes', (): void => { } }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject({ @@ -1048,7 +1191,8 @@ describe('Grant Routes', (): void => { : undefined }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject(error) diff --git a/packages/auth/src/grant/routes.ts b/packages/auth/src/grant/routes.ts index 17e6f09128..b3e84306e6 100644 --- a/packages/auth/src/grant/routes.ts +++ b/packages/auth/src/grant/routes.ts @@ -22,6 +22,7 @@ import { InteractionService } from '../interaction/service' import { canSkipInteraction } from './utils' import { GNAPErrorCode, GNAPServerRouteError } from '../shared/gnapErrors' import { generateRouteLogs } from '../shared/utils' +import { TenantService } from '../tenant/service' interface ServiceDependencies extends BaseService { grantService: GrantService @@ -29,6 +30,7 @@ interface ServiceDependencies extends BaseService { accessTokenService: AccessTokenService accessService: AccessService interactionService: InteractionService + tenantService: TenantService config: IAppConfig } @@ -72,6 +74,7 @@ export function createGrantRoutes({ accessTokenService, accessService, interactionService, + tenantService, logger, config }: ServiceDependencies): GrantRoutes { @@ -94,6 +97,7 @@ export function createGrantRoutes({ accessTokenService, accessService, interactionService, + tenantService, logger: log, config } @@ -108,6 +112,16 @@ async function createGrant( deps: ServiceDependencies, ctx: CreateContext ): Promise { + const { tenantId } = ctx.params + const tenant = tenantId ? await deps.tenantService.get(tenantId) : undefined + + if (!tenant) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.InvalidRequest, + 'Not Found' + ) + } let noInteractionRequired: boolean try { noInteractionRequired = canSkipInteraction(deps.config, ctx.request.body) @@ -119,14 +133,15 @@ async function createGrant( ) } if (noInteractionRequired) { - await createApprovedGrant(deps, ctx) + await createApprovedGrant(deps, tenantId, ctx) } else { - await createPendingGrant(deps, ctx) + await createPendingGrant(deps, tenantId, ctx) } } async function createApprovedGrant( deps: ServiceDependencies, + tenantId: string, ctx: CreateContext ): Promise { const { body } = ctx.request @@ -135,7 +150,7 @@ async function createApprovedGrant( let grant: Grant let accessToken: AccessToken try { - grant = await grantService.create(body, trx) + grant = await grantService.create(body, tenantId, trx) accessToken = await deps.accessTokenService.create(grant.id, trx) await trx.commit() } catch (err) { @@ -167,6 +182,7 @@ async function createApprovedGrant( async function createPendingGrant( deps: ServiceDependencies, + tenantId: string, ctx: CreateContext ): Promise { const { body } = ctx.request @@ -191,7 +207,7 @@ async function createPendingGrant( const trx = await Grant.startTransaction() try { - const grant = await grantService.create(body, trx) + const grant = await grantService.create(body, tenantId, trx) const interaction = await interactionService.create(grant.id, trx) await trx.commit() @@ -355,7 +371,7 @@ async function continueGrant( params, headers } = ctx - const { id: continueId } = params + const { id: continueId, tenantId } = params const continueToken = (headers['authorization'] as string)?.split('GNAP ')[1] if (!continueId || !continueToken) { @@ -386,7 +402,8 @@ async function continueGrant( if ( !interaction || !isContinuableGrant(interaction.grant) || - !isMatchingContinueRequest(continueId, continueToken, interaction.grant) + !isMatchingContinueRequest(continueId, continueToken, interaction.grant) || + interaction.grant.tenantId !== tenantId ) { throw new GNAPServerRouteError( 404, diff --git a/packages/auth/src/grant/service.test.ts b/packages/auth/src/grant/service.test.ts index c1012ac944..9f07b90e9f 100644 --- a/packages/auth/src/grant/service.test.ts +++ b/packages/auth/src/grant/service.test.ts @@ -24,12 +24,15 @@ import { AccessToken } from '../accessToken/model' import { Interaction, InteractionState } from '../interaction/model' import { Pagination, SortOrder } from '../shared/baseModel' import { getPageTests } from '../shared/baseModel.test' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Grant Service', (): void => { let deps: IocContract let appContainer: TestContainer let grantService: GrantService let trx: Knex.Transaction + let tenant: Tenant beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -38,6 +41,10 @@ describe('Grant Service', (): void => { grantService = await deps.use('grantService') }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insertAndFetch(generateTenant()) + }) + afterEach(async (): Promise => { await truncateTables(appContainer.knex) }) @@ -48,13 +55,14 @@ describe('Grant Service', (): void => { describe('getPage', (): void => { getPageTests({ - createModel: () => createGrant(deps), + createModel: () => createGrant(deps, tenant.id), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => grantService.getPage(pagination, undefined, sortOrder) }) }) describe('grant flow', (): void => { + let tenant: Tenant let grant: Grant let access: Access let accessToken: AccessToken @@ -62,6 +70,7 @@ describe('Grant Service', (): void => { const CLIENT = faker.internet.url({ appendSlash: false }) beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) grant = await Grant.query().insert({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -70,7 +79,8 @@ describe('Grant Service', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com', clientNonce: generateNonce(), - client: CLIENT + client: CLIENT, + tenantId: tenant.id }) await Interaction.query().insert({ @@ -126,7 +136,7 @@ describe('Grant Service', (): void => { } } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) expect(grant).toMatchObject({ state: GrantState.Approved, @@ -170,7 +180,7 @@ describe('Grant Service', (): void => { interact } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) expect(grant).toMatchObject({ state: expectedState, @@ -266,13 +276,13 @@ describe('Grant Service', (): void => { interact: undefined } - const grant1 = await grantService.create(grantRequest) + const grant1 = await grantService.create(grantRequest, tenant.id) await grant1 .$query() .patch({ finalizationReason: GrantFinalization.Issued }) - const grant2 = await grantService.create(grantRequest) - const grant3 = await grantService.create(grantRequest) + const grant2 = await grantService.create(grantRequest, tenant.id) + const grant3 = await grantService.create(grantRequest, tenant.id) await grant3 .$query() .patch({ finalizationReason: GrantFinalization.Revoked }) @@ -386,7 +396,7 @@ describe('Grant Service', (): void => { } } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) const timeoutMs = 50 @@ -420,7 +430,7 @@ describe('Grant Service', (): void => { ] for (const { identifier, state, finalizationReason } of grantDetails) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) const updatedGrant = await grant .$query() .patchAndFetch({ state, finalizationReason }) diff --git a/packages/auth/src/grant/service.ts b/packages/auth/src/grant/service.ts index 051b3d0984..251ceff4c9 100644 --- a/packages/auth/src/grant/service.ts +++ b/packages/auth/src/grant/service.ts @@ -8,7 +8,9 @@ import { GrantState, GrantFinalization, StartMethod, - FinishMethod + FinishMethod, + GrantWithTenant, + isGrantWithTenant } from './model' import { AccessRequest } from '../access/types' import { AccessService } from '../access/service' @@ -24,8 +26,12 @@ interface GrantFilter { export interface GrantService { getByIdWithAccess(grantId: string): Promise - create(grantRequest: GrantRequest, trx?: Transaction): Promise - markPending(grantId: string, trx?: Transaction): Promise + create( + grantRequest: GrantRequest, + tenantId: string, + trx?: Transaction + ): Promise + markPending(grantId: string, trx?: Transaction): Promise approve(grantId: string, trx?: Transaction): Promise finalize(grantId: string, reason: GrantFinalization): Promise getByContinue( @@ -115,8 +121,8 @@ export async function createGrantService({ } return { getByIdWithAccess: (grantId: string) => getByIdWithAccess(grantId), - create: (grantRequest: GrantRequest, trx?: Transaction) => - create(deps, grantRequest, trx), + create: (grantRequest: GrantRequest, tenantId: string, trx?: Transaction) => + create(deps, grantRequest, tenantId, trx), markPending: (grantId: string, trx?: Transaction) => markPending(deps, grantId, trx), approve: (grantId: string) => approve(grantId), @@ -149,12 +155,17 @@ async function markPending( deps: ServiceDependencies, id: string, trx?: Transaction -): Promise { +): Promise { const grantTrx = trx || (await deps.knex.transaction()) try { - const grant = await Grant.query(trx).patchAndFetchById(id, { - state: GrantState.Pending - }) + const grant = await Grant.query(trx) + .patchAndFetchById(id, { + state: GrantState.Pending + }) + .withGraphFetched('tenant') + + if (!isGrantWithTenant(grant)) + throw new Error('required graph not returned in query') if (!trx) { await grantTrx.commit() @@ -211,6 +222,7 @@ async function revokeGrant( async function create( deps: ServiceDependencies, grantRequest: GrantRequest, + tenantId: string, trx?: Transaction ): Promise { const { accessService, knex } = deps @@ -233,7 +245,8 @@ async function create( clientNonce: interact?.finish?.nonce, client, continueId: v4(), - continueToken: generateToken() + continueToken: generateToken(), + tenantId } const grant = await Grant.query(grantTrx).insert(grantData) diff --git a/packages/auth/src/graphql/resolvers/grant.test.ts b/packages/auth/src/graphql/resolvers/grant.test.ts index 50afb44936..dd374b1c74 100644 --- a/packages/auth/src/graphql/resolvers/grant.test.ts +++ b/packages/auth/src/graphql/resolvers/grant.test.ts @@ -20,6 +20,8 @@ import { Grant, Grant as GrantModel } from '../../grant/model' import { getPageTests } from './page.test' import { createGrant } from '../../tests/grant' import { GraphQLErrorCode } from '../errors' +import { Tenant } from '../../tenant/model' +import { generateTenant } from '../../tests/tenant' const responseHandler = (query: ApolloQueryResult): GrantsConnection => { if (query.data) { @@ -32,12 +34,17 @@ const responseHandler = (query: ApolloQueryResult): GrantsConnection => { describe('Grant Resolvers', (): void => { let deps: IocContract let appContainer: TestContainer + let tenant: Tenant beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insertAndFetch(generateTenant()) + }) + afterEach(async (): Promise => { await truncateTables(appContainer.knex) }) @@ -50,7 +57,7 @@ describe('Grant Resolvers', (): void => { describe('Grants Queries', (): void => { getPageTests({ getClient: () => appContainer.apolloClient, - createModel: () => createGrant(deps) as Promise, + createModel: () => createGrant(deps, tenant.id) as Promise, pagedQuery: 'grants' }) @@ -58,7 +65,7 @@ describe('Grant Resolvers', (): void => { const grants: GrantModel[] = [] for (let i = 0; i < 2; i++) { - grants[1 - i] = await createGrant(deps) + grants[1 - i] = await createGrant(deps, tenant.id) } const query = await appContainer.apolloClient @@ -106,7 +113,7 @@ describe('Grant Resolvers', (): void => { { identifier: 'https://abc.com/xyz' } ] for (const { identifier } of grantData) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) grants.push(grant) } }) @@ -170,7 +177,7 @@ describe('Grant Resolvers', (): void => { { identifier: 'https://abc.com/xyz' } ] for (const { identifier } of grantData) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) grants.push(grant) } @@ -231,7 +238,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -280,7 +287,7 @@ describe('Grant Resolvers', (): void => { { state: GrantState.Approved } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -339,7 +346,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -402,7 +409,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -454,7 +461,7 @@ describe('Grant Resolvers', (): void => { describe('Grant By id Queries', (): void => { let grant: GrantModel beforeEach(async (): Promise => { - grant = await createGrant(deps) + grant = await createGrant(deps, tenant.id) }) test('Can get a grant', async (): Promise => { @@ -528,7 +535,7 @@ describe('Grant Resolvers', (): void => { describe('Revoke grant', (): void => { let grant: GrantModel beforeEach(async (): Promise => { - grant = await createGrant(deps) + grant = await createGrant(deps, tenant.id) }) test('Can revoke a grant', async (): Promise => { diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 356b321cf1..107087b996 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -138,6 +138,16 @@ export function initIocContainer( } ) + container.singleton( + 'tenantService', + async (deps: IocContract) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + } + ) + container.singleton('grantRoutes', async (deps: IocContract) => { return createGrantRoutes({ grantService: await deps.use('grantService'), @@ -145,6 +155,7 @@ export function initIocContainer( accessTokenService: await deps.use('accessTokenService'), accessService: await deps.use('accessService'), interactionService: await deps.use('interactionService'), + tenantService: await deps.use('tenantService'), logger: await deps.use('logger'), config: await deps.use('config') }) @@ -157,6 +168,7 @@ export function initIocContainer( accessService: await deps.use('accessService'), interactionService: await deps.use('interactionService'), grantService: await deps.use('grantService'), + tenantService: await deps.use('tenantService'), logger: await deps.use('logger'), config: await deps.use('config') }) @@ -210,16 +222,6 @@ export function initIocContainer( return new Redis(config.redisUrl, { tls: config.redisTls }) }) - container.singleton( - 'tenantService', - async (deps: IocContract) => { - return createTenantService({ - logger: await deps.use('logger'), - knex: await deps.use('knex') - }) - } - ) - return container } diff --git a/packages/auth/src/interaction/routes.test.ts b/packages/auth/src/interaction/routes.test.ts index 8b5a539cb4..6592b13272 100644 --- a/packages/auth/src/interaction/routes.test.ts +++ b/packages/auth/src/interaction/routes.test.ts @@ -1,5 +1,5 @@ import { v4 } from 'uuid' -import * as crypto from 'crypto' +import crypto from 'crypto' import jestOpenAPI from 'jest-openapi' import { IocContract } from '@adonisjs/fold' import assert from 'assert' @@ -26,6 +26,8 @@ import { generateNonce } from '../shared/utils' import { GNAPErrorCode } from '../shared/gnapErrors' import { generateBaseGrant } from '../tests/grant' import { generateBaseInteraction } from '../tests/interaction' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' const BASE_GRANT_ACCESS = { type: AccessType.IncomingPayment, @@ -39,11 +41,15 @@ describe('Interaction Routes', (): void => { let interactionRoutes: InteractionRoutes let config: IAppConfig + let tenant: Tenant let grant: Grant let interaction: Interaction beforeEach(async (): Promise => { - grant = await Grant.query().insert(generateBaseGrant()) + tenant = await Tenant.query().insert(generateTenant()) + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -100,6 +106,7 @@ describe('Interaction Routes', (): void => { test('Interaction start fails if grant is revoked', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -139,12 +146,12 @@ describe('Interaction Routes', (): void => { }, url: `/interact/${interaction.id}/${interaction.nonce}` }, - { id: interaction.id, nonce: interaction.nonce } + { id: interaction.id, nonce: interaction.nonce, tenantId: tenant.id } ) assert.ok(interaction.id) - const redirectUrl = new URL(config.identityServerUrl) + const redirectUrl = new URL(tenant.idpConsentUrl) redirectUrl.searchParams.set('interactId', interaction.id) const redirectSpy = jest.spyOn(ctx, 'redirect') @@ -236,6 +243,7 @@ describe('Interaction Routes', (): void => { test('Cannot finish interaction with revoked grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -265,10 +273,12 @@ describe('Interaction Routes', (): void => { describe('Interactions for grant with finish method', (): void => { test('Can finish accepted interaction', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Approved - }) + const grant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, + state: GrantState.Approved + }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -325,7 +335,7 @@ describe('Interaction Routes', (): void => { test('Can finish rejected interaction', async (): Promise => { const grant = await Grant.query().insert({ - ...generateBaseGrant(), + ...generateBaseGrant({ tenantId: tenant.id }), state: GrantState.Finalized, finalizationReason: GrantFinalization.Rejected }) @@ -410,7 +420,7 @@ describe('Interaction Routes', (): void => { let grantWithoutFinish: Grant beforeEach(async (): Promise => { grantWithoutFinish = await Grant.query().insert( - generateBaseGrant({ noFinishMethod: true }) + generateBaseGrant({ noFinishMethod: true, tenantId: tenant.id }) ) await Access.query().insert({ @@ -448,6 +458,7 @@ describe('Interaction Routes', (): void => { test('Can finish rejected interaction', async (): Promise => { const grant = await Grant.query().insert({ ...generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }), state: GrantState.Finalized, @@ -487,6 +498,7 @@ describe('Interaction Routes', (): void => { test('Cannot finish invalid interaction', async (): Promise => { const grant = await Grant.query().insert({ ...generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }), state: GrantState.Finalized, @@ -527,14 +539,17 @@ describe('Interaction Routes', (): void => { }) describe('IDP - Grant details', (): void => { + let tenant: Tenant let grant: Grant let access: Access let interaction: Interaction - beforeAll(async (): Promise => { - grant = await Grant.query().insert({ - ...generateBaseGrant() - }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) + + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) access = await Access.query().insertAndFetch({ ...BASE_GRANT_ACCESS, @@ -552,7 +567,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -581,7 +596,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -596,11 +611,13 @@ describe('Interaction Routes', (): void => { }) test('Cannot get grant details for revoked grant', async (): Promise => { - const revokedGrant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) + const revokedGrant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Revoked + }) + ) const interaction = await Interaction.query().insert( generateBaseInteraction(revokedGrant) @@ -610,7 +627,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -644,13 +661,34 @@ describe('Interaction Routes', (): void => { }) }) + test('Cannot get grant details with invalid secret', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-idp-secret': 'wrong-secret' + }, + url: `/grant/${interaction.id}/${interaction.nonce}`, + method: 'GET' + }, + { id: interaction.id, nonce: interaction.nonce } + ) + + await expect(interactionRoutes.details(ctx)).rejects.toMatchObject({ + status: 401, + code: GNAPErrorCode.InvalidRequest, + message: 'invalid x-idp-secret' + }) + }) + test('Cannot get grant details for nonexistent interaction', async (): Promise => { const ctx = createContext( { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -668,7 +706,7 @@ describe('Interaction Routes', (): void => { let pendingGrant: Grant beforeEach(async (): Promise => { pendingGrant = await Grant.query().insert({ - ...generateBaseGrant(), + ...generateBaseGrant({ tenantId: tenant.id }), state: GrantState.Pending }) @@ -702,6 +740,31 @@ describe('Interaction Routes', (): void => { }) }) + test('cannot accept/reject interacetion with invalid secret', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-idp-secret': 'wrong-secret' + } + }, + { + id: interaction.id, + nonce: interaction.nonce, + choice: InteractionChoices.Accept + } + ) + + await expect( + interactionRoutes.acceptOrReject(ctx) + ).rejects.toMatchObject({ + status: 401, + code: GNAPErrorCode.InvalidInteraction, + message: 'invalid x-idp-secret' + }) + }) + test('can accept interaction', async (): Promise => { const ctx = createContext( { @@ -710,7 +773,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { @@ -744,7 +807,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { id: interactId, nonce } @@ -767,7 +830,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { @@ -800,7 +863,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { diff --git a/packages/auth/src/interaction/routes.ts b/packages/auth/src/interaction/routes.ts index 64ed405d6d..d11f4e8118 100644 --- a/packages/auth/src/interaction/routes.ts +++ b/packages/auth/src/interaction/routes.ts @@ -19,11 +19,13 @@ import { import { toOpenPaymentsAccess } from '../access/model' import { GNAPErrorCode, GNAPServerRouteError } from '../shared/gnapErrors' import { generateRouteLogs } from '../shared/utils' +import { TenantService } from '../tenant/service' interface ServiceDependencies extends BaseService { grantService: GrantService accessService: AccessService interactionService: InteractionService + tenantService: TenantService config: IAppConfig } @@ -83,6 +85,7 @@ export function createInteractionRoutes({ grantService, accessService, interactionService, + tenantService, logger, config }: ServiceDependencies): InteractionRoutes { @@ -94,6 +97,7 @@ export function createInteractionRoutes({ grantService, accessService, interactionService, + tenantService, logger: log, config } @@ -111,13 +115,32 @@ async function getGrantDetails( ctx: GetContext ): Promise { const secret = ctx.headers?.['x-idp-secret'] - const { config, interactionService, accessService } = deps + const { interactionService, accessService, tenantService } = deps const { id: interactId, nonce } = ctx.params + const interaction = await interactionService.getBySession(interactId, nonce) + if (!interaction || isRevokedGrant(interaction.grant)) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } + + // Tenant should exist as it is a foreign key requirement on grants + const tenant = await tenantService.get(interaction.grant.tenantId) + if (!tenant) { + throw new GNAPServerRouteError( + 500, + GNAPErrorCode.InvalidRequest, + 'internal server error' + ) + } + if ( !secret || !crypto.timingSafeEqual( Buffer.from(secret as string), - Buffer.from(config.identityServerSecret) + Buffer.from(tenant.idpSecret) ) ) { throw new GNAPServerRouteError( @@ -126,14 +149,6 @@ async function getGrantDetails( 'invalid x-idp-secret' ) } - const interaction = await interactionService.getBySession(interactId, nonce) - if (!interaction || isRevokedGrant(interaction.grant)) { - throw new GNAPServerRouteError( - 404, - GNAPErrorCode.UnknownInteraction, - 'unknown interaction' - ) - } const access = await accessService.getByGrant(interaction.grantId) @@ -165,9 +180,8 @@ async function startInteraction( ) const { id: interactId, nonce } = ctx.params const { clientName, clientUri } = ctx.query - const { config, interactionService, grantService, logger } = deps + const { interactionService, grantService, logger } = deps const interaction = await interactionService.getBySession(interactId, nonce) - if ( !interaction || interaction.state !== InteractionState.Pending || @@ -182,12 +196,15 @@ async function startInteraction( const trx = await Interaction.startTransaction() try { - await grantService.markPending(interaction.id, trx) + // Grant and Tenant should exist, as one is a foreign key requirement on interactions and the other a foreign key requirement on that grant. + const grant = await grantService.markPending(interaction.grant.id, trx) await trx.commit() + const { idpConsentUrl } = grant.tenant + ctx.session.nonce = interaction.nonce - const interactionUrl = new URL(config.identityServerUrl) + const interactionUrl = new URL(idpConsentUrl) interactionUrl.searchParams.set('interactId', interaction.id) interactionUrl.searchParams.set('nonce', interaction.nonce) interactionUrl.searchParams.set('clientName', clientName as string) @@ -219,14 +236,33 @@ async function handleInteractionChoice( ctx: ChooseContext ): Promise { const { id: interactId, nonce, choice } = ctx.params - const { config, interactionService, logger } = deps + const { interactionService, logger } = deps const secret = ctx.headers['x-idp-secret'] + const interaction = await interactionService.getBySession(interactId, nonce) + if (!interaction) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } + + // TODO: try to make this called from interaction.grant.tenantId instead of path param + const tenant = await deps.tenantService.get(interaction.grant.tenantId) + + if (!tenant) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } if ( !secret || !crypto.timingSafeEqual( Buffer.from(secret as string), - Buffer.from(config.identityServerSecret) + Buffer.from(tenant.idpSecret) ) ) { throw new GNAPServerRouteError( @@ -236,67 +272,58 @@ async function handleInteractionChoice( ) } - const interaction = await interactionService.getBySession(interactId, nonce) - if (!interaction) { + const { grant } = interaction + // If grant was already rejected or revoked + if ( + grant.state === GrantState.Finalized && + grant.finalizationReason !== GrantFinalization.Issued + ) { throw new GNAPServerRouteError( - 404, - GNAPErrorCode.UnknownInteraction, - 'unknown interaction' + 401, + GNAPErrorCode.UserDenied, + 'user denied interaction' ) - } else { - const { grant } = interaction - // If grant was already rejected or revoked - if ( - grant.state === GrantState.Finalized && - grant.finalizationReason !== GrantFinalization.Issued - ) { - throw new GNAPServerRouteError( - 401, - GNAPErrorCode.UserDenied, - 'user denied interaction' - ) - } - - // If grant is otherwise not pending interaction - if ( - interaction.state !== InteractionState.Pending || - isInteractionExpired(interaction) - ) { - throw new GNAPServerRouteError( - 400, - GNAPErrorCode.InvalidInteraction, - 'invalid interaction' - ) - } - - if (choice === InteractionChoices.Accept) { - logger.debug( - { - ...generateRouteLogs(ctx), - interaction - }, - 'interaction approved' - ) - await interactionService.approve(interactId) - } else if (choice === InteractionChoices.Reject) { - logger.debug( - { - ...generateRouteLogs(ctx), - interaction - }, - 'interaction rejected' - ) - await interactionService.deny(interactId) - } else { - throw new GNAPServerRouteError( - 400, - GNAPErrorCode.InvalidRequest, - 'invalid interaction choice' - ) - } + } - ctx.status = 202 + // If grant is otherwise not pending interaction + if ( + interaction.state !== InteractionState.Pending || + isInteractionExpired(interaction) + ) { + throw new GNAPServerRouteError( + 400, + GNAPErrorCode.InvalidInteraction, + 'invalid interaction' + ) } + + if (choice === InteractionChoices.Accept) { + logger.debug( + { + ...generateRouteLogs(ctx), + interaction + }, + 'interaction approved' + ) + await interactionService.approve(interactId) + } else if (choice === InteractionChoices.Reject) { + logger.debug( + { + ...generateRouteLogs(ctx), + interaction + }, + 'interaction rejected' + ) + await interactionService.deny(interactId) + } else { + throw new GNAPServerRouteError( + 400, + GNAPErrorCode.InvalidRequest, + 'invalid interaction choice' + ) + } + + ctx.status = 202 } async function handleFinishableGrant( diff --git a/packages/auth/src/interaction/service.test.ts b/packages/auth/src/interaction/service.test.ts index 8e09a567d4..236650a9e2 100644 --- a/packages/auth/src/interaction/service.test.ts +++ b/packages/auth/src/interaction/service.test.ts @@ -17,6 +17,8 @@ import { Access } from '../access/model' import { Interaction, InteractionState } from './model' import { InteractionService } from './service' import { generateNonce, generateToken } from '../shared/utils' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' const CLIENT = faker.internet.url({ appendSlash: false }) const BASE_GRANT_ACCESS = { @@ -30,6 +32,7 @@ describe('Interaction Service', (): void => { let interactionService: InteractionService let interaction: Interaction let grant: Grant + let tenant: Tenant beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -39,6 +42,7 @@ describe('Interaction Service', (): void => { }) beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) grant = await Grant.query().insert({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -47,7 +51,8 @@ describe('Interaction Service', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com', clientNonce: generateNonce(), - client: CLIENT + client: CLIENT, + tenantId: tenant.id }) interaction = await Interaction.query().insert({ @@ -75,7 +80,9 @@ describe('Interaction Service', (): void => { describe('create', (): void => { test('can create an interaction', async (): Promise => { - const grant = await Grant.query().insert(generateBaseGrant()) + const grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) const interaction = await interactionService.create(grant.id) diff --git a/packages/auth/src/interaction/service.ts b/packages/auth/src/interaction/service.ts index 61f9606891..e93430325a 100644 --- a/packages/auth/src/interaction/service.ts +++ b/packages/auth/src/interaction/service.ts @@ -14,7 +14,11 @@ import { GrantService } from '../grant/service' export interface InteractionService { getInteractionByGrant(grantId: string): Promise - getBySession(id: string, nonce: string): Promise + getBySession( + id: string, + nonce: string, + tenantId?: string + ): Promise getByRef(ref: string): Promise create(grantId: string, trx?: Transaction): Promise approve(id: string): Promise @@ -103,11 +107,12 @@ async function getBySession( id: string, nonce: string ): Promise { - const interaction = await Interaction.query() + const queryBuilder = Interaction.query() .findById(id) .where('nonce', nonce) .withGraphFetched('grant') + const interaction = await queryBuilder if (!interaction || !isInteractionWithGrant(interaction)) { return undefined } diff --git a/packages/auth/src/signature/middleware.test.ts b/packages/auth/src/signature/middleware.test.ts index df93af0bd5..595e3fefe0 100644 --- a/packages/auth/src/signature/middleware.test.ts +++ b/packages/auth/src/signature/middleware.test.ts @@ -36,6 +36,8 @@ import { ContinueContext, CreateContext } from '../grant/routes' import { Interaction, InteractionState } from '../interaction/model' import { generateNonce } from '../shared/utils' import { GNAPErrorCode } from '../shared/gnapErrors' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Signature Service', (): void => { let deps: IocContract @@ -66,6 +68,7 @@ describe('Signature Service', (): void => { let managementId: string let tokenManagementUrl: string let accessTokenService: AccessTokenService + let tenant: Tenant const generateBaseGrant = (overrides?: Partial) => ({ state: GrantState.Pending, @@ -112,7 +115,10 @@ describe('Signature Service', (): void => { }) beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(generateBaseGrant()) + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS @@ -338,12 +344,13 @@ describe('Signature Service', (): void => { }) test('middleware fails if grant is revoked', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant({ + const grant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) - }) + ) const ctx = await createContextWithSigHeaders( { diff --git a/packages/auth/src/tests/grant.ts b/packages/auth/src/tests/grant.ts index 85b1f4db7e..6dee5c889d 100644 --- a/packages/auth/src/tests/grant.ts +++ b/packages/auth/src/tests/grant.ts @@ -16,6 +16,7 @@ const CLIENT = faker.internet.url({ appendSlash: false }) export async function createGrant( deps: IocContract, + tenantId: string, options?: { identifier?: string } ): Promise { const grantService = await deps.use('grantService') @@ -36,32 +37,38 @@ export async function createGrant( } } - return await grantService.create({ - ...BASE_GRANT_REQUEST, - access_token: { - access: [ - { - ...BASE_GRANT_ACCESS, - type: AccessType.IncomingPayment - } - ] - } - }) + return await grantService.create( + { + ...BASE_GRANT_REQUEST, + access_token: { + access: [ + { + ...BASE_GRANT_ACCESS, + type: AccessType.IncomingPayment + } + ] + } + }, + tenantId + ) } export interface GenerateBaseGrantOptions { + tenantId: string state?: GrantState finalizationReason?: GrantFinalization noFinishMethod?: boolean } -export const generateBaseGrant = (options: GenerateBaseGrantOptions = {}) => { +export const generateBaseGrant = (options: GenerateBaseGrantOptions) => { const { + tenantId, state = GrantState.Processing, finalizationReason = undefined, noFinishMethod = false } = options return { + tenantId, state, finalizationReason, startMethod: [StartMethod.Redirect], diff --git a/packages/auth/src/tests/tenant.ts b/packages/auth/src/tests/tenant.ts new file mode 100644 index 0000000000..5c146eeca6 --- /dev/null +++ b/packages/auth/src/tests/tenant.ts @@ -0,0 +1,11 @@ +import crypto from 'crypto' +import { faker } from '@faker-js/faker' +import { v4 } from 'uuid' + +export function generateTenant() { + return { + id: v4(), + idpConsentUrl: faker.internet.url(), + idpSecret: crypto.randomBytes(8).toString('base64') + } +} From 4bd7546f6c7b6b861a7da407fd6a5d532a2cc5e5 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Wed, 18 Dec 2024 13:35:17 -0800 Subject: [PATCH 2/2] fix: tests --- localenv/cloud-nine-wallet/docker-compose.yml | 1 + localenv/happy-life-bank/docker-compose.yml | 1 + .../app/lib/apolloClient.ts | 3 +- packages/auth/src/app.ts | 2 +- packages/auth/src/grant/model.ts | 6 +- packages/auth/src/grant/routes.test.ts | 72 ++++++++++++++++--- packages/backend/src/index.ts | 1 + .../open_payments/payment/incoming/routes.ts | 2 +- .../payment/incoming_remote/service.ts | 4 +- .../lib/test-actions/open-payments.ts | 10 +-- 10 files changed, 81 insertions(+), 21 deletions(-) diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 6f22a11e27..db03ac395d 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -25,6 +25,7 @@ services: IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= DISPLAY_NAME: Cloud Nine Wallet DISPLAY_ICON: wallet-icon.svg + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 volumes: - ../cloud-nine-wallet/seed.yml:/workspace/seed.yml - ../cloud-nine-wallet/private-key.pem:/workspace/private-key.pem diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 15d41cce4a..e365d27b3a 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -21,6 +21,7 @@ services: IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= DISPLAY_NAME: Happy Life Bank DISPLAY_ICON: bank-icon.svg + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d volumes: - ../happy-life-bank/seed.yml:/workspace/seed.yml - ../happy-life-bank/private-key.pem:/workspace/private-key.pem diff --git a/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts b/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts index d71fcb7d6d..2ddeb515c1 100644 --- a/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts +++ b/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts @@ -68,7 +68,8 @@ const authLink = setContext((request, { headers }) => { return { headers: { ...headers, - signature: `t=${timestamp}, v${version}=${digest}` + signature: `t=${timestamp}, v${version}=${digest}`, + 'tenant-id': process.env.OPERATOR_TENANT_ID } } }) diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index 8aabae2830..843d3f9d2d 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -267,7 +267,7 @@ export class App { /* Back-channel GNAP Routes */ // Grant Initiation router.post( - '/:tenantId/', + '/:tenantId', createValidatorMiddleware(openApi.authServerSpec, { path: '/', method: HttpMethod.POST diff --git a/packages/auth/src/grant/model.ts b/packages/auth/src/grant/model.ts index 4fb8f7dce1..c395cece82 100644 --- a/packages/auth/src/grant/model.ts +++ b/packages/auth/src/grant/model.ts @@ -138,7 +138,7 @@ export function toOpenPaymentPendingGrant( access_token: { value: grant.continueToken }, - uri: `${authServerUrl}/continue/${grant.continueId}`, + uri: `${authServerUrl}/${grant.tenantId}/continue/${grant.continueId}`, wait: waitTimeSeconds } } @@ -158,7 +158,7 @@ export function toOpenPaymentsGrantContinuation( access_token: { value: grant.continueToken }, - uri: `${args.authServerUrl}/continue/${grant.continueId}`, + uri: `${args.authServerUrl}/${grant.tenantId}/continue/${grant.continueId}`, wait: args.waitTimeSeconds } } @@ -178,7 +178,7 @@ export function toOpenPaymentsGrant( access_token: { value: grant.continueToken }, - uri: `${args.authServerUrl}/continue/${grant.continueId}` + uri: `${args.authServerUrl}/${grant.tenantId}/continue/${grant.continueId}` } } } diff --git a/packages/auth/src/grant/routes.test.ts b/packages/auth/src/grant/routes.test.ts index c0544fd909..9ce4ff5622 100644 --- a/packages/auth/src/grant/routes.test.ts +++ b/packages/auth/src/grant/routes.test.ts @@ -32,7 +32,12 @@ import { AccessTokenService } from '../accessToken/service' import { generateNonce } from '../shared/utils' import { ClientService } from '../client/service' import { withConfigOverride } from '../tests/helpers' -import { AccessAction, AccessType } from '@interledger/open-payments' +import { + AccessAction, + AccessType, + GrantContinuation, + PendingGrant +} from '@interledger/open-payments' import { generateBaseGrant } from '../tests/grant' import { generateBaseInteraction } from '../tests/interaction' import { GNAPErrorCode } from '../shared/gnapErrors' @@ -73,6 +78,18 @@ const BASE_GRANT_REQUEST = { } } +function getGrantContinueId(continueUrl: string): string { + const continueUrlObj = new URL(continueUrl) + const pathItems = continueUrlObj.pathname.split('/') + return pathItems[pathItems.length - 1] +} + +function getInteractionId(redirectUrl: string): string { + const redirectUrlObj = new URL(redirectUrl) + const pathItems = redirectUrlObj.pathname.split('/') + return pathItems[pathItems.length - 2] +} + describe('Grant Routes', (): void => { let deps: IocContract let appContainer: TestContainer @@ -215,6 +232,14 @@ describe('Grant Routes', (): void => { ).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as GrantContinuation).continue.uri + ), + continueToken: (ctx.body as GrantContinuation) + .continue.access_token.value + }) + assert.ok(createdGrant) expect(ctx.body).toEqual({ access_token: { value: expect.any(String), @@ -224,9 +249,9 @@ describe('Grant Routes', (): void => { }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String) + uri: `${config.authServerUrl}/${tenant.id}/continue/${createdGrant.continueId}` } }) } @@ -270,16 +295,37 @@ describe('Grant Routes', (): void => { await expect(grantRoutes.create(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as PendingGrant).continue.uri + ), + continueToken: (ctx.body as PendingGrant).continue.access_token.value + }) + assert.ok(createdGrant) + const createdInteraction = await Interaction.query().findOne({ + nonce: (ctx.body as PendingGrant).interact.finish, + id: getInteractionId((ctx.body as PendingGrant).interact.redirect) + }) + assert.ok(createdInteraction) + const expectedRedirectUrl = new URL( + config.authServerUrl + + `/interact/${createdInteraction.id}/${createdInteraction.nonce}` + ) + expectedRedirectUrl.searchParams.set( + 'clientName', + TEST_CLIENT_DISPLAY.name + ) + expectedRedirectUrl.searchParams.set('clientUri', CLIENT) expect(ctx.body).toEqual({ interact: { - redirect: expect.any(String), - finish: expect.any(String) + redirect: expectedRedirectUrl.toString(), + finish: createdInteraction.nonce }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String), + uri: `${config.authServerUrl}/${tenant.id}/continue/${createdGrant.continueId}`, wait: Config.waitTimeSeconds } }) @@ -563,6 +609,14 @@ describe('Grant Routes', (): void => { assert.ok(accessToken) expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as GrantContinuation).continue.uri + ), + continueToken: (ctx.body as GrantContinuation).continue.access_token + .value + }) + assert.ok(createdGrant) expect(ctx.body).toEqual({ access_token: { value: accessToken.value, @@ -578,9 +632,9 @@ describe('Grant Routes', (): void => { }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String) + uri: `${config.authServerUrl}/${tenant.id}/continue/${createdGrant.continueId}` } }) }) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b167410756..959473863a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -417,6 +417,7 @@ export function initIocContainer( }) container.singleton('remoteIncomingPaymentService', async (deps) => { return await createRemoteIncomingPaymentService({ + config: await deps.use('config'), logger: await deps.use('logger'), knex: await deps.use('knex'), grantService: await deps.use('grantService'), diff --git a/packages/backend/src/open_payments/payment/incoming/routes.ts b/packages/backend/src/open_payments/payment/incoming/routes.ts index eecbc1db5a..eb1d59cb58 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.ts @@ -84,7 +84,7 @@ async function getIncomingPaymentPublic( } ctx.body = incomingPayment.toPublicOpenPaymentsType( - deps.config.authServerGrantUrl + `${deps.config.authServerGrantUrl}/${deps.config.operatorTenantId}` // TODO: update this when tenanted incoming payments added ) } diff --git a/packages/backend/src/open_payments/payment/incoming_remote/service.ts b/packages/backend/src/open_payments/payment/incoming_remote/service.ts index 72c830e5c5..dcb1e43b99 100644 --- a/packages/backend/src/open_payments/payment/incoming_remote/service.ts +++ b/packages/backend/src/open_payments/payment/incoming_remote/service.ts @@ -13,6 +13,7 @@ import { BaseService } from '../../../shared/baseService' import { Amount, serializeAmount } from '../../amount' import { RemoteIncomingPaymentError } from './errors' import { isGrantError } from '../../grant/errors' +import { IAppConfig } from '../../../config/app' interface CreateRemoteIncomingPaymentArgs { walletAddressUrl: string @@ -35,6 +36,7 @@ export interface RemoteIncomingPaymentService { } interface ServiceDependencies extends BaseService { + config: IAppConfig grantService: GrantService openPaymentsUrl: string openPaymentsClient: AuthenticatedClient @@ -102,7 +104,7 @@ async function createIncomingPayment( walletAddress.resourceServer ?? new URL(walletAddress.id).origin const grantOptions = { - authServer: walletAddress.authServer, + authServer: `${walletAddress.authServer}/cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d`, // TODO: update with wallet address tenant id when tenanted wallet addresses are in accessType: AccessType.IncomingPayment, accessActions: [AccessAction.Create, AccessAction.ReadAll] } diff --git a/test/integration/lib/test-actions/open-payments.ts b/test/integration/lib/test-actions/open-payments.ts index 6f54f25b68..6206560fab 100644 --- a/test/integration/lib/test-actions/open-payments.ts +++ b/test/integration/lib/test-actions/open-payments.ts @@ -94,11 +94,11 @@ async function grantRequestIncomingPayment( deps: OpenPaymentsActionsDeps, receiverWalletAddress: WalletAddress ): Promise { - const { sendingASE } = deps + const { sendingASE, receivingASE } = deps const grant = await sendingASE.opClient.grant.request( { - url: receiverWalletAddress.authServer + url: `${receiverWalletAddress.authServer}/${receivingASE.config.operatorTenantId}` }, { access_token: { @@ -185,7 +185,7 @@ async function grantRequestQuote( const { sendingASE } = deps const grant = await sendingASE.opClient.grant.request( { - url: senderWalletAddress.authServer + url: `${senderWalletAddress.authServer}/${sendingASE.config.operatorTenantId}` }, { access_token: { @@ -232,10 +232,10 @@ async function grantRequestOutgoingPayment( limits: GrantRequestPaymentLimits, finish?: InteractFinish ): Promise { - const { receivingASE } = deps + const { receivingASE, sendingASE } = deps const grant = await receivingASE.opClient.grant.request( { - url: senderWalletAddress.authServer + url: `${senderWalletAddress.authServer}/${sendingASE.config.operatorTenantId}` }, { access_token: {