diff --git a/packages/@uppy/box/src/Box.jsx b/packages/@uppy/box/src/Box.jsx index 93a202a1db..2fd823b383 100644 --- a/packages/@uppy/box/src/Box.jsx +++ b/packages/@uppy/box/src/Box.jsx @@ -30,6 +30,7 @@ export default class Box extends UIPlugin { companionCookiesRule: this.opts.companionCookiesRule, provider: 'box', pluginId: this.id, + supportsRefreshToken: false, }) this.defaultLocale = locale diff --git a/packages/@uppy/companion-client/src/Provider.js b/packages/@uppy/companion-client/src/Provider.js index e72e60a777..cae6b42102 100644 --- a/packages/@uppy/companion-client/src/Provider.js +++ b/packages/@uppy/companion-client/src/Provider.js @@ -40,6 +40,7 @@ export default class Provider extends RequestClient { this.tokenKey = `companion-${this.pluginId}-auth-token` this.companionKeysParams = this.opts.companionKeysParams this.preAuthToken = null + this.supportsRefreshToken = opts.supportsRefreshToken ?? true // todo false in next major } async headers () { @@ -74,7 +75,7 @@ export default class Provider extends RequestClient { return this.uppy.getPlugin(this.pluginId).storage.getItem(this.tokenKey) } - async #removeAuthToken () { + async removeAuthToken () { return this.uppy.getPlugin(this.pluginId).storage.removeItem(this.tokenKey) } @@ -92,11 +93,18 @@ export default class Provider extends RequestClient { } } - authUrl (queries = {}) { + // eslint-disable-next-line class-methods-use-this + authQuery () { + return {} + } + + authUrl ({ formSubmitEvent, query } = {}) { const params = new URLSearchParams({ + ...query, state: btoa(JSON.stringify({ origin: getOrigin() })), - ...queries, + ...this.authQuery({ formSubmitEvent }), }) + if (this.preAuthToken) { params.set('uppyPreAuthToken', this.preAuthToken) } @@ -104,12 +112,13 @@ export default class Provider extends RequestClient { return `${this.hostname}/${this.id}/connect?${params}` } - async login (queries) { + async login ({ uppyVersions, formSubmitEvent }) { await this.ensurePreAuth() return new Promise((resolve, reject) => { - const link = this.authUrl(queries) + const link = this.authUrl({ query: { uppyVersions }, formSubmitEvent }) const authWindow = window.open(link, '_blank') + const handleToken = (e) => { if (e.source !== authWindow) { this.uppy.log.warn('ignoring event from unknown source', e) @@ -164,6 +173,7 @@ export default class Provider extends RequestClient { // throw new AuthError() // testing simulate access token expired (to refresh token) return await super.request(...args) } catch (err) { + if (!this.supportsRefreshToken) throw err if (!(err instanceof AuthError)) throw err // only handle auth errors (401 from provider) await this.#refreshingTokenPromise @@ -205,7 +215,7 @@ export default class Provider extends RequestClient { async logout (options) { const response = await this.get(`${this.id}/logout`, options) - await this.#removeAuthToken() + await this.removeAuthToken() return response } diff --git a/packages/@uppy/companion/src/companion.js b/packages/@uppy/companion/src/companion.js index 3e314e72d0..f1f5a43774 100644 --- a/packages/@uppy/companion/src/companion.js +++ b/packages/@uppy/companion/src/companion.js @@ -126,6 +126,8 @@ module.exports.app = (optionsArg = {}) => { 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) + app.post('/:providerName/simple-auth', express.json(), middlewares.hasSessionAndProvider, middlewares.hasBody, middlewares.hasSimpleAuthProvider, controllers.simpleAuth) + app.get('/:providerName/list/:id?', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list) // backwards compat: app.get('/search/:providerName/list', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list) @@ -136,7 +138,7 @@ module.exports.app = (optionsArg = {}) => { app.get('/:providerName/thumbnail/:id', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.cookieAuthToken, middlewares.verifyToken, controllers.thumbnail) - app.param('providerName', providerManager.getProviderMiddleware(providers)) + app.param('providerName', providerManager.getProviderMiddleware(providers, grantConfig)) if (app.get('env') !== 'test') { jobs.startCleanUpJob(options.filePath) diff --git a/packages/@uppy/companion/src/server/controllers/callback.js b/packages/@uppy/companion/src/server/controllers/callback.js index dcd7dadd22..220d06e030 100644 --- a/packages/@uppy/companion/src/server/controllers/callback.js +++ b/packages/@uppy/companion/src/server/controllers/callback.js @@ -33,25 +33,28 @@ module.exports = function callback (req, res, next) { // eslint-disable-line no- const grant = req.session.grant || {} + const grantDynamic = oAuthState.getGrantDynamicFromRequest(req) + const origin = grantDynamic.state && oAuthState.getFromState(grantDynamic.state, 'origin', req.companion.options.secret) + if (!grant.response?.access_token) { logger.debug(`Did not receive access token for provider ${providerName}`, null, req.id) logger.debug(grant.response, 'callback.oauth.resp', req.id) - const state = oAuthState.getDynamicStateFromRequest(req) - const origin = state && oAuthState.getFromState(state, 'origin', req.companion.options.secret) return res.status(400).send(closePageHtml(origin)) } - if (!req.companion.allProvidersTokens) req.companion.allProvidersTokens = {} - req.companion.allProvidersTokens[providerName] = { + req.companion.providerUserSession = { accessToken: grant.response.access_token, refreshToken: grant.response.refresh_token, // might be undefined for some providers + ...req.companion.providerClass.grantDynamicToUserSession({ grantDynamic }), } + logger.debug(`Generating auth token for provider ${providerName}`, null, req.id) const uppyAuthToken = tokenService.generateEncryptedAuthToken( - req.companion.allProvidersTokens, req.companion.options.secret, + { [providerName]: req.companion.providerUserSession }, + req.companion.options.secret, req.companion.providerClass.authStateExpiry, ) - tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken) + tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken, req.companion.providerClass.authStateExpiry) return res.redirect(req.companion.buildURL(`/${providerName}/send-token?uppyAuthToken=${uppyAuthToken}`, true)) } diff --git a/packages/@uppy/companion/src/server/controllers/connect.js b/packages/@uppy/companion/src/server/controllers/connect.js index f34cab40e9..3db52193c9 100644 --- a/packages/@uppy/companion/src/server/controllers/connect.js +++ b/packages/@uppy/companion/src/server/controllers/connect.js @@ -1,6 +1,11 @@ const atob = require('atob') const oAuthState = require('../helpers/oauth-state') +const queryString = (params, prefix = '?') => { + const str = new URLSearchParams(params).toString() + return str ? `${prefix}${str}` : '' +} + /** * initializes the oAuth flow for a provider. * @@ -29,5 +34,17 @@ module.exports = function connect (req, res) { } const state = oAuthState.encodeState(stateObj, secret) - res.redirect(req.companion.buildURL(`/connect/${req.companion.provider.authProvider}?state=${state}`, true)) + const { provider, providerGrantConfig } = req.companion + + // pass along grant's dynamic config (if specified for the provider in its grant config `dynamic` section) + const grantDynamicConfig = Object.fromEntries(providerGrantConfig.dynamic?.map(p => [p, req.query[p]]) || []) + + const providerName = provider.authProvider + const qs = queryString({ + ...grantDynamicConfig, + state, + }) + + // Now we redirect to grant's /connect endpoint, see `app.use(Grant(grantConfig))` + res.redirect(req.companion.buildURL(`/connect/${providerName}${qs}`, true)) } diff --git a/packages/@uppy/companion/src/server/controllers/get.js b/packages/@uppy/companion/src/server/controllers/get.js index 64babedbfb..c1a6570d90 100644 --- a/packages/@uppy/companion/src/server/controllers/get.js +++ b/packages/@uppy/companion/src/server/controllers/get.js @@ -3,7 +3,8 @@ const { startDownUpload } = require('../helpers/upload') async function get (req, res) { const { id } = req.params - const { accessToken } = req.companion.providerTokens + const { providerUserSession } = req.companion + const { accessToken } = providerUserSession const { provider } = req.companion async function getSize () { @@ -11,7 +12,7 @@ async function get (req, res) { } async function download () { - const { stream } = await provider.download({ id, token: accessToken, query: req.query }) + const { stream } = await provider.download({ id, token: accessToken, providerUserSession, query: req.query }) return stream } diff --git a/packages/@uppy/companion/src/server/controllers/index.js b/packages/@uppy/companion/src/server/controllers/index.js index 4410eec3b9..90dd93fb12 100644 --- a/packages/@uppy/companion/src/server/controllers/index.js +++ b/packages/@uppy/companion/src/server/controllers/index.js @@ -6,6 +6,7 @@ module.exports = { get: require('./get'), thumbnail: require('./thumbnail'), list: require('./list'), + simpleAuth: require('./simple-auth'), logout: require('./logout'), connect: require('./connect'), preauth: require('./preauth'), diff --git a/packages/@uppy/companion/src/server/controllers/list.js b/packages/@uppy/companion/src/server/controllers/list.js index 284660c1be..43afb0b7c9 100644 --- a/packages/@uppy/companion/src/server/controllers/list.js +++ b/packages/@uppy/companion/src/server/controllers/list.js @@ -1,10 +1,14 @@ const { respondWithError } = require('../provider/error') async function list ({ query, params, companion }, res, next) { - const { accessToken } = companion.providerTokens + const { providerUserSession } = companion + const { accessToken } = providerUserSession try { - const data = await companion.provider.list({ companion, token: accessToken, directory: params.id, query }) + // todo remove backward compat `token` param from all provider methods (because it can be found in providerUserSession) + const data = await companion.provider.list({ + companion, token: accessToken, providerUserSession, directory: params.id, query, + }) res.json(data) } catch (err) { if (respondWithError(err, res)) return diff --git a/packages/@uppy/companion/src/server/controllers/logout.js b/packages/@uppy/companion/src/server/controllers/logout.js index 1ec87e303f..741dc61dda 100644 --- a/packages/@uppy/companion/src/server/controllers/logout.js +++ b/packages/@uppy/companion/src/server/controllers/logout.js @@ -13,20 +13,19 @@ async function logout (req, res, next) { req.session.grant.dynamic = null } } - const { providerName } = req.params const { companion } = req - const tokens = companion.allProvidersTokens ? companion.allProvidersTokens[providerName] : null + const { providerUserSession } = companion - if (!tokens) { + if (!providerUserSession) { cleanSession() res.json({ ok: true, revoked: false }) return } try { - const { accessToken } = tokens - const data = await companion.provider.logout({ token: accessToken, companion }) - delete companion.allProvidersTokens[providerName] + const { accessToken } = providerUserSession + const data = await companion.provider.logout({ token: accessToken, providerUserSession, companion }) + delete companion.providerUserSession tokenService.removeFromCookies(res, companion.options, companion.provider.authProvider) cleanSession() res.json({ ok: true, ...data }) diff --git a/packages/@uppy/companion/src/server/controllers/oauth-redirect.js b/packages/@uppy/companion/src/server/controllers/oauth-redirect.js index a0ef7ba477..ff4f095448 100644 --- a/packages/@uppy/companion/src/server/controllers/oauth-redirect.js +++ b/packages/@uppy/companion/src/server/controllers/oauth-redirect.js @@ -16,7 +16,7 @@ module.exports = function oauthRedirect (req, res) { return } - const state = oAuthState.getDynamicStateFromRequest(req) + const { state } = oAuthState.getGrantDynamicFromRequest(req) if (!state) { res.status(400).send('Cannot find state in session') return diff --git a/packages/@uppy/companion/src/server/controllers/refresh-token.js b/packages/@uppy/companion/src/server/controllers/refresh-token.js index 99203ebe3c..6a8c3162c9 100644 --- a/packages/@uppy/companion/src/server/controllers/refresh-token.js +++ b/packages/@uppy/companion/src/server/controllers/refresh-token.js @@ -10,36 +10,31 @@ async function refreshToken (req, res, next) { const { key: clientId, secret: clientSecret } = req.companion.options.providerOptions[providerName] - const providerTokens = req.companion.allProvidersTokens[providerName] + const { providerUserSession } = req.companion // not all providers have refresh tokens - if (providerTokens.refreshToken == null) { + if (providerUserSession.refreshToken == null) { res.sendStatus(401) return } try { const data = await req.companion.provider.refreshToken({ - clientId, clientSecret, refreshToken: providerTokens.refreshToken, + clientId, clientSecret, refreshToken: providerUserSession.refreshToken, }) - const newAllProvidersTokens = { - ...req.companion.allProvidersTokens, - [providerName]: { - ...providerTokens, - accessToken: data.accessToken, - }, + req.companion.providerUserSession = { + ...providerUserSession, + 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, + { [providerName]: req.companion.providerUserSession }, + req.companion.options.secret, req.companion.providerClass.authStateExpiry, ) - tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken) + tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken, req.companion.providerClass.authStateExpiry) res.send({ uppyAuthToken }) } catch (err) { diff --git a/packages/@uppy/companion/src/server/controllers/send-token.js b/packages/@uppy/companion/src/server/controllers/send-token.js index 17cc4c1601..60bac3474e 100644 --- a/packages/@uppy/companion/src/server/controllers/send-token.js +++ b/packages/@uppy/companion/src/server/controllers/send-token.js @@ -33,7 +33,7 @@ const htmlContent = (token, origin) => { module.exports = function sendToken (req, res, next) { const uppyAuthToken = req.companion.authToken - const state = oAuthState.getDynamicStateFromRequest(req) + const { state } = oAuthState.getGrantDynamicFromRequest(req) if (state) { const origin = oAuthState.getFromState(state, 'origin', req.companion.options.secret) const allowedClients = req.companion.options.clients diff --git a/packages/@uppy/companion/src/server/controllers/simple-auth.js b/packages/@uppy/companion/src/server/controllers/simple-auth.js new file mode 100644 index 0000000000..91d900a152 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/simple-auth.js @@ -0,0 +1,31 @@ +const tokenService = require('../helpers/jwt') +const { respondWithError } = require('../provider/error') +const logger = require('../logger') + +async function simpleAuth (req, res, next) { + const { providerName } = req.params + + try { + const simpleAuthResponse = await req.companion.provider.simpleAuth({ requestBody: req.body }) + + req.companion.providerUserSession = { + ...req.companion.providerUserSession, + ...simpleAuthResponse, + } + + logger.debug(`Generating simple auth token for provider ${providerName}`, null, req.id) + const uppyAuthToken = tokenService.generateEncryptedAuthToken( + { [providerName]: req.companion.providerUserSession }, + req.companion.options.secret, req.companion.providerClass.authStateExpiry, + ) + + tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken, req.companion.providerClass.authStateExpiry) + + res.send({ uppyAuthToken }) + } catch (err) { + if (respondWithError(err, res)) return + next(err) + } +} + +module.exports = simpleAuth diff --git a/packages/@uppy/companion/src/server/controllers/thumbnail.js b/packages/@uppy/companion/src/server/controllers/thumbnail.js index cd6c7a7dfa..06de9be9fc 100644 --- a/packages/@uppy/companion/src/server/controllers/thumbnail.js +++ b/packages/@uppy/companion/src/server/controllers/thumbnail.js @@ -4,12 +4,12 @@ * @param {object} res */ async function thumbnail (req, res, next) { - const { providerName, id } = req.params - const { accessToken } = req.companion.allProvidersTokens[providerName] - const { provider } = req.companion + const { id } = req.params + const { provider, providerUserSession } = req.companion + const { accessToken } = providerUserSession try { - const { stream } = await provider.thumbnail({ id, token: accessToken }) + const { stream } = await provider.thumbnail({ id, token: accessToken, providerUserSession }) res.set('Content-Type', 'image/jpeg') stream.pipe(res) } catch (err) { diff --git a/packages/@uppy/companion/src/server/helpers/jwt.js b/packages/@uppy/companion/src/server/helpers/jwt.js index 38217e157c..0db6249a47 100644 --- a/packages/@uppy/companion/src/server/helpers/jwt.js +++ b/packages/@uppy/companion/src/server/helpers/jwt.js @@ -1,10 +1,13 @@ const jwt = require('jsonwebtoken') const { encrypt, decrypt } = require('./utils') -// The Uppy auth token is a (JWT) container around provider OAuth access & refresh tokens. -// Providers themselves will verify these inner tokens. +// The Uppy auth token is an encrypted JWT & JSON encoded container. +// It used to simply contain an OAuth access_token and refresh_token for a specific provider. +// However now we allow more data to be stored in it. This allows for storing other state or parameters needed for that +// specific provider, like username, password, host names etc. +// The different providers APIs themselves will verify these inner tokens through Provider classes. // The expiry of the Uppy auth token should be higher than the expiry of the refresh token. -// Because some refresh tokens never expire, we set the Uppy auth token expiry very high. +// Because some refresh tokens normally never expire, we set the Uppy auth token expiry very high. // Chrome has a maximum cookie expiry of 400 days, so we'll use that (we also store the auth token in a cookie) // // If the Uppy auth token expiry were set too low (e.g. 24hr), we could risk this situation: @@ -14,15 +17,21 @@ const { encrypt, decrypt } = require('./utils') // even though the provider refresh token would still have been accepted and // there's no way for them to retry their failed files. // With 400 days, there's still a theoretical possibility but very low. -const EXPIRY = 60 * 60 * 24 * 400 +const MAX_AGE_REFRESH_TOKEN = 60 * 60 * 24 * 400 + +const MAX_AGE_24H = 60 * 60 * 24 + +module.exports.MAX_AGE_24H = MAX_AGE_24H +module.exports.MAX_AGE_REFRESH_TOKEN = MAX_AGE_REFRESH_TOKEN /** * * @param {*} data * @param {string} secret + * @param {number} maxAge */ -const generateToken = (data, secret) => { - return jwt.sign({ data }, secret, { expiresIn: EXPIRY }) +const generateToken = (data, secret, maxAge) => { + return jwt.sign({ data }, secret, { expiresIn: maxAge }) } /** @@ -40,18 +49,17 @@ const verifyToken = (token, secret) => { * @param {*} payload * @param {string} secret */ -module.exports.generateEncryptedToken = (payload, secret) => { +module.exports.generateEncryptedToken = (payload, secret, maxAge = MAX_AGE_24H) => { // return payload // for easier debugging - return encrypt(generateToken(payload, secret), secret) + return encrypt(generateToken(payload, secret, maxAge), secret) } /** - * * @param {*} payload * @param {string} secret */ -module.exports.generateEncryptedAuthToken = (payload, secret) => { - return module.exports.generateEncryptedToken(JSON.stringify(payload), secret) +module.exports.generateEncryptedAuthToken = (payload, secret, maxAge) => { + return module.exports.generateEncryptedToken(JSON.stringify(payload), secret, maxAge) } /** @@ -100,21 +108,26 @@ function getCommonCookieOptions ({ companionOptions }) { const getCookieName = (authProvider) => `uppyAuthToken--${authProvider}` -const addToCookies = (res, token, companionOptions, authProvider) => { +const addToCookies = ({ res, token, companionOptions, authProvider, maxAge = MAX_AGE_24H * 1000 }) => { const cookieOptions = { ...getCommonCookieOptions({ companionOptions }), - maxAge: EXPIRY * 1000, - httpOnly: true, + maxAge, } // send signed token to client. res.cookie(getCookieName(authProvider), token, cookieOptions) } -module.exports.addToCookiesIfNeeded = (req, res, uppyAuthToken) => { +module.exports.addToCookiesIfNeeded = (req, res, uppyAuthToken, maxAge) => { // some providers need the token in cookies for thumbnail/image requests if (req.companion.provider.needsCookieAuth) { - addToCookies(res, uppyAuthToken, req.companion.options, req.companion.provider.authProvider) + addToCookies({ + res, + token: uppyAuthToken, + companionOptions: req.companion.options, + authProvider: req.companion.provider.authProvider, + maxAge, + }) } } diff --git a/packages/@uppy/companion/src/server/helpers/oauth-state.js b/packages/@uppy/companion/src/server/helpers/oauth-state.js index bebe9fcf33..4a7d1a9b9c 100644 --- a/packages/@uppy/companion/src/server/helpers/oauth-state.js +++ b/packages/@uppy/companion/src/server/helpers/oauth-state.js @@ -22,8 +22,6 @@ module.exports.getFromState = (state, name, secret) => { return decodeState(state, secret)[name] } -module.exports.getDynamicStateFromRequest = (req) => { - const dynamic = (req.session.grant || {}).dynamic || {} - const { state } = dynamic - return state +module.exports.getGrantDynamicFromRequest = (req) => { + return req.session.grant?.dynamic ?? {} } diff --git a/packages/@uppy/companion/src/server/middlewares.js b/packages/@uppy/companion/src/server/middlewares.js index c344ff1602..325a4ff0f2 100644 --- a/packages/@uppy/companion/src/server/middlewares.js +++ b/packages/@uppy/companion/src/server/middlewares.js @@ -24,6 +24,7 @@ exports.hasSessionAndProvider = (req, res, next) => { } const isOAuthProviderReq = (req) => isOAuthProvider(req.companion.providerClass.authProvider) +const isSimpleAuthProviderReq = (req) => !!req.companion.providerClass.hasSimpleAuth /** * Middleware can be used to verify that the current request is to an OAuth provider @@ -38,6 +39,15 @@ exports.hasOAuthProvider = (req, res, next) => { return next() } +exports.hasSimpleAuthProvider = (req, res, next) => { + if (!isSimpleAuthProviderReq(req)) { + logger.debug('Provider does not support simple auth.', null, req.id) + return res.sendStatus(400) + } + + return next() +} + exports.hasBody = (req, res, next) => { if (!req.body) { logger.debug('No body attached to req object. Exiting dispatcher.', null, req.id) @@ -57,7 +67,28 @@ exports.hasSearchQuery = (req, res, next) => { } exports.verifyToken = (req, res, next) => { - // for non oauth providers, we just load the static key from options + if (isOAuthProviderReq(req) || isSimpleAuthProviderReq(req)) { + // For OAuth / simple auth provider, we find the encrypted auth token from the header: + const token = req.companion.authToken + if (token == null) { + logger.info('cannot auth token', 'token.verify.unset', req.id) + res.sendStatus(401) + return + } + const { providerName } = req.params + try { + const payload = tokenService.verifyEncryptedAuthToken(token, req.companion.options.secret, providerName) + req.companion.providerUserSession = payload[providerName] + } catch (err) { + logger.error(err.message, 'token.verify.error', req.id) + res.sendStatus(401) + return + } + next() + return + } + + // for non auth providers, we just load the static key from options if (!isOAuthProviderReq(req)) { const { providerOptions } = req.companion.options const { providerName } = req.params @@ -67,31 +98,11 @@ exports.verifyToken = (req, res, next) => { return } - req.companion.providerTokens = { + req.companion.providerUserSession = { accessToken: providerOptions[providerName].key, } next() - return - } - - // Ok, OAuth provider, we fetch the token: - const token = req.companion.authToken - if (token == null) { - logger.info('cannot auth token', 'token.verify.unset', req.id) - res.sendStatus(401) - return - } - const { providerName } = req.params - try { - const payload = tokenService.verifyEncryptedAuthToken(token, req.companion.options.secret, providerName) - req.companion.allProvidersTokens = payload - req.companion.providerTokens = payload[providerName] - } catch (err) { - logger.error(err.message, 'token.verify.error', req.id) - res.sendStatus(401) - return } - next() } // does not fail if token is invalid @@ -102,7 +113,7 @@ exports.gentleVerifyToken = (req, res, next) => { const payload = tokenService.verifyEncryptedAuthToken( req.companion.authToken, req.companion.options.secret, providerName, ) - req.companion.allProvidersTokens = payload + req.companion.providerUserSession = payload[providerName] } catch (err) { logger.error(err.message, 'token.gentle.verify.error', req.id) } diff --git a/packages/@uppy/companion/src/server/provider/Provider.js b/packages/@uppy/companion/src/server/provider/Provider.js index 074d18d156..e649cab1a6 100644 --- a/packages/@uppy/companion/src/server/provider/Provider.js +++ b/packages/@uppy/companion/src/server/provider/Provider.js @@ -1,20 +1,24 @@ +const { MAX_AGE_24H } = require('../helpers/jwt') + /** * Provider interface defines the specifications of any provider implementation */ class Provider { /** * - * @param {{providerName: string, allowLocalUrls: boolean}} options + * @param {{providerName: string, allowLocalUrls: boolean, providerGrantConfig?: object}} options */ - constructor ({ allowLocalUrls }) { + constructor ({ allowLocalUrls, providerGrantConfig }) { // Some providers might need cookie auth for the thumbnails fetched via companion this.needsCookieAuth = false this.allowLocalUrls = allowLocalUrls + this.providerGrantConfig = providerGrantConfig return this } /** * config to extend the grant config + * todo major: rename to getExtraGrantConfig */ static getExtraConfig () { return {} @@ -85,13 +89,36 @@ class Provider { } /** - * Name of the OAuth provider. Return empty string if no OAuth provider is needed. + * @param {any} param0 + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars, class-methods-use-this + async simpleAuth ({ requestBody }) { + throw new Error('method not implemented') + } + + /** + * Name of the OAuth provider (passed to Grant). Return empty string if no OAuth provider is needed. * * @returns {string} */ + // todo next major: rename authProvider to oauthProvider (we have other non-oauth auth types too now) static get authProvider () { return undefined } + + // eslint-disable-next-line no-unused-vars + static grantDynamicToUserSession ({ grantDynamic }) { + return {} + } + + static get hasSimpleAuth () { + return false + } + + static get authStateExpiry () { + return MAX_AGE_24H + } } module.exports = Provider diff --git a/packages/@uppy/companion/src/server/provider/credentials.js b/packages/@uppy/companion/src/server/provider/credentials.js index fa41dbaef1..54103547a8 100644 --- a/packages/@uppy/companion/src/server/provider/credentials.js +++ b/packages/@uppy/companion/src/server/provider/credentials.js @@ -82,10 +82,10 @@ exports.getCredentialsOverrideMiddleware = (providers, companionOptions) => { return } - const dynamic = oAuthState.getDynamicStateFromRequest(req) + const grantDynamic = oAuthState.getGrantDynamicFromRequest(req) // 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 : req.query.state + const state = override ? grantDynamic.state : req.query.state if (!state) { next() return diff --git a/packages/@uppy/companion/src/server/provider/drive/index.js b/packages/@uppy/companion/src/server/provider/drive/index.js index 44fb38d960..c7672af577 100644 --- a/packages/@uppy/companion/src/server/provider/drive/index.js +++ b/packages/@uppy/companion/src/server/provider/drive/index.js @@ -5,6 +5,7 @@ const logger = require('../../logger') const { VIRTUAL_SHARED_DIR, adaptData, isShortcut, isGsuiteFile, getGsuiteExportType } = require('./adapter') const { withProviderErrorHandling } = require('../providerErrors') const { prepareStream } = require('../../helpers/utils') +const { MAX_AGE_REFRESH_TOKEN } = require('../../helpers/jwt') const DRIVE_FILE_FIELDS = 'kind,id,imageMediaMetadata,name,mimeType,ownedByMe,size,modifiedTime,iconLink,thumbnailLink,teamDriveId,videoMediaMetadata,shortcutDetails(targetId,targetMimeType)' const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS})` @@ -49,6 +50,10 @@ class Drive extends Provider { return 'google' } + static get authStateExpiry () { + return MAX_AGE_REFRESH_TOKEN + } + async list (options) { return this.#withErrorHandling('provider.drive.list.error', async () => { const directory = options.directory || 'root' diff --git a/packages/@uppy/companion/src/server/provider/dropbox/index.js b/packages/@uppy/companion/src/server/provider/dropbox/index.js index 287803c6fb..f820581167 100644 --- a/packages/@uppy/companion/src/server/provider/dropbox/index.js +++ b/packages/@uppy/companion/src/server/provider/dropbox/index.js @@ -4,6 +4,7 @@ const Provider = require('../Provider') const adaptData = require('./adapter') const { withProviderErrorHandling } = require('../providerErrors') const { prepareStream } = require('../../helpers/utils') +const { MAX_AGE_REFRESH_TOKEN } = require('../../helpers/jwt') // From https://www.dropbox.com/developers/reference/json-encoding: // @@ -63,6 +64,10 @@ class DropBox extends Provider { return 'dropbox' } + static get authStateExpiry () { + return MAX_AGE_REFRESH_TOKEN + } + /** * * @param {object} options diff --git a/packages/@uppy/companion/src/server/provider/index.js b/packages/@uppy/companion/src/server/provider/index.js index e49aff660d..8de1beaef9 100644 --- a/packages/@uppy/companion/src/server/provider/index.js +++ b/packages/@uppy/companion/src/server/provider/index.js @@ -42,7 +42,7 @@ const providerNameToAuthName = (name, options) => { // eslint-disable-line no-un * * @param {Record} providers */ -module.exports.getProviderMiddleware = (providers) => { +module.exports.getProviderMiddleware = (providers, grantConfig) => { /** * * @param {object} req @@ -54,12 +54,17 @@ module.exports.getProviderMiddleware = (providers) => { const ProviderClass = providers[providerName] if (ProviderClass && validOptions(req.companion.options)) { const { allowLocalUrls } = req.companion.options - req.companion.provider = new ProviderClass({ providerName, allowLocalUrls }) - req.companion.providerClass = ProviderClass + const { authProvider } = ProviderClass - if (isOAuthProvider(ProviderClass.authProvider)) { + let providerGrantConfig + if (isOAuthProvider(authProvider)) { req.companion.getProviderCredentials = getCredentialsResolver(providerName, req.companion.options, req) + providerGrantConfig = grantConfig[authProvider] + req.companion.providerGrantConfig = providerGrantConfig } + + req.companion.provider = new ProviderClass({ providerName, providerGrantConfig, allowLocalUrls }) + req.companion.providerClass = ProviderClass } else { logger.warn('invalid provider options detected. Provider will not be loaded', 'provider.middleware.invalid', req.id) } diff --git a/packages/@uppy/dropbox/src/Dropbox.jsx b/packages/@uppy/dropbox/src/Dropbox.jsx index c591933087..b836ba0d90 100644 --- a/packages/@uppy/dropbox/src/Dropbox.jsx +++ b/packages/@uppy/dropbox/src/Dropbox.jsx @@ -27,6 +27,7 @@ export default class Dropbox extends UIPlugin { companionCookiesRule: this.opts.companionCookiesRule, provider: 'dropbox', pluginId: this.id, + supportsRefreshToken: true, }) this.defaultLocale = locale diff --git a/packages/@uppy/facebook/src/Facebook.jsx b/packages/@uppy/facebook/src/Facebook.jsx index f8ceac492e..30d8c5faba 100644 --- a/packages/@uppy/facebook/src/Facebook.jsx +++ b/packages/@uppy/facebook/src/Facebook.jsx @@ -30,6 +30,7 @@ export default class Facebook extends UIPlugin { companionCookiesRule: this.opts.companionCookiesRule, provider: 'facebook', pluginId: this.id, + supportsRefreshToken: false, }) this.defaultLocale = locale diff --git a/packages/@uppy/google-drive/src/GoogleDrive.jsx b/packages/@uppy/google-drive/src/GoogleDrive.jsx index 96433c33ef..fa857f429f 100644 --- a/packages/@uppy/google-drive/src/GoogleDrive.jsx +++ b/packages/@uppy/google-drive/src/GoogleDrive.jsx @@ -41,6 +41,7 @@ export default class GoogleDrive extends UIPlugin { companionCookiesRule: this.opts.companionCookiesRule, provider: 'drive', pluginId: this.id, + supportsRefreshToken: true, }) this.defaultLocale = locale diff --git a/packages/@uppy/instagram/src/Instagram.jsx b/packages/@uppy/instagram/src/Instagram.jsx index 7ba14fd33e..c0094a86b8 100644 --- a/packages/@uppy/instagram/src/Instagram.jsx +++ b/packages/@uppy/instagram/src/Instagram.jsx @@ -40,6 +40,7 @@ export default class Instagram extends UIPlugin { companionCookiesRule: this.opts.companionCookiesRule, provider: 'instagram', pluginId: this.id, + supportsRefreshToken: false, }) this.onFirstRender = this.onFirstRender.bind(this) diff --git a/packages/@uppy/onedrive/src/OneDrive.jsx b/packages/@uppy/onedrive/src/OneDrive.jsx index 6f4b939f56..b1696a0178 100644 --- a/packages/@uppy/onedrive/src/OneDrive.jsx +++ b/packages/@uppy/onedrive/src/OneDrive.jsx @@ -32,6 +32,7 @@ export default class OneDrive extends UIPlugin { companionCookiesRule: this.opts.companionCookiesRule, provider: 'onedrive', pluginId: this.id, + supportsRefreshToken: false, }) this.defaultLocale = locale diff --git a/packages/@uppy/provider-views/src/ProviderView/AuthView.jsx b/packages/@uppy/provider-views/src/ProviderView/AuthView.jsx index 5cf51f82f6..9d7b02c418 100644 --- a/packages/@uppy/provider-views/src/ProviderView/AuthView.jsx +++ b/packages/@uppy/provider-views/src/ProviderView/AuthView.jsx @@ -36,12 +36,38 @@ function GoogleIcon () { ) } -function AuthView (props) { - const { pluginName, pluginIcon, i18nArray, handleAuth } = props +const defaultRenderForm = ({ pluginName, i18nArray }) => { // In order to comply with Google's brand we need to create a different button // for the Google Drive plugin const isGoogleDrive = pluginName === 'Google Drive' + if (isGoogleDrive) { + return ( + + ) + } + + return ( + + ) +} + +function AuthView (props) { + const { pluginName, pluginIcon, i18nArray, handleAuth, renderForm = defaultRenderForm } = props + const pluginNameComponent = ( {pluginName} @@ -56,26 +82,10 @@ function AuthView (props) { pluginName: pluginNameComponent, })} - {isGoogleDrive ? ( - - ) : ( - - )} + +
+ {renderForm({ pluginName, i18nArray })} +
) } diff --git a/packages/@uppy/provider-views/src/ProviderView/ProviderView.jsx b/packages/@uppy/provider-views/src/ProviderView/ProviderView.jsx index ce3642bad9..26f010eeab 100644 --- a/packages/@uppy/provider-views/src/ProviderView/ProviderView.jsx +++ b/packages/@uppy/provider-views/src/ProviderView/ProviderView.jsx @@ -242,10 +242,12 @@ export default class ProviderView extends View { this.plugin.setPluginState({ filterInput: '' }) } - async handleAuth () { + async handleAuth (formSubmitEvent) { + formSubmitEvent.preventDefault() + const clientVersion = `@uppy/provider-views=${ProviderView.VERSION}` try { - await this.provider.login({ uppyVersions: clientVersion }) + await this.provider.login({ uppyVersions: clientVersion, formSubmitEvent }) this.plugin.setPluginState({ authenticated: true }) this.preFirstRender() } catch (e) { @@ -477,6 +479,7 @@ export default class ProviderView extends View { handleAuth={this.handleAuth} i18n={this.plugin.uppy.i18n} i18nArray={this.plugin.uppy.i18nArray} + renderForm={this.opts.renderAuthForm} /> ) diff --git a/packages/@uppy/zoom/src/Zoom.jsx b/packages/@uppy/zoom/src/Zoom.jsx index c7c3d392ba..dc465e05a3 100644 --- a/packages/@uppy/zoom/src/Zoom.jsx +++ b/packages/@uppy/zoom/src/Zoom.jsx @@ -28,6 +28,7 @@ export default class Zoom extends UIPlugin { companionCookiesRule: this.opts.companionCookiesRule, provider: 'zoom', pluginId: this.id, + supportsRefreshToken: false, }) this.defaultLocale = locale