Prechádzať zdrojové kódy

companion: add support to allow custom oauth credentials at request time (#2622)

* companion: add support to allow custom oauth credentials at request time

other affected ackages: dropbox, google-drive, zoom, instagram, facebook, onedrive, provider-views

* companion: avoid re-using "state" value from previous oauth dance

* companion: fix tests

* companion: add test for preauth endpoint

* companion: document functions

* companion: add tests for oauth credentials fetcher module

* companion: rename "params" to "parameters" to avoid ambiguity with transloadit's API

* companion: fix typos

Co-authored-by: Renée Kooi <renee@kooi.me>

* companion-client: use qs-stringify for url queries

* companion-client: rename "credentialsRequestParams" -> "companionKeysParams"

Co-authored-by: Renée Kooi <renee@kooi.me>
Ifedapo .A. Olarewaju 4 rokov pred
rodič
commit
c7b097cd90
33 zmenil súbory, kde vykonal 638 pridanie a 197 odobranie
  1. 2 1
      package-lock.json
  2. 2 1
      packages/@uppy/companion-client/package.json
  3. 37 5
      packages/@uppy/companion-client/src/Provider.js
  4. 11 101
      packages/@uppy/companion/src/companion.js
  5. 1 1
      packages/@uppy/companion/src/server/controllers/callback.js
  6. 4 0
      packages/@uppy/companion/src/server/controllers/connect.js
  7. 1 0
      packages/@uppy/companion/src/server/controllers/index.js
  8. 1 1
      packages/@uppy/companion/src/server/controllers/logout.js
  9. 19 0
      packages/@uppy/companion/src/server/controllers/preauth.js
  10. 41 12
      packages/@uppy/companion/src/server/helpers/jwt.js
  11. 1 0
      packages/@uppy/companion/src/server/logger.js
  12. 2 2
      packages/@uppy/companion/src/server/middlewares.js
  13. 137 0
      packages/@uppy/companion/src/server/provider/credentials.js
  14. 11 2
      packages/@uppy/companion/src/server/provider/index.js
  15. 48 49
      packages/@uppy/companion/src/server/provider/zoom/index.js
  16. 43 0
      packages/@uppy/companion/src/server/s3-client.js
  17. 65 0
      packages/@uppy/companion/src/server/socket.js
  18. 13 6
      packages/@uppy/companion/src/standalone/helper.js
  19. 14 3
      packages/@uppy/companion/test/__mocks__/purest.js
  20. 1 1
      packages/@uppy/companion/test/__tests__/callback.js
  21. 1 1
      packages/@uppy/companion/test/__tests__/companion.js
  22. 65 0
      packages/@uppy/companion/test/__tests__/credentials.js
  23. 67 0
      packages/@uppy/companion/test/__tests__/preauth.js
  24. 1 1
      packages/@uppy/companion/test/__tests__/providers.js
  25. 15 1
      packages/@uppy/companion/test/fixtures/zoom.js
  26. 5 1
      packages/@uppy/dropbox/src/index.js
  27. 5 1
      packages/@uppy/facebook/src/index.js
  28. 5 1
      packages/@uppy/google-drive/src/index.js
  29. 5 1
      packages/@uppy/instagram/src/index.js
  30. 4 1
      packages/@uppy/onedrive/src/index.js
  31. 4 1
      packages/@uppy/provider-views/README.md
  32. 2 2
      packages/@uppy/provider-views/src/ProviderView/ProviderView.js
  33. 5 1
      packages/@uppy/zoom/src/index.js

+ 2 - 1
package-lock.json

@@ -8532,7 +8532,8 @@
       "version": "file:packages/@uppy/companion-client",
       "requires": {
         "@uppy/utils": "file:packages/@uppy/utils",
-        "namespace-emitter": "^2.0.1"
+        "namespace-emitter": "^2.0.1",
+        "qs-stringify": "^1.1.0"
       }
     },
     "@uppy/core": {

+ 2 - 1
packages/@uppy/companion-client/package.json

@@ -22,6 +22,7 @@
   },
   "dependencies": {
     "@uppy/utils": "file:../utils",
-    "namespace-emitter": "^2.0.1"
+    "namespace-emitter": "^2.0.1",
+    "qs-stringify": "^1.1.0"
   }
 }

+ 37 - 5
packages/@uppy/companion-client/src/Provider.js

@@ -1,5 +1,6 @@
 'use strict'
 
+const qsStringify = require('qs-stringify')
 const RequestClient = require('./RequestClient')
 const tokenStorage = require('./tokenStorage')
 
