Skip to content

Commit

Permalink
feat(op): allow custom interceptor for authenticated client (#422)
Browse files Browse the repository at this point in the history
* Add custom axios instance

* Update `createAuthenticatedClient`

* Add tests

* Changeset

* Update authenticated client tests

* Remove unused import

* Address feedback

* Add `customAxiosInstance` test
  • Loading branch information
raducristianpopa committed Feb 20, 2024
1 parent 2658685 commit 8d4ccc3
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/nasty-timers-obey.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions packages/open-payments/src/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ describe('Client', (): void => {
).resolves.toBeDefined()
})

test('properly creates the client if a custom authenticated request interceptor is passed', async (): Promise<void> => {
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<void> => {
try {
await createAuthenticatedClient({
Expand Down Expand Up @@ -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.'
)
}
}
)
})
})
89 changes: 75 additions & 14 deletions packages/open-payments/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -155,7 +159,9 @@ const createUnauthenticatedDeps = async ({
const createAuthenticatedClientDeps = async ({
useHttp = false,
...args
}: Partial<CreateAuthenticatedClientArgs> = {}): Promise<AuthenticatedClientDeps> => {
}:
| CreateAuthenticatedClientArgs
| CreateAuthenticatedClientWithReqInterceptorArgs): Promise<AuthenticatedClientDeps> => {
const logger = args?.logger ?? createLogger({ name: 'Open Payments Client' })
if (args.logLevel) {
logger.level = args.logLevel
Expand All @@ -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')
)
Expand Down Expand Up @@ -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<UnauthenticatedClient, 'incomingPayment'> {
incomingPayment: IncomingPaymentRoutes
Expand All @@ -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<AuthenticatedClient> => {
): Promise<AuthenticatedClient>
/**
* @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<AuthenticatedClient>
export async function createAuthenticatedClient(
args:
| CreateAuthenticatedClientArgs
| CreateAuthenticatedClientWithReqInterceptorArgs
): Promise<AuthenticatedClient> {
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,
Expand Down
26 changes: 25 additions & 1 deletion packages/open-payments/src/client/requests.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,6 +45,24 @@ describe('requests', (): void => {
})
})

describe('createCustomAxiosInstance', (): void => {
test('sets authenticated request interceptor', async (): Promise<void> => {
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()
Expand Down
31 changes: 31 additions & 0 deletions packages/open-payments/src/client/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,34 @@ export const createAxiosInstance = (args: {

return axiosInstance
}

export type InterceptorFn = (
config: InternalAxiosRequestConfig
) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>

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
}

0 comments on commit 8d4ccc3

Please sign in to comment.