From 8c19821f73073e938987c2426f0e1d629839b6cc Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Fri, 12 May 2023 11:29:23 +0200 Subject: [PATCH] implement refresh token for dropbox and google drive closes #2721 --- .../@uppy/companion-client/src/Provider.js | 49 ++++++++++++++++--- .../companion-client/src/RequestClient.js | 8 +-- packages/@uppy/companion/src/companion.js | 1 + packages/@uppy/companion/src/config/grant.js | 2 + .../companion/src/server/controllers/index.js | 1 + .../src/server/controllers/refresh-token.js | 49 +++++++++++++++++++ .../companion/src/server/provider/Provider.js | 9 ++++ .../src/server/provider/drive/index.js | 11 +++++ .../src/server/provider/dropbox/index.js | 12 ++++- 9 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 packages/@uppy/companion/src/server/controllers/refresh-token.js diff --git a/packages/@uppy/companion-client/src/Provider.js b/packages/@uppy/companion-client/src/Provider.js index 136fc13303..e12238886a 100644 --- a/packages/@uppy/companion-client/src/Provider.js +++ b/packages/@uppy/companion-client/src/Provider.js @@ -8,6 +8,8 @@ const getName = (id) => { } export default class Provider extends RequestClient { + #refreshingTokenPromise + constructor (uppy, opts) { super(uppy, opts) this.provider = opts.provider @@ -51,6 +53,10 @@ export default class Provider extends RequestClient { return this.uppy.getPlugin(this.pluginId).storage.getItem(this.tokenKey) } + async #removeAuthToken () { + return this.uppy.getPlugin(this.pluginId).storage.removeItem(this.tokenKey) + } + /** * Ensure we have a preauth token if necessary. Attempts to fetch one if we don't, * or rejects if loading one fails. @@ -74,10 +80,43 @@ export default class Provider extends RequestClient { return `${this.hostname}/${this.id}/connect?${params}` } + refreshTokenUrl () { + return `${this.hostname}/${this.id}/refresh-token` + } + fileUrl (id) { return `${this.hostname}/${this.id}/get/${id}` } + async request (...args) { + await this.#refreshingTokenPromise + + try { + // throw Object.assign(new Error(), { isAuthError: true }) // testing simulate access token expired (to refresh token) + return await super.request(...args) + } catch (err) { + if (!err.isAuthError) throw err // only handle auth errors (401 from provider) + + await this.#refreshingTokenPromise + + // Many provider requests may be starting at once, however refresh token should only be called once. + // Once a refresh token operation has started, we need all other request to wait for this operation (atomically) + this.#refreshingTokenPromise = (async () => { + try { + const response = await super.request({ path: this.refreshTokenUrl(), method: 'POST' }) + await this.setAuthToken(response.uppyAuthToken) + } finally { + this.#refreshingTokenPromise = undefined + } + })() + + await this.#refreshingTokenPromise + + // now retry the request with our new refresh token + return super.request(...args) + } + } + async fetchPreAuthToken () { if (!this.companionKeysParams) { return @@ -95,12 +134,10 @@ export default class Provider extends RequestClient { return this.get(`${this.id}/list/${directory || ''}`) } - logout () { - return this.get(`${this.id}/logout`) - .then((response) => Promise.all([ - response, - this.uppy.getPlugin(this.pluginId).storage.removeItem(this.tokenKey), - ])).then(([response]) => response) + async logout () { + const response = await this.get(`${this.id}/logout`) + await this.#removeAuthToken() + return response } static initPlugin (plugin, opts, defaultOpts) { diff --git a/packages/@uppy/companion-client/src/RequestClient.js b/packages/@uppy/companion-client/src/RequestClient.js index 5e32d83209..96be3a721c 100644 --- a/packages/@uppy/companion-client/src/RequestClient.js +++ b/packages/@uppy/companion-client/src/RequestClient.js @@ -150,7 +150,7 @@ export default class RequestClient { })) } - async #request ({ path, method = 'GET', data, skipPostResponse, signal }) { + async request ({ path, method = 'GET', data, skipPostResponse, signal }) { try { const headers = await this.preflightAndHeaders(path) const response = await fetchWithNetworkError(this.#getUrl(path), { @@ -172,20 +172,20 @@ export default class RequestClient { // TODO: remove boolean support for options that was added for backward compatibility. // eslint-disable-next-line no-param-reassign if (typeof options === 'boolean') options = { skipPostResponse: options } - return this.#request({ ...options, path }) + return this.request({ ...options, path }) } async post (path, data, options = undefined) { // TODO: remove boolean support for options that was added for backward compatibility. // eslint-disable-next-line no-param-reassign if (typeof options === 'boolean') options = { skipPostResponse: options } - return this.#request({ ...options, path, method: 'POST', data }) + return this.request({ ...options, path, method: 'POST', data }) } async delete (path, data = undefined, options) { // TODO: remove boolean support for options that was added for backward compatibility. // eslint-disable-next-line no-param-reassign if (typeof options === 'boolean') options = { skipPostResponse: options } - return this.#request({ ...options, path, method: 'DELETE', data }) + return this.request({ ...options, path, method: 'DELETE', data }) } } diff --git a/packages/@uppy/companion/src/companion.js b/packages/@uppy/companion/src/companion.js index 77ba22bdcb..cbaf6a2a72 100644 --- a/packages/@uppy/companion/src/companion.js +++ b/packages/@uppy/companion/src/companion.js @@ -123,6 +123,7 @@ module.exports.app = (optionsArg = {}) => { app.get('/:providerName/connect', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.connect) app.get('/:providerName/redirect', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.redirect) app.get('/:providerName/callback', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.callback) + app.post('/:providerName/refresh-token', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.verifyToken, controllers.refreshToken) app.post('/:providerName/deauthorization/callback', express.json(), middlewares.hasSessionAndProvider, middlewares.hasBody, middlewares.hasOAuthProvider, controllers.deauthorizationCallback) app.get('/:providerName/logout', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.gentleVerifyToken, controllers.logout) app.get('/:providerName/send-token', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.verifyToken, controllers.sendToken) diff --git a/packages/@uppy/companion/src/config/grant.js b/packages/@uppy/companion/src/config/grant.js index c322b4004d..1e77be22ac 100644 --- a/packages/@uppy/companion/src/config/grant.js +++ b/packages/@uppy/companion/src/config/grant.js @@ -8,12 +8,14 @@ module.exports = () => { 'https://www.googleapis.com/auth/drive.readonly', ], callback: '/drive/callback', + custom_params: { access_type : 'offline' }, }, dropbox: { transport: 'session', authorize_url: 'https://www.dropbox.com/oauth2/authorize', access_url: 'https://api.dropbox.com/oauth2/token', callback: '/dropbox/callback', + custom_params: { token_access_type : 'offline' }, }, box: { transport: 'session', diff --git a/packages/@uppy/companion/src/server/controllers/index.js b/packages/@uppy/companion/src/server/controllers/index.js index b9a499eecf..4410eec3b9 100644 --- a/packages/@uppy/companion/src/server/controllers/index.js +++ b/packages/@uppy/companion/src/server/controllers/index.js @@ -10,4 +10,5 @@ module.exports = { connect: require('./connect'), preauth: require('./preauth'), redirect: require('./oauth-redirect'), + refreshToken: require('./refresh-token'), } diff --git a/packages/@uppy/companion/src/server/controllers/refresh-token.js b/packages/@uppy/companion/src/server/controllers/refresh-token.js new file mode 100644 index 0000000000..ee3789f35f --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/refresh-token.js @@ -0,0 +1,49 @@ +const tokenService = require('../helpers/jwt') +const { respondWithError } = require('../provider/error') +const logger = require('../logger') + +// https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Get-refresh-token-from-access-token/td-p/596739 +// https://developers.dropbox.com/oauth-guide +// https://github.com/simov/grant/issues/149 +async function refreshToken (req, res, next) { + const { providerName } = req.params + + const { key: clientId, secret: clientSecret } = req.companion.options.providerOptions[providerName] + + const providerTokens = req.companion.allProvidersTokens[providerName] + + // not all providers have refresh tokens + if (providerTokens.refreshToken == null) { + res.sendStatus(401) + return + } + + try { + const data = await req.companion.provider.refreshToken({ + clientId, clientSecret, refreshToken: providerTokens.refreshToken, + }) + + const newAllProvidersTokens = { + ...req.companion.allProvidersTokens, + [providerName]: { + ...providerTokens, + accessToken: data.accessToken, + }, + } + + req.companion.allProvidersTokens = newAllProvidersTokens + req.companion.providerTokens = newAllProvidersTokens[providerName] + + logger.debug(`Generating refreshed auth token for provider ${providerName}`, null, req.id) + const uppyAuthToken = tokenService.generateEncryptedAuthToken( + req.companion.allProvidersTokens, req.companion.options.secret, + ) + + res.send({ uppyAuthToken }) + } catch (err) { + if (respondWithError(err, res)) return + next(err) + } +} + +module.exports = refreshToken diff --git a/packages/@uppy/companion/src/server/provider/Provider.js b/packages/@uppy/companion/src/server/provider/Provider.js index 453b0ebadd..2268b0aec4 100644 --- a/packages/@uppy/companion/src/server/provider/Provider.js +++ b/packages/@uppy/companion/src/server/provider/Provider.js @@ -7,6 +7,7 @@ class Provider { * @param {object} options */ constructor (options) { // eslint-disable-line no-unused-vars + // Some providers might need cookie auth for the thumbnails fetched via companion this.needsCookieAuth = false return this } @@ -74,6 +75,14 @@ class Provider { throw new Error('method not implemented') } + /** + * Generate a new access token based on the refresh token + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + async refreshToken (options) { + throw new Error('method not implemented') + } + /** * Name of the OAuth provider. Return empty string if no OAuth provider is needed. * diff --git a/packages/@uppy/companion/src/server/provider/drive/index.js b/packages/@uppy/companion/src/server/provider/drive/index.js index d15a7ed376..903636c9d6 100644 --- a/packages/@uppy/companion/src/server/provider/drive/index.js +++ b/packages/@uppy/companion/src/server/provider/drive/index.js @@ -18,6 +18,10 @@ const getClient = ({ token }) => got.extend({ }, }) +const getOauthClient = () => got.extend({ + prefixUrl: 'https://oauth2.googleapis.com', +}) + async function getStats ({ id, token }) { const client = getClient({ token }) @@ -168,6 +172,13 @@ class Drive extends Provider { }) } + async refreshToken ({ clientId, clientSecret, refreshToken }) { + return this.#withErrorHandling('provider.drive.token.refresh.error', async () => { + const { access_token: accessToken } = await getOauthClient().post('token', { form: { refresh_token: refreshToken, grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret } }).json() + return { accessToken } + }) + } + async #withErrorHandling (tag, fn) { return withProviderErrorHandling({ fn, diff --git a/packages/@uppy/companion/src/server/provider/dropbox/index.js b/packages/@uppy/companion/src/server/provider/dropbox/index.js index 848c6b202c..50b4989ea6 100644 --- a/packages/@uppy/companion/src/server/provider/dropbox/index.js +++ b/packages/@uppy/companion/src/server/provider/dropbox/index.js @@ -24,6 +24,10 @@ const getClient = ({ token }) => got.extend({ }, }) +const getOauthClient = () => got.extend({ + prefixUrl: 'https://api.dropboxapi.com/oauth2', +}) + async function list ({ directory, query, token }) { if (query.cursor) { return getClient({ token }).post('files/list_folder/continue', { json: { cursor: query.cursor }, responseType: 'json' }).json() @@ -52,7 +56,6 @@ class DropBox extends Provider { constructor (options) { super(options) this.authProvider = DropBox.authProvider - // needed for the thumbnails fetched via companion this.needsCookieAuth = true } @@ -121,6 +124,13 @@ class DropBox extends Provider { }) } + async refreshToken ({ clientId, clientSecret, refreshToken }) { + return this.#withErrorHandling('provider.dropbox.token.refresh.error', async () => { + const { access_token: accessToken } = await getOauthClient().post('token', { form: { refresh_token: refreshToken, grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret } }).json() + return { accessToken } + }) + } + async #withErrorHandling (tag, fn) { return withProviderErrorHandling({ fn,