@@ -15,13 +16,25 @@ module.exports = class Provider extends RequestClient {
     this.name = this.opts.name || _getName(this.id)
     this.pluginId = this.opts.pluginId
     this.tokenKey = `companion-${this.pluginId}-auth-token`
+    this.companionKeysParams = this.opts.companionKeysParams
+    this.preAuthToken = null
   }
 
   headers () {
     return Promise.all([super.headers(), this.getAuthToken()])
-      .then(([headers, token]) =>
-        Object.assign({}, headers, { 'uppy-auth-token': token })
-      )
+      .then(([headers, token]) => {
+        const authHeaders = {}
+        if (token) {
+          authHeaders['uppy-auth-token'] = token
+        }
+
+        if (this.companionKeysParams) {
+          authHeaders['uppy-credentials-params'] = btoa(
+            JSON.stringify({ params: this.companionKeysParams })
+          )
+        }
+        return Object.assign({}, headers, authHeaders)
+      })
   }
 
   onReceiveResponse (response) {
@@ -42,14 +55,33 @@ module.exports = class Provider extends RequestClient {
     return this.uppy.getPlugin(this.pluginId).storage.getItem(this.tokenKey)
   }
 
-  authUrl () {
-    return `${this.hostname}/${this.id}/connect`
+  authUrl (queries = {}) {
+    if (this.preAuthToken) {
+      queries.uppyPreAuthToken = this.preAuthToken
+    }
+
+    let strigifiedQueries = qsStringify(queries)
+    strigifiedQueries = strigifiedQueries ? `?${strigifiedQueries}` : strigifiedQueries
+    return `${this.hostname}/${this.id}/connect${strigifiedQueries}`
   }
 
   fileUrl (id) {
     return `${this.hostname}/${this.id}/get/${id}`
   }
 
+  fetchPreAuthToken () {
+    if (!this.companionKeysParams) {
+      return Promise.resolve()
+    }
+
+    return this.post(`${this.id}/preauth/`, { params: this.companionKeysParams })
+      .then((res) => {
+        this.preAuthToken = res.token
+      }).catch((err) => {
+        this.uppy.log(`[CompanionClient] unable to fetch preAuthToken ${err}`, 'warning')
+      })
+  }
+
   list (directory) {
     return this.get(`${this.id}/list/${directory || ''}`)
   }

+ 11 - 101
packages/@uppy/companion/src/companion.js

@@ -1,26 +1,25 @@
 const fs = require('fs')
 const express = require('express')
+const ms = require('ms')
 // @ts-ignore
 const Grant = require('grant').express()
 const grantConfig = require('./config/grant')()
 const providerManager = require('./server/provider')
 const controllers = require('./server/controllers')
 const s3 = require('./server/controllers/s3')
+const getS3Client = require('./server/s3-client')
 const url = require('./server/controllers/url')
-const SocketServer = require('ws').Server
 const emitter = require('./server/emitter')
 const merge = require('lodash.merge')
 const redis = require('./server/redis')
 const cookieParser = require('cookie-parser')
-const { jsonStringify, getURLBuilder } = require('./server/helpers/utils')
+const { getURLBuilder } = require('./server/helpers/utils')
 const jobs = require('./server/jobs')
 const interceptor = require('express-interceptor')
 const logger = require('./server/logger')
-const { STORAGE_PREFIX } = require('./server/Uploader')
 const middlewares = require('./server/middlewares')
-const { shortenToken } = require('./server/Uploader')
 const { ProviderApiError, ProviderAuthError } = require('./server/provider/error')
-const ms = require('ms')
+const { getCredentialsOverrideMiddleware } = require('./server/provider/credentials')
 
 const defaultOptions = {
   server: {
@@ -42,6 +41,7 @@ const defaultOptions = {
 
 // make the errors available publicly for custom providers
 module.exports.errors = { ProviderApiError, ProviderAuthError }
+module.exports.socket = require('./server/socket')
 
 /**
  * Entry point into initializing the Companion app.
@@ -74,6 +74,8 @@ module.exports.app = (options = {}) => {
   app.use(cookieParser()) // server tokens are added to cookies
 
   app.use(interceptGrantErrorResponse)
+  // override provider credentials at request time
+  app.use('/connect/:authProvider/:override?', getCredentialsOverrideMiddleware(providers, options))
   app.use(Grant(grantConfig))
   app.use((req, res, next) => {
     res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
@@ -82,6 +84,7 @@ module.exports.app = (options = {}) => {
       [
         'uppy-auth-token',
         'uppy-versions',
+        'uppy-credentials-params',
         res.get('Access-Control-Allow-Headers')
       ].join(',')
     )
@@ -113,6 +116,7 @@ module.exports.app = (options = {}) => {
   app.use('/s3', s3(options.providerOptions.s3))
   app.use('/url', url())
 
+  app.post('/:providerName/preauth', middlewares.hasSessionAndProvider, controllers.preauth)
   app.get('/:providerName/connect', middlewares.hasSessionAndProvider, controllers.connect)
   app.get('/:providerName/redirect', middlewares.hasSessionAndProvider, controllers.redirect)
   app.get('/:providerName/callback', middlewares.hasSessionAndProvider, controllers.callback)
@@ -125,7 +129,7 @@ module.exports.app = (options = {}) => {
   app.get('/search/:searchProviderName/list', middlewares.hasSearchQuery, middlewares.loadSearchProviderToken, controllers.list)
   app.post('/search/:searchProviderName/get/:id', middlewares.loadSearchProviderToken, controllers.get)
 
-  app.param('providerName', providerManager.getProviderMiddleware(providers))
+  app.param('providerName', providerManager.getProviderMiddleware(providers, true))
   app.param('searchProviderName', providerManager.getProviderMiddleware(searchProviders))
 
   if (app.get('env') !== 'test') {
@@ -135,65 +139,6 @@ module.exports.app = (options = {}) => {
   return app
 }
 
-/**
- * the socket is used to send progress events during an upload
- *
- * @param {object} server
- */
-module.exports.socket = (server) => {
-  const wss = new SocketServer({ server })
-  const redisClient = redis.client()
-
-  // A new connection is usually created when an upload begins,
-  // or when connection fails while an upload is on-going and,
-  // client attempts to reconnect.
-  wss.on('connection', (ws, req) => {
-    // @ts-ignore
-    const fullPath = req.url
-    // the token identifies which ongoing upload's progress, the socket
-    // connection wishes to listen to.
-    const token = fullPath.replace(/^.*\/api\//, '')
-    logger.info(`connection received from ${token}`, 'socket.connect')
-
-    /**
-     *
-     * @param {{action: string, payload: object}} data
-     */
-    function sendProgress (data) {
-      ws.send(jsonStringify(data), (err) => {
-        if (err) logger.error(err, 'socket.progress.error', shortenToken(token))
-      })
-    }
-
-    // if the redisClient is available, then we attempt to check the storage
-    // if we have any already stored progress data on the upload.
-    if (redisClient) {
-      redisClient.get(`${STORAGE_PREFIX}:${token}`, (err, data) => {
-        if (err) logger.error(err, 'socket.redis.error', shortenToken(token))
-        if (data) {
-          const dataObj = JSON.parse(data.toString())
-          if (dataObj.action) sendProgress(dataObj)
-        }
-      })
-    }
-
-    emitter().emit(`connection:${token}`)
-    emitter().on(token, sendProgress)
-
-    ws.on('message', (jsonData) => {
-      const data = JSON.parse(jsonData.toString())
-      // whitelist triggered actions
-      if (['pause', 'resume', 'cancel'].includes(data.action)) {
-        emitter().emit(`${data.action}:${token}`)
-      }
-    })
-
-    ws.on('close', () => {
-      emitter().removeListener(token, sendProgress)
-    })
-  })
-}
-
 // intercepts grantJS' default response error when something goes
 // wrong during oauth process.
 const interceptGrantErrorResponse = interceptor((req, res) => {
@@ -225,41 +170,6 @@ const interceptGrantErrorResponse = interceptor((req, res) => {
  * @param {object} options
  */
 const getOptionsMiddleware = (options) => {
-  let s3Client = null
-  if (options.providerOptions.s3) {
-    const S3 = require('aws-sdk/clients/s3')
-    const AWS = require('aws-sdk')
-    const s3ProviderOptions = options.providerOptions.s3
-
-    if (s3ProviderOptions.accessKeyId || s3ProviderOptions.secretAccessKey) {
-      throw new Error('Found `providerOptions.s3.accessKeyId` or `providerOptions.s3.secretAccessKey` configuration, but Companion requires `key` and `secret` option names instead. Please use the `key` property instead of `accessKeyId` and the `secret` property instead of `secretAccessKey`.')
-    }
-
-    const rawClientOptions = s3ProviderOptions.awsClientOptions
-    if (rawClientOptions && (rawClientOptions.accessKeyId || rawClientOptions.secretAccessKey)) {
-      throw new Error('Found unsupported `providerOptions.s3.awsClientOptions.accessKeyId` or `providerOptions.s3.awsClientOptions.secretAccessKey` configuration. Please use the `providerOptions.s3.key` and `providerOptions.s3.secret` options instead.')
-    }
-
-    const s3ClientOptions = Object.assign({
-      signatureVersion: 'v4',
-      endpoint: s3ProviderOptions.endpoint,
-      region: s3ProviderOptions.region,
-      // backwards compat
-      useAccelerateEndpoint: s3ProviderOptions.useAccelerateEndpoint
-    }, rawClientOptions)
-
-    // Use credentials to allow assumed roles to pass STS sessions in.
-    // If the user doesn't specify key and secret, the default credentials (process-env)
-    // will be used by S3 in calls below.
-    if (s3ProviderOptions.key && s3ProviderOptions.secret && !s3ClientOptions.credentials) {
-      s3ClientOptions.credentials = new AWS.Credentials(
-        s3ProviderOptions.key,
-        s3ProviderOptions.secret,
-        s3ProviderOptions.sessionToken)
-    }
-    s3Client = new S3(s3ClientOptions)
-  }
-
   /**
    * @param {object} req
    * @param {object} res
@@ -269,7 +179,7 @@ const getOptionsMiddleware = (options) => {
     const versionFromQuery = req.query.uppyVersions ? decodeURIComponent(req.query.uppyVersions) : null
     req.companion = {
       options,
-      s3Client,
+      s3Client: getS3Client(options),
       authToken: req.header('uppy-auth-token') || req.query.uppyAuthToken,
       clientVersion: req.header('uppy-versions') || versionFromQuery || '1.0.0',
       buildURL: getURLBuilder(options)

+ 1 - 1
packages/@uppy/companion/src/server/controllers/callback.js

@@ -21,7 +21,7 @@ module.exports = function callback (req, res, next) {
   if (grant.response && grant.response.access_token) {
     req.companion.providerTokens[providerName] = grant.response.access_token
     logger.debug(`Generating auth token for provider ${providerName}`, null, req.id)
-    const uppyAuthToken = tokenService.generateToken(req.companion.providerTokens, req.companion.options.secret)
+    const uppyAuthToken = tokenService.generateEncryptedToken(req.companion.providerTokens, req.companion.options.secret)
     return res.redirect(req.companion.buildURL(`/${providerName}/send-token?uppyAuthToken=${uppyAuthToken}`, true))
   }
 

+ 4 - 0
packages/@uppy/companion/src/server/controllers/connect.js

@@ -25,5 +25,9 @@ module.exports = function connect (req, res) {
     state = oAuthState.addToState(state, { clientVersion: req.companion.clientVersion }, secret)
   }
 
+  if (req.query.uppyPreAuthToken) {
+    state = oAuthState.addToState(state, { preAuthToken: req.query.uppyPreAuthToken }, secret)
+  }
+
   res.redirect(req.companion.buildURL(`/connect/${req.companion.provider.authProvider}?state=${state}`, true))
 }

+ 1 - 0
packages/@uppy/companion/src/server/controllers/index.js

@@ -7,5 +7,6 @@ module.exports = {
   list: require('./list'),
   logout: require('./logout'),
   connect: require('./connect'),
+  preauth: require('./preauth'),
   redirect: require('./oauth-redirect')
 }

+ 1 - 1
packages/@uppy/companion/src/server/controllers/logout.js

@@ -27,7 +27,7 @@ function logout (req, res, next) {
       }
 
       delete companion.providerTokens[providerName]
-      tokenService.removeFromCookies(res, companion.options, companion.provider.authProviderName)
+      tokenService.removeFromCookies(res, companion.options, companion.provider.authProvider)
       cleanSession()
       res.json(Object.assign({ ok: true }, data))
     })

+ 19 - 0
packages/@uppy/companion/src/server/controllers/preauth.js

@@ -0,0 +1,19 @@
+const tokenService = require('../helpers/jwt')
+const logger = require('../logger')
+
+function preauth (req, res) {
+  if (!req.body || !req.body.params) {
+    logger.info('invalid request data received', 'preauth.bad')
+    return res.sendStatus(400)
+  }
+
+  const providerConfig = req.companion.options.providerOptions[req.params.providerName]
+  if (!providerConfig.credentialsURL) {
+    return res.sendStatus(501)
+  }
+
+  const preAuthToken = tokenService.generateEncryptedToken(req.body.params, req.companion.options.preAuthSecret)
+  return res.json({ token: preAuthToken })
+}
+
+module.exports = preauth

+ 41 - 12
packages/@uppy/companion/src/server/helpers/jwt.js

@@ -1,13 +1,15 @@
 const jwt = require('jsonwebtoken')
 const { encrypt, decrypt } = require('./utils')
 
+const EXPIRY = 60 * 60 * 24 // one day (24 hrs)
+
 /**
  *
  * @param {*} payload
  * @param {string} secret
  */
 module.exports.generateToken = (payload, secret) => {
-  return encrypt(jwt.sign({ data: payload }, secret, { expiresIn: 60 * 60 * 24 }), secret)
+  return jwt.sign({ data: payload }, secret, { expiresIn: EXPIRY })
 }
 
 /**
@@ -18,7 +20,7 @@ module.exports.generateToken = (payload, secret) => {
 module.exports.verifyToken = (token, secret) => {
   try {
     // @ts-ignore
-    return { payload: jwt.verify(decrypt(token, secret), secret, {}).data }
+    return { payload: jwt.verify(token, secret, {}).data }
   } catch (err) {
     return { err }
   }
@@ -26,14 +28,30 @@ module.exports.verifyToken = (token, secret) => {
 
 /**
  *
- * @param {object} res
+ * @param {*} payload
+ * @param {string} secret
+ */
+module.exports.generateEncryptedToken = (payload, secret) => {
+  return encrypt(module.exports.generateToken(payload, secret), secret)
+}
+
+/**
+ *
  * @param {string} token
- * @param {object} companionOptions
- * @param {string} providerName
+ * @param {string} secret
  */
-module.exports.addToCookies = (res, token, companionOptions, providerName) => {
+module.exports.verifyEncryptedToken = (token, secret) => {
+  try {
+    // @ts-ignore
+    return module.exports.verifyToken(decrypt(token, secret), secret)
+  } catch (err) {
+    return { err }
+  }
+}
+
+const addToCookies = (res, token, companionOptions, authProvider, prefix) => {
   const cookieOptions = {
-    maxAge: 1000 * 60 * 60 * 24 * 30, // would expire after 30 days
+    maxAge: 1000 * EXPIRY, // would expire after one day (24 hrs)
     httpOnly: true
   }
 
@@ -41,18 +59,29 @@ module.exports.addToCookies = (res, token, companionOptions, providerName) => {
     cookieOptions.domain = companionOptions.cookieDomain
   }
   // send signed token to client.
-  res.cookie(`uppyAuthToken--${providerName}`, token, cookieOptions)
+  res.cookie(`${prefix}--${authProvider}`, token, cookieOptions)
+}
+
+/**
+ *
+ * @param {object} res
+ * @param {string} token
+ * @param {object} companionOptions
+ * @param {string} authProvider
+ */
+module.exports.addToCookies = (res, token, companionOptions, authProvider) => {
+  addToCookies(res, token, companionOptions, authProvider, 'uppyAuthToken')
 }
 
 /**
  *
  * @param {object} res
  * @param {object} companionOptions
- * @param {string} providerName
+ * @param {string} authProvider
  */
-module.exports.removeFromCookies = (res, companionOptions, providerName) => {
+module.exports.removeFromCookies = (res, companionOptions, authProvider) => {
   const cookieOptions = {
-    maxAge: 1000 * 60 * 60 * 24 * 30, // would expire after 30 days
+    maxAge: 1000 * EXPIRY, // would expire after one day (24 hrs)
     httpOnly: true
   }
 
@@ -60,5 +89,5 @@ module.exports.removeFromCookies = (res, companionOptions, providerName) => {
     cookieOptions.domain = companionOptions.cookieDomain
   }
 
-  res.clearCookie(`uppyAuthToken--${providerName}`, cookieOptions)
+  res.clearCookie(`uppyAuthToken--${authProvider}`, cookieOptions)
 }

+ 1 - 0
packages/@uppy/companion/src/server/logger.js

@@ -55,6 +55,7 @@ exports.error = (msg, tag, traceId, shouldLogStackTrace) => {
  * @param {string=} traceId a unique id to easily trace logs tied to a request
  */
 exports.debug = (msg, tag, traceId) => {
+  // @todo: this function should depend on companion's debug option instead
   if (process.env.NODE_ENV !== 'production') {
     // @ts-ignore
     log(msg, tag, 'debug', traceId, chalk.bold.blue)

+ 2 - 2
packages/@uppy/companion/src/server/middlewares.js

@@ -31,7 +31,7 @@ exports.verifyToken = (req, res, next) => {
     return res.sendStatus(401)
   }
   const providerName = req.params.providerName
-  const { err, payload } = tokenService.verifyToken(token, req.companion.options.secret)
+  const { err, payload } = tokenService.verifyEncryptedToken(token, req.companion.options.secret)
   if (err || !payload[providerName]) {
     if (err) {
       logger.error(err, 'token.verify.error', req.id)
@@ -47,7 +47,7 @@ exports.verifyToken = (req, res, next) => {
 exports.gentleVerifyToken = (req, res, next) => {
   const providerName = req.params.providerName
   if (req.companion.authToken) {
-    const { err, payload } = tokenService.verifyToken(req.companion.authToken, req.companion.options.secret)
+    const { err, payload } = tokenService.verifyEncryptedToken(req.companion.authToken, req.companion.options.secret)
     if (!err && payload[providerName]) {
       req.companion.providerTokens = payload
     }

+ 137 - 0
packages/@uppy/companion/src/server/provider/credentials.js

@@ -0,0 +1,137 @@
+const request = require('request')
+// @ts-ignore
+const atob = require('atob')
+const logger = require('../logger')
+const oAuthState = require('../helpers/oauth-state')
+const tokenService = require('../helpers/jwt')
+// eslint-disable-next-line
+const Provider = require('./Provider')
+
+/**
+ * Returns a request middleware function that can be used to pre-fetch a provider's
+ * Oauth credentials before the request is passed to the Oauth handler (https://github.com/simov/grant in this case).
+ *
+ * @param {Object.<string, (typeof Provider)>} providers provider classes enabled for this server
+ * @param {object} companionOptions companion options object
+ * @returns {(req: object, res: object, next: function()) => void}
+ */
+exports.getCredentialsOverrideMiddleware = (providers, companionOptions) => {
+  return (req, res, next) => {
+    const { authProvider, override } = req.params
+    const [providerName] = Object.keys(providers).filter((name) => providers[name].authProvider === authProvider)
+    if (!providerName) {
+      return next()
+    }
+
+    if (!companionOptions.providerOptions[providerName].credentialsURL) {
+      return next()
+    }
+
+    const dynamic = (req.session.grant || {}).dynamic || {}
+    // only use state via session object if user isn't making intial "connect" request.
+    // override param indicates subsequent requests from the oauth flow
+    const state = override ? dynamic.state : req.query.state
+    if (!state) {
+      return next()
+    }
+
+    const preAuthToken = oAuthState.getFromState(state, 'preAuthToken', companionOptions.secret)
+    if (!preAuthToken) {
+      return next()
+    }
+
+    const { err, payload } = tokenService.verifyEncryptedToken(preAuthToken, companionOptions.preAuthSecret)
+    if (err || !payload) {
+      return next()
+    }
+
+    fetchProviderKeys(providerName, companionOptions, payload).then((credentials) => {
+      res.locals.grant = {
+        dynamic: {
+          key: credentials.key,
+          secret: credentials.secret
+        }
+      }
+
+      if (credentials.redirect_uri) {
+        res.locals.grant.dynamic.redirect_uri = credentials.redirect_uri
+      }
+    }).finally(() => next())
+  }
+}
+
+/**
+ * Returns a request scoped function that can be used to get a provider's oauth credentials
+ * through out the lifetime of the request.
+ *
+ * @param {string} providerName the name of the provider attached to the scope of the request
+ * @param {object} companionOptions the companion options object
+ * @param {object} req the express request object for the said request
+ * @returns {(providerName: string, companionOptions: object, credentialRequestParams?: object) => Promise}
+ */
+module.exports.getCredentialsResolver = (providerName, companionOptions, req) => {
+  const credentialsResolver = () => {
+    const encodedCredentialsParams = req.header('uppy-credentials-params')
+    let credentialRequestParams = null
+    if (encodedCredentialsParams) {
+      try {
+        credentialRequestParams = JSON.parse(atob(encodedCredentialsParams)).params
+      } catch (error) {
+        logger.error(error, 'credentials.resolve.fail', req.id)
+      }
+    }
+
+    return fetchProviderKeys(providerName, companionOptions, credentialRequestParams)
+  }
+
+  return credentialsResolver
+}
+
+/**
+ * Fetches for a providers OAuth credentials. If the config for thtat provider allows fetching
+ * of the credentials via http, and the `credentialRequestParams` argument is provided, the oauth
+ * credentials will be fetched via http. Otherwise, the credentials provided via companion options
+ * will be used instead.
+ *
+ * @param {string} providerName the name of the provider whose oauth keys we want to fetch (e.g onedrive)
+ * @param {object} companionOptions the companion options object
+ * @param {object} credentialRequestParams the params that should be sent if an http request is required.
+ */
+const fetchProviderKeys = (providerName, companionOptions, credentialRequestParams) => {
+  let providerConfig = companionOptions.providerOptions[providerName]
+  if (!providerConfig) {
+    providerConfig = (companionOptions.customProviders[providerName] || {}).config
+  }
+
+  if (providerConfig && providerConfig.credentialsURL && credentialRequestParams) {
+    return fetchKeys(providerConfig.credentialsURL, providerName, credentialRequestParams)
+  } else {
+    return Promise.resolve(providerConfig)
+  }
+}
+
+const fetchKeys = (url, providerName, credentialRequestParams) => {
+  return new Promise((resolve, reject) => {
+    const options = {
+      body: {
+        provider: providerName,
+        parameters: credentialRequestParams
+      },
+      json: true
+    }
+    request.post(url, options, (err, resp, body) => {
+      if (err) {
+        logger.error(err, 'credentials.fetch.fail')
+        return reject(err)
+      }
+
+      if (resp.statusCode !== 200 || !body.credentials) {
+        const err = new Error(`received status: ${resp.statusCode} with no credentials`)
+        logger.error(err, 'credentials.fetch.fail')
+        return reject(err)
+      }
+
+      return resolve(body.credentials)
+    })
+  })
+}

+ 11 - 2
packages/@uppy/companion/src/server/provider/index.js

@@ -13,6 +13,7 @@ const unsplash = require('./unsplash')
 const zoom = require('./zoom')
 const { getURLBuilder } = require('../helpers/utils')
 const logger = require('../logger')
+const { getCredentialsResolver } = require('./credentials')
 // eslint-disable-next-line
 const Provider = require('./Provider')
 // eslint-disable-next-line
@@ -48,8 +49,9 @@ config.zoom = {
  * based on the providerName parameter specified
  *
  * @param {Object.<string, (typeof Provider) | typeof SearchProvider>} providers
+ * @param {boolean=} needsProviderCredentials
  */
-module.exports.getProviderMiddleware = (providers) => {
+module.exports.getProviderMiddleware = (providers, needsProviderCredentials) => {
   /**
    *
    * @param {object} req
@@ -60,6 +62,9 @@ module.exports.getProviderMiddleware = (providers) => {
   const middleware = (req, res, next, providerName) => {
     if (providers[providerName] && validOptions(req.companion.options)) {
       req.companion.provider = new providers[providerName]({ providerName, config })
+      if (needsProviderCredentials) {
+        req.companion.getProviderCredentials = getCredentialsResolver(providerName, req.companion.options, req)
+      }
     } else {
       logger.warn('invalid provider options detected. Provider will not be loaded', 'provider.middleware.invalid', req.id)
     }
@@ -134,6 +139,10 @@ module.exports.addProviderOptions = (companionOptions, grantConfig) => {
       // explicitly add providerOptions so users don't override other providerOptions.
       grantConfig[authProvider].key = providerOptions[providerName].key
       grantConfig[authProvider].secret = providerOptions[providerName].secret
+      if (providerOptions[providerName].credentialsURL) {
+        grantConfig[authProvider].dynamic = ['key', 'secret', 'redirect_uri']
+      }
+
       const provider = exports.getDefaultProviders(companionOptions)[providerName]
       Object.assign(grantConfig[authProvider], provider.getExtraConfig())
 
@@ -152,7 +161,7 @@ module.exports.addProviderOptions = (companionOptions, grantConfig) => {
       } else if (server.path) {
         grantConfig[authProvider].callback = `${server.path}${grantConfig[authProvider].callback}`
       }
-    } else if (providerName !== 's3') {
+    } else if (!['s3', 'searchProviders'].includes(providerName)) {
       logger.warn(`skipping one found unsupported provider "${providerName}".`, 'provider.options.skip')
     }
   })

+ 48 - 49
packages/@uppy/companion/src/server/provider/zoom/index.js

@@ -251,25 +251,25 @@ class Zoom extends Provider {
   }
 
   logout ({ companion, token }, done) {
-    const { key, secret } = companion.options.providerOptions.zoom
-    const encodedAuth = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
-
-    return this.client
-      .post('https://zoom.us/oauth/revoke')
-      .options({
-        headers: {
-          Authorization: `Basic ${encodedAuth}`
-        }
-      })
-      .qs({ token })
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          logger.error(err, 'provider.zoom.logout.error')
-          done(this._error(err, resp))
-          return
-        }
-        done(null, { revoked: (body || {}).status === 'success' })
-      })
+    companion.getProviderCredentials().then(({ key, secret }) => {
+      const encodedAuth = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
+      return this.client
+        .post('https://zoom.us/oauth/revoke')
+        .options({
+          headers: {
+            Authorization: `Basic ${encodedAuth}`
+          }
+        })
+        .qs({ token })
+        .request((err, resp, body) => {
+          if (err || resp.statusCode !== 200) {
+            logger.error(err, 'provider.zoom.logout.error')
+            done(this._error(err, resp))
+            return
+          }
+          done(null, { revoked: (body || {}).status === 'success' })
+        })
+    }).catch((err) => done(err))
   }
 
   deauthorizationCallback ({ companion, body, headers }, done) {
@@ -277,37 +277,36 @@ class Zoom extends Provider {
       return done(null, {}, 400)
     }
 
-    const { verificationToken } = companion.options.providerOptions.zoom
-    const tokenSupplied = headers.authorization
-    if (!tokenSupplied || verificationToken !== tokenSupplied) {
-      return done(null, {}, 400)
-    }
-
-    const { key, secret } = companion.options.providerOptions.zoom
-    const encodedAuth = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
+    companion.getProviderCredentials().then(({ verificationToken, key, secret }) => {
+      const tokenSupplied = headers.authorization
+      if (!tokenSupplied || verificationToken !== tokenSupplied) {
+        return done(null, {}, 400)
+      }
 
-    this.client
-      .post('https://api.zoom.us/oauth/data/compliance')
-      .options({
-        headers: {
-          Authorization: `Basic ${encodedAuth}`
-        }
-      })
-      .json({
-        client_id: key,
-        user_id: body.payload.user_id,
-        account_id: body.payload.account_id,
-        deauthorization_event_received: body.payload,
-        compliance_completed: true
-      })
-      .request((err, resp) => {
-        if (err || resp.statusCode !== 200) {
-          logger.error(err, 'provider.zoom.deauth.error')
-          done(this._error(err, resp))
-          return
-        }
-        done(null, {})
-      })
+      const encodedAuth = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
+      this.client
+        .post('https://api.zoom.us/oauth/data/compliance')
+        .options({
+          headers: {
+            Authorization: `Basic ${encodedAuth}`
+          }
+        })
+        .json({
+          client_id: key,
+          user_id: body.payload.user_id,
+          account_id: body.payload.account_id,
+          deauthorization_event_received: body.payload,
+          compliance_completed: true
+        })
+        .request((err, resp) => {
+          if (err || resp.statusCode !== 200) {
+            logger.error(err, 'provider.zoom.deauth.error')
+            done(this._error(err, resp))
+            return
+          }
+          done(null, {})
+        })
+    }).catch((err) => done(err))
   }
 
   _error (err, resp) {

+ 43 - 0
packages/@uppy/companion/src/server/s3-client.js

@@ -0,0 +1,43 @@
+/**
+ * instantiates the aws-sdk s3 client that will be used for s3 uploads.
+ *
+ * @param {object} companionOptions the companion options object
+ */
+module.exports = (companionOptions) => {
+  let s3Client = null
+  if (companionOptions.providerOptions.s3) {
+    const S3 = require('aws-sdk/clients/s3')
+    const AWS = require('aws-sdk')
+    const s3ProviderOptions = companionOptions.providerOptions.s3
+
+    if (s3ProviderOptions.accessKeyId || s3ProviderOptions.secretAccessKey) {
+      throw new Error('Found `providerOptions.s3.accessKeyId` or `providerOptions.s3.secretAccessKey` configuration, but Companion requires `key` and `secret` option names instead. Please use the `key` property instead of `accessKeyId` and the `secret` property instead of `secretAccessKey`.')
+    }
+
+    const rawClientOptions = s3ProviderOptions.awsClientOptions
+    if (rawClientOptions && (rawClientOptions.accessKeyId || rawClientOptions.secretAccessKey)) {
+      throw new Error('Found unsupported `providerOptions.s3.awsClientOptions.accessKeyId` or `providerOptions.s3.awsClientOptions.secretAccessKey` configuration. Please use the `providerOptions.s3.key` and `providerOptions.s3.secret` options instead.')
+    }
+
+    const s3ClientOptions = Object.assign({
+      signatureVersion: 'v4',
+      endpoint: s3ProviderOptions.endpoint,
+      region: s3ProviderOptions.region,
+      // backwards compat
+      useAccelerateEndpoint: s3ProviderOptions.useAccelerateEndpoint
+    }, rawClientOptions)
+
+    // Use credentials to allow assumed roles to pass STS sessions in.
+    // If the user doesn't specify key and secret, the default credentials (process-env)
+    // will be used by S3 in calls below.
+    if (s3ProviderOptions.key && s3ProviderOptions.secret && !s3ClientOptions.credentials) {
+      s3ClientOptions.credentials = new AWS.Credentials(
+        s3ProviderOptions.key,
+        s3ProviderOptions.secret,
+        s3ProviderOptions.sessionToken)
+    }
+    s3Client = new S3(s3ClientOptions)
+  }
+
+  return s3Client
+}

+ 65 - 0
packages/@uppy/companion/src/server/socket.js

@@ -0,0 +1,65 @@
+const SocketServer = require('ws').Server
+const { jsonStringify } = require('./helpers/utils')
+const emitter = require('./emitter')
+const redis = require('./redis')
+const logger = require('./logger')
+const { STORAGE_PREFIX, shortenToken } = require('./Uploader')
+
+/**
+ * the socket is used to send progress events during an upload
+ *
+ * @param {object} server
+ */
+module.exports = (server) => {
+  const wss = new SocketServer({ server })
+  const redisClient = redis.client()
+
+  // A new connection is usually created when an upload begins,
+  // or when connection fails while an upload is on-going and,
+  // client attempts to reconnect.
+  wss.on('connection', (ws, req) => {
+    // @ts-ignore
+    const fullPath = req.url
+    // the token identifies which ongoing upload's progress, the socket
+    // connection wishes to listen to.
+    const token = fullPath.replace(/^.*\/api\//, '')
+    logger.info(`connection received from ${token}`, 'socket.connect')
+
+    /**
+     *
+     * @param {{action: string, payload: object}} data
+     */
+    function sendProgress (data) {
+      ws.send(jsonStringify(data), (err) => {
+        if (err) logger.error(err, 'socket.progress.error', shortenToken(token))
+      })
+    }
+
+    // if the redisClient is available, then we attempt to check the storage
+    // if we have any already stored progress data on the upload.
+    if (redisClient) {
+      redisClient.get(`${STORAGE_PREFIX}:${token}`, (err, data) => {
+        if (err) logger.error(err, 'socket.redis.error', shortenToken(token))
+        if (data) {
+          const dataObj = JSON.parse(data.toString())
+          if (dataObj.action) sendProgress(dataObj)
+        }
+      })
+    }
+
+    emitter().emit(`connection:${token}`)
+    emitter().on(token, sendProgress)
+
+    ws.on('message', (jsonData) => {
+      const data = JSON.parse(jsonData.toString())
+      // whitelist triggered actions
+      if (['pause', 'resume', 'cancel'].includes(data.action)) {
+        emitter().emit(`${data.action}:${token}`)
+      }
+    })
+
+    ws.on('close', () => {
+      emitter().removeListener(token, sendProgress)
+    })
+  })
+}

+ 13 - 6
packages/@uppy/companion/src/standalone/helper.js

@@ -31,11 +31,13 @@ const getConfigFromEnv = () => {
     providerOptions: {
       drive: {
         key: process.env.COMPANION_GOOGLE_KEY,
-        secret: getSecret('COMPANION_GOOGLE_SECRET')
+        secret: getSecret('COMPANION_GOOGLE_SECRET'),
+        credentialsURL: process.env.COMPANION_GOOGLE_KEYS_ENDPOINT
       },
       dropbox: {
         key: process.env.COMPANION_DROPBOX_KEY,
-        secret: getSecret('COMPANION_DROPBOX_SECRET')
+        secret: getSecret('COMPANION_DROPBOX_SECRET'),
+        credentialsURL: process.env.COMPANION_DROPBOX_KEYS_ENDPOINT
       },
       box: {
         key: process.env.COMPANION_BOX_KEY,
@@ -43,20 +45,24 @@ const getConfigFromEnv = () => {
       },
       instagram: {
         key: process.env.COMPANION_INSTAGRAM_KEY,
-        secret: getSecret('COMPANION_INSTAGRAM_SECRET')
+        secret: getSecret('COMPANION_INSTAGRAM_SECRET'),
+        credentialsURL: process.env.COMPANION_INSTAGRAM_KEYS_ENDPOINT
       },
       facebook: {
         key: process.env.COMPANION_FACEBOOK_KEY,
-        secret: getSecret('COMPANION_FACEBOOK_SECRET')
+        secret: getSecret('COMPANION_FACEBOOK_SECRET'),
+        credentialsURL: process.env.COMPANION_FACEBOOK_KEYS_ENDPOINT
       },
       onedrive: {
         key: process.env.COMPANION_ONEDRIVE_KEY,
-        secret: getSecret('COMPANION_ONEDRIVE_SECRET')
+        secret: getSecret('COMPANION_ONEDRIVE_SECRET'),
+        credentialsURL: process.env.COMPANION_ONEDRIVE_KEYS_ENDPOINT
       },
       zoom: {
         key: process.env.COMPANION_ZOOM_KEY,
         secret: getSecret('COMPANION_ZOOM_SECRET'),
-        verificationToken: getSecret('COMPANION_ZOOM_VERIFICATION_TOKEN')
+        verificationToken: getSecret('COMPANION_ZOOM_VERIFICATION_TOKEN'),
+        credentialsURL: process.env.COMPANION_ZOOM_KEYS_ENDPOINT
       },
       searchProviders: {
         unsplash: {
@@ -91,6 +97,7 @@ const getConfigFromEnv = () => {
     sendSelfEndpoint: process.env.COMPANION_SELF_ENDPOINT,
     uploadUrls: uploadUrls ? uploadUrls.split(',') : null,
     secret: getSecret('COMPANION_SECRET') || generateSecret(),
+    preAuthSecret: getSecret('COMPANION_PREAUTH_SECRET') || generateSecret(),
     debug: process.env.NODE_ENV && process.env.NODE_ENV !== 'production',
     // TODO: this is a temporary hack to support distributed systems.
     // it is not documented, because it should be changed soon.

+ 14 - 3
packages/@uppy/companion/test/__mocks__/purest.js

@@ -8,7 +8,7 @@ function has (object, property) {
 
 class MockPurest {
   constructor (opts) {
-    const methodsToMock = ['query', 'select', 'where', 'auth', 'json', 'options']
+    const methodsToMock = ['query', 'select', 'where', 'auth', 'json']
     const httpMethodsToMock = ['get', 'put', 'post', 'head', 'delete']
     methodsToMock.forEach((item) => {
       this[item] = () => this
@@ -28,6 +28,11 @@ class MockPurest {
     return this
   }
 
+  options (reqOpts) {
+    this._requestOptions = reqOpts
+    return this
+  }
+
   request (done) {
     if (typeof done === 'function') {
       const responses = fixtures[this.opts.providerName].responses
@@ -38,8 +43,14 @@ class MockPurest {
         return
       }
 
-      const body = endpointResponses[this._method]
-      done(null, { body, statusCode: 200 }, body)
+      let statusCode = 200
+      const validators = fixtures[this.opts.providerName].validators
+      if (validators && validators[this._requestUrl]) {
+        statusCode = validators[this._requestUrl](this._requestOptions) ? 200 : 400
+      }
+
+      const body = statusCode === 200 ? endpointResponses[this._method] : {}
+      done(null, { body, statusCode }, body)
     }
 
     return this

+ 1 - 1
packages/@uppy/companion/test/__tests__/callback.js

@@ -10,7 +10,7 @@ const authData = {
   dropbox: 'token value',
   drive: 'token value'
 }
-const token = tokenService.generateToken(authData, process.env.COMPANION_SECRET)
+const token = tokenService.generateEncryptedToken(authData, process.env.COMPANION_SECRET)
 
 describe('test authentication callback', () => {
   test('authentication callback redirects to send-token url', () => {

+ 1 - 1
packages/@uppy/companion/test/__tests__/companion.js

@@ -13,7 +13,7 @@ const authData = {
   box: 'token value',
   drive: 'token value'
 }
-const token = tokenService.generateToken(authData, process.env.COMPANION_SECRET)
+const token = tokenService.generateEncryptedToken(authData, process.env.COMPANION_SECRET)
 const OAUTH_STATE = 'some-cool-nice-encrytpion'
 
 describe('validate upload data', () => {

+ 65 - 0
packages/@uppy/companion/test/__tests__/credentials.js

@@ -0,0 +1,65 @@
+/* global jest:false, test:false, expect:false, describe:false */
+
+// mocking request module used to fetch custom oauth credentials
+jest.mock('request', () => {
+  const { remoteZoomKey, remoteZoomSecret, remoteZoomVerificationToken } = require('../fixtures/zoom').expects
+
+  return {
+    post: (url, options, done) => {
+      if (url === 'http://localhost:2111/zoom-keys') {
+        const { body } = options
+        if (body.provider !== 'zoom') {
+          return done(new Error('wrong provider'))
+        }
+
+        if (body.parameters !== 'ZOOM-CREDENTIALS-PARAMS') {
+          return done(new Error('wrong params'))
+        }
+
+        const respBody = {
+          credentials: {
+            key: remoteZoomKey,
+            secret: remoteZoomSecret,
+            verificationToken: remoteZoomVerificationToken
+          }
+        }
+        return done(null, { statusCode: 200, body: respBody }, respBody)
+      }
+
+      done(new Error('unsupported request with mock function'))
+    }
+  }
+})
+
+const request = require('supertest')
+const tokenService = require('../../src/server/helpers/jwt')
+const { getServer } = require('../mockserver')
+const authServer = getServer({ COMPANION_ZOOM_KEYS_ENDPOINT: 'http://localhost:2111/zoom-keys' })
+const authData = {
+  zoom: 'token value'
+}
+const token = tokenService.generateEncryptedToken(authData, process.env.COMPANION_SECRET)
+
+describe('providers requests with remote oauth keys', () => {
+  test('zoom logout with remote oauth keys happy path', () => {
+    const params = { params: 'ZOOM-CREDENTIALS-PARAMS' }
+    const encodedParams = Buffer.from(JSON.stringify(params), 'binary').toString('base64')
+    return request(authServer)
+      .get('/zoom/logout/')
+      .set('uppy-auth-token', token)
+      .set('uppy-credentials-params', encodedParams)
+      .expect(200)
+      .then((res) => expect(res.body.ok).toBe(true))
+  })
+
+  test('zoom logout with wrong credentials params', () => {
+    const params = { params: 'WRONG-ZOOM-CREDENTIALS-PARAMS' }
+    const encodedParams = Buffer.from(JSON.stringify(params), 'binary').toString('base64')
+    return request(authServer)
+      .get('/zoom/logout/')
+      .set('uppy-auth-token', token)
+      .set('uppy-credentials-params', encodedParams)
+      // todo: handle failures differently to return 400 for this case instead
+      .expect(500)
+  })
+})

+ 67 - 0
packages/@uppy/companion/test/__tests__/preauth.js

@@ -0,0 +1,67 @@
+/* global jest:false, test:false, expect:false, describe:false */
+
+jest.mock('../../src/server/helpers/jwt', () => {
+  return {
+    generateToken: (payload, secret) => {},
+    verifyToken: (token, secret) => {},
+    generateEncryptedToken: (payload, secret) => {
+      return 'dummy token'
+    },
+    verifyEncryptedToken: (token, secret) => {
+      return { payload: '' }
+    },
+    addToCookies: (res, token, companionOptions, authProvider) => {},
+    removeFromCookies: (res, companionOptions, authProvider) => {}
+  }
+})
+
+const request = require('supertest')
+const { getServer } = require('../mockserver')
+// the order in which getServer is called matters because, once an env is passed,
+// it won't be overridden when you call getServer without an argument
+const serverWithFixedOauth = getServer()
+const serverWithDynamicOauth = getServer({ COMPANION_DROPBOX_KEYS_ENDPOINT: 'http://localhost:1000/endpoint' })
+
+describe('handle preauth endpoint', () => {
+  test('happy path', () => {
+    return request(serverWithDynamicOauth)
+      .post('/dropbox/preauth')
+      .set('Content-Type', 'application/json')
+      .send({
+        params: 'param value'
+      })
+      .expect(200)
+      // see jwt.generateEncryptedToken mock above
+      .then((res) => expect(res.body.token).toBe('dummy token'))
+  })
+
+  test('preauth request without params in body', () => {
+    return request(serverWithDynamicOauth)
+      .post('/dropbox/preauth')
+      .set('Content-Type', 'application/json')
+      .send({
+        notParams: 'value'
+      })
+      .expect(400)
+  })
+
+  test('providers with dynamic credentials disabled', () => {
+    return request(serverWithDynamicOauth)
+      .post('/drive/preauth')
+      .set('Content-Type', 'application/json')
+      .send({
+        params: 'param value'
+      })
+      .expect(501)
+  })
+
+  test('server with dynamic credentials disabled', () => {
+    return request(serverWithFixedOauth)
+      .post('/dropbox/preauth')
+      .set('Content-Type', 'application/json')
+      .send({
+        params: 'param value'
+      })
+      .expect(501)
+  })
+})

+ 1 - 1
packages/@uppy/companion/test/__tests__/providers.js

@@ -25,7 +25,7 @@ const authData = {}
 providerNames.forEach((provider) => {
   authData[provider] = 'token value'
 })
-const token = tokenService.generateToken(authData, process.env.COMPANION_SECRET)
+const token = tokenService.generateEncryptedToken(authData, process.env.COMPANION_SECRET)
 
 const thisOrThat = (value1, value2) => {
   if (value1 !== undefined) {

+ 15 - 1
packages/@uppy/companion/test/fixtures/zoom.js

@@ -61,5 +61,19 @@ module.exports.expects = {
   itemName: 'DUMMY TOPIC - shared screen with speaker view (2020-05-29, 13:23).mp4',
   itemId: 'DUMMY-UUID%3D%3D__DUMMY-FILE-ID',
   itemRequestPath: 'DUMMY-UUID%3D%3D?recordingId=DUMMY-FILE-ID',
-  itemIcon: 'video'
+  itemIcon: 'video',
+  remoteZoomKey: 'REMOTE-ZOOM-KEY',
+  remoteZoomSecret: 'REMOTE-ZOOM-SECRET',
+  remoteZoomVerificationToken: 'REMOTE-ZOOM-VERIFICATION-TOKEN'
+}
+
+module.exports.validators = {
+  'https://zoom.us/oauth/revoke': ({ headers }) => {
+    if (process.env.COMPANION_ZOOM_KEYS_ENDPOINT) {
+      const auth = `${module.exports.expects.remoteZoomKey}:${module.exports.expects.remoteZoomSecret}`
+      return headers.Authorization === `Basic ${Buffer.from(auth, 'binary').toString('base64')}`
+    }
+
+    return true
+  }
 }

+ 5 - 1
packages/@uppy/dropbox/src/index.js

@@ -23,6 +23,7 @@ module.exports = class Dropbox extends Plugin {
     this.provider = new Provider(uppy, {
       companionUrl: this.opts.companionUrl,
       companionHeaders: this.opts.companionHeaders || this.opts.serverHeaders,
+      companionKeysParams: this.opts.companionKeysParams,
       companionCookiesRule: this.opts.companionCookiesRule,
       provider: 'dropbox',
       pluginId: this.id
@@ -49,7 +50,10 @@ module.exports = class Dropbox extends Plugin {
   }
 
   onFirstRender () {
-    return this.view.getFolder()
+    return Promise.all([
+      this.provider.fetchPreAuthToken(),
+      this.view.getFolder()
+    ])
   }
 
   render (state) {

+ 5 - 1
packages/@uppy/facebook/src/index.js

@@ -23,6 +23,7 @@ module.exports = class Facebook extends Plugin {
     this.provider = new Provider(uppy, {
       companionUrl: this.opts.companionUrl,
       companionHeaders: this.opts.companionHeaders || this.opts.serverHeaders,
+      companionKeysParams: this.opts.companionKeysParams,
       companionCookiesRule: this.opts.companionCookiesRule,
       provider: 'facebook',
       pluginId: this.id
@@ -49,7 +50,10 @@ module.exports = class Facebook extends Plugin {
   }
 
   onFirstRender () {
-    return this.view.getFolder()
+    return Promise.all([
+      this.provider.fetchPreAuthToken(),
+      this.view.getFolder()
+    ])
   }
 
   render (state) {

+ 5 - 1
packages/@uppy/google-drive/src/index.js

@@ -24,6 +24,7 @@ module.exports = class GoogleDrive extends Plugin {
     this.provider = new Provider(uppy, {
       companionUrl: this.opts.companionUrl,
       companionHeaders: this.opts.companionHeaders || this.opts.serverHeaders,
+      companionKeysParams: this.opts.companionKeysParams,
       companionCookiesRule: this.opts.companionCookiesRule,
       provider: 'drive',
       pluginId: this.id
@@ -50,7 +51,10 @@ module.exports = class GoogleDrive extends Plugin {
   }
 
   onFirstRender () {
-    return this.view.getFolder('root', '/')
+    return Promise.all([
+      this.provider.fetchPreAuthToken(),
+      this.view.getFolder('root', '/')
+    ])
   }
 
   render (state) {

+ 5 - 1
packages/@uppy/instagram/src/index.js

@@ -23,6 +23,7 @@ module.exports = class Instagram extends Plugin {
     this.provider = new Provider(uppy, {
       companionUrl: this.opts.companionUrl,
       companionHeaders: this.opts.companionHeaders || this.opts.serverHeaders,
+      companionKeysParams: this.opts.companionKeysParams,
       companionCookiesRule: this.opts.companionCookiesRule,
       provider: 'instagram',
       pluginId: this.id
@@ -53,7 +54,10 @@ module.exports = class Instagram extends Plugin {
   }
 
   onFirstRender () {
-    this.view.getFolder('recent')
+    return Promise.all([
+      this.provider.fetchPreAuthToken(),
+      this.view.getFolder('recent')
+    ])
   }
 
   render (state) {

+ 4 - 1
packages/@uppy/onedrive/src/index.js

@@ -52,7 +52,10 @@ module.exports = class OneDrive extends Plugin {
   }
 
   onFirstRender () {
-    return this.view.getFolder()
+    return Promise.all([
+      this.provider.fetchPreAuthToken(),
+      this.view.getFolder()
+    ])
   }
 
   render (state) {

+ 4 - 1
packages/@uppy/provider-views/README.md

@@ -23,7 +23,10 @@ class GoogleDrive extends Plugin {
   }
 
   onFirstRender () {
-    return this.view.getFolder('root', '/')
+    return Promise.all([
+      this.provider.fetchPreAuthToken(),
+      this.view.getFolder('root', '/')
+    ])
   }
 
   render (state) {

+ 2 - 2
packages/@uppy/provider-views/src/ProviderView/ProviderView.js

@@ -361,8 +361,8 @@ module.exports = class ProviderView {
 
   handleAuth () {
     const authState = btoa(JSON.stringify({ origin: getOrigin() }))
-    const clientVersion = encodeURIComponent(`@uppy/provider-views=${ProviderView.VERSION}`)
-    const link = `${this.provider.authUrl()}?state=${authState}&uppyVersions=${clientVersion}`
+    const clientVersion = `@uppy/provider-views=${ProviderView.VERSION}`
+    const link = this.provider.authUrl({ state: authState, uppyVersions: clientVersion })
 
     const authWindow = window.open(link, '_blank')
     const handleToken = (e) => {

+ 5 - 1
packages/@uppy/zoom/src/index.js

@@ -24,6 +24,7 @@ module.exports = class Zoom extends Plugin {
     this.provider = new Provider(uppy, {
       companionUrl: this.opts.companionUrl,
       companionHeaders: this.opts.companionHeaders || this.opts.serverHeaders,
+      companionKeysParams: this.opts.companionKeysParams,
       companionCookiesRule: this.opts.companionCookiesRule,
       provider: 'zoom',
       pluginId: this.id
@@ -50,7 +51,10 @@ module.exports = class Zoom extends Plugin {
   }
 
   onFirstRender () {
-    return this.view.getFolder()
+    return Promise.all([
+      this.provider.fetchPreAuthToken(),
+      this.view.getFolder()
+    ])
   }
 
   render (state) {