@uppy/companion
Version:
OAuth helper and remote fetcher for Uppy's (https://uppy.io) extensible file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox and Google Drive, S3 and more :dog:
243 lines (222 loc) • 7.79 kB
JavaScript
import { htmlEscape } from 'escape-goat'
import got from 'got'
import * as tokenService from '../helpers/jwt.js'
import * as oAuthState from '../helpers/oauth-state.js'
import { getRedirectPath, getURLBuilder } from '../helpers/utils.js'
import logger from '../logger.js'
// biome-ignore lint/correctness/noUnusedImports: It is used as a type
import Provider from './Provider.js'
/**
* @param {string} url
* @param {string} providerName
* @param {object|null} credentialRequestParams - null asks for default credentials.
*/
async function fetchKeys(url, providerName, credentialRequestParams) {
try {
const { credentials } = await got
.post(url, {
json: { provider: providerName, parameters: credentialRequestParams },
})
.json()
if (!credentials) throw new Error('Received no remote credentials')
return credentials
} catch (err) {
logger.error(err, 'credentials.fetch.fail')
throw err
}
}
/**
* Fetches for a providers OAuth credentials. If the config for that 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 {Record<string, typeof Provider>} providers provider classes enabled for this server
* @param {object} companionOptions companion options object
* @returns {import('express').RequestHandler}
*/
export const getCredentialsOverrideMiddleware = (
providers,
companionOptions,
) => {
return async (req, res, next) => {
try {
const { oauthProvider, override } = req.params
const [providerName] = Object.keys(providers).filter(
(name) => providers[name].oauthProvider === oauthProvider,
)
if (!providerName) {
next()
return
}
if (!companionOptions.providerOptions[providerName]?.credentialsURL) {
next()
return
}
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 ? grantDynamic.state : req.query.state
if (!state) {
next()
return
}
const preAuthToken = oAuthState.getFromState(
state,
'preAuthToken',
companionOptions.secret,
)
if (!preAuthToken) {
next()
return
}
let payload
try {
payload = tokenService.verifyEncryptedToken(
preAuthToken,
companionOptions.preAuthSecret,
)
} catch (_err) {
next()
return
}
const credentials = await fetchProviderKeys(
providerName,
companionOptions,
payload,
)
// Besides the key and secret the fetched credentials can also contain `origins`,
// which is an array of strings of allowed origins to prevent any origin from getting the OAuth
// token through window.postMessage (see comment in connect.js).
// postMessage happens in send-token.js, which is a different request, so we need to put the allowed origins
// on the encrypted session state to access it later there.
if (
Array.isArray(credentials.origins) &&
credentials.origins.length > 0
) {
const decodedState = oAuthState.decodeState(
state,
companionOptions.secret,
)
decodedState.customerDefinedAllowedOrigins = credentials.origins
const newState = oAuthState.encodeState(
decodedState,
companionOptions.secret,
)
// @ts-expect-error untyped
req.session.grant = {
// @ts-expect-error untyped
...req.session.grant,
dynamic: {
// @ts-expect-error untyped
...req.session.grant?.dynamic,
state: newState,
},
}
}
res.locals.grant = {
dynamic: {
key: credentials.key,
secret: credentials.secret,
origins: credentials.origins,
},
}
if (credentials.transloadit_gateway) {
const redirectPath = getRedirectPath(providerName)
const fullRedirectPath = getURLBuilder(companionOptions)(
redirectPath,
true,
true,
)
const redirectUri = new URL(
fullRedirectPath,
credentials.transloadit_gateway,
).toString()
logger.info('Using redirect URI from transloadit_gateway', redirectUri)
res.locals.grant.dynamic.redirect_uri = redirectUri
}
next()
} catch (keyErr) {
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>
`)
}
}
}
/**
* Returns a request scoped function that can be used to get a provider's oauth credentials
* through out the lifetime of the request.
*
* @param {string} providerName the name of the provider attached to the scope of the request
* @param {object} companionOptions the companion options object
* @param {object} req the express request object for the said request
* @returns {(providerName: string, companionOptions: object, credentialRequestParams?: object) => Promise}
*/
export const getCredentialsResolver = (providerName, companionOptions, req) => {
const credentialsResolver = () => {
const encodedCredentialsParams = req.header('uppy-credentials-params')
let credentialRequestParams = null
if (encodedCredentialsParams) {
try {
credentialRequestParams = JSON.parse(
atob(encodedCredentialsParams),
).params
} catch (error) {
logger.error(error, 'credentials.resolve.fail', req.id)
}
}
return fetchProviderKeys(
providerName,
companionOptions,
credentialRequestParams,
)
}
return credentialsResolver
}