diff --git a/index.js b/index.js index d9b3bc40..24d35bfd 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ import Auth from './src/auth'; import Users from './src/management/users'; import WebAuth from './src/webauth'; +export {TimeoutError} from './src/utils/fetchWithTimeout'; /** * Auth0 for React Native client @@ -9,7 +10,6 @@ import WebAuth from './src/webauth'; * @class Auth0 */ export default class Auth0 { - /** * Creates an instance of Auth0. * @param {Object} options your Auth0 application information @@ -19,7 +19,7 @@ export default class Auth0 { * @memberof Auth0 */ constructor(options = {}) { - const { domain, clientId, ...extras } = options; + const {domain, clientId, ...extras} = options; this.auth = new Auth({baseUrl: domain, clientId, ...extras}); this.webAuth = new WebAuth(this.auth); this.options = options; @@ -31,7 +31,7 @@ export default class Auth0 { * @return {Users} */ users(token) { - const { domain, clientId, ...extras } = this.options; + const {domain, clientId, ...extras} = this.options; return new Users({baseUrl: domain, clientId, ...extras, token}); } -}; \ No newline at end of file +} diff --git a/src/auth/__tests__/__snapshots__/index.spec.js.snap b/src/auth/__tests__/__snapshots__/index.spec.js.snap index 8810995a..9b349c44 100644 --- a/src/auth/__tests__/__snapshots__/index.spec.js.snap +++ b/src/auth/__tests__/__snapshots__/index.spec.js.snap @@ -124,6 +124,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -163,6 +164,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -178,6 +180,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -207,6 +210,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -237,6 +241,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -252,6 +257,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -267,6 +273,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -302,6 +309,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -317,6 +325,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -332,6 +341,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -347,6 +357,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -362,6 +373,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -377,6 +389,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -392,6 +405,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -407,6 +421,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -422,6 +437,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -437,6 +453,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -452,6 +469,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -485,6 +503,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -506,6 +525,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -546,6 +566,7 @@ Array [ "Content-Type": "application/json", }, "method": "GET", + "signal": AbortSignal {}, }, ] `; diff --git a/src/management/__tests__/__snapshots__/users.spec.js.snap b/src/management/__tests__/__snapshots__/users.spec.js.snap index 20dac04e..75b9bb1f 100644 --- a/src/management/__tests__/__snapshots__/users.spec.js.snap +++ b/src/management/__tests__/__snapshots__/users.spec.js.snap @@ -128,6 +128,7 @@ Array [ "Content-Type": "application/json", }, "method": "GET", + "signal": AbortSignal {}, }, ] `; @@ -261,6 +262,7 @@ Array [ "Content-Type": "application/json", }, "method": "PATCH", + "signal": AbortSignal {}, }, ] `; diff --git a/src/networking/__tests__/__snapshots__/index.spec.js.snap b/src/networking/__tests__/__snapshots__/index.spec.js.snap index a8b35bec..c14f805e 100644 --- a/src/networking/__tests__/__snapshots__/index.spec.js.snap +++ b/src/networking/__tests__/__snapshots__/index.spec.js.snap @@ -13,6 +13,7 @@ Array [ "Content-Type": "application/json", }, "method": "GET", + "signal": AbortSignal {}, }, ] `; @@ -28,6 +29,7 @@ Array [ "Content-Type": "application/json", }, "method": "GET", + "signal": AbortSignal {}, }, ] `; @@ -77,6 +79,7 @@ Array [ "Content-Type": "application/json", }, "method": "PATCH", + "signal": AbortSignal {}, }, ] `; @@ -92,6 +95,7 @@ Array [ "Content-Type": "application/json", }, "method": "PATCH", + "signal": AbortSignal {}, }, ] `; @@ -141,6 +145,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; @@ -156,6 +161,7 @@ Array [ "Content-Type": "application/json", }, "method": "POST", + "signal": AbortSignal {}, }, ] `; diff --git a/src/networking/__tests__/index.spec.js b/src/networking/__tests__/index.spec.js index df59ccee..c41520cd 100644 --- a/src/networking/__tests__/index.spec.js +++ b/src/networking/__tests__/index.spec.js @@ -1,6 +1,7 @@ import Client from '../'; import defaults from '../telemetry'; import fetchMock from 'fetch-mock'; +import {TimeoutError} from '../../utils/fetchWithTimeout'; describe('client', () => { const domain = 'samples.auth0.com'; @@ -8,37 +9,37 @@ describe('client', () => { describe('constructor', () => { it('should accept only baseUrl', () => { - const client = new Client({ baseUrl }); + const client = new Client({baseUrl}); expect(client.baseUrl).toEqual(baseUrl); expect(client.telemetry).toMatchObject(defaults); }); it('should accept only domain', () => { - const client = new Client({ baseUrl: domain }); + const client = new Client({baseUrl: domain}); expect(client.baseUrl).toEqual(baseUrl); expect(client.telemetry).toMatchObject(defaults); }); it('should accept only http baseUrl', () => { - const client = new Client({ baseUrl: 'http://insecure.com' }); + const client = new Client({baseUrl: 'http://insecure.com'}); expect(client.baseUrl).toEqual('http://insecure.com'); expect(client.telemetry).toMatchObject(defaults); }); it('should allow to customize telemetry', () => { - const custom = { name: 'react-native-lock', version: '1.0.0-rc.1' }; - const client = new Client({ baseUrl, telemetry: custom }); + const custom = {name: 'react-native-lock', version: '1.0.0-rc.1'}; + const client = new Client({baseUrl, telemetry: custom}); expect(client.telemetry).toMatchObject({ ...custom, env: { - 'react-native-auth0': defaults.version - } + 'react-native-auth0': defaults.version, + }, }); }); it('should allow to specify a bearer token', () => { const token = 'a.bearer.token'; - const client = new Client({ baseUrl, token }); + const client = new Client({baseUrl, token}); expect(client.bearer).toEqual('Bearer a.bearer.token'); }); @@ -50,8 +51,8 @@ describe('client', () => { describe('requests', () => { const client = new Client({ baseUrl, - telemetry: { name: 'react-native-auth0', version: '1.0.0' }, - token: 'a.bearer.token' + telemetry: {name: 'react-native-auth0', version: '1.0.0'}, + token: 'a.bearer.token', }); beforeEach(fetchMock.restore); @@ -59,15 +60,15 @@ describe('client', () => { describe('POST json', () => { const body = { string: 'value', - number: 10 + number: 10, }; const response = { body: { - key: 'value' + key: 'value', }, headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }; it('should build proper request with body', async () => { @@ -91,14 +92,14 @@ describe('client', () => { }); it('should handle no response', async () => { - fetchMock.postOnce('https://samples.auth0.com/method', { status: 201 }); + fetchMock.postOnce('https://samples.auth0.com/method', {status: 201}); expect.assertions(1); await expect(client.post('/method', body)).resolves.toMatchSnapshot(); }); it('should handle request error', async () => { fetchMock.postOnce('https://samples.auth0.com/method', { - throws: new Error('pawned!') + throws: new Error('pawned!'), }); expect.assertions(1); await expect(client.post('/method', body)).rejects.toMatchSnapshot(); @@ -108,15 +109,15 @@ describe('client', () => { describe('PATCH json', () => { const body = { string: 'value', - number: 10 + number: 10, }; const response = { body: { - key: 'value' + key: 'value', }, headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }; it('should build proper request with body', async () => { @@ -141,7 +142,7 @@ describe('client', () => { it('should handle no response', async () => { fetchMock.patchOnce('https://samples.auth0.com/method', { - status: 201 + status: 201, }); expect.assertions(1); await expect(client.patch('/method', body)).resolves.toMatchSnapshot(); @@ -149,7 +150,7 @@ describe('client', () => { it('should handle request error', async () => { fetchMock.patchOnce('https://samples.auth0.com/method', { - throws: new Error('pawned!') + throws: new Error('pawned!'), }); expect.assertions(1); await expect(client.patch('/method', body)).rejects.toMatchSnapshot(); @@ -159,21 +160,21 @@ describe('client', () => { describe('GET json', () => { const query = { string: 'value', - number: 10 + number: 10, }; const response = { body: { - key: 'value' + key: 'value', }, headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }; it('should build proper request with query', async () => { fetchMock.getOnce( 'https://samples.auth0.com/method?string=value&number=10', - response + response, ); expect.assertions(1); await client.get('/method', query); @@ -190,7 +191,7 @@ describe('client', () => { it('should return json on success', async () => { fetchMock.getOnce( 'https://samples.auth0.com/method?string=value&number=10', - response + response, ); expect.assertions(1); await expect(client.get('/method', query)).resolves.toMatchSnapshot(); @@ -199,7 +200,7 @@ describe('client', () => { it('should handle no response', async () => { fetchMock.getOnce( 'https://samples.auth0.com/method?string=value&number=10', - { status: 201 } + {status: 201}, ); expect.assertions(1); await expect(client.get('/method', query)).resolves.toMatchSnapshot(); @@ -208,7 +209,7 @@ describe('client', () => { it('should handle request error', async () => { fetchMock.getOnce( 'https://samples.auth0.com/method?string=value&number=10', - { throws: new Error('pawned!') } + {throws: new Error('pawned!')}, ); expect.assertions(1); await expect(client.get('/method', query)).rejects.toMatchSnapshot(); @@ -219,8 +220,8 @@ describe('client', () => { describe('url', () => { const client = new Client({ baseUrl, - telemetry: { name: 'react-native-auth0', version: '1.0.0' }, - token: 'a.bearer.token' + telemetry: {name: 'react-native-auth0', version: '1.0.0'}, + token: 'a.bearer.token', }); it('should build url with no query', () => { @@ -233,8 +234,53 @@ describe('client', () => { it('should build url with query', () => { expect( - client.url('/authorize', { client_id: 'A_CLIENT_ID' }, true) + client.url('/authorize', {client_id: 'A_CLIENT_ID'}, true), ).toMatchSnapshot(); }); }); + + describe('timeout', () => { + const client = new Client({ + baseUrl, + telemetry: {name: 'react-native-auth0', version: '1.0.0'}, + token: 'a.bearer.token', + timeout: 2, + }); + + const response = { + body: { + key: 'value', + }, + headers: { + 'Content-Type': 'application/json', + }, + }; + + let responseTimerId = null; + + beforeEach(() => { + fetchMock.restore(); + + fetchMock.mock('https://samples.auth0.com/method', () => { + return new Promise(resolve => { + responseTimerId = setTimeout(() => { + resolve(response); + }, 2000); + }); + }); + }); + + afterEach(() => { + if (responseTimerId) { + clearTimeout(responseTimerId); + } + }); + + it('aborts the request and throws TimeoutError if the request takes longer than specified timeout', async () => { + await expect(client.get('/method')).rejects.toThrow(TimeoutError); + + const [_, fetchOptions] = fetchMock.lastCall(); + expect(fetchOptions.signal.aborted).toBeTruthy(); + }); + }); }); diff --git a/src/networking/index.js b/src/networking/index.js index a0626410..5447870e 100644 --- a/src/networking/index.js +++ b/src/networking/index.js @@ -1,15 +1,16 @@ import defaults from './telemetry'; import url from 'url'; import base64 from 'base-64'; +import {fetchWithTimeout} from '../utils/fetchWithTimeout'; export default class Client { constructor(options) { - const { baseUrl, telemetry = {}, token } = options; + const {baseUrl, telemetry = {}, token, timeout = 10000} = options; if (!baseUrl) { throw new Error('Missing Auth0 domain'); } - const { name = defaults.name, version = defaults.version } = telemetry; - this.telemetry = { name, version }; + const {name = defaults.name, version = defaults.version} = telemetry; + this.telemetry = {name, version}; if (name !== defaults.name) { this.telemetry.env = {}; this.telemetry.env[defaults.name] = defaults.version; @@ -23,6 +24,8 @@ export default class Client { if (token) { this.bearer = `Bearer ${token}`; } + + this.timeout = timeout; } post(path, body) { @@ -59,13 +62,15 @@ export default class Client { 'Auth0-Client': this._encodedTelemetry(), }, }; + if (this.bearer) { options.headers.Authorization = this.bearer; } if (body) { options.body = JSON.stringify(body); } - return fetch(url, options).then(response => { + + return fetchWithTimeout(url, options, this.timeout).then(response => { const payload = { status: response.status, ok: response.ok, @@ -74,16 +79,16 @@ export default class Client { return response .json() .then(json => { - return { ...payload, json }; + return {...payload, json}; }) .catch(() => { return response .text() .then(text => { - return { ...payload, text }; + return {...payload, text}; }) .catch(() => { - return { ...payload, text: response.statusText }; + return {...payload, text: response.statusText}; }); }); }); diff --git a/src/utils/fetchWithTimeout.js b/src/utils/fetchWithTimeout.js new file mode 100644 index 00000000..cf995c12 --- /dev/null +++ b/src/utils/fetchWithTimeout.js @@ -0,0 +1,51 @@ +import BaseError from './baseError'; + +class TimeoutError extends BaseError { + constructor(msg) { + super('TimeoutError', msg); + } +} + +function makeTimeout(timeoutMs) { + let timerId = null; + + const promise = new Promise((_, reject) => { + timerId = setTimeout(() => { + reject(new TimeoutError('Timeout')); + }, timeoutMs); + }); + + return { + timerId: timerId, + promise: promise, + }; +} + +function fetchWithTimeout(url, options, timeoutMs) { + const {promise: timeoutPromise, timerId} = makeTimeout(timeoutMs); + const abortController = new AbortController(); + + return Promise.race([ + fetch(url, { + ...options, + signal: abortController.signal, + }), + timeoutPromise, + ]) + .catch(error => { + if (error instanceof TimeoutError) { + abortController.abort(); + } + + throw error; + }) + .then(response => { + clearTimeout(timerId); + return response; + }); +} + +module.exports = { + fetchWithTimeout, + TimeoutError, +};