Skip to content

Commit

Permalink
Implement alternative provider auth
Browse files Browse the repository at this point in the history
New concept "simple auth" - authentication that happens immediately (in one http request) without redirecting to any third party.

uppyAuthToken initially used to simply contain an encrypted & json encoded OAuth2 access_token for a specific provider. Then we added refresh tokens as well inside uppyAuthToken #4448. Now we also allow storing other state or parameters needed for that specific provider, like username, password, host name, webdav URL etc... This is needed for providers like webdav, ftp etc, where the user needs to give some more input data while authenticating

Companion:
- `providerTokens` has been renamed to `providerUserSession` because it now includes not only tokens, but a user's session with a provider.

Companion `Provider` class:
- New `hasSimpleAuth` static boolean property - whether this provider uses simple auth
- uppyAuthToken expiry default 24hr again for providers that don't support refresh tokens
- make uppyAuthToken expiry configurable per provider - new `authStateExpiry` static property (defaults to 24hr)
- new static property `grantDynamicToUserSession`, allows providers to specify which state from Grant `dynamic` to include into the provider's `providerUserSession`.
  • Loading branch information
mifi committed Aug 9, 2023
1 parent 600b2d0 commit da67ac9
Show file tree
Hide file tree
Showing 30 changed files with 267 additions and 120 deletions.
1 change: 1 addition & 0 deletions packages/@uppy/box/src/Box.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default class Box extends UIPlugin {
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'box',
pluginId: this.id,
supportsRefreshToken: false,
})

this.defaultLocale = locale
Expand Down
22 changes: 16 additions & 6 deletions packages/@uppy/companion-client/src/Provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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)
}

Expand All @@ -92,24 +93,32 @@ 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)
}

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

Expand Down
4 changes: 3 additions & 1 deletion packages/@uppy/companion/src/companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
15 changes: 9 additions & 6 deletions packages/@uppy/companion/src/server/controllers/callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
19 changes: 18 additions & 1 deletion packages/@uppy/companion/src/server/controllers/connect.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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))
}
5 changes: 3 additions & 2 deletions packages/@uppy/companion/src/server/controllers/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ 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 () {
return provider.size({ id, token: accessToken, query: req.query })
}

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
}

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 @@ -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'),
Expand Down
8 changes: 6 additions & 2 deletions packages/@uppy/companion/src/server/controllers/list.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 5 additions & 6 deletions packages/@uppy/companion/src/server/controllers/logout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 9 additions & 14 deletions packages/@uppy/companion/src/server/controllers/refresh-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions packages/@uppy/companion/src/server/controllers/simple-auth.js
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions packages/@uppy/companion/src/server/controllers/thumbnail.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit da67ac9

Please sign in to comment.