Ver código fonte

Finishing touches on Companion dynamic Oauth (#2802)

Co-authored-by: Mikael Finstad <finstaden@gmail.com>
Renée Kooi 3 anos atrás
pai
commit
db47700321

+ 25 - 10
packages/@uppy/companion-client/src/Provider.js

@@ -53,29 +53,44 @@ module.exports = class Provider extends RequestClient {
     return this.uppy.getPlugin(this.pluginId).storage.getItem(this.tokenKey)
   }
 
+  /**
+   * Ensure we have a preauth token if necessary. Attempts to fetch one if we don't,
+   * or rejects if loading one fails.
+   */
+  async ensurePreAuth () {
+    if (this.companionKeysParams && !this.preAuthToken) {
+      await this.fetchPreAuthToken()
+
+      if (!this.preAuthToken) {
+        throw new Error('Could not load authentication data required for third-party login. Please try again later.')
+      }
+    }
+  }
+
   authUrl (queries = {}) {
+    const params = new URLSearchParams(queries)
     if (this.preAuthToken) {
-      queries.uppyPreAuthToken = this.preAuthToken
+      params.set('uppyPreAuthToken', this.preAuthToken)
     }
 
-    return `${this.hostname}/${this.id}/connect?${new URLSearchParams(queries)}`
+    return `${this.hostname}/${this.id}/connect?${params}`
   }
 
   fileUrl (id) {
     return `${this.hostname}/${this.id}/get/${id}`
   }
 
-  fetchPreAuthToken () {
+  async fetchPreAuthToken () {
     if (!this.companionKeysParams) {
-      return Promise.resolve()
+      return
     }
 
-    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')
-      })
+    try {
+      const res = await this.post(`${this.id}/preauth/`, { params: this.companionKeysParams })
+      this.preAuthToken = res.token
+    } catch (err) {
+      this.uppy.log(`[CompanionClient] unable to fetch preAuthToken ${err}`, 'warning')
+    }
   }
 
   list (directory) {

+ 1 - 0
packages/@uppy/companion/package.json

@@ -37,6 +37,7 @@
     "connect-redis": "4.0.3",
     "cookie-parser": "1.4.6",
     "cors": "^2.8.5",
+    "escape-goat": "3.0.0",
     "escape-string-regexp": "2.0.0",
     "express": "4.17.1",
     "express-interceptor": "1.2.0",

+ 99 - 56
packages/@uppy/companion/src/server/provider/credentials.js

@@ -1,30 +1,98 @@
 const request = require('request')
 // @ts-ignore
 const atob = require('atob')
+const { htmlEscape } = require('escape-goat')
 const logger = require('../logger')
 const oAuthState = require('../helpers/oauth-state')
 const tokenService = require('../helpers/jwt')
 // eslint-disable-next-line
 const Provider = require('./Provider')
 
+/**
+ * @param {string} url
+ * @param {string} providerName
+ * @param {object|null} credentialRequestParams - null asks for default credentials.
+ */
+function fetchKeys (url, providerName, credentialRequestParams) {
+  return new Promise((resolve, reject) => {
+    const options = {
+      body: {
+        provider: providerName,
+        parameters: credentialRequestParams,
+      },
+      json: true,
+    }
+    request.post(url, options, (requestErr, resp, body) => {
+      if (requestErr) {
+        logger.error(requestErr, 'credentials.fetch.fail')
+        return reject(requestErr)
+      }
+
+      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)
+    })
+  })
+}
+
+/**
+ * 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.
+ */
+async function fetchProviderKeys (providerName, companionOptions, credentialRequestParams) {
+  let providerConfig = companionOptions.providerOptions[providerName]
+  if (!providerConfig) {
+    providerConfig = (companionOptions.customProviders[providerName] || {}).config
+  }
+
+  if (!providerConfig) {
+    return null
+  }
+
+  if (!providerConfig.credentialsURL) {
+    return providerConfig
+  }
+
+  // If a default key is configured, do not ask the credentials endpoint for it.
+  // In a future version we could make this an XOR thing, providing either an endpoint or global keys,
+  // but not both.
+  if (!credentialRequestParams && providerConfig.key) {
+    return providerConfig
+  }
+
+  return fetchKeys(providerConfig.credentialsURL, providerName, credentialRequestParams || null)
+}
+
 /**
  * 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 {Record<string, typeof Provider>} providers provider classes enabled for this server
  * @param {object} companionOptions companion options object
- * @returns {(req: object, res: object, next: Function) => void}
+ * @returns {import('express').RequestHandler}
  */
 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()
