UNPKG

dropbox

Version:

The Dropbox JavaScript SDK is a lightweight, promise based interface to the Dropbox v2 API that works in both nodejs and browser environments.

404 lines (365 loc) 13.8 kB
import { getTokenExpiresAtDate, isBrowserEnv, createBrowserSafeString, OAuth2AuthorizationUrl, OAuth2TokenUrl, isWorkerEnv, } from './utils.js'; import { parseResponse } from './response.js'; let fetch; let crypto; let Encoder; // Expiration is 300 seconds but needs to be in milliseconds for Date object const TokenExpirationBuffer = 300 * 1000; const PKCELength = 128; const TokenAccessTypes = ['legacy', 'offline', 'online']; const GrantTypes = ['code', 'token']; const IncludeGrantedScopes = ['none', 'user', 'team']; /** * @class DropboxAuth * @classdesc The DropboxAuth class that provides methods to manage, acquire, and refresh tokens. * @arg {Object} options * @arg {Function} [options.fetch] - fetch library for making requests. * @arg {String} [options.accessToken] - An access token for making authenticated * requests. * @arg {Date} [options.AccessTokenExpiresAt] - Date of the current access token's * expiration (if available) * @arg {String} [options.refreshToken] - A refresh token for retrieving access tokens * @arg {String} [options.clientId] - The client id for your app. Used to create * 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. * @arg {String} [options.domainDelimiter] - A custom delimiter to use when separating domain from * subdomain. This should only be used for testing as scaffolding. * @arg {Object} [options.customHeaders] - An object (in the form of header: value) designed to set * custom headers to use during a request. * @arg {Boolean} [options.dataOnBody] - Whether request data is sent on body or as URL params. * Defaults to false. */ export default class DropboxAuth { constructor(options) { options = options || {}; if (isBrowserEnv()) { fetch = window.fetch.bind(window); crypto = window.crypto || window.msCrypto; // for IE11 } else if (isWorkerEnv()) { /* eslint-disable no-restricted-globals */ fetch = self.fetch.bind(self); crypto = self.crypto; /* eslint-enable no-restricted-globals */ } else { fetch = require('node-fetch'); // eslint-disable-line global-require crypto = require('crypto'); // eslint-disable-line global-require } if (typeof TextEncoder === 'undefined') { Encoder = require('util').TextEncoder; // eslint-disable-line global-require } else { Encoder = TextEncoder; } this.fetch = options.fetch || fetch; this.accessToken = options.accessToken; this.accessTokenExpiresAt = options.accessTokenExpiresAt; this.refreshToken = options.refreshToken; this.clientId = options.clientId; this.clientSecret = options.clientSecret; this.domain = options.domain; this.domainDelimiter = options.domainDelimiter; this.customHeaders = options.customHeaders; this.dataOnBody = options.dataOnBody; } /** * Set the access token used to authenticate requests to the API. * @arg {String} accessToken - An access token * @returns {undefined} */ setAccessToken(accessToken) { this.accessToken = accessToken; } /** * Get the access token * @returns {String} Access token */ getAccessToken() { return this.accessToken; } /** * Set the client id, which is used to help gain an access token. * @arg {String} clientId - Your apps client id * @returns {undefined} */ setClientId(clientId) { this.clientId = clientId; } /** * Get the client id * @returns {String} Client id */ getClientId() { return this.clientId; } /** * Set the client secret * @arg {String} clientSecret - Your app's client secret * @returns {undefined} */ setClientSecret(clientSecret) { this.clientSecret = clientSecret; } /** * Get the client secret * @returns {String} Client secret */ getClientSecret() { return this.clientSecret; } /** * Gets the refresh token * @returns {String} Refresh token */ getRefreshToken() { return this.refreshToken; } /** * Sets the refresh token * @param refreshToken - A refresh token */ setRefreshToken(refreshToken) { this.refreshToken = refreshToken; } /** * Gets the access token's expiration date * @returns {Date} date of token expiration */ getAccessTokenExpiresAt() { return this.accessTokenExpiresAt; } /** * Sets the access token's expiration date * @param accessTokenExpiresAt - new expiration date */ setAccessTokenExpiresAt(accessTokenExpiresAt) { this.accessTokenExpiresAt = accessTokenExpiresAt; } /** * Sets the code verifier for PKCE flow * @param {String} codeVerifier - new code verifier */ setCodeVerifier(codeVerifier) { this.codeVerifier = codeVerifier; } /** * Gets the code verifier for PKCE flow * @returns {String} - code verifier for PKCE */ getCodeVerifier() { return this.codeVerifier; } generateCodeChallenge() { const encoder = new Encoder(); const codeData = encoder.encode(this.codeVerifier); let codeChallenge; if (isBrowserEnv() || isWorkerEnv()) { return crypto.subtle.digest('SHA-256', codeData) .then((digestedHash) => { const base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(digestedHash))); codeChallenge = createBrowserSafeString(base64String).substr(0, 128); this.codeChallenge = codeChallenge; }); } const digestedHash = crypto.createHash('sha256').update(codeData).digest(); codeChallenge = createBrowserSafeString(digestedHash); this.codeChallenge = codeChallenge; return Promise.resolve(); } generatePKCECodes() { let codeVerifier; if (isBrowserEnv() || isWorkerEnv()) { const array = new Uint8Array(PKCELength); const randomValueArray = crypto.getRandomValues(array); const base64String = btoa(randomValueArray); codeVerifier = createBrowserSafeString(base64String).substr(0, 128); } else { const randomBytes = crypto.randomBytes(PKCELength); codeVerifier = createBrowserSafeString(randomBytes).substr(0, 128); } this.codeVerifier = codeVerifier; return this.generateCodeChallenge(); } /** * Get a URL that can be used to authenticate users for the Dropbox API. * @arg {String} redirectUri - A URL to redirect the user to after * authenticating. This must be added to your app through the admin interface. * @arg {String} [state] - State that will be returned in the redirect URL to help * prevent cross site scripting attacks. * @arg {String} [authType] - auth type, defaults to 'token', other option is 'code' * @arg {String} [tokenAccessType] - type of token to request. From the following: * null - creates a token with the app default (either legacy or online) * legacy - creates one long-lived token with no expiration * online - create one short-lived token with an expiration * offline - create one short-lived token with an expiration with a refresh token * @arg {Array<String>} [scope] - scopes to request for the grant * @arg {String} [includeGrantedScopes] - whether or not to include previously granted scopes. * From the following: * user - include user scopes in the grant * team - include team scopes in the grant * Note: if this user has never linked the app, include_granted_scopes must be None * @arg {boolean} [usePKCE] - Whether or not to use Sha256 based PKCE. PKCE should be only use * on client apps which doesn't call your server. It is less secure than non-PKCE flow but * can be used if you are unable to safely retrieve your app secret * @returns {Promise<String>} - Url to send user to for Dropbox API authentication * returned in a promise */ getAuthenticationUrl(redirectUri, state, authType = 'token', tokenAccessType = null, scope = null, includeGrantedScopes = 'none', usePKCE = false) { const clientId = this.getClientId(); const baseUrl = OAuth2AuthorizationUrl(this.domain); if (!clientId) { throw new Error('A client id is required. You can set the client id using .setClientId().'); } if (authType !== 'code' && !redirectUri) { throw new Error('A redirect uri is required.'); } if (!GrantTypes.includes(authType)) { throw new Error('Authorization type must be code or token'); } if (tokenAccessType && !TokenAccessTypes.includes(tokenAccessType)) { throw new Error('Token Access Type must be legacy, offline, or online'); } if (scope && !(scope instanceof Array)) { throw new Error('Scope must be an array of strings'); } if (!IncludeGrantedScopes.includes(includeGrantedScopes)) { throw new Error('includeGrantedScopes must be none, user, or team'); } let authUrl; if (authType === 'code') { authUrl = `${baseUrl}?response_type=code&client_id=${clientId}`; } else { authUrl = `${baseUrl}?response_type=token&client_id=${clientId}`; } if (redirectUri) { authUrl += `&redirect_uri=${redirectUri}`; } if (state) { authUrl += `&state=${state}`; } if (tokenAccessType) { authUrl += `&token_access_type=${tokenAccessType}`; } if (scope) { authUrl += `&scope=${scope.join(' ')}`; } if (includeGrantedScopes !== 'none') { authUrl += `&include_granted_scopes=${includeGrantedScopes}`; } if (usePKCE) { return this.generatePKCECodes() .then(() => { authUrl += '&code_challenge_method=S256'; authUrl += `&code_challenge=${this.codeChallenge}`; return authUrl; }); } return Promise.resolve(authUrl); } /** * Get an OAuth2 access token from an OAuth2 Code. * @arg {String} redirectUri - A URL to redirect the user to after * authenticating. This must be added to your app through the admin interface. * @arg {String} code - An OAuth2 code. * @returns {Object} An object containing the token and related info (if applicable) */ getAccessTokenFromCode(redirectUri, code) { const clientId = this.getClientId(); const clientSecret = this.getClientSecret(); if (!clientId) { throw new Error('A client id is required. You can set the client id using .setClientId().'); } let path = OAuth2TokenUrl(this.domain, this.domainDelimiter); path += '?grant_type=authorization_code'; path += `&code=${code}`; path += `&client_id=${clientId}`; if (clientSecret) { path += `&client_secret=${clientSecret}`; } else { if (!this.codeVerifier) { throw new Error('You must use PKCE when generating the authorization URL to not include a client secret'); } path += `&code_verifier=${this.codeVerifier}`; } if (redirectUri) { path += `&redirect_uri=${redirectUri}`; } const fetchOptions = { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }; return this.fetch(path, fetchOptions) .then((res) => parseResponse(res)); } /** * Checks if a token is needed, can be refreshed and if the token is expired. * If so, attempts to refresh access token * @returns {Promise<*>} */ checkAndRefreshAccessToken() { const canRefresh = this.getRefreshToken() && this.getClientId(); const needsRefresh = !this.getAccessTokenExpiresAt() || (new Date(Date.now() + TokenExpirationBuffer)) >= this.getAccessTokenExpiresAt(); const needsToken = !this.getAccessToken(); if ((needsRefresh || needsToken) && canRefresh) { return this.refreshAccessToken(); } return Promise.resolve(); } /** * Refreshes the access token using the refresh token, if available * @arg {Array<String>} scope - a subset of scopes from the original * refresh to acquire with an access token * @returns {Promise<*>} */ refreshAccessToken(scope = null) { const clientId = this.getClientId(); const clientSecret = this.getClientSecret(); if (!clientId) { throw new Error('A client id is required. You can set the client id using .setClientId().'); } if (scope && !(scope instanceof Array)) { throw new Error('Scope must be an array of strings'); } let refreshUrl = OAuth2TokenUrl(this.domain, this.domainDelimiter); const fetchOptions = { headers: { 'Content-Type': 'application/json' }, method: 'POST', }; if (this.dataOnBody) { const body = { grant_type: 'refresh_token', client_id: clientId, refresh_token: this.getRefreshToken() }; if (clientSecret) { body.client_secret = clientSecret; } if (scope) { body.scope = scope.join(' '); } fetchOptions.body = body; } else { refreshUrl += `?grant_type=refresh_token&refresh_token=${this.getRefreshToken()}`; refreshUrl += `&client_id=${clientId}`; if (clientSecret) { refreshUrl += `&client_secret=${clientSecret}`; } if (scope) { refreshUrl += `&scope=${scope.join(' ')}`; } } return this.fetch(refreshUrl, fetchOptions) .then((res) => parseResponse(res)) .then((res) => { this.setAccessToken(res.result.access_token); this.setAccessTokenExpiresAt(getTokenExpiresAtDate(res.result.expires_in)); }); } }