diff --git a/.changeset/orange-hornets-help.md b/.changeset/orange-hornets-help.md new file mode 100644 index 00000000000..6c117c073ab --- /dev/null +++ b/.changeset/orange-hornets-help.md @@ -0,0 +1,6 @@ +--- +"@fuel-ts/account": patch +"@fuel-ts/errors": patch +--- + +feat: `provider.url` now returns auth url diff --git a/apps/docs/src/guide/errors/index.md b/apps/docs/src/guide/errors/index.md index 5e7e7783aa8..ad57e214d8c 100644 --- a/apps/docs/src/guide/errors/index.md +++ b/apps/docs/src/guide/errors/index.md @@ -228,6 +228,12 @@ When the word list length is not equal to 2048. The word list provided to the mnemonic length should be equal to 2048. +### `INVALID_URL` + +When the URL provided is invalid. + +Ensure that the URL is valid. + ### `JSON_ABI_ERROR` When an ABI type does not conform to the correct format. diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index d4343928f6c..d2fba43d3fc 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -1,6 +1,6 @@ import { Address } from '@fuel-ts/address'; import { ZeroBytes32 } from '@fuel-ts/address/configs'; -import { randomBytes } from '@fuel-ts/crypto'; +import { randomBytes, randomUUID } from '@fuel-ts/crypto'; import { FuelError, ErrorCode } from '@fuel-ts/errors'; import { expectToThrowFuelError, safeExec } from '@fuel-ts/errors/test-utils'; import { BN, bn } from '@fuel-ts/math'; @@ -56,53 +56,165 @@ const getCustomFetch = return fetch(url, options); }; +const createBasicAuth = (launchNodeUrl: string) => { + const username: string = randomUUID(); + const password: string = randomUUID(); + const usernameAndPassword = `${username}:${password}`; + + const parsedUrl = new URL(launchNodeUrl); + const hostAndPath = `${parsedUrl.host}${parsedUrl.pathname}`; + const urlWithAuth = `http://${usernameAndPassword}@${hostAndPath}`; + + return { + urlWithAuth, + urlWithoutAuth: launchNodeUrl, + usernameAndPassword, + expectedHeaders: { + Authorization: `Basic ${btoa(usernameAndPassword)}`, + }, + }; +}; + /** * @group node */ describe('Provider', () => { - it('supports basic auth', async () => { + it('should ensure supports basic auth', async () => { using launched = await setupTestProviderAndWallets(); const { provider: { url }, } = launched; - const usernameAndPassword = 'securest:ofpasswords'; - const parsedUrl = new URL(url); - const hostAndPath = `${parsedUrl.host}${parsedUrl.pathname}`; - const authUrl = `http://${usernameAndPassword}@${hostAndPath}`; - const provider = await Provider.create(authUrl); + const { urlWithAuth, expectedHeaders } = createBasicAuth(url); + const provider = await Provider.create(urlWithAuth); const fetchSpy = vi.spyOn(global, 'fetch'); await provider.operations.getChain(); - const expectedAuthToken = `Basic ${btoa(usernameAndPassword)}`; const [requestUrl, request] = fetchSpy.mock.calls[0]; expect(requestUrl).toEqual(url); - expect(request?.headers).toMatchObject({ - Authorization: expectedAuthToken, - }); + expect(request?.headers).toMatchObject(expectedHeaders); }); - it('custom requestMiddleware is not overwritten by basic auth', async () => { + it('should ensure we can reuse provider URL to connect to a authenticated endpoint', async () => { using launched = await setupTestProviderAndWallets(); const { provider: { url }, } = launched; - const usernameAndPassword = 'securest:ofpasswords'; - const parsedUrl = new URL(url); - const hostAndPath = `${parsedUrl.host}${parsedUrl.pathname}`; - const authUrl = `http://${usernameAndPassword}@${hostAndPath}`; + const { urlWithAuth, expectedHeaders } = createBasicAuth(url); + const provider = await Provider.create(urlWithAuth); + + const fetchSpy = vi.spyOn(global, 'fetch'); + + await provider.operations.getChain(); + + const [requestUrlA, requestA] = fetchSpy.mock.calls[0]; + expect(requestUrlA).toEqual(url); + expect(requestA?.headers).toMatchObject(expectedHeaders); - const requestMiddleware = vi.fn(); - await Provider.create(authUrl, { + // Reuse the provider URL to connect to an authenticated endpoint + const newProvider = await Provider.create(provider.url); + + fetchSpy.mockClear(); + + await newProvider.operations.getChain(); + const [requestUrl, request] = fetchSpy.mock.calls[0]; + expect(requestUrl).toEqual(url); + expect(request?.headers).toMatchObject(expectedHeaders); + }); + + it('should ensure that custom requestMiddleware is not overwritten by basic auth', async () => { + using launched = await setupTestProviderAndWallets(); + const { + provider: { url }, + } = launched; + + const { urlWithAuth } = createBasicAuth(url); + + const requestMiddleware = vi.fn().mockImplementation((options) => options); + + await Provider.create(urlWithAuth, { requestMiddleware, }); expect(requestMiddleware).toHaveBeenCalled(); }); + it('should ensure that we can connect to a new entrypoint with basic auth', async () => { + using launchedA = await setupTestProviderAndWallets(); + using launchedB = await setupTestProviderAndWallets(); + const { + provider: { url: urlA }, + } = launchedA; + const { + provider: { url: urlB }, + } = launchedB; + + // Should enable connection via `create` method + const basicAuthA = createBasicAuth(urlA); + const provider = await Provider.create(basicAuthA.urlWithAuth); + + const fetchSpy = vi.spyOn(global, 'fetch'); + + await provider.operations.getChain(); + + const [requestUrlA, requestA] = fetchSpy.mock.calls[0]; + expect(requestUrlA, 'expect to request with the unauthenticated URL').toEqual(urlA); + expect(requestA?.headers).toMatchObject({ + Authorization: basicAuthA.expectedHeaders.Authorization, + }); + expect(provider.url).toEqual(basicAuthA.urlWithAuth); + + fetchSpy.mockClear(); + + // Should enable reconnection + const basicAuthB = createBasicAuth(urlB); + + await provider.connect(basicAuthB.urlWithAuth); + await provider.operations.getChain(); + + const [requestUrlB, requestB] = fetchSpy.mock.calls[0]; + expect(requestUrlB, 'expect to request with the unauthenticated URL').toEqual(urlB); + expect(requestB?.headers).toMatchObject( + expect.objectContaining({ + Authorization: basicAuthB.expectedHeaders.Authorization, + }) + ); + expect(provider.url).toEqual(basicAuthB.urlWithAuth); + }); + + it('should ensure that custom headers can be passed', async () => { + using launched = await setupTestProviderAndWallets(); + const { + provider: { url }, + } = launched; + + const customHeaders = { + 'X-Custom-Header': 'custom-value', + }; + + const provider = await Provider.create(url, { + headers: customHeaders, + }); + + const fetchSpy = vi.spyOn(global, 'fetch'); + await provider.operations.getChain(); + + const [, request] = fetchSpy.mock.calls[0]; + expect(request?.headers).toMatchObject(customHeaders); + }); + + it('should throw an error if the URL is no in the correct format', async () => { + const url = 'immanotavalidurl'; + + await expectToThrowFuelError( + async () => Provider.create(url), + new FuelError(ErrorCode.INVALID_URL, 'Invalid URL provided.') + ); + }); + it('should throw an error when retrieving a transaction with an unknown transaction type', async () => { using launched = await setupTestProviderAndWallets(); const { provider } = launched; diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index 309181d00e3..7bda63dbe53 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -320,6 +320,10 @@ export type ProviderOptions = { * Retry options to use when fetching data from the node. */ retryOptions?: RetryOptions; + /** + * Custom headers to include in the request. + */ + headers?: RequestInit['headers']; /** * Middleware to modify the request before it is sent. * This can be used to add headers, modify the body, etc. @@ -417,6 +421,10 @@ export default class Provider { Provider.chainInfoCache = {}; } + /** @hidden */ + public url: string; + /** @hidden */ + private urlWithoutAuth: string; /** @hidden */ private static chainInfoCache: ChainInfoCache = {}; /** @hidden */ @@ -427,20 +435,25 @@ export default class Provider { resourceCacheTTL: undefined, fetch: undefined, retryOptions: undefined, + headers: undefined, }; /** * @hidden */ private static getFetchFn(options: ProviderOptions): NonNullable { - const { retryOptions, timeout } = options; + const { retryOptions, timeout, headers } = options; return autoRetryFetch(async (...args) => { const url = args[0]; const request = args[1]; const signal = timeout ? AbortSignal.timeout(timeout) : undefined; - let fullRequest: RequestInit = { ...request, signal }; + let fullRequest: RequestInit = { + ...request, + signal, + headers: { ...request?.headers, ...headers }, + }; if (options.requestMiddleware) { fullRequest = await options.requestMiddleware(fullRequest); @@ -457,14 +470,19 @@ export default class Provider { * @param options - Additional options for the provider * @hidden */ - protected constructor( - public url: string, - options: ProviderOptions = {} - ) { + protected constructor(url: string, options: ProviderOptions = {}) { + const { url: rawUrl, urlWithoutAuth, headers } = Provider.extractBasicAuth(url); + + this.url = rawUrl; + this.urlWithoutAuth = urlWithoutAuth; this.options = { ...this.options, ...options }; this.url = url; - this.operations = this.createOperations(); + if (headers) { + this.options = { ...this.options, headers: { ...this.options.headers, ...headers } }; + } + + this.operations = this.createOperations(); const { resourceCacheTTL } = this.options; if (isDefined(resourceCacheTTL)) { if (resourceCacheTTL !== -1) { @@ -477,18 +495,30 @@ export default class Provider { } } - private static extractBasicAuth(url: string): { url: string; auth: string | undefined } { - const parsedUrl = new URL(url); + private static extractBasicAuth(url: string): { + url: string; + urlWithoutAuth: string; + headers: ProviderOptions['headers']; + } { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch (error) { + throw new FuelError(FuelError.CODES.INVALID_URL, 'Invalid URL provided.', { url }, error); + } const username = parsedUrl.username; const password = parsedUrl.password; - const urlNoBasicAuth = `${parsedUrl.origin}${parsedUrl.pathname}`; + const urlWithoutAuth = `${parsedUrl.origin}${parsedUrl.pathname}`; if (!(username && password)) { - return { url, auth: undefined }; + return { url, urlWithoutAuth: url, headers: undefined }; } - const auth = `Basic ${btoa(`${username}:${password}`)}`; - return { url: urlNoBasicAuth, auth }; + return { + url, + urlWithoutAuth, + headers: { Authorization: `Basic ${btoa(`${username}:${password}`)}` }, + }; } /** @@ -500,17 +530,7 @@ export default class Provider { * @returns A promise that resolves to a Provider instance. */ static async create(url: string, options: ProviderOptions = {}): Promise { - const { url: urlToUse, auth } = this.extractBasicAuth(url); - const provider = new Provider(urlToUse, { - ...options, - requestMiddleware: async (request) => { - if (auth && request) { - request.headers ??= {}; - (request.headers as Record).Authorization = auth; - } - return options.requestMiddleware?.(request) ?? request; - }, - }); + const provider = new Provider(url, options); await provider.fetchChainAndNodeInfo(); @@ -523,7 +543,7 @@ export default class Provider { * @returns the chain information configuration. */ getChain(): ChainInfo { - const chain = Provider.chainInfoCache[this.url]; + const chain = Provider.chainInfoCache[this.urlWithoutAuth]; if (!chain) { throw new FuelError( ErrorCode.CHAIN_INFO_CACHE_EMPTY, @@ -539,7 +559,7 @@ export default class Provider { * @returns the node information configuration. */ getNode(): NodeInfo { - const node = Provider.nodeInfoCache[this.url]; + const node = Provider.nodeInfoCache[this.urlWithoutAuth]; if (!node) { throw new FuelError( ErrorCode.NODE_INFO_CACHE_EMPTY, @@ -575,8 +595,13 @@ export default class Provider { * @param options - Additional options for the provider. */ async connect(url: string, options?: ProviderOptions): Promise { - this.url = url; + const { url: rawUrl, urlWithoutAuth, headers } = Provider.extractBasicAuth(url); + + this.url = rawUrl; + this.urlWithoutAuth = urlWithoutAuth; this.options = options ?? this.options; + this.options = { ...this.options, headers: { ...this.options.headers, ...headers } }; + this.operations = this.createOperations(); await this.fetchChainAndNodeInfo(); } @@ -623,7 +648,7 @@ Supported fuel-core version: ${supportedVersion}.` */ private createOperations(): SdkOperations { const fetchFn = Provider.getFetchFn(this.options); - const gqlClient = new GraphQLClient(this.url, { + const gqlClient = new GraphQLClient(this.urlWithoutAuth, { fetch: (url: string, requestInit: RequestInit) => fetchFn(url, requestInit, this.options), responseMiddleware: (response: GraphQLResponse | Error) => { if ('response' in response) { @@ -646,7 +671,7 @@ Supported fuel-core version: ${supportedVersion}.` if (isSubscription) { return FuelGraphqlSubscriber.create({ - url: this.url, + url: this.urlWithoutAuth, query, fetchFn: (url, requestInit) => fetchFn(url as string, requestInit, this.options), variables: vars, @@ -722,7 +747,7 @@ Supported fuel-core version: ${supportedVersion}.` vmBacktrace: nodeInfo.vmBacktrace, }; - Provider.nodeInfoCache[this.url] = processedNodeInfo; + Provider.nodeInfoCache[this.urlWithoutAuth] = processedNodeInfo; return processedNodeInfo; } @@ -737,7 +762,7 @@ Supported fuel-core version: ${supportedVersion}.` const processedChain = processGqlChain(chain); - Provider.chainInfoCache[this.url] = processedChain; + Provider.chainInfoCache[this.urlWithoutAuth] = processedChain; return processedChain; } diff --git a/packages/account/src/providers/transaction-request/transaction-request.test.ts b/packages/account/src/providers/transaction-request/transaction-request.test.ts index e9afe5592ea..96fdc2ab874 100644 --- a/packages/account/src/providers/transaction-request/transaction-request.test.ts +++ b/packages/account/src/providers/transaction-request/transaction-request.test.ts @@ -131,7 +131,7 @@ describe('TransactionRequest', () => { } } - const provider = await ProviderCustom.create('nope'); + const provider = await ProviderCustom.create('http://example.com'); const signer = WalletUnlocked.generate({ provider }); const txRequest = new ScriptTransactionRequest(); diff --git a/packages/errors/src/error-codes.ts b/packages/errors/src/error-codes.ts index 24da1de11a5..151235770bd 100644 --- a/packages/errors/src/error-codes.ts +++ b/packages/errors/src/error-codes.ts @@ -35,6 +35,7 @@ export enum ErrorCode { MISSING_PROVIDER = 'missing-provider', INVALID_PROVIDER = 'invalid-provider', CONNECTION_REFUSED = 'connection-refused', + INVALID_URL = 'invalid-url', // wallet INVALID_PUBLIC_KEY = 'invalid-public-key',