diff --git a/.changeset/nasty-timers-obey.md b/.changeset/nasty-timers-obey.md new file mode 100644 index 00000000..c86c0e71 --- /dev/null +++ b/.changeset/nasty-timers-obey.md @@ -0,0 +1,5 @@ +--- +'@interledger/open-payments': minor +--- + +(EXPERIMENTAL) Allow a custom request interceptor for the authenticated client instead of providing the private key and key ID. The request interceptor should be responsible for HTTP signature generation and it will replace the built-in interceptor. diff --git a/packages/open-payments/src/client/index.test.ts b/packages/open-payments/src/client/index.test.ts index 811f688a..4e850bb6 100644 --- a/packages/open-payments/src/client/index.test.ts +++ b/packages/open-payments/src/client/index.test.ts @@ -64,6 +64,16 @@ describe('Client', (): void => { ).resolves.toBeDefined() }) + test('properly creates the client if a custom authenticated request interceptor is passed', async (): Promise => { + await expect( + createAuthenticatedClient({ + logger: silentLogger, + walletAddressUrl: 'http://localhost:1000/.well-known/pay', + authenticatedRequestInterceptor: (config) => config + }) + ).resolves.toBeDefined() + }) + test('throws error if could not load private key as Buffer', async (): Promise => { try { await createAuthenticatedClient({ @@ -97,5 +107,34 @@ describe('Client', (): void => { expect(error.description).toBe('Key is not a valid path or file') } }) + + test.each` + keyId | privateKey + ${'my-key-id'} | ${'my-private-key'} + ${'my-key-id'} | ${undefined} + ${undefined} | ${'my-private-key'} + `( + 'throws an error if both authenticatedRequestInterceptor and privateKey or keyId are provided', + async ({ keyId, privateKey }) => { + try { + // @ts-expect-error Invalid args + await createAuthenticatedClient({ + logger: silentLogger, + keyId: keyId, + walletAddressUrl: 'http://localhost:1000/.well-known/pay', + privateKey: privateKey, + authenticatedRequestInterceptor: (config) => config + }) + } catch (error) { + assert.ok(error instanceof OpenPaymentsClientError) + expect(error.message).toBe( + 'Invalid arguments when creating authenticated client.' + ) + expect(error.description).toBe( + 'Both `authenticatedRequestInterceptor` and `privateKey`/`keyId` were provided. Please use only one of these options.' + ) + } + } + ) }) }) diff --git a/packages/open-payments/src/client/index.ts b/packages/open-payments/src/client/index.ts index fd1f49c6..be96908e 100644 --- a/packages/open-payments/src/client/index.ts +++ b/packages/open-payments/src/client/index.ts @@ -14,7 +14,11 @@ import { createWalletAddressRoutes, WalletAddressRoutes } from './wallet-address' -import { createAxiosInstance } from './requests' +import { + createAxiosInstance, + createCustomAxiosInstance, + InterceptorFn +} from './requests' import { AxiosInstance } from 'axios' import { createGrantRoutes, GrantRoutes } from './grant' import { @@ -155,7 +159,9 @@ const createUnauthenticatedDeps = async ({ const createAuthenticatedClientDeps = async ({ useHttp = false, ...args -}: Partial = {}): Promise => { +}: + | CreateAuthenticatedClientArgs + | CreateAuthenticatedClientWithReqInterceptorArgs): Promise => { const logger = args?.logger ?? createLogger({ name: 'Open Payments Client' }) if (args.logLevel) { logger.level = args.logLevel @@ -176,12 +182,23 @@ const createAuthenticatedClientDeps = async ({ }) } - const axiosInstance = createAxiosInstance({ - privateKey, - keyId: args.keyId, - requestTimeoutMs: - args?.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS - }) + let axiosInstance: AxiosInstance | undefined + + if ('authenticatedRequestInterceptor' in args) { + axiosInstance = createCustomAxiosInstance({ + requestTimeoutMs: + args?.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS, + authenticatedRequestInterceptor: args.authenticatedRequestInterceptor + }) + } else { + axiosInstance = createAxiosInstance({ + privateKey, + keyId: args.keyId, + requestTimeoutMs: + args?.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS + }) + } + const walletAddressServerOpenApi = await createOpenAPI( path.resolve(__dirname, '../openapi/wallet-address-server.yaml') ) @@ -239,16 +256,29 @@ export const createUnauthenticatedClient = async ( } } -export interface CreateAuthenticatedClientArgs - extends CreateUnauthenticatedClientArgs { +interface BaseAuthenticatedClientArgs extends CreateUnauthenticatedClientArgs { + /** The wallet address which the client will identify itself by */ + walletAddressUrl: string +} + +interface PrivateKeyConfig { /** The private EdDSA-Ed25519 key (or the relative or absolute path to the key) with which requests will be signed */ privateKey: string | KeyLike /** The key identifier referring to the private key */ keyId: string - /** The wallet address which the client will identify itself by */ - walletAddressUrl: string } +interface InterceptorConfig { + /** The custom authenticated request interceptor to use. */ + authenticatedRequestInterceptor: InterceptorFn +} + +export type CreateAuthenticatedClientArgs = BaseAuthenticatedClientArgs & + PrivateKeyConfig + +export type CreateAuthenticatedClientWithReqInterceptorArgs = + BaseAuthenticatedClientArgs & InterceptorConfig + export interface AuthenticatedClient extends Omit { incomingPayment: IncomingPaymentRoutes @@ -258,9 +288,40 @@ export interface AuthenticatedClient quote: QuoteRoutes } -export const createAuthenticatedClient = async ( +/** + * Creates an Open Payments client that exposes methods to call all of the Open Payments APIs. + * Each request requiring authentication will be signed with the given private key. + */ +export async function createAuthenticatedClient( args: CreateAuthenticatedClientArgs -): Promise => { +): Promise +/** + * @experimental The `authenticatedRequestInterceptor` feature is currently experimental and might be removed + * in upcoming versions. Use at your own risk! It offers the capability to add a custom method for + * generating HTTP signatures. It is recommended to create the authenticated client with the `privateKey` + * and `keyId` arguments. If both `authenticatedRequestInterceptor` and `privateKey`/`keyId` are provided, an error will be thrown. + * @throws OpenPaymentsClientError + */ +export async function createAuthenticatedClient( + args: CreateAuthenticatedClientWithReqInterceptorArgs +): Promise +export async function createAuthenticatedClient( + args: + | CreateAuthenticatedClientArgs + | CreateAuthenticatedClientWithReqInterceptorArgs +): Promise { + if ( + 'authenticatedRequestInterceptor' in args && + ('privateKey' in args || 'keyId' in args) + ) { + throw new OpenPaymentsClientError( + 'Invalid arguments when creating authenticated client.', + { + description: + 'Both `authenticatedRequestInterceptor` and `privateKey`/`keyId` were provided. Please use only one of these options.' + } + ) + } const { resourceServerOpenApi, authServerOpenApi, diff --git a/packages/open-payments/src/client/requests.test.ts b/packages/open-payments/src/client/requests.test.ts index 8064e381..305512c3 100644 --- a/packages/open-payments/src/client/requests.test.ts +++ b/packages/open-payments/src/client/requests.test.ts @@ -1,5 +1,11 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import { createAxiosInstance, deleteRequest, get, post } from './requests' +import { + createAxiosInstance, + createCustomAxiosInstance, + deleteRequest, + get, + post +} from './requests' import { generateKeyPairSync } from 'crypto' import nock from 'nock' import { createTestDeps, mockOpenApiResponseValidators } from '../test/helpers' @@ -39,6 +45,24 @@ describe('requests', (): void => { }) }) + describe('createCustomAxiosInstance', (): void => { + test('sets authenticated request interceptor', async (): Promise => { + const customAxiosInstance = createCustomAxiosInstance({ + requestTimeoutMs: 0, + authenticatedRequestInterceptor: (config) => config + }) + expect( + customAxiosInstance.interceptors.request['handlers'][0] + ).toBeDefined() + expect( + customAxiosInstance.interceptors.request['handlers'][0].fulfilled + ).toBeDefined() + expect( + customAxiosInstance.interceptors.request['handlers'][0].fulfilled + ).toEqual(expect.any(Function)) + }) + }) + describe('get', (): void => { const baseUrl = 'http://localhost:1000' const responseValidators = mockOpenApiResponseValidators() diff --git a/packages/open-payments/src/client/requests.ts b/packages/open-payments/src/client/requests.ts index 137ff983..6c4cb6a7 100644 --- a/packages/open-payments/src/client/requests.ts +++ b/packages/open-payments/src/client/requests.ts @@ -214,3 +214,34 @@ export const createAxiosInstance = (args: { return axiosInstance } + +export type InterceptorFn = ( + config: InternalAxiosRequestConfig +) => InternalAxiosRequestConfig | Promise + +export const createCustomAxiosInstance = (args: { + requestTimeoutMs: number + authenticatedRequestInterceptor: InterceptorFn +}): AxiosInstance => { + const axiosInstance = axios.create({ + headers: { + common: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + timeout: args.requestTimeoutMs + }) + + axiosInstance.interceptors.request.use( + args.authenticatedRequestInterceptor, + undefined, + { + runWhen: (config: InternalAxiosRequestConfig) => + config.method?.toLowerCase() === 'post' || + !!(config.headers && config.headers['Authorization']) + } + ) + + return axiosInstance +}