+      next()
+      return
     }
 
     if (!companionOptions.providerOptions[providerName].credentialsURL) {
-      return next()
+      next()
+      return
     }
 
     const dynamic = oAuthState.getDynamicStateFromRequest(req)
@@ -32,17 +100,20 @@ exports.getCredentialsOverrideMiddleware = (providers, companionOptions) => {
     // override param indicates subsequent requests from the oauth flow
     const state = override ? dynamic : req.query.state
     if (!state) {
-      return next()
+      next()
+      return
     }
 
     const preAuthToken = oAuthState.getFromState(state, 'preAuthToken', companionOptions.secret)
     if (!preAuthToken) {
-      return next()
+      next()
+      return
     }
 
     const { err, payload } = tokenService.verifyEncryptedToken(preAuthToken, companionOptions.preAuthSecret)
     if (err || !payload) {
-      return next()
+      next()
+      return
     }
 
     fetchProviderKeys(providerName, companionOptions, payload).then((credentials) => {
@@ -56,7 +127,27 @@ exports.getCredentialsOverrideMiddleware = (providers, companionOptions) => {
       if (credentials.redirect_uri) {
         res.locals.grant.dynamic.redirect_uri = credentials.redirect_uri
       }
-    }).finally(() => next())
+
+      next()
+    }).catch((keyErr) => {
+      // TODO we should return an html page here that can communicate the error
+      // back to the Uppy client, just like /send-token does
+      res.send(`
+        <!DOCTYPE html>
+        <html>
+        <head>
+          <meta charset="utf-8">
+        </head>
+        <body>
+          <h1>Could not fetch credentials</h1>
+          <p>
+            This is probably an Uppy configuration issue. Check that your Transloadit key is correct, and that the configured <code>credentialsName</code> for this remote provider matches the name you gave it in the Template Credentials setup on the Transloadit side.
+          </p>
+          <p>Internal error message: ${htmlEscape(keyErr.message)}</p>
+        </body>
+        </html>
+      `)
+    })
   }
 }
 
@@ -86,51 +177,3 @@ module.exports.getCredentialsResolver = (providerName, companionOptions, req) =>
 
   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)
-  }
-  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)
-    })
-  })
-}

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

@@ -218,7 +218,9 @@ module.exports = class ProviderView extends View {
     })
   }
 
