From 8687571fbf7a5f421a98a9d5567b992462ad96bd Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Tue, 7 Feb 2023 00:11:04 +0100 Subject: [PATCH] 662/mk/use open payments types auth (#1061) * feat(open-payments): export the different IncomingPayment types * feat(backend): use open-payment types for IncomingPayment * feat(backend): use open-payment types for Connection * feat(backend): use open-payment types for OutgoingPayment * feat(backend): use open-payment types for Quote * chore(backend): fix IncomingPayments.toOpenPaymentsType * chore(open-payments): update mocks * chore(backend): formatting * chore(backend): add better typing for incomingPayment.toOpenPaymentsType * chore(backend): remove QuoteJSON * chore(open-payments): fix mocks * chore(backend): fix IncomingPayment types * chore(open-payments): fix incoming payment types * chore(backend): fix incoming payment method * chore(backend): make toOpenPaymentsType async * chore(backend): getUrl for OutgoingPayment * chore(backend): fix tests with async toOpenPaymentsType * chore(backend): return undefined instead of null * chore(backend): add awaits where necessary * chore(open-payments): make getBody async * chore(backend): fix outgoingPayment model method * chore(backend): fix list tests * chore(backend): update receiver tests * chore(backend): make toOpenPaymentType sync (#1056) * chore(backend): make toOpenPaymentType sync * chore(backend): make quote.toOpenPaymentsType sync * chore(backend): convert tests to sync * chore(backend): update tests * chore(open-payments): updating AccessType type & exporting AccessToken * chore(auth): adding toOpenPayments type methods for grants & access & accessTokens * chore(auth): remove unused type * chore(backend): remove $formatJson where applicable * chore(backend): remove $formatJson where applicable * chore(backend): add test for incomingPayment model * chore(auth): await createGrantInitiation functions * chore(open-payments): export AccessItem * chore(auth): update toOpenPaymentsAccess * chore(open-payments): update AccessAction type * chore(backend): fix incomingPayment model test * chore(auth): exclude instead of Omit to give us ctx types * chore(open-payments): use suggestion * chore(backend): fix test * chore(backend): use suggestion * chore(auth): updates types for routes tests * chore(auth): update toOpenPaymentsGrant name * chore(auth): update accessToken tests * chore(auth): address feedback * feat(auth): lock grant when rotating accessToken * chore(auth): add tests for revoked accessToken * chore(auth): remove locking for now --- packages/auth/src/access/model.ts | 18 +- packages/auth/src/accessToken/model.ts | 19 +++ packages/auth/src/accessToken/routes.test.ts | 17 +- packages/auth/src/accessToken/routes.ts | 54 +++--- packages/auth/src/accessToken/service.test.ts | 123 ++++++++++---- packages/auth/src/accessToken/service.ts | 83 ++++----- packages/auth/src/app.ts | 2 + packages/auth/src/grant/model.ts | 69 ++++++++ packages/auth/src/grant/routes.test.ts | 134 +++++++++------ packages/auth/src/grant/routes.ts | 157 ++++++++---------- packages/auth/src/index.ts | 3 +- packages/auth/src/shared/utils.ts | 15 -- packages/auth/src/tests/context.ts | 6 +- packages/open-payments/src/index.ts | 4 +- packages/open-payments/src/types.ts | 16 +- 15 files changed, 435 insertions(+), 285 deletions(-) diff --git a/packages/auth/src/access/model.ts b/packages/auth/src/access/model.ts index 6c301e3bb7..242f3b23c2 100644 --- a/packages/auth/src/access/model.ts +++ b/packages/auth/src/access/model.ts @@ -2,7 +2,11 @@ import { Model } from 'objection' import { BaseModel } from '../shared/baseModel' import { LimitData } from './types' import { join } from 'path' -import { AccessType, AccessAction } from 'open-payments' +import { + AccessType, + AccessAction, + AccessItem as OpenPaymentsAccessItem +} from 'open-payments' export class Access extends BaseModel { public static get tableName(): string { @@ -28,3 +32,15 @@ export class Access extends BaseModel { public identifier?: string public limits?: LimitData } + +export function toOpenPaymentsAccess( + accessItem: Access +): OpenPaymentsAccessItem { + return { + actions: accessItem.actions, + identifier: accessItem.identifier ?? undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: accessItem.type as any, + limits: accessItem.limits ?? undefined + } +} diff --git a/packages/auth/src/accessToken/model.ts b/packages/auth/src/accessToken/model.ts index b6619960e2..fccb9c2745 100644 --- a/packages/auth/src/accessToken/model.ts +++ b/packages/auth/src/accessToken/model.ts @@ -2,6 +2,8 @@ import { Model } from 'objection' import { BaseModel } from '../shared/baseModel' import { join } from 'path' import { Grant } from '../grant/model' +import { AccessToken as OpenPaymentsAccessToken } from 'open-payments' +import { Access, toOpenPaymentsAccess } from '../access/model' // https://datatracker.ietf.org/doc/html/draft-ietf-gnap-core-protocol#section-3.2.1 export class AccessToken extends BaseModel { @@ -26,3 +28,20 @@ export class AccessToken extends BaseModel { public grantId!: string public expiresIn!: number } + +interface ToOpenPaymentsAccessTokenArgs { + authServerUrl: string +} + +export function toOpenPaymentsAccessToken( + accessToken: AccessToken, + accessItems: Access[], + args: ToOpenPaymentsAccessTokenArgs +): OpenPaymentsAccessToken['access_token'] { + return { + access: accessItems.map(toOpenPaymentsAccess), + value: accessToken.value, + manage: `${args.authServerUrl}/token/${accessToken.managementId}`, + expires_in: accessToken.expiresIn + } +} diff --git a/packages/auth/src/accessToken/routes.test.ts b/packages/auth/src/accessToken/routes.test.ts index de4da80680..f66453bf8c 100644 --- a/packages/auth/src/accessToken/routes.test.ts +++ b/packages/auth/src/accessToken/routes.test.ts @@ -4,6 +4,7 @@ import { Knex } from 'knex' import { v4 } from 'uuid' import jestOpenAPI from 'jest-openapi' +import { createContext } from '../tests/context' import { createTestApp, TestContainer } from '../tests/app' import { Config } from '../config/app' import { IocContract } from '@adonisjs/fold' @@ -13,8 +14,7 @@ import { truncateTables } from '../tests/tableManager' import { FinishMethod, Grant, GrantState, StartMethod } from '../grant/model' import { AccessToken } from './model' import { Access } from '../access/model' -import { AccessTokenRoutes } from './routes' -import { createContext } from '../tests/context' +import { AccessTokenRoutes, IntrospectContext } from './routes' import { generateNonce, generateToken } from '../shared/utils' import { AccessType, AccessAction } from 'open-payments' @@ -25,7 +25,7 @@ describe('Access Token Routes', (): void => { let accessTokenRoutes: AccessTokenRoutes beforeAll(async (): Promise => { - deps = await initIocContainer(Config) + deps = initIocContainer(Config) appContainer = await createTestApp(deps) accessTokenRoutes = await deps.use('accessTokenRoutes') const openApi = await deps.use('openApi') @@ -102,7 +102,7 @@ describe('Access Token Routes', (): void => { jestOpenAPI(openApi.tokenIntrospectionSpec) }) test('Cannot introspect fake token', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json' @@ -127,7 +127,7 @@ describe('Access Token Routes', (): void => { }) test('Successfully introspects valid token', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json' @@ -141,6 +141,7 @@ describe('Access Token Routes', (): void => { ctx.request.body = { access_token: token.value } + await expect(accessTokenRoutes.introspect(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(200) @@ -170,7 +171,7 @@ describe('Access Token Routes', (): void => { ) jest.useFakeTimers({ now }) - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json' @@ -324,7 +325,7 @@ describe('Access Token Routes', (): void => { await expect(accessTokenRoutes.rotate(ctx)).rejects.toMatchObject({ status: 404, - message: 'token not found' + message: 'Token not found' }) }) @@ -343,7 +344,7 @@ describe('Access Token Routes', (): void => { await expect(accessTokenRoutes.rotate(ctx)).rejects.toMatchObject({ status: 404, - message: 'token not found' + message: 'Token not found' }) }) diff --git a/packages/auth/src/accessToken/routes.ts b/packages/auth/src/accessToken/routes.ts index 3a8d34fec9..efe34eedcb 100644 --- a/packages/auth/src/accessToken/routes.ts +++ b/packages/auth/src/accessToken/routes.ts @@ -1,26 +1,27 @@ import { Logger } from 'pino' -import { ActiveTokenInfo, TokenInfo } from 'token-introspection' -import { Access } from '../access/model' +import { TokenInfo } from 'token-introspection' +import { toOpenPaymentsAccess } from '../access/model' import { AppContext } from '../app' import { IAppConfig } from '../config/app' import { AccessTokenService } from './service' -import { accessToBody } from '../shared/utils' import { ClientService } from '../client/service' import { Grant } from '../grant/model' +import { toOpenPaymentsAccessToken } from './model' +import { AccessService } from '../access/service' -type TokenRequest = Omit & { +type TokenRequest = Exclude & { body: BodyT } -type TokenContext = Omit & { +type TokenContext = Exclude & { request: TokenRequest } -type ManagementRequest = Omit & { +type ManagementRequest = Exclude & { params?: Record<'id', string> } -type ManagementContext = Omit & { +type ManagementContext = Exclude & { request: ManagementRequest } @@ -35,6 +36,7 @@ interface ServiceDependencies { config: IAppConfig logger: Logger accessTokenService: AccessTokenService + accessService: AccessService clientService: ClientService } @@ -79,9 +81,7 @@ function grantToTokenInfo(grant?: Grant): TokenInfo { return { active: true, grant: grant.id, - access: grant.access.map((a: Access) => - accessToBody(a) - ) as ActiveTokenInfo['access'], + access: grant.access.map(toOpenPaymentsAccess), client: grant.client } } @@ -102,18 +102,26 @@ async function rotateToken( ): Promise { const { id: managementId } = ctx.params const token = (ctx.headers['authorization'] ?? '').replace('GNAP ', '') - const result = await deps.accessTokenService.rotate(managementId, token) - if (result.success == true) { - ctx.status = 200 - ctx.body = { - access_token: { - access: result.access.map((a) => accessToBody(a)), - value: result.value, - manage: deps.config.authServerDomain + `/token/${result.managementId}`, - expires_in: result.expiresIn - } - } - } else { - ctx.throw(404, { message: result.error.message }) + + let newToken + try { + newToken = await deps.accessTokenService.rotate(managementId, token) + } catch (error) { + const errorMessage = 'Could not rotate token' + deps.logger.error({ error: error && error['message'] }, errorMessage) + ctx.throw(400, { message: errorMessage }) + } + + if (!newToken) { + ctx.throw(404, { message: 'Token not found' }) + } + + const accessItems = await deps.accessService.getByGrant(newToken.grantId) + + ctx.status = 200 + ctx.body = { + access_token: toOpenPaymentsAccessToken(newToken, accessItems, { + authServerUrl: deps.config.authServerDomain + }) } } diff --git a/packages/auth/src/accessToken/service.test.ts b/packages/auth/src/accessToken/service.test.ts index 777f58e020..f0b5a92aa1 100644 --- a/packages/auth/src/accessToken/service.test.ts +++ b/packages/auth/src/accessToken/service.test.ts @@ -2,6 +2,7 @@ import { faker } from '@faker-js/faker' import nock from 'nock' import { Knex } from 'knex' import { v4 } from 'uuid' +import assert from 'assert' import { createTestApp, TestContainer } from '../tests/app' import { Config } from '../config/app' @@ -23,7 +24,7 @@ describe('Access Token Service', (): void => { let accessTokenService: AccessTokenService beforeAll(async (): Promise => { - deps = await initIocContainer(Config) + deps = initIocContainer(Config) appContainer = await createTestApp(deps) accessTokenService = await deps.use('accessTokenService') }) @@ -68,7 +69,6 @@ describe('Access Token Service', (): void => { } let grant: Grant - let token: AccessToken beforeEach(async (): Promise => { grant = await Grant.query(trx).insertAndFetch({ ...BASE_GRANT, @@ -84,12 +84,6 @@ describe('Access Token Service', (): void => { ...BASE_ACCESS }) ] - token = await AccessToken.query(trx).insertAndFetch({ - grantId: grant.id, - ...BASE_TOKEN, - value: generateToken(), - managementId: v4() - }) }) describe('Create', (): void => { @@ -115,19 +109,38 @@ describe('Access Token Service', (): void => { }) test('Can get an access token by its value', async (): Promise => { - const fetchedToken = await accessTokenService.get(accessToken.value) - expect(fetchedToken.value).toEqual(accessToken.value) - expect(fetchedToken.managementId).toEqual(accessToken.managementId) - expect(fetchedToken.grantId).toEqual(accessToken.grantId) + await expect(accessTokenService.get(accessToken.value)).resolves.toEqual( + accessToken + ) }) - test('Can get an access token by its managementId', async (): Promise => { - const fetchedToken = await accessTokenService.getByManagementId( - accessToken.managementId + test('Cannot get rotated access token', async (): Promise => { + await accessTokenService.rotate( + accessToken.managementId, + accessToken.value ) - expect(fetchedToken.value).toEqual(accessToken.value) - expect(fetchedToken.managementId).toEqual(accessToken.managementId) - expect(fetchedToken.grantId).toEqual(accessToken.grantId) + + await expect( + accessTokenService.get(accessToken.value) + ).resolves.toBeUndefined() + }) + }) + + describe('getByManagementId', (): void => { + let accessToken: AccessToken + beforeEach(async (): Promise => { + accessToken = await AccessToken.query(trx).insert({ + value: 'test-access-token', + managementId: v4(), + grantId: grant.id, + expiresIn: 1234 + }) + }) + + test('Can get an access token by its managementId', async (): Promise => { + await expect( + accessTokenService.getByManagementId(accessToken.managementId) + ).resolves.toMatchObject(accessToken) }) test('Cannot get an access token that does not exist', async (): Promise => { @@ -136,36 +149,68 @@ describe('Access Token Service', (): void => { accessTokenService.getByManagementId(v4()) ).resolves.toBeUndefined() }) + + test('Cannot get rotated access token by managementId', async (): Promise => { + await accessTokenService.rotate( + accessToken.managementId, + accessToken.value + ) + + await expect( + accessTokenService.getByManagementId(accessToken.managementId) + ).resolves.toBeUndefined() + }) }) describe('Introspect', (): void => { + let accessToken: AccessToken + beforeEach(async (): Promise => { + accessToken = await AccessToken.query(trx).insert({ + value: 'test-access-token', + managementId: v4(), + grantId: grant.id, + expiresIn: 1234 + }) + }) + test('Can introspect active token', async (): Promise => { - await expect(accessTokenService.introspect(token.value)).resolves.toEqual( - grant - ) + await expect( + accessTokenService.introspect(accessToken.value) + ).resolves.toEqual(grant) }) test('Can introspect expired token', async (): Promise => { - const tokenCreatedDate = new Date(token.createdAt) + const tokenCreatedDate = new Date(accessToken.createdAt) const now = new Date( - tokenCreatedDate.getTime() + (token.expiresIn + 1) * 1000 + tokenCreatedDate.getTime() + (accessToken.expiresIn + 1) * 1000 ) jest.useFakeTimers({ now }) await expect( - accessTokenService.introspect(token.value) + accessTokenService.introspect(accessToken.value) ).resolves.toBeUndefined() }) test('Can introspect active token for revoked grant', async (): Promise => { await grant.$query(trx).patch({ state: GrantState.Revoked }) await expect( - accessTokenService.introspect(token.value) + accessTokenService.introspect(accessToken.value) ).resolves.toBeUndefined() }) test('Cannot introspect non-existing token', async (): Promise => { expect(accessTokenService.introspect('uuid')).resolves.toBeUndefined() }) + + test('Cannot introspect rotated access token', async (): Promise => { + await accessTokenService.rotate( + accessToken.managementId, + accessToken.value + ) + + await expect( + accessTokenService.introspect(accessToken.value) + ).resolves.toBeUndefined() + }) }) describe('Revoke', (): void => { @@ -217,6 +262,14 @@ describe('Access Token Service', (): void => { AccessToken.query(trx).findById(token.id) ).resolves.toBeUndefined() }) + + test('Cannot revoke rotated access token', async (): Promise => { + await accessTokenService.rotate(token.managementId, token.value) + + await expect( + accessTokenService.revoke(token.id, token.value) + ).resolves.toBeUndefined() + }) }) describe('Rotate', (): void => { @@ -251,8 +304,8 @@ describe('Access Token Service', (): void => { token.managementId, token.value ) - expect(result.success).toBe(true) - expect(result.success && result.value).not.toBe(originalTokenValue) + assert.ok(result) + expect(result.value).not.toBe(originalTokenValue) }) test('Can rotate expired token', async (): Promise => { await token.$query(trx).patch({ expiresIn: -1 }) @@ -260,20 +313,22 @@ describe('Access Token Service', (): void => { token.managementId, token.value ) - expect(result.success).toBe(true) + assert.ok(result) const rotatedToken = await AccessToken.query(trx).findOne({ - managementId: result.success && result.managementId + managementId: result.managementId }) - expect(rotatedToken).toBeDefined() + assert.ok(rotatedToken) expect(rotatedToken?.value).not.toBe(originalTokenValue) }) test('Cannot rotate token with incorrect management id', async (): Promise => { - const result = await accessTokenService.rotate(v4(), token.value) - expect(result.success).toBe(false) + await expect( + accessTokenService.rotate(v4(), token.value) + ).resolves.toBeUndefined() }) test('Cannot rotate token with incorrect value', async (): Promise => { - const result = await accessTokenService.rotate(token.managementId, v4()) - expect(result.success).toBe(false) + await expect( + accessTokenService.rotate(token.managementId, v4()) + ).resolves.toBeUndefined() }) }) }) diff --git a/packages/auth/src/accessToken/service.ts b/packages/auth/src/accessToken/service.ts index 2efb4d16de..f3508a156e 100644 --- a/packages/auth/src/accessToken/service.ts +++ b/packages/auth/src/accessToken/service.ts @@ -7,7 +7,6 @@ import { Grant, GrantState } from '../grant/model' import { ClientService } from '../client/service' import { AccessToken } from './model' import { IAppConfig } from '../config/app' -import { Access } from '../access/model' export interface AccessTokenService { get(token: string): Promise @@ -15,7 +14,10 @@ export interface AccessTokenService { introspect(token: string): Promise revoke(id: string, tokenValue: string): Promise create(grantId: string, opts?: AccessTokenOpts): Promise - rotate(managementId: string, tokenValue: string): Promise + rotate( + managementId: string, + tokenValue: string + ): Promise } interface ServiceDependencies extends BaseService { @@ -29,19 +31,6 @@ interface AccessTokenOpts { trx?: Transaction } -export type Rotation = - | { - success: true - access: Array - value: string - managementId: string - expiresIn?: number - } - | { - success: false - error: Error - } - export async function createAccessTokenService({ logger, knex, @@ -140,45 +129,29 @@ async function rotate( deps: ServiceDependencies, managementId: string, tokenValue: string -): Promise { - try { - return await AccessToken.transaction(async (trx) => { - const oldToken = await AccessToken.query(trx) - .delete() - .returning('*') - .findOne({ - managementId, - value: tokenValue - }) - if (oldToken) { - const token = await AccessToken.query(trx).insertAndFetch({ - value: generateToken(), - grantId: oldToken.grantId, - expiresIn: oldToken.expiresIn, - managementId: v4() - }) - const access = await Access.query(trx).where({ - grantId: token.grantId - }) - - return { - success: true, - access, - value: token.value, - managementId: token.managementId, - expiresIn: token.expiresIn - } - } else { - return { - success: false, - error: new Error('token not found') - } - } - }) - } catch (error) { - return { - success: false, - error: new Error(error && error['message']) +): Promise { + return AccessToken.transaction(async (trx) => { + const oldToken = await AccessToken.query(trx) + .delete() + .returning('*') + .findOne({ + managementId, + value: tokenValue + }) + + if (!oldToken) { + deps.logger.warn( + { managementId, tokenValue }, + 'Could not find old token during token rotation' + ) + return } - } + + return AccessToken.query(trx).insertAndFetch({ + value: generateToken(), + grantId: oldToken.grantId, + expiresIn: oldToken.expiresIn, + managementId: v4() + }) + }) } diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index 93b90e6f98..6968005e97 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -44,6 +44,7 @@ import { grantContinueHttpsigMiddleware, tokenHttpsigMiddleware } from './signature/middleware' +import { AccessService } from './access/service' export interface AppContextData extends DefaultContext { logger: Logger @@ -87,6 +88,7 @@ export interface AppServices { config: Promise clientService: Promise grantService: Promise + accessService: Promise accessTokenRoutes: Promise grantRoutes: Promise } diff --git a/packages/auth/src/grant/model.ts b/packages/auth/src/grant/model.ts index 0760fca85b..3efa48b61a 100644 --- a/packages/auth/src/grant/model.ts +++ b/packages/auth/src/grant/model.ts @@ -2,6 +2,11 @@ import { Model } from 'objection' import { BaseModel } from '../shared/baseModel' import { Access } from '../access/model' import { join } from 'path' +import { + InteractiveGrant as OpenPaymentsInteractiveGrant, + NonInteractiveGrant as OpenPaymentsGrant +} from 'open-payments' +import { AccessToken, toOpenPaymentsAccessToken } from '../accessToken/model' export enum StartMethod { Redirect = 'redirect' @@ -61,6 +66,70 @@ export class Grant extends BaseModel { public interactNonce?: string // AS-generated nonce for post-interaction hash } +interface ToOpenPaymentsInteractiveGrantArgs { + authServerUrl: string + client: { + name: string + uri: string + } + waitTimeSeconds?: number +} + +export function toOpenPaymentsInteractiveGrant( + grant: Grant, + args: ToOpenPaymentsInteractiveGrantArgs +): OpenPaymentsInteractiveGrant { + if (!isInteractiveGrant(grant)) { + throw new Error('Expected interactive grant') + } + + const { authServerUrl, client, waitTimeSeconds } = args + + const redirectUri = new URL( + authServerUrl + `/interact/${grant.interactId}/${grant.interactNonce}` + ) + + redirectUri.searchParams.set('clientName', client.name) + redirectUri.searchParams.set('clientUri', client.uri) + + return { + interact: { + redirect: redirectUri.toString(), + finish: grant.interactNonce + }, + continue: { + access_token: { + value: grant.continueToken + }, + uri: `${authServerUrl}/auth/continue/${grant.continueId}`, + wait: waitTimeSeconds + } + } +} + +interface ToOpenPaymentsGrantArgs { + authServerUrl: string +} + +export function toOpenPaymentsNonInteractiveGrant( + grant: Grant, + args: ToOpenPaymentsGrantArgs, + accessToken: AccessToken, + accessItems: Access[] +): OpenPaymentsGrant { + return { + access_token: toOpenPaymentsAccessToken(accessToken, accessItems, { + authServerUrl: args.authServerUrl + }), + continue: { + access_token: { + value: grant.continueToken + }, + uri: `${args.authServerUrl}/continue/${grant.continueId}` + } + } +} + export interface InteractiveGrant extends Grant { finishMethod: NonNullable finishUri: NonNullable diff --git a/packages/auth/src/grant/routes.test.ts b/packages/auth/src/grant/routes.test.ts index edf551e1f4..f9e9b4e3be 100644 --- a/packages/auth/src/grant/routes.test.ts +++ b/packages/auth/src/grant/routes.test.ts @@ -5,14 +5,25 @@ import { IocContract } from '@adonisjs/fold' import nock from 'nock' import jestOpenAPI from 'jest-openapi' import { URL } from 'url' +import assert from 'assert' -import { createContext as createAppContext } from '../tests/context' +import { createContext } from '../tests/context' import { createTestApp, TestContainer } from '../tests/app' import { Config, IAppConfig } from '../config/app' import { initIocContainer } from '..' import { AppServices } from '../app' import { truncateTables } from '../tests/tableManager' -import { GrantRoutes, GrantChoices } from './routes' +import { + GrantRoutes, + GrantChoices, + CreateContext, + ContinueContext, + DeleteContext, + StartContext, + FinishContext, + GetContext, + ChooseContext +} from './routes' import { Access } from '../access/model' import { Grant, StartMethod, FinishMethod, GrantState } from '../grant/model' import { AccessToken } from '../accessToken/model' @@ -79,11 +90,6 @@ describe('Grant Routes', (): void => { interactNonce: generateNonce() }) - const createContext = ( - reqOpts: httpMocks.RequestOptions, - params: Record - ) => createAppContext(reqOpts, params) - beforeEach(async (): Promise => { grant = await Grant.query().insert(generateBaseGrant()) @@ -129,7 +135,7 @@ describe('Grant Routes', (): void => { authServer: Config.authServerDomain }) - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -164,7 +170,7 @@ describe('Grant Routes', (): void => { }) test('Can get a software-only authorization grant', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -216,7 +222,7 @@ describe('Grant Routes', (): void => { .spyOn(accessTokenService, 'create') .mockRejectedValueOnce(new Error()) - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -251,7 +257,7 @@ describe('Grant Routes', (): void => { ) }) test('Fails to initiate a grant w/o interact field', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -274,7 +280,7 @@ describe('Grant Routes', (): void => { test('Fails to initiate a grant if payment pointer has no public name', async (): Promise => { jest.spyOn(clientService, 'get').mockResolvedValueOnce(undefined) - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -307,7 +313,7 @@ describe('Grant Routes', (): void => { grantId: grant.id }) - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -322,6 +328,8 @@ describe('Grant Routes', (): void => { } ) + assert.ok(grant.interactRef) + ctx.request.body = { interact_ref: grant.interactRef } @@ -334,6 +342,8 @@ describe('Grant Routes', (): void => { grantId: grant.id }) + assert.ok(accessToken) + expect(ctx.status).toBe(200) expect(ctx.body).toEqual({ access_token: { @@ -359,7 +369,7 @@ describe('Grant Routes', (): void => { }) test('Cannot issue access token if grant does not exist', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -388,7 +398,7 @@ describe('Grant Routes', (): void => { grantId: grant.id }) - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -401,6 +411,8 @@ describe('Grant Routes', (): void => { } ) + assert.ok(grant.interactRef) + ctx.request.body = { interact_ref: grant.interactRef } @@ -412,7 +424,7 @@ describe('Grant Routes', (): void => { }) test('Cannot issue access token without interact ref', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -425,7 +437,9 @@ describe('Grant Routes', (): void => { } ) - ctx.request.body = {} + ctx.request.body = {} as { + interact_ref: string + } await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ status: 401, @@ -434,7 +448,7 @@ describe('Grant Routes', (): void => { }) test('Cannot issue access token without continue token', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -446,6 +460,8 @@ describe('Grant Routes', (): void => { } ) + assert.ok(grant.interactRef) + ctx.request.body = { interact_ref: grant.interactRef } @@ -457,7 +473,7 @@ describe('Grant Routes', (): void => { }) test('Cannot issue access token without continue id', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -468,6 +484,8 @@ describe('Grant Routes', (): void => { {} ) + assert.ok(grant.interactRef) + ctx.request.body = { interact_ref: grant.interactRef } @@ -479,10 +497,10 @@ describe('Grant Routes', (): void => { }) test('Can cancel a grant request / pending grant', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { url: '/continue/{id}', - method: 'delete', + method: 'DELETE', headers: { Authorization: `GNAP ${grant.continueToken}` } @@ -501,10 +519,10 @@ describe('Grant Routes', (): void => { ...generateBaseGrant(), state: GrantState.Granted }) - const ctx = createContext( + const ctx = createContext( { url: '/continue/{id}', - method: 'delete', + method: 'DELETE', headers: { Authorization: `GNAP ${grant.continueToken}` } @@ -519,10 +537,10 @@ describe('Grant Routes', (): void => { }) test('Cannot delete non-existing grant', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { url: '/continue/{id}', - method: 'delete', + method: 'DELETE', headers: { Authorization: `GNAP ${grant.continueToken}` } @@ -544,10 +562,10 @@ describe('Grant Routes', (): void => { `( 'Cannot delete without$description continueToken', async ({ token, status, error }): Promise => { - const ctx = createContext( + const ctx = createContext( { url: '/continue/{id}', - method: 'delete', + method: 'DELETE', headers: token ? { Authorization: `GNAP ${v4()}` @@ -575,7 +593,7 @@ describe('Grant Routes', (): void => { describe('Client - interaction start', (): void => { test('Interaction start fails if grant is invalid', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -592,7 +610,7 @@ describe('Grant Routes', (): void => { }) test('Can start an interaction', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -607,6 +625,8 @@ describe('Grant Routes', (): void => { { id: grant.interactId, nonce: grant.interactNonce } ) + assert.ok(grant.interactId) + const redirectUrl = new URL(config.identityServerDomain) redirectUrl.searchParams.set('interactId', grant.interactId) const redirectSpy = jest.spyOn(ctx, 'redirect') @@ -628,7 +648,7 @@ describe('Grant Routes', (): void => { describe('Client - interaction complete', (): void => { test('cannot finish interaction with missing id', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -651,7 +671,7 @@ describe('Grant Routes', (): void => { test('Cannot finish interaction with invalid session', async (): Promise => { const invalidNonce = generateNonce() - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -672,7 +692,7 @@ describe('Grant Routes', (): void => { test('Cannot finish interaction that does not exist', async (): Promise => { const fakeInteractId = v4() - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -702,7 +722,7 @@ describe('Grant Routes', (): void => { grantId: grant.id }) - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -716,14 +736,17 @@ describe('Grant Routes', (): void => { { id: grant.interactId, nonce: grant.interactNonce } ) + assert.ok(grant.finishUri) const clientRedirectUri = new URL(grant.finishUri) const { clientNonce, interactNonce, interactRef } = grant + const interactUrl = config.identityServerDomain + `/interact/${grant.interactId}` const data = `${clientNonce}\n${interactNonce}\n${interactRef}\n${interactUrl}` const hash = crypto.createHash('sha3-512').update(data).digest('base64') clientRedirectUri.searchParams.set('hash', hash) + assert.ok(interactRef) clientRedirectUri.searchParams.set('interact_ref', interactRef) const redirectSpy = jest.spyOn(ctx, 'redirect') @@ -747,7 +770,7 @@ describe('Grant Routes', (): void => { grantId: grant.id }) - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -761,6 +784,7 @@ describe('Grant Routes', (): void => { { id: grant.interactId, nonce: grant.interactNonce } ) + assert.ok(grant.finishUri) const clientRedirectUri = new URL(grant.finishUri) clientRedirectUri.searchParams.set('result', 'grant_rejected') @@ -775,7 +799,7 @@ describe('Grant Routes', (): void => { }) test('Cannot finish invalid interaction', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -789,6 +813,7 @@ describe('Grant Routes', (): void => { { id: grant.interactId, nonce: grant.interactNonce } ) + assert.ok(grant.finishUri) const clientRedirectUri = new URL(grant.finishUri) clientRedirectUri.searchParams.set('result', 'grant_invalid') @@ -819,7 +844,7 @@ describe('Grant Routes', (): void => { }) test('Can get grant details', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -832,21 +857,23 @@ describe('Grant Routes', (): void => { { id: grant.interactId, nonce: grant.interactNonce } ) - const formattedAccess = access - delete formattedAccess.id - delete formattedAccess.grantId - delete formattedAccess.createdAt - delete formattedAccess.updatedAt - delete formattedAccess.limits await expect( grantRoutes.interaction.details(ctx) ).resolves.toBeUndefined() expect(ctx.status).toBe(200) - expect(ctx.body).toEqual({ access: [formattedAccess] }) + expect(ctx.body).toEqual({ + access: [ + { + actions: access.actions, + identifier: access.identifier, + type: access.type + } + ] + }) }) test('Cannot get grant details for nonexistent grant', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -866,7 +893,7 @@ describe('Grant Routes', (): void => { }) test('Cannot get grant details without secret', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -886,7 +913,7 @@ describe('Grant Routes', (): void => { }) test('Cannot get grant details for nonexistent grant', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -906,7 +933,7 @@ describe('Grant Routes', (): void => { }) test('Cannot get grant details without secret', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -927,7 +954,7 @@ describe('Grant Routes', (): void => { }) describe('IDP - accept/reject grant', (): void => { test('cannot accept/reject grant without secret', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -950,7 +977,7 @@ describe('Grant Routes', (): void => { }) test('can accept grant', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { url: `/grant/${grant.id}/${grant.interactNonce}/accept`, method: 'POST', @@ -974,13 +1001,14 @@ describe('Grant Routes', (): void => { expect(ctx.status).toBe(202) const issuedGrant = await Grant.query().findById(grant.id) + assert.ok(issuedGrant) expect(issuedGrant.state).toEqual(GrantState.Granted) }) test('Cannot accept or reject grant if grant does not exist', async (): Promise => { const interactId = v4() const nonce = generateNonce() - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -1000,7 +1028,7 @@ describe('Grant Routes', (): void => { }) test('Can reject grant', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { url: `/grant/${grant.id}/${grant.interactNonce}/reject`, method: 'POST', @@ -1024,11 +1052,12 @@ describe('Grant Routes', (): void => { expect(ctx.status).toBe(202) const issuedGrant = await Grant.query().findById(grant.id) + assert.ok(issuedGrant) expect(issuedGrant.state).toEqual(GrantState.Rejected) }) test('Cannot make invalid grant choice', async (): Promise => { - const ctx = createContext( + const ctx = createContext( { headers: { Accept: 'application/json', @@ -1050,6 +1079,7 @@ describe('Grant Routes', (): void => { }) const issuedGrant = await Grant.query().findById(grant.id) + assert.ok(issuedGrant) expect(issuedGrant.state).toEqual(GrantState.Pending) }) }) diff --git a/packages/auth/src/grant/routes.ts b/packages/auth/src/grant/routes.ts index 3e9a4efdb7..3b0ce1f45b 100644 --- a/packages/auth/src/grant/routes.ts +++ b/packages/auth/src/grant/routes.ts @@ -4,8 +4,13 @@ import { ParsedUrlQuery } from 'querystring' import { AppContext } from '../app' import { GrantService, GrantRequest as GrantRequestBody } from './service' -import { Grant, GrantState } from './model' -import { Access } from '../access/model' +import { + Grant, + GrantState, + toOpenPaymentsInteractiveGrant, + toOpenPaymentsNonInteractiveGrant +} from './model' +import { toOpenPaymentsAccess } from '../access/model' import { ClientService } from '../client/service' import { BaseService } from '../shared/baseService' import { @@ -15,7 +20,6 @@ import { import { IAppConfig } from '../config/app' import { AccessTokenService } from '../accessToken/service' import { AccessService } from '../access/service' -import { accessToBody } from '../shared/utils' import { AccessToken } from '../accessToken/model' interface ServiceDependencies extends BaseService { @@ -64,7 +68,7 @@ type InteractionRequest< params: ParamsT } -type InteractionContext = Omit & { +type InteractionContext = Exclude & { request: InteractionRequest } @@ -142,42 +146,58 @@ async function createGrantInitiation( deps: ServiceDependencies, ctx: CreateContext ): Promise { - const { body } = ctx.request - const { grantService, config } = deps - - if ( - !deps.config.incomingPaymentInteraction && - body.access_token.access + const isOnlyIncomingPaymentAccessRequest = + ctx.request.body.access_token.access .map((acc) => { return isIncomingPaymentAccessRequest(acc as IncomingPaymentRequest) }) .every((el) => el === true) + + if ( + isOnlyIncomingPaymentAccessRequest && + !deps.config.incomingPaymentInteraction ) { - const trx = await Grant.startTransaction() - let grant: Grant - let accessToken: AccessToken - try { - grant = await grantService.create(body, trx) - accessToken = await deps.accessTokenService.create(grant.id, { - trx - }) - await trx.commit() - } catch (err) { - await trx.rollback() - ctx.throw(500) - return - } - const access = await deps.accessService.getByGrant(grant.id) - ctx.status = 200 - ctx.body = createGrantBody({ - domain: deps.config.authServerDomain, - grant, - access, - accessToken + await createNonInteractiveGrantInitiation(deps, ctx) + } else { + await createInteractiveGrantInitiation(deps, ctx) + } +} + +async function createNonInteractiveGrantInitiation( + deps: ServiceDependencies, + ctx: CreateContext +): Promise { + const { body } = ctx.request + const { grantService, config } = deps + const trx = await Grant.startTransaction() + let grant: Grant + let accessToken: AccessToken + try { + grant = await grantService.create(body, trx) + accessToken = await deps.accessTokenService.create(grant.id, { + trx }) - return + await trx.commit() + } catch (err) { + await trx.rollback() + ctx.throw(500) } + const access = await deps.accessService.getByGrant(grant.id) + ctx.status = 200 + ctx.body = toOpenPaymentsNonInteractiveGrant( + grant, + { authServerUrl: config.authServerDomain }, + accessToken, + access + ) +} +async function createInteractiveGrantInitiation( + deps: ServiceDependencies, + ctx: CreateContext +): Promise { + const { body } = ctx.request + const { grantService, config } = deps if (!body.interact) { ctx.throw(400, { error: 'interaction_required' }) } @@ -185,31 +205,15 @@ async function createGrantInitiation( const client = await deps.clientService.get(body.client) if (!client) { ctx.throw(400, 'invalid_client', { error: 'invalid_client' }) - } else { - const grant = await grantService.create(body) - ctx.status = 200 - - const redirectUri = new URL( - config.authServerDomain + - `/interact/${grant.interactId}/${grant.interactNonce}` - ) - - redirectUri.searchParams.set('clientName', client.name) - redirectUri.searchParams.set('clientUri', client.uri) - ctx.body = { - interact: { - redirect: redirectUri.toString(), - finish: grant.interactNonce - }, - continue: { - access_token: { - value: grant.continueToken - }, - uri: config.authServerDomain + `/auth/continue/${grant.continueId}`, - wait: config.waitTimeSeconds - } - } } + + const grant = await grantService.create(body) + ctx.status = 200 + ctx.body = toOpenPaymentsInteractiveGrant(grant, { + client, + authServerUrl: config.authServerDomain, + waitTimeSeconds: config.waitTimeSeconds + }) } async function getGrantDetails( @@ -234,7 +238,9 @@ async function getGrantDetails( return } - ctx.body = { access: grant.access.map((a: Access) => accessToBody(a)) } + ctx.body = { + access: grant.access.map(toOpenPaymentsAccess) + } } async function startInteraction( @@ -390,12 +396,12 @@ async function continueGrant( const access = await accessService.getByGrant(grant.id) // TODO: add "continue" to response if additional grant request steps are added - ctx.body = createGrantBody({ - domain: config.authServerDomain, + ctx.body = toOpenPaymentsNonInteractiveGrant( grant, - access, - accessToken - }) + { authServerUrl: config.authServerDomain }, + accessToken, + access + ) } } @@ -421,30 +427,3 @@ async function deleteGrant( } ctx.status = 204 } - -function createGrantBody({ - domain, - grant, - access, - accessToken -}: { - domain: string - grant: Grant - access: Access[] - accessToken: AccessToken -}) { - return { - access_token: { - value: accessToken.value, - manage: domain + `/token/${accessToken.managementId}`, - access: access.map((a: Access) => accessToBody(a)), - expires_in: accessToken.expiresIn - }, - continue: { - access_token: { - value: grant.continueToken - }, - uri: domain + `/continue/${grant.continueId}` - } - } -} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 1bb1925fa0..4680aa586d 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -142,7 +142,8 @@ export function initIocContainer( config: await deps.use('config'), logger: await deps.use('logger'), accessTokenService: await deps.use('accessTokenService'), - clientService: await deps.use('clientService') + clientService: await deps.use('clientService'), + accessService: await deps.use('accessService') }) } ) diff --git a/packages/auth/src/shared/utils.ts b/packages/auth/src/shared/utils.ts index c7b829f5db..ef229c661f 100644 --- a/packages/auth/src/shared/utils.ts +++ b/packages/auth/src/shared/utils.ts @@ -1,19 +1,4 @@ import * as crypto from 'crypto' -import { Access } from '../access/model' - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function accessToBody(access: Access) { - return Object.fromEntries( - Object.entries(access.toJSON()).filter( - ([k, v]) => - v != null && - k != 'id' && - k != 'grantId' && - k != 'createdAt' && - k != 'updatedAt' - ) - ) -} export function generateNonce(): string { return crypto.randomBytes(8).toString('hex').toUpperCase() diff --git a/packages/auth/src/tests/context.ts b/packages/auth/src/tests/context.ts index 6361a21ec3..5502b0817e 100644 --- a/packages/auth/src/tests/context.ts +++ b/packages/auth/src/tests/context.ts @@ -8,11 +8,11 @@ import { createHeaders } from 'http-signature-utils' import { AppContext, AppContextData, AppServices } from '../app' -export function createContext( +export function createContext( reqOpts: httpMocks.RequestOptions, params: Record, container?: IocContract -): AppContext { +): T { const req = httpMocks.createRequest(reqOpts) const res = httpMocks.createResponse() const koa = new Koa() @@ -35,7 +35,7 @@ export function createContext( ctx.session = { ...req.session } ctx.closeEmitter = new EventEmitter() ctx.container = container - return ctx as AppContext + return ctx as T } export async function createContextWithSigHeaders( diff --git a/packages/open-payments/src/index.ts b/packages/open-payments/src/index.ts index 1cb9457589..e053ea2219 100644 --- a/packages/open-payments/src/index.ts +++ b/packages/open-payments/src/index.ts @@ -15,7 +15,9 @@ export { JWKS, PaymentPointer, AccessType, - AccessAction + AccessAction, + AccessToken, + AccessItem } from './types' export { diff --git a/packages/open-payments/src/types.ts b/packages/open-payments/src/types.ts index 473a534039..b9c8c74002 100644 --- a/packages/open-payments/src/types.ts +++ b/packages/open-payments/src/types.ts @@ -100,6 +100,11 @@ export type AccessOutgoingActions = ASExternalComponents['access-outgoing']['actions'] export type AccessQuoteActions = ASExternalComponents['access-quote']['actions'] +export type AccessItem = + | ASExternalComponents['access-incoming'] + | ASExternalComponents['access-outgoing'] + | ASExternalComponents['access-quote'] + export type AccessType = | ASExternalComponents['access-incoming']['type'] | ASExternalComponents['access-outgoing']['type'] @@ -111,13 +116,18 @@ export type AccessAction = ( | AccessQuoteActions )[number] -export const AccessType: Record = Object.freeze({ +export const AccessType: { + [key in 'IncomingPayment' | 'OutgoingPayment' | 'Quote']: AccessType +} = { IncomingPayment: 'incoming-payment', OutgoingPayment: 'outgoing-payment', Quote: 'quote' -}) +} -export const AccessAction: Record = Object.freeze({ +export const AccessAction: Record< + 'Create' | 'Read' | 'ReadAll' | 'Complete' | 'List' | 'ListAll', + AccessAction +> = Object.freeze({ Create: 'create', Read: 'read', ReadAll: 'read-all',