diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index c7f4561a56b..9298eb2525e 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a `getInfuraAuthToken` option to `NetworkController`, which presents the token it returns as a bearer credential in the `Authorization` header on requests to the built-in Infura endpoints ([#10252](https://github.com/MetaMask/core/pull/10252)) + - The token is read immediately before each request, so a refreshed token is used by the next request. + - Only the built-in Infura endpoints reached through `infuraProjectId` present the token. Custom RPC endpoints and failover endpoints never do, even when hosted by Infura, so a user supplied Infura key is never paired with it. + - Because the token identifies the user, the caller decides when to withhold it. Resolve with `undefined` rather than rejecting in the states where no token can be read, such as while the wallet is locked. +- Add a `getRequestHeaders` option to `RpcServiceOptions`, which can be supplied per endpoint through `NetworkControllerOptions.getRpcServiceOptions` ([#10252](https://github.com/MetaMask/core/pull/10252)) + - The callback runs immediately before each HTTP request, including each retry attempt, so headers that change over the lifetime of a network client are read again rather than captured once. + - Its headers take precedence over those in `fetchOptions` and those passed to `request`. Because an RPC service is bound to a single endpoint, they only ever reach that endpoint. + - If the callback rejects, the request attempt fails with that error. Resolve with `undefined` to make the request without the headers instead. + ### Changed - Bump `uuid` from `^8.3.2` to `^9.0.1` ([#10117](https://github.com/MetaMask/core/pull/10117)) diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index e45abeea8bf..f8d6a3f8fad 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -795,6 +795,21 @@ export type NetworkControllerOptions = { getBlockTrackerOptions?: ( rpcEndpointUrl: string, ) => Omit; + /** + * Returns the token to present as a bearer credential on requests to the + * built-in Infura endpoints, or `undefined` to make them without one. + * + * Called immediately before each request, so a refreshed token is used by the + * next request. Only the built-in Infura endpoints reached through + * `infuraProjectId` present the token; custom RPC endpoints and failover + * endpoints never do, even when hosted by Infura. + * + * Because the token identifies the user, the caller is responsible for + * withholding it when the user has not consented to being identified, and for + * resolving with `undefined` rather than rejecting in the states where no + * token can be read, such as while the wallet is locked. + */ + getInfuraAuthToken?: () => Promise; /** * Configuration for the "RPC Service Unavailable" and "RPC Service Degraded" * analytics events the controller emits via the `AnalyticsController:trackEvent` @@ -1305,6 +1320,8 @@ export class NetworkController extends BaseController< readonly #getBlockTrackerOptions: NetworkControllerOptions['getBlockTrackerOptions']; + readonly #getInfuraAuthToken: NetworkControllerOptions['getInfuraAuthToken']; + readonly #analyticsOptions: ResolvedNetworkControllerAnalyticsOptions; #networkConfigurationsByNetworkClientId: Map< @@ -1328,6 +1345,7 @@ export class NetworkController extends BaseController< log, getRpcServiceOptions, getBlockTrackerOptions, + getInfuraAuthToken, analyticsOptions, } = options; const initialState = { @@ -1372,6 +1390,7 @@ export class NetworkController extends BaseController< this.#log = log; this.#getRpcServiceOptions = getRpcServiceOptions; this.#getBlockTrackerOptions = getBlockTrackerOptions; + this.#getInfuraAuthToken = getInfuraAuthToken; this.#analyticsOptions = { isRpcEndpointUrlPublic: (): boolean => false, rpcServiceEventsSampleRate: 0, @@ -2865,6 +2884,7 @@ export class NetworkController extends BaseController< }, getRpcServiceOptions: this.#getRpcServiceOptions, getBlockTrackerOptions: this.#getBlockTrackerOptions, + getInfuraAuthToken: this.#getInfuraAuthToken, messenger: this.messenger, rpcFailoverMode: this.#rpcFailoverMode, logger: this.#log, @@ -2884,6 +2904,7 @@ export class NetworkController extends BaseController< }, getRpcServiceOptions: this.#getRpcServiceOptions, getBlockTrackerOptions: this.#getBlockTrackerOptions, + getInfuraAuthToken: this.#getInfuraAuthToken, messenger: this.messenger, rpcFailoverMode: this.#rpcFailoverMode, logger: this.#log, @@ -3050,6 +3071,7 @@ export class NetworkController extends BaseController< }, getRpcServiceOptions: this.#getRpcServiceOptions, getBlockTrackerOptions: this.#getBlockTrackerOptions, + getInfuraAuthToken: this.#getInfuraAuthToken, messenger: this.messenger, rpcFailoverMode: this.#rpcFailoverMode, logger: this.#log, @@ -3069,6 +3091,7 @@ export class NetworkController extends BaseController< }, getRpcServiceOptions: this.#getRpcServiceOptions, getBlockTrackerOptions: this.#getBlockTrackerOptions, + getInfuraAuthToken: this.#getInfuraAuthToken, messenger: this.messenger, rpcFailoverMode: this.#rpcFailoverMode, logger: this.#log, diff --git a/packages/network-controller/src/create-auto-managed-network-client.ts b/packages/network-controller/src/create-auto-managed-network-client.ts index 458fee625d3..d1e17021313 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.ts @@ -77,6 +77,8 @@ const UNINITIALIZED_TARGET = { __UNINITIALIZED__: true }; * options. See {@link NetworkControllerOptions.getRpcServiceOptions}. * @param args.getBlockTrackerOptions - Factory for constructing block tracker * options. See {@link NetworkControllerOptions.getBlockTrackerOptions}. + * @param args.getInfuraAuthToken - Returns the token to present as a bearer + * credential on requests to a built-in Infura endpoint. * @param args.messenger - The network controller messenger. * @param args.rpcFailoverMode - The RPC failover mode to apply: `disabled`, * `enabled` (divert to the failover URLs when the primary is unavailable), or @@ -95,6 +97,7 @@ export function createAutoManagedNetworkClient< PollingBlockTrackerOptions, 'provider' > => ({}), + getInfuraAuthToken, messenger, rpcFailoverMode: givenRpcFailoverMode, logger, @@ -107,6 +110,7 @@ export function createAutoManagedNetworkClient< getBlockTrackerOptions?: ( rpcEndpointUrl: string, ) => Omit; + getInfuraAuthToken?: () => Promise; messenger: NetworkControllerMessenger; rpcFailoverMode: RpcFailoverMode; logger?: Logger; @@ -120,6 +124,7 @@ export function createAutoManagedNetworkClient< configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, + getInfuraAuthToken, messenger, rpcFailoverMode, logger, diff --git a/packages/network-controller/src/create-network-client-tests/infura-auth-token.test.ts b/packages/network-controller/src/create-network-client-tests/infura-auth-token.test.ts new file mode 100644 index 00000000000..b7a87b4fc15 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/infura-auth-token.test.ts @@ -0,0 +1,369 @@ +import { buildRootMessenger } from '../../tests/helpers.js'; +import { + withMockedCommunications, + withNetworkClient, +} from '../../tests/network-client/helpers.js'; + +const FAILOVER_URL = 'https://failover.example.com'; + +type RecordedRequest = { + host: string; + method: string; + authorization: string | null; +}; + +/** + * Extracts the host from the first argument of a `fetch` call. + * + * @param input - The URL or request passed to `fetch`. + * @returns The host of the URL. + */ +function getHost(input: RequestInfo | URL): string { + if (typeof input === 'string') { + return new URL(input).host; + } + if (input instanceof URL) { + return input.host; + } + return new URL(input.url).host; +} + +/** + * Builds a `fetch` that records the host, JSON-RPC method, and `Authorization` + * header of every request before forwarding it to the global `fetch`. + * + * @returns The recording `fetch` along with the list it records into. + */ +function buildRecordingFetch(): { + requests: RecordedRequest[]; + recordingFetch: typeof fetch; +} { + const requests: RecordedRequest[] = []; + const recordingFetch: typeof fetch = async (input, init) => { + // The RPC service always sends a JSON-encoded body and plain-object headers. + const { method } = JSON.parse(init?.body as string) as { method: string }; + const headers = init?.headers as Record | undefined; + requests.push({ + host: getHost(input), + method, + authorization: headers?.Authorization ?? null, + }); + return await fetch(input, init); + }; + return { requests, recordingFetch }; +} + +describe('createNetworkClient - Infura auth token', () => { + it('presents the token as a bearer credential on requests to a built-in Infura endpoint', async () => { + await withMockedCommunications( + { providerType: 'infura' }, + async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + const { requests, recordingFetch } = buildRecordingFetch(); + + const result = await withNetworkClient( + { + providerType: 'infura', + messenger: buildRootMessenger(), + getRpcServiceOptions: () => ({ + fetch: recordingFetch, + btoa, + isOffline: (): boolean => false, + }), + getInfuraAuthToken: async () => 'some-token', + }, + async ({ makeRpcCall }) => + await makeRpcCall({ method: 'eth_gasPrice', params: [] }), + ); + + expect(result).toBe('0xabc'); + expect(requests).toContainEqual({ + host: 'mainnet.infura.io', + method: 'eth_gasPrice', + authorization: 'Bearer some-token', + }); + expect( + requests.filter( + ({ authorization }) => authorization !== 'Bearer some-token', + ), + ).toStrictEqual([]); + }, + ); + }); + + it('presents the token when no `fetch` is given', async () => { + await withMockedCommunications( + { + providerType: 'infura', + // The mock only matches when the request presents this header. + expectedHeaders: { Authorization: 'Bearer some-token' }, + }, + async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + + const result = await withNetworkClient( + { + providerType: 'infura', + messenger: buildRootMessenger(), + getRpcServiceOptions: () => ({ + btoa, + isOffline: (): boolean => false, + }), + getInfuraAuthToken: async () => 'some-token', + }, + async ({ makeRpcCall }) => + await makeRpcCall({ method: 'eth_gasPrice', params: [] }), + ); + + expect(result).toBe('0xabc'); + }, + ); + }); + + it('retrieves the token for each request', async () => { + await withMockedCommunications( + { providerType: 'infura' }, + async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + const { requests, recordingFetch } = buildRecordingFetch(); + let tokenCount = 0; + const getInfuraAuthToken = jest.fn(async () => { + tokenCount += 1; + return `token-${tokenCount}`; + }); + + await withNetworkClient( + { + providerType: 'infura', + messenger: buildRootMessenger(), + getRpcServiceOptions: () => ({ + fetch: recordingFetch, + btoa, + isOffline: (): boolean => false, + }), + getInfuraAuthToken, + }, + async ({ makeRpcCall }) => + await makeRpcCall({ method: 'eth_gasPrice', params: [] }), + ); + + // The block tracker's request plus the RPC call itself. + expect(requests.length).toBeGreaterThanOrEqual(2); + expect(getInfuraAuthToken).toHaveBeenCalledTimes(requests.length); + expect( + new Set(requests.map(({ authorization }) => authorization)).size, + ).toBe(requests.length); + }, + ); + }); + + it('keeps the headers from a `getRequestHeaders` given via `getRpcServiceOptions`', async () => { + await withMockedCommunications( + { providerType: 'infura' }, + async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + const headersByRequest: (Record | undefined)[] = []; + // The RPC service always sends plain-object headers. + const recordingFetch: typeof fetch = async (input, init) => { + headersByRequest.push(init?.headers as Record); + return await fetch(input, init); + }; + + const result = await withNetworkClient( + { + providerType: 'infura', + messenger: buildRootMessenger(), + getRpcServiceOptions: () => ({ + fetch: recordingFetch, + btoa, + isOffline: (): boolean => false, + getRequestHeaders: async (): Promise> => ({ + 'X-Foo': 'Bar', + }), + }), + getInfuraAuthToken: async () => 'some-token', + }, + async ({ makeRpcCall }) => + await makeRpcCall({ method: 'eth_gasPrice', params: [] }), + ); + + expect(result).toBe('0xabc'); + expect(headersByRequest.length).toBeGreaterThan(0); + expect( + headersByRequest.filter( + (headers) => + headers?.Authorization !== 'Bearer some-token' || + headers?.['X-Foo'] !== 'Bar', + ), + ).toStrictEqual([]); + }, + ); + }); + + it('does not present the token on requests to a custom endpoint', async () => { + await withMockedCommunications( + { providerType: 'custom' }, + async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + const { requests, recordingFetch } = buildRecordingFetch(); + const getInfuraAuthToken = jest.fn(async () => 'some-token'); + + const result = await withNetworkClient( + { + providerType: 'custom', + messenger: buildRootMessenger(), + getRpcServiceOptions: () => ({ + fetch: recordingFetch, + btoa, + isOffline: (): boolean => false, + }), + getInfuraAuthToken, + }, + async ({ makeRpcCall }) => + await makeRpcCall({ method: 'eth_gasPrice', params: [] }), + ); + + expect(result).toBe('0xabc'); + expect(getInfuraAuthToken).not.toHaveBeenCalled(); + expect(requests.length).toBeGreaterThan(0); + expect( + requests.filter(({ authorization }) => authorization !== null), + ).toStrictEqual([]); + }, + ); + }); + + it('does not present the token on requests to a failover endpoint', async () => { + await withMockedCommunications( + { providerType: 'custom', customRpcUrl: FAILOVER_URL }, + async (failoverComms) => { + failoverComms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + failoverComms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + const { requests, recordingFetch } = buildRecordingFetch(); + const getInfuraAuthToken = jest.fn(async () => 'some-token'); + + const result = await withNetworkClient( + { + providerType: 'infura', + failoverRpcUrls: [FAILOVER_URL], + rpcFailoverMode: 'forced', + messenger: buildRootMessenger(), + getRpcServiceOptions: () => ({ + fetch: recordingFetch, + btoa, + isOffline: (): boolean => false, + }), + getInfuraAuthToken, + }, + async ({ makeRpcCall }) => + await makeRpcCall({ method: 'eth_gasPrice', params: [] }), + ); + + expect(result).toBe('0xabc'); + expect(getInfuraAuthToken).not.toHaveBeenCalled(); + expect(requests.length).toBeGreaterThan(0); + expect( + requests.filter( + ({ host, authorization }) => + host !== 'failover.example.com' || authorization !== null, + ), + ).toStrictEqual([]); + }, + ); + }); + + it('makes the request without the credential when no token is available', async () => { + await withMockedCommunications( + { providerType: 'infura' }, + async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + const { requests, recordingFetch } = buildRecordingFetch(); + + const result = await withNetworkClient( + { + providerType: 'infura', + messenger: buildRootMessenger(), + getRpcServiceOptions: () => ({ + fetch: recordingFetch, + btoa, + isOffline: (): boolean => false, + }), + getInfuraAuthToken: async () => undefined, + }, + async ({ makeRpcCall }) => + await makeRpcCall({ method: 'eth_gasPrice', params: [] }), + ); + + expect(result).toBe('0xabc'); + expect(requests.length).toBeGreaterThan(0); + expect( + requests.filter(({ authorization }) => authorization !== null), + ).toStrictEqual([]); + }, + ); + }); + + it('makes the request without the credential when retrieving the token fails', async () => { + await withMockedCommunications( + { providerType: 'infura' }, + async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + const { requests, recordingFetch } = buildRecordingFetch(); + + const result = await withNetworkClient( + { + providerType: 'infura', + messenger: buildRootMessenger(), + getRpcServiceOptions: () => ({ + fetch: recordingFetch, + btoa, + isOffline: (): boolean => false, + }), + getInfuraAuthToken: async () => { + throw new Error('wallet is locked'); + }, + }, + async ({ makeRpcCall }) => + await makeRpcCall({ method: 'eth_gasPrice', params: [] }), + ); + + expect(result).toBe('0xabc'); + expect(requests.length).toBeGreaterThan(0); + expect( + requests.filter(({ authorization }) => authorization !== null), + ).toStrictEqual([]); + }, + ); + }); +}); diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index c83df15bc59..a1a3b548ddc 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -126,6 +126,8 @@ type RpcApiMiddleware = JsonRpcMiddleware< * options. See {@link NetworkControllerOptions.getRpcServiceOptions}. * @param args.getBlockTrackerOptions - Factory for constructing block tracker * options. See {@link NetworkControllerOptions.getBlockTrackerOptions}. + * @param args.getInfuraAuthToken - Returns the token to present as a bearer + * credential on requests to a built-in Infura endpoint. * @param args.messenger - The network controller messenger. * @param args.rpcFailoverMode - The RPC failover mode to apply: `disabled` * (failover off), `enabled` (divert to the configured failover URLs when the @@ -139,6 +141,7 @@ export function createNetworkClient({ configuration, getRpcServiceOptions, getBlockTrackerOptions, + getInfuraAuthToken, messenger, rpcFailoverMode, logger, @@ -151,6 +154,7 @@ export function createNetworkClient({ getBlockTrackerOptions: ( rpcEndpointUrl: string, ) => Omit; + getInfuraAuthToken?: () => Promise; messenger: NetworkControllerMessenger; rpcFailoverMode: RpcFailoverMode; logger?: Logger; @@ -164,6 +168,7 @@ export function createNetworkClient({ primaryEndpointUrl, configuration, getRpcServiceOptions, + getInfuraAuthToken, messenger, rpcFailoverMode, logger, @@ -221,6 +226,43 @@ export function createNetworkClient({ return { configuration, provider, blockTracker, destroy }; } +/** + * Builds the `getRequestHeaders` hook for an RPC service that presents the + * token returned by `getInfuraAuthToken` as a bearer credential. + * + * The RPC service calls this immediately before each request, so a token + * refreshed during the lifetime of a network client is used by the next + * request. If no token is available, or reading it throws, no credential is + * presented and the request is made without it. + * + * @param getInfuraAuthToken - Returns the token to present. + * @param getOverriddenRequestHeaders - The hook supplied for this endpoint via + * `getRpcServiceOptions`, if any. Its headers are preserved; the credential + * wins if both set `Authorization`. + * @returns The hook to pass as `getRequestHeaders`. + */ +function buildInfuraAuthHeaders( + getInfuraAuthToken: () => Promise, + getOverriddenRequestHeaders: RpcServiceOptionsWithDefaults['getRequestHeaders'], +): () => Promise | undefined> { + return async () => { + const overriddenHeaders = await getOverriddenRequestHeaders?.(); + + let token: string | undefined; + try { + token = await getInfuraAuthToken(); + } catch { + return overriddenHeaders; + } + + if (!token) { + return overriddenHeaders; + } + + return { ...overriddenHeaders, Authorization: `Bearer ${token}` }; + }; +} + /** * Determines the ordered list of endpoints that make up the RPC service chain * for a network, honoring the RPC failover flags. @@ -282,6 +324,8 @@ function getAvailableEndpoints({ * @param args.configuration - The network configuration. * @param args.getRpcServiceOptions - Factory for constructing RPC service * options. See {@link NetworkControllerOptions.getRpcServiceOptions}. + * @param args.getInfuraAuthToken - Returns the token to present as a bearer + * credential on requests to a built-in Infura endpoint. * @param args.messenger - The network controller messenger. * @param args.rpcFailoverMode - The RPC failover mode to apply: `disabled` * (failover off), `enabled` (divert to the configured failover URLs when the @@ -295,6 +339,7 @@ function createRpcServiceChain({ primaryEndpointUrl, configuration, getRpcServiceOptions, + getInfuraAuthToken, messenger, rpcFailoverMode, logger, @@ -305,6 +350,7 @@ function createRpcServiceChain({ getRpcServiceOptions?: ( rpcEndpointUrl: string, ) => RpcServiceOptionsWithDefaults; + getInfuraAuthToken?: () => Promise; messenger: NetworkControllerMessenger; rpcFailoverMode: RpcFailoverMode; logger?: Logger; @@ -340,6 +386,11 @@ function createRpcServiceChain({ const rpcServiceConfigurations = availableEndpoints.map((endpoint) => { const overriddenOptions = getRpcServiceOptions?.(endpoint.url) ?? {}; + // Only the primary endpoint of an `infura`-type client is reached through + // our project ID. A custom endpoint may point at Infura with somebody + // else's key, and failover endpoints belong to other providers. + const isBuiltInInfuraEndpoint = + configuration.type === NetworkClientType.Infura && !endpoint.isFailover; return { fetch: globalThis.fetch.bind(globalThis), btoa: globalThis.btoa.bind(globalThis), @@ -353,6 +404,14 @@ function createRpcServiceChain({ ...(overriddenOptions.policyOptions ?? {}), }, ...overriddenOptions, + ...(isBuiltInInfuraEndpoint && getInfuraAuthToken + ? { + getRequestHeaders: buildInfuraAuthHeaders( + getInfuraAuthToken, + overriddenOptions.getRequestHeaders, + ), + } + : {}), endpointUrl: endpoint.url, logger, }; diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index 2e98f9c8094..3c191b02de0 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -939,6 +939,238 @@ describe('RpcService', () => { expect(scope.isDone()).toBe(true); }); + it('adds the headers returned by `getRequestHeaders` to the request', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer some-token', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, + getRequestHeaders: async (): Promise> => ({ + Authorization: 'Bearer some-token', + }), + }); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(scope.isDone()).toBe(true); + }); + + it('prefers the headers returned by `getRequestHeaders` over the service and request headers', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + // Overrides the Basic credential derived from the URL. + Authorization: 'Bearer some-token', + // Overrides the header given to `request`. + 'X-Foo': 'Baz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + isOffline: (): boolean => false, + getRequestHeaders: async (): Promise> => ({ + Authorization: 'Bearer some-token', + 'X-Foo': 'Baz', + }), + }); + + await service.request( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }, + { + headers: { + 'X-Foo': 'Bar', + }, + }, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('leaves the request headers alone if `getRequestHeaders` returns undefined', async () => { + const scope = nock('https://rpc.example.chain', { + badheaders: ['Authorization'], + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, + getRequestHeaders: async (): Promise => undefined, + }); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(scope.isDone()).toBe(true); + }); + + it('calls `getRequestHeaders` before each request, so that a refreshed header is picked up', async () => { + const endpointUrl = 'https://rpc.example.chain'; + const firstScope = nock(endpointUrl, { + reqheaders: { Authorization: 'Bearer token-1' }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { id: 1, jsonrpc: '2.0', result: '0x1' }); + const secondScope = nock(endpointUrl, { + reqheaders: { Authorization: 'Bearer token-2' }, + }) + .post('/', { + id: 2, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { id: 2, jsonrpc: '2.0', result: '0x1' }); + let tokenCount = 0; + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + getRequestHeaders: async (): Promise> => { + tokenCount += 1; + return { Authorization: `Bearer token-${tokenCount}` }; + }, + }); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await service.request({ + id: 2, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(firstScope.isDone()).toBe(true); + expect(secondScope.isDone()).toBe(true); + }); + + it('calls `getRequestHeaders` before each retry attempt', async () => { + const mockFetch = jest.fn(() => { + throw new TypeError('fetch failed'); + }); + const getRequestHeaders = jest.fn(async () => ({ + Authorization: 'Bearer some-token', + })); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, + getRequestHeaders, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + await ignoreRejection( + service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }), + ); + + // The initial attempt plus 4 retries. + expect(mockFetch).toHaveBeenCalledTimes(5); + expect(getRequestHeaders).toHaveBeenCalledTimes(5); + }); + + it('does not make the request if `getRequestHeaders` rejects', async () => { + const mockFetch = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, + getRequestHeaders: async (): Promise => { + throw new Error('could not read the headers'); + }, + }); + + await expect( + service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }), + ).rejects.toThrow('could not read the headers'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('returns the JSON-decoded response if the request succeeds', async () => { const endpointUrl = 'https://rpc.example.chain'; nock(endpointUrl) diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index 1eef498ba30..d28c2fcbb82 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -62,6 +62,21 @@ export type RpcServiceOptions = { * overridden on the request level (e.g. to add headers). */ fetchOptions?: FetchOptions; + /** + * Returns extra headers to include in the request. Called immediately before + * each HTTP request (including each retry attempt), so a value that changes + * over the lifetime of the service, such as a credential that expires, is + * read again rather than captured once. + * + * These headers take precedence over those in `fetchOptions` and over those + * passed to {@link RpcService.request}. Because a service is bound to a + * single endpoint, they are only ever sent to {@link endpointUrl}. + * + * If this rejects, the request attempt fails with that error. A caller that + * would rather make the request without the headers should resolve with + * `undefined` instead of rejecting. + */ + getRequestHeaders?: () => Promise | undefined>; /** * A `loglevel` logger. */ @@ -360,6 +375,11 @@ export class RpcService { */ readonly #fetchOptions: FetchOptions; + /** + * Returns extra headers to include in the request. + */ + readonly #getRequestHeaders: RpcServiceOptions['getRequestHeaders']; + /** * A `loglevel` logger. */ @@ -382,11 +402,13 @@ export class RpcService { fetch: givenFetch, logger, fetchOptions = {}, + getRequestHeaders, policyOptions = {}, isOffline, } = options; this.#fetch = givenFetch; + this.#getRequestHeaders = getRequestHeaders; const normalizedUrl = getNormalizedEndpointUrl(endpointUrl); this.#fetchOptions = this.#getDefaultFetchOptions( normalizedUrl, @@ -709,7 +731,17 @@ export class RpcService { // ServicePolicy is just wrong. `(attempt ${context.attempt + 1})`, ); - response = await this.#fetch(this.endpointUrl, fetchOptions); + // Resolved per attempt so that a header which changes over the + // lifetime of this service is never stale, and deliberately after + // the log above so that a credential does not reach the log. + const additionalHeaders = await this.#getRequestHeaders?.(); + const completeFetchOptions = additionalHeaders + ? deepmerge(fetchOptions, { headers: additionalHeaders }) + : fetchOptions; + response = await this.#fetch( + this.endpointUrl, + completeFetchOptions, + ); if (!response.ok) { throw new HttpError(response.status); } diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 0b318595ca2..e399eb364ff 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -997,6 +997,41 @@ describe('NetworkController', () => { }); }); + describe('getInfuraAuthToken', () => { + it('hands the given function to the network clients it creates', async () => { + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const getInfuraAuthToken = jest.fn(async () => 'some-token'); + + await withController({ getInfuraAuthToken }, () => { + // The controller creates its network clients on construction. + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalled(); + for (const [options] of createAutoManagedNetworkClientSpy.mock.calls) { + expect(options.getInfuraAuthToken).toBe(getInfuraAuthToken); + } + }); + + it('hands nothing to the network clients when the option is omitted', async () => { + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + + await withController({}, () => { + // The controller creates its network clients on construction. + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalled(); + for (const [options] of createAutoManagedNetworkClientSpy.mock.calls) { + expect(options.getInfuraAuthToken).toBeUndefined(); + } + }); + }); + describe('init', () => { it('auto-enables networks that are set as auto-enabled in the config registry', async () => { const networkConfig = buildMockConfigRegistryControllerNetwork({ diff --git a/packages/network-controller/tests/network-client/helpers.ts b/packages/network-controller/tests/network-client/helpers.ts index 850026dc21c..37968354dfb 100644 --- a/packages/network-controller/tests/network-client/helpers.ts +++ b/packages/network-controller/tests/network-client/helpers.ts @@ -331,6 +331,7 @@ export type MockOptions = { customTicker?: string; getRpcServiceOptions?: NetworkControllerOptions['getRpcServiceOptions']; getBlockTrackerOptions?: NetworkControllerOptions['getBlockTrackerOptions']; + getInfuraAuthToken?: () => Promise; expectedHeaders?: Record; messenger?: RootMessenger; networkClientId?: NetworkClientId; @@ -479,6 +480,9 @@ export async function waitForPromiseToBeFulfilledAfterRunningAllTimers( * that `providerType` is "custom" (default: "ETH"). * @param options.getRpcServiceOptions - RPC service options factory. * @param options.getBlockTrackerOptions - Block tracker options factory. + * @param options.getInfuraAuthToken - Returns the token to present as a bearer + * credential on requests to the primary endpoint of a built-in Infura network + * client. * @param options.messenger - The root messenger to use in tests. * @param options.networkClientId - The ID of the new network client. * @param options.rpcFailoverMode - The RPC failover mode to apply, defaults to @@ -500,6 +504,7 @@ export async function withNetworkClient( 'failoverService' | 'endpointUrl' > => ({ fetch, btoa, isOffline: (): boolean => false }), getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({}), + getInfuraAuthToken, messenger = buildRootMessenger(), networkClientId = 'some-network-client-id', rpcFailoverMode = 'disabled', @@ -553,6 +558,7 @@ export async function withNetworkClient( configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, + getInfuraAuthToken, messenger: networkControllerMessenger, rpcFailoverMode, });