From 2ffdc441e6aba387820992ee39018c8d4055daf8 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Fri, 15 Sep 2023 10:49:17 -0700 Subject: [PATCH] feat(auth): add interaction model (#1797) Co-authored-by: Nathan Lie --- ...0230810154819_create_interactions_table.js | 43 +++ packages/auth/src/access/service.test.ts | 43 ++- packages/auth/src/accessToken/routes.test.ts | 5 +- packages/auth/src/accessToken/service.test.ts | 63 ++--- packages/auth/src/app.ts | 2 + packages/auth/src/config/app.ts | 1 + packages/auth/src/grant/model.ts | 37 +-- packages/auth/src/grant/routes.test.ts | 109 ++++---- packages/auth/src/grant/routes.ts | 76 ++++- packages/auth/src/grant/service.test.ts | 104 ++----- packages/auth/src/grant/service.ts | 103 +++---- packages/auth/src/index.ts | 16 ++ packages/auth/src/interaction/model.ts | 45 +++ packages/auth/src/interaction/routes.test.ts | 259 ++++++++++-------- packages/auth/src/interaction/routes.ts | 108 +++++--- packages/auth/src/interaction/service.test.ts | 155 +++++++++++ packages/auth/src/interaction/service.ts | 142 ++++++++++ .../auth/src/signature/middleware.test.ts | 23 +- packages/auth/src/signature/middleware.ts | 16 +- packages/auth/src/tests/grant.ts | 35 ++- packages/auth/src/tests/interaction.ts | 24 ++ .../documentation/docs/apis/auth/enums.md | 70 +++++ .../content/docs/integration/deployment.md | 2 + 23 files changed, 1018 insertions(+), 463 deletions(-) create mode 100644 packages/auth/migrations/20230810154819_create_interactions_table.js create mode 100644 packages/auth/src/interaction/model.ts create mode 100644 packages/auth/src/interaction/service.test.ts create mode 100644 packages/auth/src/interaction/service.ts create mode 100644 packages/auth/src/tests/interaction.ts create mode 100644 packages/documentation/docs/apis/auth/enums.md diff --git a/packages/auth/migrations/20230810154819_create_interactions_table.js b/packages/auth/migrations/20230810154819_create_interactions_table.js new file mode 100644 index 0000000000..60e539076a --- /dev/null +++ b/packages/auth/migrations/20230810154819_create_interactions_table.js @@ -0,0 +1,43 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return Promise.all([ + knex.schema.createTable('interactions', function (table) { + table.uuid('id').notNullable().primary() + + table.string('ref').notNullable().unique() + table.string('nonce').notNullable() + table.string('state').notNullable() + + table.uuid('grantId').notNullable() + table.foreign('grantId').references('grants.id').onDelete('CASCADE') + + table.integer('expiresIn').notNullable() + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + }), + knex.schema.alterTable('grants', function (table) { + table.dropColumn('interactId') + table.dropColumn('interactRef') + table.dropColumn('interactNonce') + }) + ]) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.all([ + knex.schema.dropTableIfExists('interactions'), + knex.schema.alterTable('grants', function (table) { + table.string('interactId').unique() + table.string('interactRef').unique() + table.string('interactNonce').unique() + }) + ]) +} diff --git a/packages/auth/src/access/service.test.ts b/packages/auth/src/access/service.test.ts index fbd6dd44e5..5415fcf039 100644 --- a/packages/auth/src/access/service.test.ts +++ b/packages/auth/src/access/service.test.ts @@ -20,6 +20,22 @@ describe('Access Service', (): void => { let appContainer: TestContainer let accessService: AccessService 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()) + }) beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -36,26 +52,8 @@ describe('Access Service', (): void => { await appContainer.shutdown() }) - const BASE_GRANT = { - state: GrantState.Processing, - startMethod: [StartMethod.Redirect], - continueToken: generateToken(), - continueId: v4(), - finishMethod: FinishMethod.Redirect, - finishUri: 'https://example.com/finish', - clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }), - interactId: v4(), - interactRef: generateNonce(), - interactNonce: generateNonce() - } - describe('create', (): void => { test('Can create incoming payment access', async (): Promise => { - const grant = await Grant.query(trx).insertAndFetch({ - ...BASE_GRANT - }) - const incomingPaymentAccess: IncomingPaymentRequest = { type: 'incoming-payment', actions: [AccessAction.Create, AccessAction.Read, AccessAction.List] @@ -92,8 +90,6 @@ describe('Access Service', (): void => { limits: outgoingPaymentLimit } - const grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) - const access = await accessService.createAccess(grant.id, [ outgoingPaymentAccess ]) @@ -107,10 +103,6 @@ describe('Access Service', (): void => { describe('getByGrant', (): void => { test('gets access', async () => { - const grant = await Grant.query(trx).insertAndFetch({ - ...BASE_GRANT - }) - const incomingPaymentAccess: IncomingPaymentRequest = { type: 'incoming-payment', actions: [AccessAction.Create, AccessAction.Read, AccessAction.List] @@ -136,9 +128,6 @@ describe('Access Service', (): void => { describe('revoke access', (): void => { test('revokeByGrantId', async () => { - const grant = await Grant.query(trx).insertAndFetch({ - ...BASE_GRANT - }) const access = await Access.query(trx).insert({ grantId: grant.id, type: 'incoming-payment', diff --git a/packages/auth/src/accessToken/routes.test.ts b/packages/auth/src/accessToken/routes.test.ts index f9282e3962..f228aed63c 100644 --- a/packages/auth/src/accessToken/routes.test.ts +++ b/packages/auth/src/accessToken/routes.test.ts @@ -58,10 +58,7 @@ describe('Access Token Routes', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com/finish', clientNonce: generateNonce(), - client: CLIENT, - interactId: v4(), - interactRef: generateNonce(), - interactNonce: generateNonce() + client: CLIENT } const BASE_ACCESS = { diff --git a/packages/auth/src/accessToken/service.test.ts b/packages/auth/src/accessToken/service.test.ts index c9325d8c09..daff1ba31b 100644 --- a/packages/auth/src/accessToken/service.test.ts +++ b/packages/auth/src/accessToken/service.test.ts @@ -1,4 +1,3 @@ -import { faker } from '@faker-js/faker' import nock from 'nock' import { Knex } from 'knex' import { v4 } from 'uuid' @@ -10,18 +9,13 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '..' import { AppServices } from '../app' import { truncateTables } from '../tests/tableManager' -import { - FinishMethod, - Grant, - GrantState, - GrantFinalization, - StartMethod -} from '../grant/model' +import { Grant, GrantState, GrantFinalization } from '../grant/model' import { AccessToken } from './model' import { AccessTokenService } from './service' import { Access } from '../access/model' -import { generateNonce, generateToken } from '../shared/utils' +import { generateToken } from '../shared/utils' import { AccessType, AccessAction } from '@interledger/open-payments' +import { generateBaseGrant } from '../tests/grant' describe('Access Token Service', (): void => { let deps: IocContract @@ -45,17 +39,6 @@ describe('Access Token Service', (): void => { await appContainer.shutdown() }) - const CLIENT = faker.internet.url({ appendSlash: false }) - - const BASE_GRANT = { - state: GrantState.Processing, - startMethod: [StartMethod.Redirect], - finishMethod: FinishMethod.Redirect, - finishUri: 'https://example.com/finish', - clientNonce: generateNonce(), - client: CLIENT - } - const BASE_ACCESS = { type: AccessType.OutgoingPayment, actions: [AccessAction.Read, AccessAction.Create], @@ -76,14 +59,9 @@ describe('Access Token Service', (): void => { let grant: Grant beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch({ - ...BASE_GRANT, - continueToken: generateToken(), - continueId: v4(), - interactId: v4(), - interactRef: generateNonce(), - interactNonce: generateNonce() - }) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ state: GrantState.Approved }) + ) grant.access = [ await Access.query(trx).insertAndFetch({ grantId: grant.id, @@ -190,14 +168,13 @@ describe('Access Token Service', (): void => { let grant: Grant let token: AccessToken beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch({ - ...BASE_GRANT, - continueToken: generateToken(), - continueId: v4(), - interactId: v4(), - interactRef: generateNonce(), - interactNonce: generateNonce() - }) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Issued + }) + ) + token = await AccessToken.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_TOKEN, @@ -287,14 +264,12 @@ describe('Access Token Service', (): void => { let token: AccessToken let originalTokenValue: string beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch({ - ...BASE_GRANT, - continueToken: generateToken(), - continueId: v4(), - interactId: v4(), - interactRef: generateNonce(), - interactNonce: generateNonce() - }) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Issued + }) + ) await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index caa74215ec..3e3b015320 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -20,6 +20,7 @@ import { loadSchemaSync } from '@graphql-tools/load' import { resolvers } from './graphql/resolvers' import { ClientService } from './client/service' import { GrantService } from './grant/service' +import { InteractionService } from './interaction/service' import { CreateContext, ContinueContext, @@ -89,6 +90,7 @@ export interface AppServices { config: Promise clientService: Promise grantService: Promise + interactionService: Promise accessService: Promise accessTokenRoutes: Promise accessTokenService: Promise diff --git a/packages/auth/src/config/app.ts b/packages/auth/src/config/app.ts index 81014f44c0..6b2fa7a266 100644 --- a/packages/auth/src/config/app.ts +++ b/packages/auth/src/config/app.ts @@ -40,6 +40,7 @@ export const Config = { ), waitTimeSeconds: envInt('WAIT_SECONDS', 5), cookieKey: envString('COOKIE_KEY', crypto.randomBytes(32).toString('hex')), + interactionExpirySeconds: envInt('INTERACTION_EXPIRY_SECONDS', 10 * 60), // Default 10 minutes accessTokenExpirySeconds: envInt('ACCESS_TOKEN_EXPIRY_SECONDS', 10 * 60), // Default 10 minutes databaseCleanupWorkers: envInt('DATABASE_CLEANUP_WORKERS', 1), accessTokenDeletionDays: envInt('ACCESS_TOKEN_DELETION_DAYS', 30), diff --git a/packages/auth/src/grant/model.ts b/packages/auth/src/grant/model.ts index 2bfbe76d36..0f453876ec 100644 --- a/packages/auth/src/grant/model.ts +++ b/packages/auth/src/grant/model.ts @@ -7,6 +7,7 @@ import { Grant as OpenPaymentsGrant } from '@interledger/open-payments' import { AccessToken, toOpenPaymentsAccessToken } from '../accessToken/model' +import { Interaction } from '../interaction/model' export enum StartMethod { Redirect = 'redirect' @@ -51,6 +52,14 @@ export class Grant extends BaseModel { from: 'grants.id', to: 'accesses.grantId' } + }, + interaction: { + relation: Model.HasManyRelation, + modelClass: join(__dirname, '../interaction/model'), + join: { + from: 'grants.id', + to: 'interactions.grantId' + } } }) public access!: Access[] @@ -67,10 +76,6 @@ export class Grant extends BaseModel { public finishUri?: string public client!: string public clientNonce?: string // client-generated nonce for post-interaction hash - - public interactId?: string - public interactRef?: string - public interactNonce?: string // AS-generated nonce for post-interaction hash } interface ToOpenPaymentsPendingGrantArgs { @@ -84,16 +89,13 @@ interface ToOpenPaymentsPendingGrantArgs { export function toOpenPaymentPendingGrant( grant: Grant, + interaction: Interaction, args: ToOpenPaymentsPendingGrantArgs ): OpenPaymentsPendingGrant { - if (!isInteractiveGrant(grant)) { - throw new Error('Expected pending/interactive grant') - } - const { authServerUrl, client, waitTimeSeconds } = args const redirectUri = new URL( - authServerUrl + `/interact/${grant.interactId}/${grant.interactNonce}` + authServerUrl + `/interact/${interaction.id}/${interaction.nonce}` ) redirectUri.searchParams.set('clientName', client.name) @@ -102,7 +104,7 @@ export function toOpenPaymentPendingGrant( return { interact: { redirect: redirectUri.toString(), - finish: grant.interactNonce + finish: interaction.nonce }, continue: { access_token: { @@ -137,22 +139,13 @@ export function toOpenPaymentsGrant( } } -export interface InteractiveGrant extends Grant { +export interface FinishableGrant extends Grant { finishMethod: NonNullable finishUri: NonNullable - interactId: NonNullable - interactRef: NonNullable - interactNonce: NonNullable // AS-generated nonce for post-interaction hash } -export function isInteractiveGrant(grant: Grant): grant is InteractiveGrant { - return !!( - grant.finishMethod && - grant.finishUri && - grant.interactId && - grant.interactRef && - grant.interactNonce - ) +export function isFinishableGrant(grant: Grant): grant is FinishableGrant { + return !!(grant.finishMethod && grant.finishUri) } export function isRejectedGrant(grant: Grant): boolean { diff --git a/packages/auth/src/grant/routes.test.ts b/packages/auth/src/grant/routes.test.ts index 715d5e77d7..58e5327a86 100644 --- a/packages/auth/src/grant/routes.test.ts +++ b/packages/auth/src/grant/routes.test.ts @@ -25,12 +25,15 @@ import { GrantState, GrantFinalization } from '../grant/model' +import { Interaction, InteractionState } from '../interaction/model' import { AccessToken } from '../accessToken/model' import { AccessTokenService } from '../accessToken/service' -import { generateNonce, generateToken } from '../shared/utils' +import { generateNonce } from '../shared/utils' import { ClientService } from '../client/service' import { withConfigOverride } from '../tests/helpers' import { AccessAction, AccessType } from '@interledger/open-payments' +import { generateBaseGrant } from '../tests/grant' +import { generateBaseInteraction } from '../tests/interaction' export const TEST_CLIENT_DISPLAY = { name: 'Test Client', @@ -76,20 +79,6 @@ describe('Grant Routes', (): void => { let grant: Grant - const generateBaseGrant = () => ({ - state: GrantState.Processing, - startMethod: [StartMethod.Redirect], - continueToken: generateToken(), - continueId: v4(), - finishMethod: FinishMethod.Redirect, - finishUri: 'https://example.com', - clientNonce: generateNonce(), - client: CLIENT, - interactId: v4(), - interactRef: v4(), - interactNonce: generateNonce() - }) - beforeEach(async (): Promise => { grant = await Grant.query().insert(generateBaseGrant()) @@ -336,17 +325,29 @@ describe('Grant Routes', (): void => { }) describe('/continue', (): void => { - test('Can issue access token', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Approved - }) + let grant: Grant + let interaction: Interaction + let access: Access + beforeEach(async (): Promise => { + grant = await Grant.query().insert( + generateBaseGrant({ + state: GrantState.Approved + }) + ) - const access = await Access.query().insert({ + access = await Access.query().insert({ ...BASE_GRANT_ACCESS, grantId: grant.id }) + interaction = await Interaction.query().insert( + generateBaseInteraction(grant, { + state: InteractionState.Approved + }) + ) + }) + + test('Can issue access token', async (): Promise => { const ctx = createContext( { headers: { @@ -362,10 +363,8 @@ describe('Grant Routes', (): void => { } ) - assert.ok(grant.interactRef) - ctx.request.body = { - interact_ref: grant.interactRef + interact_ref: interaction.ref } await expect(grantRoutes.continue(ctx)).resolves.toBeUndefined() @@ -427,11 +426,20 @@ describe('Grant Routes', (): void => { }) test('Cannot issue access token if grant has not been granted', async (): Promise => { + const grant = await Grant.query().insert( + generateBaseGrant({ + state: GrantState.Pending + }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, grantId: grant.id }) + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) + const ctx = createContext( { headers: { @@ -445,10 +453,8 @@ describe('Grant Routes', (): void => { } ) - assert.ok(grant.interactRef) - ctx.request.body = { - interact_ref: grant.interactRef + interact_ref: interaction.ref } await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ @@ -458,16 +464,21 @@ describe('Grant Routes', (): void => { }) test('Cannot issue access token if grant has been revoked', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) + const grant = await Grant.query().insert( + generateBaseGrant({ + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Revoked + }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, grantId: grant.id }) + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) + const ctx = createContext( { headers: { @@ -481,10 +492,8 @@ describe('Grant Routes', (): void => { } ) - assert.ok(grant.interactRef) - ctx.request.body = { - interact_ref: grant.interactRef + interact_ref: interaction.ref } await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ @@ -530,10 +539,8 @@ describe('Grant Routes', (): void => { } ) - assert.ok(grant.interactRef) - ctx.request.body = { - interact_ref: grant.interactRef + interact_ref: interaction.ref } await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ @@ -554,10 +561,8 @@ describe('Grant Routes', (): void => { {} ) - assert.ok(grant.interactRef) - ctx.request.body = { - interact_ref: grant.interactRef + interact_ref: interaction.ref } await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ @@ -585,11 +590,12 @@ describe('Grant Routes', (): void => { }) test('Can revoke an existing grant', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Issued - }) + const grant = await Grant.query().insert( + generateBaseGrant({ + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Issued + }) + ) const ctx = createContext( { url: '/continue/{id}', @@ -608,11 +614,12 @@ describe('Grant Routes', (): void => { }) test('Cannot revoke an already revoked grant', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) + const grant = await Grant.query().insert( + generateBaseGrant({ + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Revoked + }) + ) const ctx = createContext( { url: '/continue/{id}', diff --git a/packages/auth/src/grant/routes.ts b/packages/auth/src/grant/routes.ts index f3276491e4..6d0b49d992 100644 --- a/packages/auth/src/grant/routes.ts +++ b/packages/auth/src/grant/routes.ts @@ -7,7 +7,9 @@ import { GrantFinalization, GrantState, toOpenPaymentPendingGrant, - toOpenPaymentsGrant + toOpenPaymentsGrant, + isRevokedGrant, + isRejectedGrant } from './model' import { ClientService } from '../client/service' import { BaseService } from '../shared/baseService' @@ -19,12 +21,14 @@ import { IAppConfig } from '../config/app' import { AccessTokenService } from '../accessToken/service' import { AccessService } from '../access/service' import { AccessToken } from '../accessToken/model' +import { InteractionService } from '../interaction/service' interface ServiceDependencies extends BaseService { grantService: GrantService clientService: ClientService accessTokenService: AccessTokenService accessService: AccessService + interactionService: InteractionService config: IAppConfig } @@ -67,6 +71,7 @@ export function createGrantRoutes({ clientService, accessTokenService, accessService, + interactionService, logger, config }: ServiceDependencies): GrantRoutes { @@ -79,6 +84,7 @@ export function createGrantRoutes({ clientService, accessTokenService, accessService, + interactionService, logger: log, config } @@ -144,7 +150,7 @@ async function createPendingGrant( ctx: CreateContext ): Promise { const { body } = ctx.request - const { grantService, config } = deps + const { grantService, interactionService, config } = deps if (!body.interact) { ctx.throw(400, { error: 'interaction_required' }) } @@ -154,15 +160,45 @@ async function createPendingGrant( ctx.throw(400, 'invalid_client', { error: 'invalid_client' }) } - const grant = await grantService.create(body) - ctx.status = 200 - ctx.body = toOpenPaymentPendingGrant(grant, { - client, - authServerUrl: config.authServerDomain, - waitTimeSeconds: config.waitTimeSeconds - }) + const trx = await Grant.startTransaction() + + try { + const grant = await grantService.create(body, trx) + const interaction = await interactionService.create(grant.id, trx) + await trx.commit() + + ctx.status = 200 + ctx.body = toOpenPaymentPendingGrant(grant, interaction, { + client, + authServerUrl: config.authServerDomain, + waitTimeSeconds: config.waitTimeSeconds + }) + } catch (err) { + await trx.rollback() + ctx.throw(500) + } } +function isMatchingContinueRequest( + reqContinueId: string, + reqContinueToken: string, + grant: Grant +): boolean { + return ( + reqContinueId === grant.continueId && + reqContinueToken === grant.continueToken + ) +} + +function isContinuableGrant(grant: Grant): boolean { + return !isRejectedGrant(grant) && !isRevokedGrant(grant) +} + +/* + GNAP indicates that a grant may be continued even if it didn't require interaction. + Rafiki only needs to continue a grant if it required an interaction, noninteractive grants immediately issue an access token without needing continuation + so continuation only expects interactive grants to be continued. +*/ async function continueGrant( deps: ServiceDependencies, ctx: ContinueContext @@ -177,13 +213,23 @@ async function continueGrant( ctx.throw(401, { error: 'invalid_request' }) } - const { config, accessTokenService, grantService, accessService } = deps - const grant = await grantService.getByContinue(continueId, continueToken, { - interactRef - }) - if (!grant) { + const { + config, + accessTokenService, + grantService, + accessService, + interactionService + } = deps + + const interaction = await interactionService.getByRef(interactRef) + if ( + !interaction || + !isContinuableGrant(interaction.grant) || + !isMatchingContinueRequest(continueId, continueToken, interaction.grant) + ) { ctx.throw(404, { error: 'unknown_request' }) } else { + const { grant } = interaction if (grant.state !== GrantState.Approved) { ctx.throw(401, { error: 'request_denied' }) } @@ -194,7 +240,7 @@ async function continueGrant( // TODO: add "continue" to response if additional grant request steps are added ctx.body = toOpenPaymentsGrant( - grant, + interaction.grant, { authServerUrl: config.authServerDomain }, accessToken, access diff --git a/packages/auth/src/grant/service.test.ts b/packages/auth/src/grant/service.test.ts index 3d634f5a96..388d825f6d 100644 --- a/packages/auth/src/grant/service.test.ts +++ b/packages/auth/src/grant/service.test.ts @@ -21,6 +21,7 @@ import { generateNonce, generateToken } from '../shared/utils' import { AccessType, AccessAction } from '@interledger/open-payments' import { createGrant } from '../tests/grant' import { AccessToken } from '../accessToken/model' +import { Interaction, InteractionState } from '../interaction/model' describe('Grant Service', (): void => { let deps: IocContract @@ -47,10 +48,15 @@ describe('Grant Service', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com', clientNonce: generateNonce(), - client: CLIENT, - interactId: v4(), - interactRef: v4(), - interactNonce: generateNonce() + client: CLIENT + }) + + await Interaction.query().insert({ + ref: v4(), + nonce: generateNonce(), + state: InteractionState.Pending, + expiresIn: Config.interactionExpirySeconds, + grantId: grant.id }) await Access.query().insert({ @@ -112,9 +118,6 @@ describe('Grant Service', (): void => { state: GrantState.Pending, continueId: expect.any(String), continueToken: expect.any(String), - interactRef: expect.any(String), - interactId: expect.any(String), - interactNonce: expect.any(String), finishMethod: FinishMethod.Redirect, finishUri: BASE_GRANT_REQUEST.interact.finish.uri, clientNonce: BASE_GRANT_REQUEST.interact.finish.nonce, @@ -166,6 +169,14 @@ describe('Grant Service', (): void => { }) }) + describe('pending', (): void => { + test('Can mark a grant pending for an interaction', async (): Promise => { + const pendingGrant = await grantService.markPending(grant.id) + assert.ok(pendingGrant) + expect(pendingGrant.state).toEqual(GrantState.Pending) + }) + }) + describe('issue', (): void => { test('Can issue an approved grant', async (): Promise => { const issuedGrant = await grantService.approve(grant.id) @@ -175,18 +186,15 @@ describe('Grant Service', (): void => { describe('continue', (): void => { test('Can fetch a grant by its continuation information', async (): Promise => { - const { continueId, continueToken, interactRef } = grant - assert.ok(interactRef) + const { continueId, continueToken } = grant const fetchedGrant = await grantService.getByContinue( continueId, - continueToken, - { interactRef } + continueToken ) expect(fetchedGrant?.id).toEqual(grant.id) expect(fetchedGrant?.continueId).toEqual(continueId) expect(fetchedGrant?.continueToken).toEqual(continueToken) - expect(fetchedGrant?.interactRef).toEqual(interactRef) }) test('Defaults to excluding revoked grants', async (): Promise => { @@ -195,15 +203,13 @@ describe('Grant Service', (): void => { finalizationReason: GrantFinalization.Revoked }) - const { continueId, continueToken, interactRef } = grant - assert.ok(interactRef) + const { continueId, continueToken } = grant const fetchedGrant = await grantService.getByContinue( continueId, - continueToken, - { interactRef } + continueToken ) - expect(fetchedGrant).toBeNull() + expect(fetchedGrant).toBeUndefined() }) test('Can fetch revoked grants with includeRevoked', async (): Promise => { @@ -212,18 +218,16 @@ describe('Grant Service', (): void => { finalizationReason: GrantFinalization.Revoked }) - const { continueId, continueToken, interactRef } = grant - assert.ok(interactRef) + const { continueId, continueToken } = grant const fetchedGrant = await grantService.getByContinue( continueId, continueToken, - { interactRef, includeRevoked: true } + { includeRevoked: true } ) expect(fetchedGrant?.id).toEqual(grant.id) expect(fetchedGrant?.continueId).toEqual(continueId) expect(fetchedGrant?.continueToken).toEqual(continueToken) - expect(fetchedGrant?.interactRef).toEqual(interactRef) }) }) @@ -235,64 +239,6 @@ describe('Grant Service', (): void => { }) }) - describe('getByInteractiveSession', (): void => { - test('Can fetch a grant by interact id and nonce', async () => { - assert.ok(grant.interactId) - assert.ok(grant.interactNonce) - const fetchedGrant = await grantService.getByInteractionSession( - grant.interactId, - grant.interactNonce - ) - expect(fetchedGrant?.id).toEqual(grant.id) - }) - test.each` - interactId | interactNonce | description - ${true} | ${false} | ${'interactId'} - ${false} | ${true} | ${'interactNonce'} - ${false} | ${false} | ${'interactId and interactNonce'} - `( - 'Cannot fetch a grant by unknown $description', - async ({ interactId, interactNonce }): Promise => { - assert.ok(grant.interactId) - assert.ok(grant.interactNonce) - - await expect( - grantService.getByInteractionSession( - interactId ? grant.interactId : v4(), - interactNonce ? grant.interactNonce : v4() - ) - ).resolves.toBeUndefined() - } - ) - test('Defaults to excluding revoked grants', async () => { - await grant.$query().patch({ - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) - assert.ok(grant.interactId) - assert.ok(grant.interactNonce) - const fetchedGrant = await grantService.getByInteractionSession( - grant.interactId, - grant.interactNonce - ) - expect(fetchedGrant).toBeUndefined() - }) - test('Can fetch revoked grants with includeRevoked', async () => { - await grant.$query().patch({ - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) - assert.ok(grant.interactId) - assert.ok(grant.interactNonce) - const fetchedGrant = await grantService.getByInteractionSession( - grant.interactId, - grant.interactNonce, - { includeRevoked: true } - ) - expect(fetchedGrant?.id).toEqual(grant.id) - }) - }) - describe('finalize', (): void => { test.each` reason | description diff --git a/packages/auth/src/grant/service.ts b/packages/auth/src/grant/service.ts index e44bcb6d1f..77fffb2f51 100644 --- a/packages/auth/src/grant/service.ts +++ b/packages/auth/src/grant/service.ts @@ -2,15 +2,13 @@ import { v4 } from 'uuid' import { Transaction, TransactionOrKnex } from 'objection' import { BaseService } from '../shared/baseService' -import { generateNonce, generateToken } from '../shared/utils' +import { generateToken } from '../shared/utils' import { Grant, GrantState, GrantFinalization, StartMethod, - FinishMethod, - InteractiveGrant, - isInteractiveGrant + FinishMethod } from './model' import { AccessRequest } from '../access/types' import { AccessService } from '../access/service' @@ -18,21 +16,21 @@ import { Pagination } from '../shared/baseModel' import { FilterString } from '../shared/filters' import { AccessTokenService } from '../accessToken/service' +interface GrantFilter { + identifier?: FilterString +} + export interface GrantService { getByIdWithAccess(grantId: string): Promise create(grantRequest: GrantRequest, trx?: Transaction): Promise - getByInteractionSession( - interactId: string, - interactNonce: string, - options?: GetByInteractionSessionOpts - ): Promise - approve(grantId: string): Promise + markPending(grantId: string, trx?: Transaction): Promise + approve(grantId: string, trx?: Transaction): Promise finalize(grantId: string, reason: GrantFinalization): Promise getByContinue( continueId: string, continueToken: string, options?: GetByContinueOpts - ): Promise + ): Promise revokeGrant(grantId: string): Promise getPage(pagination?: Pagination, filter?: GrantFilter): Promise lock(grantId: string, trx: Transaction, timeoutMs?: number): Promise @@ -109,12 +107,9 @@ export async function createGrantService({ getByIdWithAccess: (grantId: string) => getByIdWithAccess(grantId), create: (grantRequest: GrantRequest, trx?: Transaction) => create(deps, grantRequest, trx), - getByInteractionSession: ( - interactId: string, - interactNonce: string, - options: GetByInteractionSessionOpts - ) => getByInteractionSession(interactId, interactNonce, options), - approve: (grantId: string) => approve(deps, grantId), + markPending: (grantId: string, trx?: Transaction) => + markPending(deps, grantId, trx), + approve: (grantId: string) => approve(grantId), finalize: (id: string, reason: GrantFinalization) => finalize(id, reason), getByContinue: ( continueId: string, @@ -132,15 +127,34 @@ async function getByIdWithAccess(grantId: string): Promise { return Grant.query().findById(grantId).withGraphJoined('access') } -async function approve( - deps: ServiceDependencies, - grantId: string -): Promise { +async function approve(grantId: string): Promise { return Grant.query().patchAndFetchById(grantId, { state: GrantState.Approved }) } +async function markPending( + deps: ServiceDependencies, + id: string, + trx?: Transaction +): Promise { + const grantTrx = trx || (await deps.knex.transaction()) + try { + const grant = await Grant.query(trx).patchAndFetchById(id, { + state: GrantState.Pending + }) + + if (!trx) { + await grantTrx.commit() + } + + return grant + } catch (err) { + await grantTrx.rollback() + throw err + } +} + async function finalize(id: string, reason: GrantFinalization): Promise { return Grant.query().patchAndFetchById(id, { state: GrantState.Finalized, @@ -205,9 +219,6 @@ async function create( finishUri: interact?.finish?.uri, clientNonce: interact?.finish?.nonce, client, - interactId: interact ? v4() : undefined, - interactRef: interact ? v4() : undefined, - interactNonce: interact ? generateNonce() : undefined, continueId: v4(), continueToken: generateToken() } @@ -231,38 +242,7 @@ async function create( } } -interface GetByInteractionSessionOpts { - includeRevoked?: boolean -} - -async function getByInteractionSession( - interactId: string, - interactNonce: string, - options: GetByInteractionSessionOpts = {} -): Promise { - const { includeRevoked = false } = options - - const queryBuilder = Grant.query() - .findOne({ interactId, interactNonce }) - .withGraphFetched('access') - - if (!includeRevoked) { - queryBuilder - .whereNull('finalizationReason') - .orWhereNot('finalizationReason', GrantFinalization.Revoked) - } - - const grant = await queryBuilder - - if (!grant || !isInteractiveGrant(grant)) { - return undefined - } else { - return grant - } -} - interface GetByContinueOpts { - interactRef?: string includeRevoked?: boolean } @@ -270,10 +250,12 @@ async function getByContinue( continueId: string, continueToken: string, options: GetByContinueOpts = {} -): Promise { - const { interactRef, includeRevoked = false } = options +): Promise { + const { includeRevoked = false } = options - const queryBuilder = Grant.query().findOne({ continueId }) + const queryBuilder = Grant.query() + .findOne({ continueId, continueToken }) + .withGraphFetched('interaction') if (!includeRevoked) { queryBuilder @@ -283,11 +265,6 @@ async function getByContinue( const grant = await queryBuilder - if ( - continueToken !== grant?.continueToken || - (interactRef && interactRef !== grant?.interactRef) - ) - return null return grant } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 8e69503df8..a6be1e4951 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -15,6 +15,7 @@ import { createGrantRoutes } from './grant/routes' import { createInteractionRoutes } from './interaction/routes' import { createOpenAPI } from '@interledger/openapi' import { createUnauthenticatedClient as createOpenPaymentsClient } from '@interledger/open-payments' +import { createInteractionService } from './interaction/service' const container = initIocContainer(Config) const app = new App(container) @@ -109,12 +110,25 @@ export function initIocContainer( } ) + container.singleton( + 'interactionService', + async (deps: IocContract) => { + return createInteractionService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + config: await deps.use('config'), + grantService: await deps.use('grantService') + }) + } + ) + container.singleton('grantRoutes', async (deps: IocContract) => { return createGrantRoutes({ grantService: await deps.use('grantService'), clientService: await deps.use('clientService'), accessTokenService: await deps.use('accessTokenService'), accessService: await deps.use('accessService'), + interactionService: await deps.use('interactionService'), logger: await deps.use('logger'), config: await deps.use('config') }) @@ -124,6 +138,8 @@ export function initIocContainer( 'interactionRoutes', async (deps: IocContract) => { return createInteractionRoutes({ + accessService: await deps.use('accessService'), + interactionService: await deps.use('interactionService'), grantService: await deps.use('grantService'), logger: await deps.use('logger'), config: await deps.use('config') diff --git a/packages/auth/src/interaction/model.ts b/packages/auth/src/interaction/model.ts new file mode 100644 index 0000000000..98d17ee2fe --- /dev/null +++ b/packages/auth/src/interaction/model.ts @@ -0,0 +1,45 @@ +import { Model } from 'objection' +import { join } from 'path' +import { BaseModel } from '../shared/baseModel' +import { FinishableGrant, isFinishableGrant } from '../grant/model' + +export enum InteractionState { + Pending = 'PENDING', // Awaiting interaction from resource owner (RO) + Approved = 'APPROVED', // RO approved interaction + Denied = 'DENIED' // RO Rejected interaction +} + +export class Interaction extends BaseModel { + public static get tableName(): string { + return 'interactions' + } + + static relationMappings = () => ({ + grant: { + relation: Model.HasOneRelation, + modelClass: join(__dirname, '../grant/model'), + join: { + from: 'interactions.grantId', + to: 'grants.id' + } + } + }) + + public grantId!: string + public ref!: string + public nonce!: string // AS-generated nonce for post-interaction hash + public state!: InteractionState + public expiresIn!: number + + public grant?: FinishableGrant +} + +export interface InteractionWithGrant extends Interaction { + grant: NonNullable +} + +export function isInteractionWithGrant( + interaction: Interaction +): interaction is InteractionWithGrant { + return !!(interaction.grant && isFinishableGrant(interaction.grant)) +} diff --git a/packages/auth/src/interaction/routes.test.ts b/packages/auth/src/interaction/routes.test.ts index 2a34c23730..0fb0e242b7 100644 --- a/packages/auth/src/interaction/routes.test.ts +++ b/packages/auth/src/interaction/routes.test.ts @@ -1,4 +1,3 @@ -import { faker } from '@faker-js/faker' import { v4 } from 'uuid' import * as crypto from 'crypto' import jestOpenAPI from 'jest-openapi' @@ -14,23 +13,18 @@ import { AppServices } from '../app' import { truncateTables } from '../tests/tableManager' import { InteractionRoutes, - GrantChoices, + InteractionChoices, StartContext, FinishContext, GetContext, ChooseContext } from './routes' -import { - Grant, - StartMethod, - FinishMethod, - GrantState, - GrantFinalization -} from '../grant/model' +import { Interaction, InteractionState } from './model' +import { Grant, GrantState, GrantFinalization } from '../grant/model' import { Access } from '../access/model' -import { generateNonce, generateToken } from '../shared/utils' - -const CLIENT = faker.internet.url({ appendSlash: false }) +import { generateNonce } from '../shared/utils' +import { generateBaseGrant } from '../tests/grant' +import { generateBaseInteraction } from '../tests/interaction' const BASE_GRANT_ACCESS = { type: AccessType.IncomingPayment, @@ -45,20 +39,7 @@ describe('Interaction Routes', (): void => { let config: IAppConfig let grant: Grant - - const generateBaseGrant = () => ({ - state: GrantState.Pending, - startMethod: [StartMethod.Redirect], - continueToken: generateToken(), - continueId: v4(), - finishMethod: FinishMethod.Redirect, - finishUri: 'https://example.com', - clientNonce: generateNonce(), - client: CLIENT, - interactId: v4(), - interactRef: v4(), - interactNonce: generateNonce() - }) + let interaction: Interaction beforeEach(async (): Promise => { grant = await Grant.query().insert(generateBaseGrant()) @@ -67,6 +48,10 @@ describe('Interaction Routes', (): void => { ...BASE_GRANT_ACCESS, grantId: grant.id }) + + interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) }) beforeAll(async (): Promise => { @@ -93,7 +78,7 @@ describe('Interaction Routes', (): void => { }) describe('Client - interaction start', (): void => { - test('Interaction start fails if grant is invalid', async (): Promise => { + test('Interaction start fails if interaction is invalid', async (): Promise => { const ctx = createContext( { headers: { @@ -101,7 +86,7 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json' } }, - { id: 'unknown_interaction', nonce: grant.interactNonce } + { id: v4(), nonce: interaction.nonce } ) await expect(interactionRoutes.start(ctx)).rejects.toMatchObject({ @@ -111,11 +96,16 @@ describe('Interaction Routes', (): void => { }) test('Interaction start fails if grant is revoked', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) + const grant = await Grant.query().insert( + generateBaseGrant({ + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Revoked + }) + ) + + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) const ctx = createContext( { @@ -124,7 +114,7 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json' } }, - { id: grant.interactId, nonce: grant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) await expect(interactionRoutes.start(ctx)).rejects.toMatchObject({ @@ -144,31 +134,41 @@ describe('Interaction Routes', (): void => { clientName: 'Test Client', clientUri: 'https://example.com' }, - url: `/interact/${grant.interactId}/${grant.interactNonce}` + url: `/interact/${interaction.id}/${interaction.nonce}` }, - { id: grant.interactId, nonce: grant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) - assert.ok(grant.interactId) + assert.ok(interaction.id) const redirectUrl = new URL(config.identityServerDomain) - redirectUrl.searchParams.set('interactId', grant.interactId) + redirectUrl.searchParams.set('interactId', interaction.id) const redirectSpy = jest.spyOn(ctx, 'redirect') await expect(interactionRoutes.start(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() - redirectUrl.searchParams.set('nonce', grant.interactNonce as string) + redirectUrl.searchParams.set('nonce', interaction.nonce) redirectUrl.searchParams.set('clientName', 'Test Client') redirectUrl.searchParams.set('clientUri', 'https://example.com') expect(ctx.status).toBe(302) expect(redirectSpy).toHaveBeenCalledWith(redirectUrl.toString()) - expect(ctx.session.nonce).toEqual(grant.interactNonce) + expect(ctx.session.nonce).toEqual(interaction.nonce) }) }) describe('Client - interaction complete', (): void => { + let interaction: Interaction + + beforeEach(async (): Promise => { + interaction = await Interaction.query().insert( + generateBaseInteraction(grant, { + state: InteractionState.Approved + }) + ) + }) + test('cannot finish interaction with missing id', async (): Promise => { const ctx = createContext( { @@ -177,10 +177,10 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json' }, session: { - nonce: grant.interactNonce + nonce: interaction.nonce } }, - { id: '', nonce: grant.interactNonce } + { id: null, nonce: interaction.nonce } ) await expect(interactionRoutes.finish(ctx)).rejects.toMatchObject({ @@ -199,7 +199,7 @@ describe('Interaction Routes', (): void => { }, session: { nonce: invalidNonce } }, - { nonce: grant.interactNonce, id: grant.interactId } + { nonce: interaction.nonce, id: interaction.id } ) await expect(interactionRoutes.finish(ctx)).rejects.toMatchObject({ @@ -216,9 +216,9 @@ describe('Interaction Routes', (): void => { Accept: 'application/json', 'Content-Type': 'application/json' }, - session: { nonce: grant.interactNonce } + session: { nonce: interaction.nonce } }, - { id: fakeInteractId, nonce: grant.interactNonce } + { id: fakeInteractId, nonce: interaction.nonce } ) await expect(interactionRoutes.finish(ctx)).rejects.toMatchObject({ @@ -228,11 +228,16 @@ describe('Interaction Routes', (): void => { }) test('Cannot finish interaction with revoked grant', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) + const grant = await Grant.query().insert( + generateBaseGrant({ + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Revoked + }) + ) + + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) const ctx = createContext( { @@ -240,9 +245,9 @@ describe('Interaction Routes', (): void => { Accept: 'application/json', 'Content-Type': 'application/json' }, - session: { nonce: grant.interactNonce } + session: { nonce: interaction.nonce } }, - { id: grant.interactId, nonce: grant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) await expect(interactionRoutes.finish(ctx)).rejects.toMatchObject({ @@ -262,6 +267,12 @@ describe('Interaction Routes', (): void => { grantId: grant.id }) + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant, { + state: InteractionState.Approved + }) + ) + const ctx = createContext( { headers: { @@ -269,19 +280,20 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json' }, session: { - nonce: grant.interactNonce + nonce: interaction.nonce }, - url: `/interact/${grant.interactId}/${grant.interactNonce}/finish` + url: `/interact/${interaction.id}/${interaction.nonce}/finish` }, - { id: grant.interactId, nonce: grant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) assert.ok(grant.finishUri) const clientRedirectUri = new URL(grant.finishUri) - const { clientNonce, interactNonce, interactRef } = grant + const { clientNonce } = grant + const { nonce: interactNonce, ref: interactRef } = interaction const interactUrl = - config.identityServerDomain + `/interact/${grant.interactId}` + config.identityServerDomain + `/interact/${interaction.id}` const data = `${clientNonce}\n${interactNonce}\n${interactRef}\n${interactUrl}` const hash = crypto.createHash('sha3-512').update(data).digest('base64') @@ -295,6 +307,10 @@ describe('Interaction Routes', (): void => { expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(302) expect(redirectSpy).toHaveBeenCalledWith(clientRedirectUri.toString()) + + const issuedGrant = await Grant.query().findById(grant.id) + assert.ok(issuedGrant) + expect(issuedGrant.state).toEqual(GrantState.Approved) }) test('Can finish rejected interaction', async (): Promise => { @@ -309,6 +325,11 @@ describe('Interaction Routes', (): void => { grantId: grant.id }) + const interaction = await Interaction.query().insert({ + ...generateBaseInteraction(grant), + state: InteractionState.Denied + }) + const ctx = createContext( { headers: { @@ -316,11 +337,11 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json' }, session: { - nonce: grant.interactNonce + nonce: interaction.nonce }, - url: `/interact/${grant.interactId}/${grant.interactNonce}/finish` + url: `/interact/${interaction.id}/${interaction.nonce}/finish` }, - { id: grant.interactId, nonce: grant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) assert.ok(grant.finishUri) @@ -333,9 +354,21 @@ describe('Interaction Routes', (): void => { expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(302) expect(redirectSpy).toHaveBeenCalledWith(clientRedirectUri.toString()) + + const rejectedGrant = await Grant.query().findById(grant.id) + assert.ok(rejectedGrant) + expect(rejectedGrant.state).toEqual(GrantState.Finalized) + expect(rejectedGrant.finalizationReason).toEqual( + GrantFinalization.Rejected + ) }) test('Cannot finish invalid interaction', async (): Promise => { + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant, { + state: InteractionState.Pending + }) + ) const ctx = createContext( { headers: { @@ -343,11 +376,11 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json' }, session: { - nonce: grant.interactNonce + nonce: interaction.nonce }, - url: `/interact/${grant.interactId}/${grant.interactNonce}/finish` + url: `/interact/${interaction.id}/${interaction.nonce}/finish` }, - { id: grant.interactId, nonce: grant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) assert.ok(grant.finishUri) @@ -386,10 +419,10 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json', 'x-idp-secret': Config.identityServerSecret }, - url: `/grant/${grant.interactId}/${grant.interactNonce}`, + url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' }, - { id: grant.interactId, nonce: grant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) await expect(interactionRoutes.details(ctx)).resolves.toBeUndefined() @@ -405,7 +438,7 @@ describe('Interaction Routes', (): void => { }) }) - test('Cannot get grant details for nonexistent grant', async (): Promise => { + test('Cannot get grant details for nonexistent interaction', async (): Promise => { const ctx = createContext( { headers: { @@ -413,10 +446,10 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json', 'x-idp-secret': Config.identityServerSecret }, - url: `/grant/${grant.interactId}/${grant.interactNonce}`, + url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' }, - { id: v4(), nonce: grant.interactNonce } + { id: v4(), nonce: interaction.nonce } ) await expect(interactionRoutes.details(ctx)).rejects.toMatchObject({ status: 404 @@ -429,6 +462,10 @@ describe('Interaction Routes', (): void => { state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) + + const interaction = await Interaction.query().insert( + generateBaseInteraction(revokedGrant) + ) const ctx = createContext( { headers: { @@ -436,10 +473,10 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json', 'x-idp-secret': Config.identityServerSecret }, - url: `/grant/${revokedGrant.interactId}/${revokedGrant.interactNonce}`, + url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' }, - { id: revokedGrant.interactId, nonce: revokedGrant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) await expect(interactionRoutes.details(ctx)).rejects.toMatchObject({ status: 404 @@ -453,10 +490,10 @@ describe('Interaction Routes', (): void => { Accept: 'application/json', 'Content-Type': 'application/json' }, - url: `/grant/${grant.interactId}/${grant.interactNonce}`, + url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' }, - { id: grant.interactId, nonce: grant.interactNonce } + { id: interaction.id, nonce: interaction.nonce } ) await expect(interactionRoutes.details(ctx)).rejects.toMatchObject({ @@ -464,7 +501,7 @@ describe('Interaction Routes', (): void => { }) }) - test('Cannot get grant details for nonexistent grant', async (): Promise => { + test('Cannot get grant details for nonexistent interaction', async (): Promise => { const ctx = createContext( { headers: { @@ -472,35 +509,17 @@ describe('Interaction Routes', (): void => { 'Content-Type': 'application/json', 'x-idp-secret': Config.identityServerSecret }, - url: `/grant/${grant.interactId}/${grant.interactNonce}`, + url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' }, - { id: v4(), nonce: grant.interactNonce } + { id: v4(), nonce: interaction.nonce } ) await expect(interactionRoutes.details(ctx)).rejects.toMatchObject({ status: 404 }) }) - - test('Cannot get grant details without secret', async (): Promise => { - const ctx = createContext( - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - url: `/grant/${grant.interactId}/${grant.interactNonce}`, - method: 'GET' - }, - { id: grant.interactId, nonce: grant.interactNonce } - ) - - await expect(interactionRoutes.details(ctx)).rejects.toMatchObject({ - status: 401 - }) - }) }) - describe('IDP - accept/reject grant', (): void => { + describe('IDP - accept/reject interaction', (): void => { let pendingGrant: Grant beforeEach(async (): Promise => { pendingGrant = await Grant.query().insert({ @@ -514,7 +533,7 @@ describe('Interaction Routes', (): void => { }) }) - test('cannot accept/reject grant without secret', async (): Promise => { + test('cannot accept/reject interaction without secret', async (): Promise => { const ctx = createContext( { headers: { @@ -523,9 +542,9 @@ describe('Interaction Routes', (): void => { } }, { - id: pendingGrant.interactId, - nonce: pendingGrant.interactNonce, - choice: GrantChoices.Accept + id: interaction.id, + nonce: interaction.nonce, + choice: InteractionChoices.Accept } ) @@ -537,10 +556,10 @@ describe('Interaction Routes', (): void => { }) }) - test('can accept grant', async (): Promise => { + test('can accept interaction', async (): Promise => { const ctx = createContext( { - url: `/grant/${pendingGrant.id}/${pendingGrant.interactNonce}/accept`, + url: `/grant/${interaction.id}/${interaction.nonce}/accept`, method: 'POST', headers: { Accept: 'application/json', @@ -549,9 +568,9 @@ describe('Interaction Routes', (): void => { } }, { - id: pendingGrant.interactId, - nonce: pendingGrant.interactNonce, - choice: GrantChoices.Accept + id: interaction.id, + nonce: interaction.nonce, + choice: InteractionChoices.Accept } ) @@ -562,8 +581,13 @@ describe('Interaction Routes', (): void => { expect(ctx.status).toBe(202) const issuedGrant = await Grant.query().findById(pendingGrant.id) + const acceptedInteraction = await Interaction.query().findById( + interaction.id + ) assert.ok(issuedGrant) - expect(issuedGrant.state).toEqual(GrantState.Approved) + assert.ok(acceptedInteraction) + expect(issuedGrant.state).toEqual(GrantState.Pending) + expect(acceptedInteraction.state).toEqual(InteractionState.Approved) }) test('Cannot accept or reject grant if grant does not exist', async (): Promise => { @@ -591,7 +615,7 @@ describe('Interaction Routes', (): void => { test('Can reject grant', async (): Promise => { const ctx = createContext( { - url: `/grant/${pendingGrant.id}/${pendingGrant.interactNonce}/reject`, + url: `/grant/${interaction.id}/${interaction.nonce}/reject`, method: 'POST', headers: { Accept: 'application/json', @@ -600,9 +624,9 @@ describe('Interaction Routes', (): void => { } }, { - id: pendingGrant.interactId, - nonce: pendingGrant.interactNonce, - choice: GrantChoices.Reject + id: interaction.id, + nonce: interaction.nonce, + choice: InteractionChoices.Reject } ) @@ -614,10 +638,13 @@ describe('Interaction Routes', (): void => { const issuedGrant = await Grant.query().findById(pendingGrant.id) assert.ok(issuedGrant) - expect(issuedGrant.state).toEqual(GrantState.Finalized) - expect(issuedGrant.finalizationReason).toEqual( - GrantFinalization.Rejected + expect(issuedGrant.state).toEqual(GrantState.Pending) + + const rejectedInteraction = await Interaction.query().findById( + interaction.id ) + assert.ok(rejectedInteraction) + expect(rejectedInteraction.state).toEqual(InteractionState.Denied) }) test('Cannot make invalid grant choice', async (): Promise => { @@ -630,8 +657,8 @@ describe('Interaction Routes', (): void => { } }, { - id: pendingGrant.interactId, - nonce: pendingGrant.interactNonce, + id: interaction.id, + nonce: interaction.nonce, choice: 'invalidChoice' } ) @@ -645,6 +672,12 @@ describe('Interaction Routes', (): void => { const issuedGrant = await Grant.query().findById(pendingGrant.id) assert.ok(issuedGrant) expect(issuedGrant.state).toEqual(GrantState.Pending) + + const stillActiveInteraction = await Interaction.query().findById( + interaction.id + ) + assert.ok(stillActiveInteraction) + expect(stillActiveInteraction.state).toEqual(InteractionState.Pending) }) }) }) diff --git a/packages/auth/src/interaction/routes.ts b/packages/auth/src/interaction/routes.ts index a87affb901..1ba80e3f63 100644 --- a/packages/auth/src/interaction/routes.ts +++ b/packages/auth/src/interaction/routes.ts @@ -5,11 +5,16 @@ import { AppContext } from '../app' import { IAppConfig } from '../config/app' import { BaseService } from '../shared/baseService' import { GrantService } from '../grant/service' -import { GrantState, GrantFinalization, isRejectedGrant } from '../grant/model' +import { AccessService } from '../access/service' +import { InteractionService } from '../interaction/service' +import { Interaction, InteractionState } from '../interaction/model' +import { GrantState, GrantFinalization, isRevokedGrant } from '../grant/model' import { toOpenPaymentsAccess } from '../access/model' interface ServiceDependencies extends BaseService { grantService: GrantService + accessService: AccessService + interactionService: InteractionService config: IAppConfig } @@ -40,7 +45,7 @@ export type StartContext = InteractionContext export type GetContext = InteractionContext -export enum GrantChoices { +export enum InteractionChoices { Accept = 'accept', Reject = 'reject' } @@ -58,8 +63,17 @@ export interface InteractionRoutes { details(ctx: GetContext): Promise } +function isInteractionExpired(interaction: Interaction): boolean { + const now = new Date(Date.now()) + const expiresAt = + interaction.createdAt.getTime() + interaction.expiresIn * 1000 + return expiresAt < now.getTime() +} + export function createInteractionRoutes({ grantService, + accessService, + interactionService, logger, config }: ServiceDependencies): InteractionRoutes { @@ -69,6 +83,8 @@ export function createInteractionRoutes({ const deps = { grantService, + accessService, + interactionService, logger: log, config } @@ -76,7 +92,7 @@ export function createInteractionRoutes({ return { start: (ctx: StartContext) => startInteraction(deps, ctx), finish: (ctx: FinishContext) => finishInteraction(deps, ctx), - acceptOrReject: (ctx: ChooseContext) => handleGrantChoice(deps, ctx), + acceptOrReject: (ctx: ChooseContext) => handleInteractionChoice(deps, ctx), details: (ctx: GetContext) => getGrantDetails(deps, ctx) } } @@ -86,7 +102,7 @@ async function getGrantDetails( ctx: GetContext ): Promise { const secret = ctx.headers?.['x-idp-secret'] - const { config, grantService } = deps + const { config, interactionService, accessService } = deps if ( !ctx.headers['x-idp-secret'] || !crypto.timingSafeEqual( @@ -97,13 +113,15 @@ async function getGrantDetails( ctx.throw(401) } const { id: interactId, nonce } = ctx.params - const grant = await grantService.getByInteractionSession(interactId, nonce) - if (!grant) { + const interaction = await interactionService.getBySession(interactId, nonce) + if (!interaction || isRevokedGrant(interaction.grant)) { ctx.throw(404) } + const access = await accessService.getByGrant(interaction.grantId) + ctx.body = { - access: grant.access.map(toOpenPaymentsAccess) + access: access.map(toOpenPaymentsAccess) } } @@ -120,33 +138,46 @@ async function startInteraction( ) const { id: interactId, nonce } = ctx.params const { clientName, clientUri } = ctx.query - const { config, grantService } = deps - const grant = await grantService.getByInteractionSession(interactId, nonce) + const { config, interactionService, grantService } = deps + const interaction = await interactionService.getBySession(interactId, nonce) - if (!grant || grant.state !== GrantState.Pending) { + if ( + !interaction || + interaction.state !== InteractionState.Pending || + isRevokedGrant(interaction.grant) + ) { ctx.throw(401, { error: 'unknown_request' }) - } else { + } + + const trx = await Interaction.startTransaction() + try { // TODO: also establish session in redis with short expiry - ctx.session.nonce = grant.interactNonce + await grantService.markPending(interaction.id, trx) + await trx.commit() + + ctx.session.nonce = interaction.nonce const interactionUrl = new URL(config.identityServerDomain) - interactionUrl.searchParams.set('interactId', grant.interactId) - interactionUrl.searchParams.set('nonce', grant.interactNonce) + interactionUrl.searchParams.set('interactId', interaction.id) + interactionUrl.searchParams.set('nonce', interaction.nonce) interactionUrl.searchParams.set('clientName', clientName as string) interactionUrl.searchParams.set('clientUri', clientUri as string) ctx.redirect(interactionUrl.toString()) + } catch (err) { + await trx.rollback() + ctx.throw(500) } } // TODO: allow idp to specify the reason for rejection // https://github.com/interledger/rafiki/issues/886 -async function handleGrantChoice( +async function handleInteractionChoice( deps: ServiceDependencies, ctx: ChooseContext ): Promise { const { id: interactId, nonce, choice } = ctx.params - const { config, grantService } = deps + const { config, interactionService } = deps if ( !ctx.headers['x-idp-secret'] || @@ -158,13 +189,11 @@ async function handleGrantChoice( ctx.throw(401, { error: 'invalid_interaction' }) } - const grant = await grantService.getByInteractionSession(interactId, nonce, { - includeRevoked: true - }) - - if (!grant) { + const interaction = await interactionService.getBySession(interactId, nonce) + if (!interaction) { ctx.throw(404, { error: 'unknown_request' }) } else { + const { grant } = interaction // If grant was already rejected or revoked if ( grant.state === GrantState.Finalized && @@ -174,14 +203,17 @@ async function handleGrantChoice( } // If grant is otherwise not pending interaction - if (grant.state !== GrantState.Pending) { + if ( + interaction.state !== InteractionState.Pending || + isInteractionExpired(interaction) + ) { ctx.throw(400, { error: 'request_denied' }) } - if (choice === GrantChoices.Accept) { - await grantService.approve(grant.id) - } else if (choice === GrantChoices.Reject) { - await grantService.finalize(grant.id, GrantFinalization.Rejected) + if (choice === InteractionChoices.Accept) { + await interactionService.approve(interactId) + } else if (choice === InteractionChoices.Reject) { + await interactionService.deny(interactId) } else { ctx.throw(404) } @@ -202,16 +234,23 @@ async function finishInteraction( ctx.throw(401, { error: 'invalid_request' }) } - const { grantService, config } = deps - const grant = await grantService.getByInteractionSession(interactId, nonce) + const { grantService, interactionService, config } = deps + const interaction = await interactionService.getBySession(interactId, nonce) // TODO: redirect with this error in query string - if (!grant) { + if (!interaction || isRevokedGrant(interaction.grant)) { ctx.throw(404, { error: 'unknown_request' }) } else { - const clientRedirectUri = new URL(grant.finishUri) - if (grant.state === GrantState.Approved) { - const { clientNonce, interactNonce, interactRef } = grant + const { grant } = interaction + const clientRedirectUri = new URL(grant.finishUri as string) + if (interaction.state === InteractionState.Approved) { + await grantService.approve(interaction.grantId) + + const { + grant: { clientNonce }, + nonce: interactNonce, + ref: interactRef + } = interaction const interactUrl = config.identityServerDomain + `/interact/${interactId}` @@ -222,11 +261,12 @@ async function finishInteraction( clientRedirectUri.searchParams.set('hash', hash) clientRedirectUri.searchParams.set('interact_ref', interactRef) ctx.redirect(clientRedirectUri.toString()) - } else if (isRejectedGrant(grant)) { + } else if (interaction.state === InteractionState.Denied) { + await grantService.finalize(grant.id, GrantFinalization.Rejected) clientRedirectUri.searchParams.set('result', 'grant_rejected') ctx.redirect(clientRedirectUri.toString()) } else { - // Grant is not in either an accepted or rejected state + // Interaction is not in an accepted or rejected state clientRedirectUri.searchParams.set('result', 'grant_invalid') ctx.redirect(clientRedirectUri.toString()) } diff --git a/packages/auth/src/interaction/service.test.ts b/packages/auth/src/interaction/service.test.ts new file mode 100644 index 0000000000..8e09a567d4 --- /dev/null +++ b/packages/auth/src/interaction/service.test.ts @@ -0,0 +1,155 @@ +import { faker } from '@faker-js/faker' +import { v4 } from 'uuid' +import { IocContract } from '@adonisjs/fold' +import assert from 'assert' + +import { AccessType, AccessAction } from '@interledger/open-payments' + +import { truncateTables } from '../tests/tableManager' +import { generateBaseGrant } from '../tests/grant' +import { createTestApp, TestContainer } from '../tests/app' +import { AppServices } from '../app' +import { Config } from '../config/app' +import { initIocContainer } from '../' +import { Grant, StartMethod, FinishMethod, GrantState } from '../grant/model' +import { Access } from '../access/model' + +import { Interaction, InteractionState } from './model' +import { InteractionService } from './service' +import { generateNonce, generateToken } from '../shared/utils' + +const CLIENT = faker.internet.url({ appendSlash: false }) +const BASE_GRANT_ACCESS = { + actions: [AccessAction.Create, AccessAction.Read, AccessAction.List], + identifier: `https://example.com/${v4()}` +} + +describe('Interaction Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let interactionService: InteractionService + let interaction: Interaction + let grant: Grant + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + + interactionService = await deps.use('interactionService') + }) + + beforeEach(async (): Promise => { + grant = await Grant.query().insert({ + state: GrantState.Processing, + startMethod: [StartMethod.Redirect], + continueToken: generateToken(), + continueId: v4(), + finishMethod: FinishMethod.Redirect, + finishUri: 'https://example.com', + clientNonce: generateNonce(), + client: CLIENT + }) + + interaction = await Interaction.query().insert({ + ref: v4(), + nonce: generateNonce(), + state: InteractionState.Pending, + expiresIn: Config.interactionExpirySeconds, + grantId: grant.id + }) + + await Access.query().insert({ + ...BASE_GRANT_ACCESS, + type: AccessType.IncomingPayment, + grantId: grant.id + }) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create', (): void => { + test('can create an interaction', async (): Promise => { + const grant = await Grant.query().insert(generateBaseGrant()) + + const interaction = await interactionService.create(grant.id) + + expect(interaction).toMatchObject({ + id: expect.any(String), + ref: expect.any(String), + nonce: expect.any(String), + state: InteractionState.Pending, + grantId: grant.id, + expiresIn: Config.interactionExpirySeconds + }) + }) + }) + + describe('getBySession', (): void => { + test('can get an interaction by session info', async (): Promise => { + const retrievedInteraction = await interactionService.getBySession( + interaction.id, + interaction.nonce + ) + + assert.ok(retrievedInteraction) + expect(retrievedInteraction.id).toEqual(interaction.id) + expect(retrievedInteraction.nonce).toEqual(interaction.nonce) + }) + + test('Cannot retrieve an interaction when the id does not match', async (): Promise => { + const retrievedInteraction = await interactionService.getBySession( + v4(), + interaction.nonce + ) + expect(retrievedInteraction).toBeUndefined() + }) + + test('Cannot retrieve an interaction when the nonce does not match', async (): Promise => { + const retrievedInteraction = await interactionService.getBySession( + interaction.id, + generateNonce() + ) + expect(retrievedInteraction).toBeUndefined() + }) + }) + + describe('getByRef', (): void => { + test('can get an interaction by its reference', async (): Promise => { + const retrievedInteraction = await interactionService.getByRef( + interaction.ref + ) + + assert.ok(retrievedInteraction) + expect(retrievedInteraction.id).toEqual(interaction.id) + expect(retrievedInteraction.ref).toEqual(interaction.ref) + }) + }) + + describe('approve', (): void => { + test('can approve an interaction', async (): Promise => { + const approvedInteraction = await interactionService.approve( + interaction.id + ) + + assert.ok(approvedInteraction) + expect(approvedInteraction.id).toEqual(interaction.id) + expect(approvedInteraction.state).toEqual(InteractionState.Approved) + }) + }) + + describe('deny', (): void => { + test('can deny an interaction', async (): Promise => { + const deniedInteraction = await interactionService.deny(interaction.id) + + assert.ok(deniedInteraction) + expect(deniedInteraction.id).toEqual(interaction.id) + expect(deniedInteraction.state).toEqual(InteractionState.Denied) + }) + }) +}) diff --git a/packages/auth/src/interaction/service.ts b/packages/auth/src/interaction/service.ts new file mode 100644 index 0000000000..61f9606891 --- /dev/null +++ b/packages/auth/src/interaction/service.ts @@ -0,0 +1,142 @@ +import { v4 } from 'uuid' +import { Transaction, TransactionOrKnex } from 'objection' + +import { IAppConfig } from '../config/app' +import { BaseService } from '../shared/baseService' +import { generateNonce } from '../shared/utils' +import { + Interaction, + InteractionState, + InteractionWithGrant, + isInteractionWithGrant +} from './model' +import { GrantService } from '../grant/service' + +export interface InteractionService { + getInteractionByGrant(grantId: string): Promise + getBySession(id: string, nonce: string): Promise + getByRef(ref: string): Promise + create(grantId: string, trx?: Transaction): Promise + approve(id: string): Promise + deny(id: string): Promise +} + +interface ServiceDependencies extends BaseService { + grantService: GrantService + knex: TransactionOrKnex + config: IAppConfig +} + +export async function createInteractionService({ + grantService, + logger, + knex, + config +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'InteractionService' + }) + const deps: ServiceDependencies = { + grantService, + logger: log, + knex, + config + } + return { + getInteractionByGrant: (grantId: string) => getInteractionByGrant(grantId), + getBySession: (id: string, nonce: string) => getBySession(id, nonce), + getByRef: (ref: string) => getByRef(ref), + create: (grantId: string, trx?: Transaction) => create(deps, grantId, trx), + approve: (id: string) => approve(id), + deny: (id: string) => deny(id) + } +} + +async function getInteractionByGrant( + grantId: string +): Promise { + const interaction = await Interaction.query() + .findOne({ + grantId + }) + .withGraphFetched('grant') + + if (!interaction || !isInteractionWithGrant(interaction)) { + return undefined + } + + return interaction +} + +async function create( + deps: ServiceDependencies, + grantId: string, + trx?: Transaction +): Promise { + const { knex, config } = deps + const interactionTrx = trx || (await Interaction.startTransaction(knex)) + + try { + const interaction = await Interaction.query(interactionTrx).insert({ + grantId, + ref: v4(), + nonce: generateNonce(), + state: InteractionState.Pending, + expiresIn: config.interactionExpirySeconds + }) + + if (!trx) { + await interactionTrx.commit() + } + + return interaction + } catch (err) { + if (!trx) { + await interactionTrx.rollback() + } + + throw err + } +} + +async function getBySession( + id: string, + nonce: string +): Promise { + const interaction = await Interaction.query() + .findById(id) + .where('nonce', nonce) + .withGraphFetched('grant') + + if (!interaction || !isInteractionWithGrant(interaction)) { + return undefined + } + + return interaction +} + +async function getByRef(ref: string): Promise { + const interaction = await Interaction.query() + .findOne({ + ref + }) + .withGraphFetched('grant') + + if (!interaction || !isInteractionWithGrant(interaction)) { + return undefined + } + + return interaction +} + +async function approve(id: string): Promise { + return await Interaction.query().patchAndFetchById(id, { + state: InteractionState.Approved + }) +} + +async function deny(id: string): Promise { + return Interaction.query().patchAndFetchById(id, { + state: InteractionState.Denied + }) +} diff --git a/packages/auth/src/signature/middleware.test.ts b/packages/auth/src/signature/middleware.test.ts index f2d721db99..8d736a3785 100644 --- a/packages/auth/src/signature/middleware.test.ts +++ b/packages/auth/src/signature/middleware.test.ts @@ -33,6 +33,8 @@ import { import { AccessTokenService } from '../accessToken/service' import { AccessType, AccessAction } from '@interledger/open-payments' import { ContinueContext, CreateContext } from '../grant/routes' +import { Interaction, InteractionState } from '../interaction/model' +import { generateNonce } from '../shared/utils' describe('Signature Service', (): void => { let deps: IocContract @@ -55,6 +57,7 @@ describe('Signature Service', (): void => { describe('Signature middleware', (): void => { let grant: Grant + let interaction: Interaction let token: AccessToken let trx: Knex.Transaction // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -72,12 +75,17 @@ describe('Signature Service', (): void => { finishUri: 'https://example.com/finish', clientNonce: crypto.randomBytes(8).toString('hex').toUpperCase(), client: CLIENT, - interactId: v4(), - interactRef: crypto.randomBytes(8).toString('hex').toUpperCase(), - interactNonce: crypto.randomBytes(8).toString('hex').toUpperCase(), ...overrides }) + const generateBaseInteraction = (grant: Grant) => ({ + ref: v4(), + nonce: generateNonce(), + state: InteractionState.Pending, + grantId: grant.id, + expiresIn: Config.interactionExpirySeconds + }) + const BASE_ACCESS = { type: AccessType.OutgoingPayment, actions: [AccessAction.Read, AccessAction.Create], @@ -108,6 +116,9 @@ describe('Signature Service', (): void => { grantId: grant.id, ...BASE_ACCESS }) + interaction = await Interaction.query(trx).insertAndFetch( + generateBaseInteraction(grant) + ) token = await AccessToken.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_TOKEN @@ -175,7 +186,7 @@ describe('Signature Service', (): void => { method: 'POST' }, { id: grant.continueId }, - { interact_ref: grant.interactRef }, + { interact_ref: interaction.ref }, testKeys.privateKey, testKeys.publicKey.kid, deps @@ -304,7 +315,7 @@ describe('Signature Service', (): void => { method: 'POST' }, { id: grant.continueId }, - { interact_ref: grant.interactRef }, + { interact_ref: interaction.ref }, testKeys.privateKey, testKeys.publicKey.kid, deps @@ -337,7 +348,7 @@ describe('Signature Service', (): void => { method: 'POST' }, { id: grant.continueId }, - { interact_ref: grant.interactRef }, + { interact_ref: interaction.ref }, testKeys.privateKey, testKeys.publicKey.kid, deps diff --git a/packages/auth/src/signature/middleware.ts b/packages/auth/src/signature/middleware.ts index daabe65326..f50e18540c 100644 --- a/packages/auth/src/signature/middleware.ts +++ b/packages/auth/src/signature/middleware.ts @@ -71,9 +71,9 @@ export async function grantContinueHttpsigMiddleware( const grantService = await ctx.container.use('grantService') const grant = await grantService.getByContinue( ctx.params['id'], - continueToken, - { interactRef } + continueToken ) + if (!grant) { ctx.status = 401 ctx.body = { @@ -83,6 +83,18 @@ export async function grantContinueHttpsigMiddleware( return } + const interactionService = await ctx.container.use('interactionService') + const interaction = + interactRef && (await interactionService.getByRef(interactRef)) + + if (!interaction) { + ctx.status = 401 + ctx.body = { + error: 'invalid_continuation', + message: 'invalid interaction' + } + } + const sigVerified = await verifySigFromClient(grant.client, ctx) if (!sigVerified) { ctx.throw(401, 'invalid signature') diff --git a/packages/auth/src/tests/grant.ts b/packages/auth/src/tests/grant.ts index 4cd8a27027..7ef9a69cda 100644 --- a/packages/auth/src/tests/grant.ts +++ b/packages/auth/src/tests/grant.ts @@ -1,16 +1,24 @@ +import { v4 } from 'uuid' import { faker } from '@faker-js/faker' -import { FinishMethod, Grant, StartMethod } from '../grant/model' -import { generateNonce } from '../shared/utils' import { AccessAction, AccessType } from '@interledger/open-payments' import { IocContract } from '@adonisjs/fold' import { AppServices } from '../app' +import { + Grant, + GrantState, + GrantFinalization, + StartMethod, + FinishMethod +} from '../grant/model' +import { generateNonce, generateToken } from '../shared/utils' + +const CLIENT = faker.internet.url({ appendSlash: false }) export async function createGrant( deps: IocContract, options?: { identifier?: string } ): Promise { const grantService = await deps.use('grantService') - const CLIENT = faker.internet.url({ appendSlash: false }) const BASE_GRANT_ACCESS = { actions: [AccessAction.Create, AccessAction.Read, AccessAction.List], identifier: options?.identifier @@ -40,3 +48,24 @@ export async function createGrant( } }) } + +export interface GenerateBaseGrantOptions { + state?: GrantState + finalizationReason?: GrantFinalization +} + +export const generateBaseGrant = (options: GenerateBaseGrantOptions = {}) => { + const { state = GrantState.Processing, finalizationReason = undefined } = + options + return { + state, + finalizationReason, + startMethod: [StartMethod.Redirect], + continueToken: generateToken(), + continueId: v4(), + finishMethod: FinishMethod.Redirect, + finishUri: 'https://example.com', + clientNonce: generateNonce(), + client: CLIENT + } +} diff --git a/packages/auth/src/tests/interaction.ts b/packages/auth/src/tests/interaction.ts new file mode 100644 index 0000000000..8ad183a1eb --- /dev/null +++ b/packages/auth/src/tests/interaction.ts @@ -0,0 +1,24 @@ +import { v4 } from 'uuid' + +import { Config } from '../config/app' +import { Grant } from '../grant/model' +import { InteractionState } from '../interaction/model' +import { generateNonce } from '../shared/utils' + +export interface GenerateBaseInteractionOptions { + state?: InteractionState +} + +export const generateBaseInteraction = ( + grant: Grant, + options: GenerateBaseInteractionOptions = {} +) => { + const { state = InteractionState.Pending } = options + return { + ref: v4(), + nonce: generateNonce(), + state, + expiresIn: Config.interactionExpirySeconds, + grantId: grant.id + } +} diff --git a/packages/documentation/docs/apis/auth/enums.md b/packages/documentation/docs/apis/auth/enums.md new file mode 100644 index 0000000000..db2ed9ffb0 --- /dev/null +++ b/packages/documentation/docs/apis/auth/enums.md @@ -0,0 +1,70 @@ +--- +id: enums +title: Enums +slug: enums +sidebar_position: 6 +--- + + + +## GrantFinalization + +

Values

+ + + + + + + + + + + + + + + + + +
ValueDescription
ISSUED +

grant was issued

+
REVOKED +

grant was revoked

+
REJECTED +

grant was rejected

+
+ +## GrantState + +

Values

+ + + + + + + + + + + + + + + + + + + + + +
ValueDescription
PROCESSING +

grant request is determining what state to enter next

+
PENDING +

grant request is awaiting interaction

+
APPROVED +

grant was approved

+
FINALIZED +

grant was finalized and no more access tokens or interactions can be made on it

+
diff --git a/packages/documentation/src/content/docs/integration/deployment.md b/packages/documentation/src/content/docs/integration/deployment.md index 69e27189da..f97c9c001f 100644 --- a/packages/documentation/src/content/docs/integration/deployment.md +++ b/packages/documentation/src/content/docs/integration/deployment.md @@ -68,6 +68,7 @@ Now, the Admin UI can be found on localhost:3010. | auth.accessToken.deletionDays | `ACCESS_TOKEN_DELETION_DAYS` | | auth.accessToken.expirySeconds | `ACCESS_TOKEN_EXPIRY_SECONDS` | | auth.cookieKey | `COOKIE_KEY` | +| auth.interactionExpirySeconds | `INTERACTION_EXPIRY_SECONDS` | | auth.workers.cleanup | `DATABASE_CLEANUP_WORKERS` | | backend.nodeEnv | `NODE_ENV` | | backend.logLevel | `LOG_LEVEL` | @@ -182,6 +183,7 @@ Now, the Admin UI can be found on localhost:3010. | `NODE_ENV` | `development` | node environment, `development`, `test`, or `production` | | `PORT` | `3006` | port of this Open Payments Auth Server, same as in `AUTH_SERVER_DOMAIN` | | `WAIT_SECONDS` | `5` | wait time included in `grant.continue` | +| `INTERACTION_EXPIRY_SECONDS` | `600` | amount of time an interaction is active | #### Frontend