Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(op): allow custom interceptor for authenticated client #422

Merged
merged 10 commits into from
Feb 20, 2024
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im trying to think of a better naming when it comes to the "experimental" nature of the change. Maybe I'm over-complicating this though

---

(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({
raducristianpopa marked this conversation as resolved.
Show resolved Hide resolved
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
raducristianpopa marked this conversation as resolved.
Show resolved Hide resolved
*/
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
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'])
}
)
Comment on lines +236 to +244
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the only thing that would be nice is a small test for this to check that the interceptor was added. I think you can use

 createCustomAxiosInstance({
          requestTimeoutMs: 0,
          authenticatedRequestInterceptor: interceptor
        }).interceptors.request['handlers'][0]

to get a reference to the interceptor


return axiosInstance
}
Loading