-  handleAuth () {
+  async handleAuth () {
+    await this.provider.ensurePreAuth()
+
     const authState = btoa(JSON.stringify({ origin: getOrigin() }))
     const clientVersion = `@uppy/provider-views=${ProviderView.VERSION}`
     const link = this.provider.authUrl({ state: authState, uppyVersions: clientVersion })

+ 16 - 1
packages/@uppy/robodog/src/addProviders.js

@@ -1,6 +1,7 @@
 const Transloadit = require('@uppy/transloadit')
 const has = require('@uppy/utils/lib/hasProperty')
 
+// We add providers to Robodog when they hit version 1.0.
 const remoteProviders = {
   dropbox: require('@uppy/dropbox'),
   'google-drive': require('@uppy/google-drive'),
@@ -37,9 +38,23 @@ function addRemoteProvider (uppy, name, opts) {
   remoteProviderOptionNames.forEach((name) => {
     if (has(opts, name)) providerOptions[name] = opts[name]
   })
+
   // Apply overrides for a specific provider plugin.
   if (typeof opts[name] === 'object') {
-    Object.assign(providerOptions, opts[name])
+    const overrides = { ...opts[name] }
+
+    // Use the app's own oauth credentials instead of the shared
+    // Transloadit ones.
+    if (overrides.credentialsName) {
+      const { key } = opts.params.auth
+      overrides.companionKeysParams = {
+        key,
+        credentialsName: overrides.credentialsName,
+      }
+      delete overrides.credentialsName
+    }
+
+    Object.assign(providerOptions, overrides)
   }
 
   uppy.use(Provider, providerOptions)

+ 1 - 1
private/dev/Dashboard.js

@@ -135,7 +135,7 @@ export default () => {
       })
       uppyDashboard.use(XHRUpload, {
         method: 'POST',
-        endpoint: 'https://api2.transloadit.com/assemblies',
+        endpoint: `${TRANSLOADIT_SERVICE_URL}/assemblies`,
         metaFields: ['params'],
         bundle: true,
       })

+ 21 - 2
website/src/docs/robodog-dashboard.md

@@ -7,7 +7,7 @@ order: 4
 category: "File Processing"
 ---
 
-Add the [Dashboard UI][dashboard] to your page, all wired up and ready to go! This is a basic wrapper around the [Transloadit][transloadit] and [Dashboard][dashboard] plugins. Unlike the [File Picker][file picker] API, this Dashboard is embedded directly into the page. Users can upload many files after another.
+Add the [Dashboard UI][dashboard] to your page, all wired up and ready to go! This is a wrapper around the [Transloadit][transloadit] and [Dashboard][dashboard] plugins. Unlike the [File Picker][file picker] API, this Dashboard is embedded directly into the page. Users can upload many files after another.
 
 ```html
 <div id="dashboard"></div>
@@ -69,10 +69,29 @@ The minimum number of files that must be selected before the upload. The upload
 
 Array of mime type wildcards `image/*`, exact mime types `image/jpeg`, or file extensions `.jpg`: `['image/*', '.jpg', '.jpeg', '.png', '.gif']`.
 
-If provided, the [`<input accept>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Limiting\_accepted\_file\_types) attribute will be added to `<input type="file">` fields, so only acceptable files can be selected in the system file dialog.
+If provided, the [`<input accept>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Limiting_accepted_file_types) attribute will be added to `<input type="file">` fields, so only acceptable files can be selected in the system file dialog.
+
+## Using your own OAuth applications when importing files
+
+When importing files from remote providers, Transloadit’s OAuth applications are used by default. Your users will be asked to provide Transloadit access to their files. Since your users are probably not aware of Transloadit, this may be confusing or decrease trust. You may also hit rate limits, because the OAuth application is shared between everyone using Transloadit.
+
+You can use your own OAuth keys with Transloadit’s hosted Companion servers by using Transloadit Template Credentials. [Create a Template Credential][template-credentials] on the Transloadit site. Select “Companion OAuth” for the service, and enter the key and secret for the provider you want to use. Then you can pass the name of the new credentials to that provider:
+
+```js
+Robodog.dashboard({
+  providers: ['dropbox'],
+  dropbox: {
+    credentialsName: 'my_companion_dropbox_creds',
+  },
+})
+```
+
+Users will now be asked to allow _your_ application access, and they’re probably already familiar with that!
 
 [dashboard]: /docs/dashboard
 
 [transloadit]: /docs/transloadit
 
 [file picker]: /docs/robodog/picker
+
+[template-credentials]: https://transloadit.com/docs/#how-to-create-template-credentials

+ 22 - 3
website/src/docs/robodog-picker.md

@@ -67,7 +67,7 @@ The minimum number of files that must be selected before the upload.
 
 Array of mime type wildcards `image/*`, exact mime types `image/jpeg`, or file extensions `.jpg`: `['image/*', '.jpg', '.jpeg', '.png', '.gif']`.
 
-If provided, the [`<input accept>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Limiting\_accepted\_file\_types) attribute will be used for the internal file input field, so only acceptable files can be selected in the system file dialog.
+If provided, the [`<input accept>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Limiting_accepted_file_types) attribute will be used for the internal file input field, so only acceptable files can be selected in the system file dialog.
 
 ## Providers
 
@@ -87,7 +87,7 @@ Array of providers to use. Each entry is the name of a provider. The available o
 
 ### `companionUrl: Transloadit.COMPANION`
 
-The URL to a [Uppy Companion][companion] server to use.
+The URL to a [Uppy Companion][companion] server to use. By default, Transloadit’s hosted servers are used. These servers are restricted to importing files from remote providers into Transloadit Assemblies.
 
 ### `companionAllowedHosts: Transloadit.COMPANION_PATTERN`
 
@@ -95,7 +95,7 @@ The valid and authorised URL(s) from which OAuth responses should be accepted.
 
 This value can be a `String`, a `Regex` pattern, or an `Array` of both.
 
-This is useful when you have your [Uppy Companion][companion] running on several hosts. Otherwise, the default value should do fine.
+This is useful when you have your own [Uppy Companion][companion] instances running on many hostnames.
 
 ### `companionHeaders: {}`
 
@@ -121,10 +121,29 @@ Specific options for the [URL](/docs/url) provider.
 
 Specific options for the [Webcam](/docs/webcam) provider.
 
+## Using your own OAuth applications when importing files
+
+When importing files from remote providers, Transloadit’s OAuth applications are used by default. Your users will be asked to provide Transloadit access to their files. Since your users are probably not aware of Transloadit, this may be confusing or decrease trust. You may also hit rate limits, because the OAuth application is shared between everyone using Transloadit.
+
+You can use your own OAuth keys with Transloadit’s hosted Companion servers by using Transloadit Template Credentials. [Create a Template Credential][template-credentials] on the Transloadit site. Select “Companion OAuth” for the service, and enter the key and secret for the provider you want to use. Then you can pass the name of the new credentials to that provider:
+
+```js
+Robodog.pick({
+  providers: ['dropbox'],
+  dropbox: {
+    credentialsName: 'my_companion_dropbox_creds',
+  },
+})
+```
+
+Users will now be asked to allow _your_ application access, and they’re probably already familiar with that!
+
 [companion]: /docs/companion
 
 [transloadit]: /docs/transloadit#options
 
 [assembly-status]: https://transloadit.com/docs/api/#assembly-status-response
 
+[template-credentials]: https://transloadit.com/docs/#how-to-create-template-credentials
+
 [promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

+ 29 - 0
website/src/docs/transloadit.md

@@ -50,6 +50,33 @@ In the [CDN package](/docs/#With-a-script-tag), the plugin class is available on
 const { Transloadit } = Uppy
 ```
 
+## Hosted Companion Service
+
+You can use this plugin together with Transloadit’s hosted Companion service to let your users import files from third party sources across the web.
+To do so each provider plugin must be configured with Transloadit’s Companion URLs:
+
+```js
+uppy.use(Dropbox, {
+  companionUrl: Transloadit.COMPANION,
+  companionAllowedHosts: Transloadit.COMPANION_PATTERN,
+})
+```
+
+This will already work. Transloadit’s OAuth applications are used to authenticate your users by default. Your users will be asked to provide Transloadit access to their files. Since your users are probably not aware of Transloadit, this may be confusing or decrease trust. You may also hit rate limits, because the OAuth application is shared between everyone using Transloadit.
+
+To solve that, you can use your own OAuth keys with Transloadit’s hosted Companion servers by using Transloadit Template Credentials. [Create a Template Credential][template-credentials] on the Transloadit site. Select “Companion OAuth” for the service, and enter the key and secret for the provider you want to use. Then you can pass the name of the new credentials to that provider:
+
+```js
+uppy.use(Dropbox, {
+  companionUrl: Transloadit.COMPANION,
+  companionAllowedHosts: Transloadit.COMPANION_PATTERN,
+  companionKeysParams: {
+    key: 'YOUR_TRANSLOADIT_API_KEY',
+    credentialsName: 'my_companion_dropbox_creds',
+  },
+})
+```
+
 ## Properties
 
 ### `Transloadit.COMPANION`
@@ -383,3 +410,5 @@ uppy.on('transloadit:complete', (assembly) => {
 ```
 
 [assembly-status]: https://transloadit.com/docs/api/#assembly-status-response
+
+[template-credentials]: https://transloadit.com/docs/#how-to-create-template-credentials

+ 8 - 0
yarn.lock

@@ -8951,6 +8951,7 @@ __metadata:
     connect-redis: 4.0.3
     cookie-parser: 1.4.6
     cors: ^2.8.5
+    escape-goat: 3.0.0
     escape-string-regexp: 2.0.0
     express: 4.17.1
     express-interceptor: 1.2.0
@@ -18244,6 +18245,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"escape-goat@npm:3.0.0":
+  version: 3.0.0
+  resolution: "escape-goat@npm:3.0.0"
+  checksum: 6719196d073cc72d0bbe079646d6fa32f226f24fd7d00c1a71fa375bd4c5b8999050021d9e62c232a8874230328ebf89a5c8bd76fb72f7ccd6229efbe5abd04e
+  languageName: node
+  linkType: hard
+
 "escape-goat@npm:^2.0.0":
   version: 2.1.1
   resolution: "escape-goat@npm:2.1.1"