diff --git a/packages/@uppy/companion/src/companion.ts b/packages/@uppy/companion/src/companion.ts index ab32d7d64..a86847a75 100644 --- a/packages/@uppy/companion/src/companion.ts +++ b/packages/@uppy/companion/src/companion.ts @@ -222,6 +222,21 @@ export function app(optionsArg: CompanionInitOptions) { controllers.sendToken, ) + // Returns the decrypted Dropbox OAuth tokens for the current session so a client + // can drive a server-side /dropbox/import Assembly. Deliberately weakens the + // encrypted-token posture, so it is off unless explicitly enabled. The route keeps + // the :providerName segment because verifyToken and the provider middleware are + // keyed on it; the controller restricts the result to Dropbox. + if (options.enableDropboxTokensEndpoint) { + app.get( + '/:providerName/bridge-tokens', + middlewares.hasSessionAndProvider, + middlewares.hasOAuthProvider, + middlewares.verifyToken, + controllers.bridgeTokens, + ) + } + app.post( '/:providerName/simple-auth', express.json(), diff --git a/packages/@uppy/companion/src/config/companion.ts b/packages/@uppy/companion/src/config/companion.ts index 8f1762607..a963a6358 100644 --- a/packages/@uppy/companion/src/config/companion.ts +++ b/packages/@uppy/companion/src/config/companion.ts @@ -24,6 +24,7 @@ export const defaultOptions = { }, enableUrlEndpoint: false, enableGooglePickerEndpoint: false, + enableDropboxTokensEndpoint: false, allowLocalUrls: false, periodicPingUrls: defaultPeriodicPingUrls, streamingUpload: true, diff --git a/packages/@uppy/companion/src/schemas/companion.ts b/packages/@uppy/companion/src/schemas/companion.ts index f225227fd..066627904 100644 --- a/packages/@uppy/companion/src/schemas/companion.ts +++ b/packages/@uppy/companion/src/schemas/companion.ts @@ -54,6 +54,12 @@ export interface CompanionInitOptions { sendSelfEndpoint?: string | undefined enableUrlEndpoint?: boolean | undefined enableGooglePickerEndpoint?: boolean | undefined + /** + * Exposes the opt decrypted Dropbox OAuth tokens for the current session via + * `GET /dropbox/bridge-tokens`. This deliberately returns raw OAuth tokens to the + * client. + */ + enableDropboxTokensEndpoint?: boolean | undefined metrics?: boolean | undefined periodicPingUrls?: string[] | undefined periodicPingInterval?: number | undefined diff --git a/packages/@uppy/companion/src/server/controllers/bridge-tokens.ts b/packages/@uppy/companion/src/server/controllers/bridge-tokens.ts new file mode 100644 index 000000000..0cdf046b9 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/bridge-tokens.ts @@ -0,0 +1,33 @@ +import type { Request, Response } from 'express' + +/** + * Returns the decrypted Dropbox OAuth tokens for the current session. + **/ +export default function bridgeTokens(req: Request, res: Response): void { + // Responses on this route may carry OAuth tokens — never let them be cached. + res.set('Cache-Control', 'no-store') + + // The route is `/:providerName/bridge-tokens` because `verifyToken` and the + // provider middleware are keyed on `:providerName`. A token minted for another + // provider cannot be read here: `verifyToken` decrypts against this same param, + // so a mismatched path is rejected by the middleware before reaching this point. + if (req.params['providerName'] !== 'dropbox') { + res + .status(400) + .json({ error: 'The bridge-tokens endpoint only supports Dropbox.' }) + return + } + + const session = req.companion.providerUserSession + if (session?.accessToken == null) { + // The auth token decrypted fine, but this session has no connected Dropbox + // account — that is "not found", not an authentication failure. + res.status(404).json({ error: 'No Dropbox tokens for this session.' }) + return + } + + res.json({ + accessToken: session.accessToken, + refreshToken: session.refreshToken ?? null, + }) +} diff --git a/packages/@uppy/companion/src/server/controllers/index.ts b/packages/@uppy/companion/src/server/controllers/index.ts index 277490715..9c7ddf24c 100644 --- a/packages/@uppy/companion/src/server/controllers/index.ts +++ b/packages/@uppy/companion/src/server/controllers/index.ts @@ -1,3 +1,4 @@ +export { default as bridgeTokens } from './bridge-tokens.js' export { default as callback } from './callback.js' export { default as connect } from './connect.js' export { default as deauthorizationCallback } from './deauth-callback.js' diff --git a/packages/@uppy/companion/src/standalone/helper.ts b/packages/@uppy/companion/src/standalone/helper.ts index d14cf8eb3..5a88b38bf 100644 --- a/packages/@uppy/companion/src/standalone/helper.ts +++ b/packages/@uppy/companion/src/standalone/helper.ts @@ -94,6 +94,7 @@ type StandaloneCompanionOptions = Pick< | 's3' | 'enableUrlEndpoint' | 'enableGooglePickerEndpoint' + | 'enableDropboxTokensEndpoint' | 'periodicPingUrls' | 'periodicPingInterval' | 'periodicPingStaticPayload' @@ -190,6 +191,8 @@ const getConfigFromEnv = (): StandaloneCompanionOptions => { enableUrlEndpoint: process.env['COMPANION_ENABLE_URL_ENDPOINT'] === 'true', enableGooglePickerEndpoint: process.env['COMPANION_ENABLE_GOOGLE_PICKER_ENDPOINT'] === 'true', + enableDropboxTokensEndpoint: + process.env['COMPANION_ENABLE_DROPBOX_TOKENS_ENDPOINT'] === 'true', periodicPingUrls: process.env['COMPANION_PERIODIC_PING_URLS'] ? process.env['COMPANION_PERIODIC_PING_URLS'].split(',') : [], diff --git a/packages/@uppy/companion/test/bridge-tokens.test.ts b/packages/@uppy/companion/test/bridge-tokens.test.ts new file mode 100644 index 000000000..f81d9a378 --- /dev/null +++ b/packages/@uppy/companion/test/bridge-tokens.test.ts @@ -0,0 +1,70 @@ +import request from 'supertest' +import { describe, expect, test, vi } from 'vitest' +import * as tokenService from '../src/server/helpers/jwt.js' +import { getServer } from './mockserver.js' + +// getServer is called once per test; without this the prom-client metric +// registered inside the metrics middleware collides on the second server. +vi.mock('express-prom-bundle') + +// Matches COMPANION_SECRET in test/mockserver.ts defaultEnv. +const secret = 'secret' + +const enabledEnv = { COMPANION_ENABLE_DROPBOX_TOKENS_ENDPOINT: 'true' } +const disabledEnv = { COMPANION_ENABLE_DROPBOX_TOKENS_ENDPOINT: 'false' } + +describe('GET /:providerName/bridge-tokens', () => { + test('returns the dropbox tokens, marked no-store, for a valid token', async () => { + const token = tokenService.generateEncryptedAuthToken( + { dropbox: { accessToken: 'acc-123', refreshToken: 'ref-456' } }, + secret, + ) + return request(await getServer(enabledEnv)) + .get('/dropbox/bridge-tokens') + .set('uppy-auth-token', token) + .expect('Cache-Control', 'no-store') + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + accessToken: 'acc-123', + refreshToken: 'ref-456', + }) + }) + }) + + test('responds 404 when the Dropbox session has no access token', async () => { + // The auth token decrypts fine, but holds no usable Dropbox access token. + const token = tokenService.generateEncryptedAuthToken( + { dropbox: {} }, + secret, + ) + return request(await getServer(enabledEnv)) + .get('/dropbox/bridge-tokens') + .set('uppy-auth-token', token) + .expect(404) + }) + + test('rejects non-dropbox providers with 400', async () => { + const token = tokenService.generateEncryptedAuthToken( + { drive: { accessToken: 'acc-123' } }, + secret, + ) + return request(await getServer(enabledEnv)) + .get('/drive/bridge-tokens') + .set('uppy-auth-token', token) + .expect(400) + }) + + test('responds 401 when no auth token is provided', async () => { + return request(await getServer(enabledEnv)) + .get('/dropbox/bridge-tokens') + .expect(401) + }) + + test('responds 404 when the option is disabled', async () => { + return request(await getServer(disabledEnv)) + .get('/dropbox/bridge-tokens') + .set('uppy-auth-token', 'whatever') + .expect(404) + }) +})