UNPKG

@uppy/companion-client

Version:

Client library for communication with Companion. Intended for use in Uppy plugins.

251 lines (250 loc) 10.7 kB
import { isOriginAllowed } from './getAllowedHosts.js'; import RequestClient, { authErrorStatusCode } from './RequestClient.js'; const getName = (id) => { return id .split('-') .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) .join(' '); }; function getOrigin() { return location.origin; } export default class Provider extends RequestClient { #refreshingTokenPromise; provider; id; name; pluginId; tokenKey; companionKeysParams; preAuthToken; supportsRefreshToken; constructor(uppy, opts) { super(uppy, opts); this.provider = opts.provider; this.id = this.provider; this.name = this.opts.name || getName(this.id); this.pluginId = this.opts.pluginId; this.tokenKey = `companion-${this.pluginId}-auth-token`; this.companionKeysParams = this.opts.companionKeysParams; this.preAuthToken = null; this.supportsRefreshToken = !!opts.supportsRefreshToken; } async headers() { const [headers, token] = await Promise.all([ super.headers(), this.#getAuthToken(), ]); const authHeaders = {}; if (token) { authHeaders['uppy-auth-token'] = token; } if (this.companionKeysParams) { authHeaders['uppy-credentials-params'] = btoa(JSON.stringify({ params: this.companionKeysParams })); } return { ...headers, ...authHeaders }; } onReceiveResponse(response) { super.onReceiveResponse(response); const plugin = this.#getPlugin(); const oldAuthenticated = plugin.getPluginState().authenticated; const authenticated = oldAuthenticated ? response.status !== authErrorStatusCode : response.status < 400; plugin.setPluginState({ authenticated }); return response; } async setAuthToken(token) { return this.#getPlugin().storage.setItem(this.tokenKey, token); } async #getAuthToken() { return this.#getPlugin().storage.getItem(this.tokenKey); } async removeAuthToken() { return this.#getPlugin().storage.removeItem(this.tokenKey); } #getPlugin() { const plugin = this.uppy.getPlugin(this.pluginId); if (plugin == null) throw new Error('Plugin was nullish'); return plugin; } /** * Ensure we have a preauth token if necessary. Attempts to fetch one if we don't, * or rejects if loading one fails. */ async ensurePreAuth() { if (this.companionKeysParams && !this.preAuthToken) { await this.fetchPreAuthToken(); if (!this.preAuthToken) { throw new Error('Could not load authentication data required for third-party login. Please try again later.'); } } } authQuery(data) { return {}; } authUrl({ authFormData, query, }) { const params = new URLSearchParams({ ...query, // This is only used for Companion instances configured to accept multiple origins. state: btoa(JSON.stringify({ origin: getOrigin() })), ...this.authQuery({ authFormData }), }); if (this.preAuthToken) { params.set('uppyPreAuthToken', this.preAuthToken); } return `${this.hostname}/${this.id}/connect?${params}`; } async loginSimpleAuth({ uppyVersions, authFormData, signal, }) { const response = await this.post(`${this.id}/simple-auth`, { form: authFormData }, { qs: { uppyVersions }, signal }); this.setAuthToken(response.uppyAuthToken); } async loginOAuth({ uppyVersions, authFormData, signal, }) { await this.ensurePreAuth(); signal.throwIfAborted(); const link = this.authUrl({ query: { uppyVersions }, authFormData }); const authWindow = window.open(link, '_blank'); let interval; let handleMessage; try { return await new Promise((resolve, reject) => { handleMessage = (e) => { if (e.source !== authWindow) { let jsonData = ''; try { // TODO improve our uppy logger so that it can take an arbitrary number of arguments, // each either objects, errors or strings, // then we don’t have to manually do these things like json stringify when logging. // the logger should never throw an error. jsonData = JSON.stringify(e.data); } catch (_err) { // in case JSON.stringify fails (ignored) } this.uppy.log(`ignoring event from unknown source ${jsonData}`, 'warning'); return; } const { companionAllowedHosts } = this.#getPlugin().opts; if (!isOriginAllowed(e.origin, companionAllowedHosts)) { this.uppy.log(`ignoring event from ${e.origin} vs allowed pattern ${companionAllowedHosts}`, 'warning'); // We cannot reject here because the page might send events from other origins // before sending the "real" auth completed event. // for example Box has a "Pendo" tool that sends events to the opener // https://github.com/transloadit/uppy/pull/5719 return; } // Check if it's a string before doing the JSON.parse to maintain support // for older Companion versions that used object references const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; if (data.error) { const { uppy } = this; const message = uppy.i18n('authAborted'); uppy.info({ message }, 'warning', 5000); reject(new Error('auth aborted')); return; } if (!data.token) { reject(new Error('did not receive token from auth window')); return; } resolve(this.setAuthToken(data.token)); }; // poll for user closure of the window, so we can reject when it happens if (authWindow) { interval = window.setInterval(() => { if (authWindow.closed) { reject(new Error('Auth window was closed by the user')); } }, 500); } signal.addEventListener('abort', () => reject(new Error('Aborted'))); window.addEventListener('message', handleMessage); }); } finally { // cleanup: authWindow?.close(); window.clearInterval(interval); if (handleMessage) window.removeEventListener('message', handleMessage); } } async login({ uppyVersions, authFormData, signal, }) { return this.loginOAuth({ uppyVersions, authFormData, signal }); } refreshTokenUrl() { return `${this.hostname}/${this.id}/refresh-token`; } fileUrl(id) { return `${this.hostname}/${this.id}/get/${id}`; } async request(...args) { await this.#refreshingTokenPromise; try { // to test simulate access token expired (leading to a token token refresh), // see mockAccessTokenExpiredError in companion/drive. // If you want to test refresh token *and* access token invalid, do this for example with Google Drive: // While uploading, go to your google account settings, // "Third-party apps & services", then click "Companion" and "Remove access". return await super.request(...args); } catch (err) { if (!this.supportsRefreshToken) throw err; // only handle auth errors (401 from provider), and only handle them if we have a (refresh) token const authTokenAfter = await this.#getAuthToken(); if (!err.isAuthError || !authTokenAfter) throw err; if (this.#refreshingTokenPromise == null) { // 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 { this.uppy.log(`[CompanionClient] Refreshing expired auth token`); const response = await super.request({ path: this.refreshTokenUrl(), method: 'POST', }); await this.setAuthToken(response.uppyAuthToken); } catch (refreshTokenErr) { if (refreshTokenErr.isAuthError) { // if refresh-token has failed with auth error, delete token, so we don't keep trying to refresh in future await this.removeAuthToken(); } throw err; } 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; } try { const res = await this.post(`${this.id}/preauth/`, { params: this.companionKeysParams, }); this.preAuthToken = res.token; } catch (err) { this.uppy.log(`[CompanionClient] unable to fetch preAuthToken ${err}`, 'warning'); } } list(directory, options) { return this.get(`${this.id}/list/${directory || ''}`, options); } async logout(options) { const response = await this.get(`${this.id}/logout`, options); await this.removeAuthToken(); return response; } }