@uppy/companion-client
Version:
Client library for communication with Companion. Intended for use in Uppy plugins.
372 lines (320 loc) • 11.2 kB
text/typescript
import type {
Uppy,
Body,
Meta,
PluginOpts,
UnknownProviderPlugin,
} from '@uppy/core'
import type {
RequestOptions,
CompanionClientProvider,
} from '@uppy/utils/lib/CompanionClientProvider'
import RequestClient, { authErrorStatusCode } from './RequestClient.js'
import type { CompanionPluginOptions } from './index.js'
import { isOriginAllowed } from './getAllowedHosts.js'
export interface Opts extends PluginOpts, CompanionPluginOptions {
pluginId: string
name?: string
supportsRefreshToken?: boolean
provider: string
}
const getName = (id: string) => {
return id
.split('-')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(' ')
}
function getOrigin() {
// eslint-disable-next-line no-restricted-globals
return location.origin
}
export default class Provider<M extends Meta, B extends Body>
extends RequestClient<M, B>
implements CompanionClientProvider
{
#refreshingTokenPromise: Promise<void> | undefined
provider: string
id: string
name: string
pluginId: string
tokenKey: string
companionKeysParams?: Record<string, string>
preAuthToken: string | null
supportsRefreshToken: boolean
constructor(uppy: Uppy<M, B>, opts: 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(): Promise<Record<string, string>> {
const [headers, token] = await Promise.all([
super.headers(),
this.#getAuthToken(),
])
const authHeaders: Record<string, string> = {}
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: Response): 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: string): Promise<void> {
return this.#getPlugin().storage.setItem(this.tokenKey, token)
}
async #getAuthToken(): Promise<string | null> {
return this.#getPlugin().storage.getItem(this.tokenKey)
}
protected async removeAuthToken(): Promise<void> {
return this.#getPlugin().storage.removeItem(this.tokenKey)
}
#getPlugin() {
const plugin = this.uppy.getPlugin(this.pluginId) as UnknownProviderPlugin<
M,
B
>
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(): Promise<void> {
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.',
)
}
}
}
// eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars
authQuery(data: unknown): Record<string, string> {
return {}
}
authUrl({
authFormData,
query,
}: {
authFormData: unknown
query: Record<string, string>
}): string {
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}`
}
protected async loginSimpleAuth({
uppyVersions,
authFormData,
signal,
}: {
uppyVersions: string
authFormData: unknown
signal: AbortSignal
}): Promise<void> {
type Res = { uppyAuthToken: string }
const response = await this.post<Res>(
`${this.id}/simple-auth`,
{ form: authFormData },
{ qs: { uppyVersions }, signal },
)
this.setAuthToken(response.uppyAuthToken)
}
protected async loginOAuth({
uppyVersions,
authFormData,
signal,
}: {
uppyVersions: string
authFormData: unknown
signal: AbortSignal
}): Promise<void> {
await this.ensurePreAuth()
signal.throwIfAborted()
const link = this.authUrl({ query: { uppyVersions }, authFormData })
const authWindow = window.open(link, '_blank')
let interval: number | undefined
let handleMessage: ((e: MessageEvent<any>) => void) | undefined
try {
return await new Promise((resolve, reject) => {
handleMessage = (e: MessageEvent<any>) => {
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,
}: {
uppyVersions: string
authFormData: unknown
signal: AbortSignal
}): Promise<void> {
return this.loginOAuth({ uppyVersions, authFormData, signal })
}
refreshTokenUrl(): string {
return `${this.hostname}/${this.id}/refresh-token`
}
fileUrl(id: string): string {
return `${this.hostname}/${this.id}/get/${id}`
}
protected async request<ResBody>(
...args: Parameters<RequestClient<M, B>['request']>
): Promise<ResBody> {
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<ResBody>(...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<{ uppyAuthToken: string }>({
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(): Promise<void> {
if (!this.companionKeysParams) {
return
}
try {
const res = await this.post<{ token: string }>(`${this.id}/preauth/`, {
params: this.companionKeysParams,
})
this.preAuthToken = res.token
} catch (err) {
this.uppy.log(
`[CompanionClient] unable to fetch preAuthToken ${err}`,
'warning',
)
}
}
list<ResBody>(
directory: string | null,
options: RequestOptions,
): Promise<ResBody> {
return this.get<ResBody>(`${this.id}/list/${directory || ''}`, options)
}
async logout<ResBody>(options?: RequestOptions): Promise<ResBody> {
const response = await this.get<ResBody>(`${this.id}/logout`, options)
await this.removeAuthToken()
return response
}
}