diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 8605bfd2a4..214a7d2c97 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Authenticate `RampsService.getPaymentMethods` and `RampsService.getQuotes` by sourcing a bearer token from `AuthenticationController:getBearerToken` and sending it as an `Authorization: Bearer ` header ([#8888](https://github.com/MetaMask/core/pull/8888)) + ## [14.0.0] ### Added diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 7619d99be7..1174153004 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -1879,6 +1879,93 @@ describe('RampsService', () => { `Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/v2/regions/us-al/payments?sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios®ion=us-al&fiat=usd&crypto=eip155%3A1%2Fslip44%3A60&provider=%2Fproviders%2Fstripe' failed with status '500'`, ); }); + + it('sends an Authorization header containing the bearer token', async () => { + const scope = nock('https://on-ramp-cache.uat-api.cx.metamask.io', { + reqheaders: { + Authorization: 'Bearer mock-bearer-token', + }, + }) + .get('/v2/regions/us-al/payments') + .query({ + region: 'us-al', + fiat: 'usd', + crypto: 'eip155:1/slip44:60', + provider: '/providers/stripe', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, mockPaymentMethodsResponse); + const { service } = getService(); + + const paymentMethodsPromise = service.getPaymentMethods({ + region: 'us-al', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + provider: '/providers/stripe', + }); + await jest.runAllTimersAsync(); + await flushPromises(); + await paymentMethodsPromise; + + expect(scope.isDone()).toBe(true); + }); + + it('requests a bearer token exactly once per call', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io', { + reqheaders: { + Authorization: 'Bearer mock-bearer-token', + }, + }) + .get('/v2/regions/us-al/payments') + .query({ + region: 'us-al', + fiat: 'usd', + crypto: 'eip155:1/slip44:60', + provider: '/providers/stripe', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, mockPaymentMethodsResponse); + const { service, mockGetBearerToken } = getService(); + + const paymentMethodsPromise = service.getPaymentMethods({ + region: 'us-al', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + provider: '/providers/stripe', + }); + await jest.runAllTimersAsync(); + await flushPromises(); + await paymentMethodsPromise; + + expect(mockGetBearerToken).toHaveBeenCalledTimes(1); + }); + + it('rejects without making an HTTP call when the bearer token cannot be retrieved', async () => { + const interceptor = nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/v2/regions/us-al/payments') + .query(true) + .reply(200, mockPaymentMethodsResponse); + const { service } = getService({ + mockGetBearerToken: jest + .fn() + .mockRejectedValue(new Error('Wallet is locked')), + }); + + await expect( + service.getPaymentMethods({ + region: 'us-al', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + provider: '/providers/stripe', + }), + ).rejects.toThrow('Wallet is locked'); + expect(interceptor.isDone()).toBe(false); + cleanAll(); + }); }); describe('getQuotes', () => { @@ -2464,6 +2551,105 @@ describe('RampsService', () => { expect(quotesResponse.success).toHaveLength(2); }); + + it('sends an Authorization header containing the bearer token', async () => { + const scope = nock('https://on-ramp.uat-api.cx.metamask.io', { + reqheaders: { + Authorization: 'Bearer mock-bearer-token', + }, + }) + .get('/v2/quotes') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + region: 'us', + fiat: 'usd', + crypto: 'eip155:1/slip44:60', + amount: '100', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + payments: '/payments/debit-credit-card', + }) + .reply(200, mockQuotesResponse); + const { service } = getService(); + + const quotesPromise = service.getQuotes({ + region: 'us', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + paymentMethods: ['/payments/debit-credit-card'], + }); + await jest.runAllTimersAsync(); + await flushPromises(); + await quotesPromise; + + expect(scope.isDone()).toBe(true); + }); + + it('requests a bearer token exactly once per call', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io', { + reqheaders: { + Authorization: 'Bearer mock-bearer-token', + }, + }) + .get('/v2/quotes') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + region: 'us', + fiat: 'usd', + crypto: 'eip155:1/slip44:60', + amount: '100', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + payments: '/payments/debit-credit-card', + }) + .reply(200, mockQuotesResponse); + const { service, mockGetBearerToken } = getService(); + + const quotesPromise = service.getQuotes({ + region: 'us', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + paymentMethods: ['/payments/debit-credit-card'], + }); + await jest.runAllTimersAsync(); + await flushPromises(); + await quotesPromise; + + expect(mockGetBearerToken).toHaveBeenCalledTimes(1); + }); + + it('rejects without making an HTTP call when the bearer token cannot be retrieved', async () => { + const interceptor = nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/v2/quotes') + .query(true) + .reply(200, mockQuotesResponse); + const { service } = getService({ + mockGetBearerToken: jest + .fn() + .mockRejectedValue(new Error('Wallet is locked')), + }); + + await expect( + service.getQuotes({ + region: 'us', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + paymentMethods: ['/payments/debit-credit-card'], + }), + ).rejects.toThrow('Wallet is locked'); + expect(interceptor.isDone()).toBe(false); + cleanAll(); + }); }); describe('RampsService:getBuyWidgetUrl', () => { diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 1f084f635b..5c83c49102 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -1234,8 +1234,10 @@ export class RampsService { url.searchParams.set('crypto', options.assetId); url.searchParams.set('provider', options.provider); + const headers = await this.#getRequestHeaders(); + const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); + const fetchResponse = await this.#fetch(url, { headers }); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1290,6 +1292,8 @@ export class RampsService { url.searchParams.set('amount', String(params.amount)); url.searchParams.set('walletAddress', params.walletAddress); + const headers = await this.#getRequestHeaders(); + // Add payment methods as array parameters params.paymentMethods.forEach((paymentMethod) => { url.searchParams.append('payments', paymentMethod); @@ -1306,7 +1310,7 @@ export class RampsService { } const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); + const fetchResponse = await this.#fetch(url, { headers }); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status,