From 038089b109f00968625215a8cb9270c6d1a21f01 Mon Sep 17 00:00:00 2001 From: Brad Rogers Date: Mon, 12 Apr 2021 10:06:58 -0700 Subject: [PATCH] Add support for custom domains (#609) - Add ability to pass in custom domains to Dropbox and DropboxAuth objects - Refactor url generation to function based on hosts --- generator/typescript/index.d.tstemplate | 4 ++ package.json | 4 +- src/auth.js | 14 +++-- src/constants.js | 3 + src/dropbox.js | 12 ++-- src/utils.js | 18 +++--- test/unit/auth.js | 82 +++++++++++++------------ test/unit/utils.js | 43 +++++++++++-- types/index.d.ts | 4 ++ 9 files changed, 122 insertions(+), 62 deletions(-) diff --git a/generator/typescript/index.d.tstemplate b/generator/typescript/index.d.tstemplate index 19f5bd28..e0503705 100644 --- a/generator/typescript/index.d.tstemplate +++ b/generator/typescript/index.d.tstemplate @@ -17,6 +17,8 @@ export interface DropboxAuthOptions { clientSecret?: string; // The fetch library for making requests. fetch?: Function; + // A custom domain to use when making api requests. This should only be used for testing as scaffolding to avoid making network requests. + domain?: string; } export class DropboxAuth { @@ -169,6 +171,8 @@ export interface DropboxOptions { clientSecret?: string; // The fetch library for making requests. fetch?: Function; + // A custom domain to use when making api requests. This should only be used for testing as scaffolding to avoid making network requests. + domain?: string; } export class DropboxResponseError { diff --git a/package.json b/package.json index e71a8c2c..b767a2da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dropbox", - "version": "9.4.0", + "version": "9.5.0", "registry": "npm", "description": "The Dropbox JavaScript SDK is a lightweight, promise based interface to the Dropbox v2 API that works in both nodejs and browser environments.", "main": "cjs/index.js", @@ -107,4 +107,4 @@ "dependencies": { "node-fetch": "^2.6.1" } -} +} \ No newline at end of file diff --git a/src/auth.js b/src/auth.js index 12a72c37..3de1925e 100644 --- a/src/auth.js +++ b/src/auth.js @@ -2,6 +2,8 @@ import { getTokenExpiresAtDate, isBrowserEnv, createBrowserSafeString, + OAuth2AuthorizationUrl, + OAuth2TokenUrl, } from './utils.js'; import { parseResponse } from './response.js'; @@ -32,8 +34,6 @@ const PKCELength = 128; const TokenAccessTypes = ['legacy', 'offline', 'online']; const GrantTypes = ['code', 'token']; const IncludeGrantedScopes = ['none', 'user', 'team']; -const BaseAuthorizeUrl = 'https://www.dropbox.com/oauth2/authorize'; -const BaseTokenUrl = 'https://api.dropboxapi.com/oauth2/token'; /** * @class DropboxAuth @@ -49,6 +49,8 @@ const BaseTokenUrl = 'https://api.dropboxapi.com/oauth2/token'; * authentication URL. * @arg {String} [options.clientSecret] - The client secret for your app. Used to create * authentication URL and refresh access tokens. + * @arg {String} [options.domain] - A custom domain to use when making api requests. This + * should only be used for testing as scaffolding to avoid making network requests. */ export default class DropboxAuth { constructor(options) { @@ -60,6 +62,8 @@ export default class DropboxAuth { this.refreshToken = options.refreshToken; this.clientId = options.clientId; this.clientSecret = options.clientSecret; + + this.domain = options.domain; } /** @@ -221,7 +225,7 @@ export default class DropboxAuth { */ getAuthenticationUrl(redirectUri, state, authType = 'token', tokenAccessType = null, scope = null, includeGrantedScopes = 'none', usePKCE = false) { const clientId = this.getClientId(); - const baseUrl = BaseAuthorizeUrl; + const baseUrl = OAuth2AuthorizationUrl(this.domain); if (!clientId) { throw new Error('A client id is required. You can set the client id using .setClientId().'); @@ -289,7 +293,7 @@ export default class DropboxAuth { if (!clientId) { throw new Error('A client id is required. You can set the client id using .setClientId().'); } - let path = BaseTokenUrl; + let path = OAuth2TokenUrl(this.domain); path += '?grant_type=authorization_code'; path += `&code=${code}`; path += `&client_id=${clientId}`; @@ -339,7 +343,7 @@ export default class DropboxAuth { * @returns {Promise<*>} */ refreshAccessToken(scope = null) { - let refreshUrl = BaseTokenUrl; + let refreshUrl = OAuth2TokenUrl(this.domain); const clientId = this.getClientId(); const clientSecret = this.getClientSecret(); diff --git a/src/constants.js b/src/constants.js index 747ffc38..dce9f397 100644 --- a/src/constants.js +++ b/src/constants.js @@ -6,3 +6,6 @@ export const APP_AUTH = 'app'; export const USER_AUTH = 'user'; export const TEAM_AUTH = 'team'; export const NO_AUTH = 'noauth'; + +export const DEFAULT_API_DOMAIN = 'dropboxapi.com'; +export const DEFAULT_DOMAIN = 'dropbox.com'; diff --git a/src/dropbox.js b/src/dropbox.js index 31fa40ee..6369e8d8 100644 --- a/src/dropbox.js +++ b/src/dropbox.js @@ -9,7 +9,7 @@ import { } from './constants.js'; import { routes } from '../lib/routes.js'; import DropboxAuth from './auth.js'; -import { getBaseURL, httpHeaderSafeJson } from './utils.js'; +import { baseApiUrl, httpHeaderSafeJson } from './utils.js'; import { parseDownloadResponse, parseResponse } from './response.js'; let fetch; @@ -46,6 +46,8 @@ const b64 = typeof btoa === 'undefined' * authentication URL. * @arg {String} [options.clientSecret] - The client secret for your app. Used to create * authentication URL and refresh access tokens. + * @arg {String} [options.domain] - A custom domain to use when making api requests. This + * should only be used for testing as scaffolding to avoid making network requests. */ export default class Dropbox { constructor(options) { @@ -62,6 +64,8 @@ export default class Dropbox { this.selectAdmin = options.selectAdmin; this.pathRoot = options.pathRoot; + this.domain = options.domain; + Object.assign(this, routes); } @@ -125,7 +129,7 @@ export default class Dropbox { this.setCommonHeaders(fetchOptions); return fetchOptions; }) - .then((fetchOptions) => this.fetch(getBaseURL(host) + path, fetchOptions)) + .then((fetchOptions) => this.fetch(baseApiUrl(host, this.domain) + path, fetchOptions)) .then((res) => parseResponse(res)); } @@ -148,7 +152,7 @@ export default class Dropbox { return fetchOptions; }) - .then((fetchOptions) => fetch(getBaseURL(host) + path, fetchOptions)) + .then((fetchOptions) => fetch(baseApiUrl(host, this.domain) + path, fetchOptions)) .then((res) => parseDownloadResponse(res)); } @@ -176,7 +180,7 @@ export default class Dropbox { return fetchOptions; }) - .then((fetchOptions) => this.fetch(getBaseURL(host) + path, fetchOptions)) + .then((fetchOptions) => this.fetch(baseApiUrl(host, this.domain) + path, fetchOptions)) .then((res) => parseResponse(res)); } diff --git a/src/utils.js b/src/utils.js index f9f33eb6..d1ea8586 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,11 +1,13 @@ +import { DEFAULT_API_DOMAIN, DEFAULT_DOMAIN } from './constants'; + function getSafeUnicode(c) { const unicode = `000${c.charCodeAt(0).toString(16)}`.slice(-4); return `\\u${unicode}`; } -export function getBaseURL(host) { - return `https://${host}.dropboxapi.com/2/`; -} +export const baseApiUrl = (subdomain, domain = DEFAULT_API_DOMAIN) => `https://${subdomain}.${domain}/2/`; +export const OAuth2AuthorizationUrl = (domain = DEFAULT_DOMAIN) => `https://${domain}/oauth2/authorize`; +export const OAuth2TokenUrl = (domain = DEFAULT_API_DOMAIN) => `https://api.${domain}/oauth2/token`; // source https://www.dropboxforum.com/t5/API-support/HTTP-header-quot-Dropbox-API-Arg-quot-could-not-decode-input-as/m-p/173823/highlight/true#M6786 export function httpHeaderSafeJson(args) { @@ -21,12 +23,12 @@ export function isWindowOrWorker() { return ( ( typeof WorkerGlobalScope !== 'undefined' - && self instanceof WorkerGlobalScope // eslint-disable-line no-restricted-globals - ) - || ( - typeof module === 'undefined' - || typeof window !== 'undefined' + && self instanceof WorkerGlobalScope // eslint-disable-line no-restricted-globals ) + || ( + typeof module === 'undefined' + || typeof window !== 'undefined' + ) ); } diff --git a/test/unit/auth.js b/test/unit/auth.js index 80f1409b..54d57953 100644 --- a/test/unit/auth.js +++ b/test/unit/auth.js @@ -1,90 +1,94 @@ import chai from 'chai'; import sinon from 'sinon'; import chaiAsPromised from 'chai-as-promised'; -import { Dropbox, DropboxAuth } from '../../index.js'; +import { DropboxAuth } from '../../index.js'; chai.use(chaiAsPromised); describe('DropboxAuth', () => { describe('accessToken', () => { it('can be set in the constructor', () => { - const dbx = new Dropbox({ accessToken: 'foo' }); - chai.assert.equal(dbx.auth.getAccessToken(), 'foo'); + const dbx = new DropboxAuth({ accessToken: 'foo' }); + chai.assert.equal(dbx.getAccessToken(), 'foo'); }); it('is undefined if not set in constructor', () => { - const dbx = new Dropbox(); - chai.assert.equal(dbx.auth.getAccessToken(), undefined); + const dbx = new DropboxAuth(); + chai.assert.equal(dbx.getAccessToken(), undefined); }); it('can be set after being instantiated', () => { - const dbx = new Dropbox(); - dbx.auth.setAccessToken('foo'); - chai.assert.equal(dbx.auth.getAccessToken(), 'foo'); + const dbx = new DropboxAuth(); + dbx.setAccessToken('foo'); + chai.assert.equal(dbx.getAccessToken(), 'foo'); }); }); describe('clientId', () => { it('can be set in the constructor', () => { - const dbx = new Dropbox({ clientId: 'foo' }); - chai.assert.equal(dbx.auth.getClientId(), 'foo'); + const dbx = new DropboxAuth({ clientId: 'foo' }); + chai.assert.equal(dbx.getClientId(), 'foo'); }); it('is undefined if not set in constructor', () => { - const dbx = new Dropbox(); - chai.assert.equal(dbx.auth.getClientId(), undefined); + const dbx = new DropboxAuth(); + chai.assert.equal(dbx.getClientId(), undefined); }); it('can be set after being instantiated', () => { - const dbx = new Dropbox(); - dbx.auth.setClientId('foo'); - chai.assert.equal(dbx.auth.getClientId(), 'foo'); + const dbx = new DropboxAuth(); + dbx.setClientId('foo'); + chai.assert.equal(dbx.getClientId(), 'foo'); }); }); describe('getAuthenticationUrl()', () => { it('throws an error if the client id isn\'t set', () => { - const dbx = new Dropbox(); + const dbx = new DropboxAuth(); chai.assert.throws( - DropboxAuth.prototype.getAuthenticationUrl.bind(dbx.auth, 'https://redirecturl.com'), + DropboxAuth.prototype.getAuthenticationUrl.bind(dbx, 'https://redirecturl.com'), Error, 'A client id is required. You can set the client id using .setClientId().', ); }); it('throws an error if the redirect url isn\'t set', () => { - const dbx = new Dropbox({ clientId: 'CLIENT_ID' }); + const dbx = new DropboxAuth({ clientId: 'CLIENT_ID' }); chai.assert.throws( - DropboxAuth.prototype.getAuthenticationUrl.bind(dbx.auth), + DropboxAuth.prototype.getAuthenticationUrl.bind(dbx), Error, 'A redirect uri is required.', ); }); it('throws an error if the redirect url isn\'t set and type is code', () => { - const dbx = new Dropbox({ clientId: 'CLIENT_ID' }); + const dbx = new DropboxAuth({ clientId: 'CLIENT_ID' }); return chai.expect( - dbx.auth.getAuthenticationUrl('', null, 'code'), - ).to.eventually.deep.equal('https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=CLIENT_ID'); + dbx.getAuthenticationUrl('', null, 'code'), + ).to.eventually.deep.equal('https://dropbox.com/oauth2/authorize?response_type=code&client_id=CLIENT_ID'); }); - const dbx = new Dropbox({ clientId: 'CLIENT_ID' }); + it('changes the domain if a custom domain is set', () => { + const dbx = new DropboxAuth({ + clientId: 'CLIENT_ID', + domain: 'mydomain.com', + }); + dbx.getAuthenticationUrl('localhost', null, 'code') + .then((url) => { + chai.assertEqual(url, 'https://mydomain.com/oauth2/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=localhost'); + }); + }); + + const dbx = new DropboxAuth({ clientId: 'CLIENT_ID' }); for (const redirectUri of ['', 'localhost']) { for (const state of ['', 'state']) { for (const tokenAccessType of [null, 'legacy', 'offline', 'online']) { for (const scope of [null, ['files.metadata.read', 'files.metadata.write']]) { for (const includeGrantedScopes of ['none', 'user', 'team']) { - const input = { - redirectUri, - state, - tokenAccessType, - scope, - includeGrantedScopes, - }; it(`returns correct auth url with all combinations of valid input. redirectUri: ${redirectUri}, state: ${state}, tokenAccessType: ${tokenAccessType}, scope: ${scope}, includeGrantedScopes: ${includeGrantedScopes}`, (done) => { - dbx.auth.getAuthenticationUrl(redirectUri, state, 'code', tokenAccessType, scope, includeGrantedScopes) // eslint-disable-line no-await-in-loop + dbx.getAuthenticationUrl(redirectUri, state, 'code', tokenAccessType, scope, includeGrantedScopes) // eslint-disable-line no-await-in-loop .then((url) => { - chai.assert(url.startsWith('https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=CLIENT_ID')); + chai.assert(url.startsWith('https://dropbox.com/oauth2/authorize?response_type=code&client_id=CLIENT_ID')); if (redirectUri) { chai.assert(url.includes(`&redirect_uri=${redirectUri}`)); @@ -128,19 +132,19 @@ describe('DropboxAuth', () => { describe('clientSecret', () => { it('can be set in the constructor', () => { - const dbx = new Dropbox({ clientSecret: 'foo' }); - chai.assert.equal(dbx.auth.getClientSecret(), 'foo'); + const dbx = new DropboxAuth({ clientSecret: 'foo' }); + chai.assert.equal(dbx.getClientSecret(), 'foo'); }); it('is undefined if not set in constructor', () => { - const dbx = new Dropbox(); - chai.assert.equal(dbx.auth.getClientSecret(), undefined); + const dbx = new DropboxAuth(); + chai.assert.equal(dbx.getClientSecret(), undefined); }); it('can be set after being instantiated', () => { - const dbx = new Dropbox(); - dbx.auth.setClientSecret('foo'); - chai.assert.equal(dbx.auth.getClientSecret(), 'foo'); + const dbx = new DropboxAuth(); + dbx.setClientSecret('foo'); + chai.assert.equal(dbx.getClientSecret(), 'foo'); }); }); diff --git a/test/unit/utils.js b/test/unit/utils.js index 38a0aeff..1befb0da 100644 --- a/test/unit/utils.js +++ b/test/unit/utils.js @@ -1,18 +1,53 @@ import { fail } from 'assert'; import chai from 'chai'; import { - getBaseURL, + baseApiUrl, getTokenExpiresAtDate, isWindowOrWorker, + OAuth2AuthorizationUrl, + OAuth2TokenUrl, } from '../../src/utils.js'; describe('Dropbox utils', () => { - describe('getBaseUrl', () => { - it('correctly sets base url when provided a host', () => { + describe('baseApiUrl', () => { + it('correctly sets base url when provided a subdomain', () => { const host = 'test'; - const testUrl = getBaseURL(host); + const testUrl = baseApiUrl(host); chai.assert.equal(testUrl, 'https://test.dropboxapi.com/2/'); }); + + it('correctly sets base url when provided a subdomain and domain', () => { + const host = 'test'; + const domain = 'mydomain.com'; + const testUrl = baseApiUrl(host, domain); + chai.assert.equal(testUrl, 'https://test.mydomain.com/2/'); + }); + }); + + describe('OAuth2AuthorizationUrl', () => { + it('correctly returns the authorization url when not provided an override', () => { + const testUrl = OAuth2AuthorizationUrl(); + chai.assert.equal(testUrl, 'https://dropbox.com/oauth2/authorize'); + }); + + it('correctly returns the authorization url when provided an override', () => { + const domain = 'mydomain.com'; + const testUrl = OAuth2AuthorizationUrl(domain); + chai.assert.equal(testUrl, 'https://mydomain.com/oauth2/authorize'); + }); + }); + + describe('OAuth2TokenUrl', () => { + it('correctly returns the authorization url when not provided an override', () => { + const testUrl = OAuth2TokenUrl(); + chai.assert.equal(testUrl, 'https://api.dropboxapi.com/oauth2/token'); + }); + + it('correctly returns the authorization url when provided an override', () => { + const domain = 'mydomain.com'; + const testUrl = OAuth2TokenUrl(domain); + chai.assert.equal(testUrl, 'https://api.mydomain.com/oauth2/token'); + }); }); describe('getTokenExpiresAtDate', () => { diff --git a/types/index.d.ts b/types/index.d.ts index 3ad06206..89ed1a7c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -17,6 +17,8 @@ export interface DropboxAuthOptions { clientSecret?: string; // The fetch library for making requests. fetch?: Function; + // A custom domain to use when making api requests. This should only be used for testing as scaffolding to avoid making network requests. + domain?: string; } export class DropboxAuth { @@ -169,6 +171,8 @@ export interface DropboxOptions { clientSecret?: string; // The fetch library for making requests. fetch?: Function; + // A custom domain to use when making api requests. This should only be used for testing as scaffolding to avoid making network requests. + domain?: string; } export class DropboxResponseError {