Skip to content

Commit

Permalink
implement refresh token
Browse files Browse the repository at this point in the history
for dropbox and google drive

closes #2721
  • Loading branch information
mifi committed May 12, 2023
1 parent cc6ee4c commit 8c19821
Show file tree
Hide file tree
Showing 9 changed files with 131 additions and 11 deletions.
49 changes: 43 additions & 6 deletions packages/@uppy/companion-client/src/Provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const getName = (id) => {
}

export default class Provider extends RequestClient {
#refreshingTokenPromise

constructor (uppy, opts) {
super(uppy, opts)
this.provider = opts.provider
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions packages/@uppy/companion-client/src/RequestClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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), {
Expand All @@ -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 })
}
}
1 change: 1 addition & 0 deletions packages/@uppy/companion/src/companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/@uppy/companion/src/config/grant.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/@uppy/companion/src/server/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ module.exports = {
connect: require('./connect'),
preauth: require('./preauth'),
redirect: require('./oauth-redirect'),
refreshToken: require('./refresh-token'),
}
49 changes: 49 additions & 0 deletions packages/@uppy/companion/src/server/controllers/refresh-token.js
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions packages/@uppy/companion/src/server/provider/Provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
*
Expand Down
11 changes: 11 additions & 0 deletions packages/@uppy/companion/src/server/provider/drive/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion packages/@uppy/companion/src/server/provider/dropbox/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 8c19821

Please sign in to comment.