Provider.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import type { Uppy, BasePlugin } from '@uppy/core'
  2. import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
  3. import type { PluginOpts } from '@uppy/core/lib/BasePlugin.ts'
  4. import RequestClient, {
  5. authErrorStatusCode,
  6. type RequestOptions,
  7. } from './RequestClient.ts'
  8. import * as tokenStorage from './tokenStorage.ts'
  9. // TODO: remove deprecated options in next major release
  10. export interface Opts extends PluginOpts {
  11. /** @deprecated */
  12. serverUrl?: string
  13. /** @deprecated */
  14. serverPattern?: string
  15. companionUrl: string
  16. companionAllowedHosts?: string | RegExp | Array<string | RegExp>
  17. storage?: typeof tokenStorage
  18. pluginId: string
  19. name?: string
  20. supportsRefreshToken?: boolean
  21. provider: string
  22. }
  23. interface ProviderPlugin<M extends Meta, B extends Body>
  24. extends BasePlugin<Opts, M, B> {
  25. files: UppyFile<M, B>[]
  26. storage: typeof tokenStorage
  27. }
  28. const getName = (id: string) => {
  29. return id
  30. .split('-')
  31. .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
  32. .join(' ')
  33. }
  34. function getOrigin() {
  35. // eslint-disable-next-line no-restricted-globals
  36. return location.origin
  37. }
  38. function getRegex(value?: string | RegExp) {
  39. if (typeof value === 'string') {
  40. return new RegExp(`^${value}$`)
  41. }
  42. if (value instanceof RegExp) {
  43. return value
  44. }
  45. return undefined
  46. }
  47. function isOriginAllowed(
  48. origin: string,
  49. allowedOrigin: string | RegExp | Array<string | RegExp> | undefined,
  50. ) {
  51. const patterns =
  52. Array.isArray(allowedOrigin) ?
  53. allowedOrigin.map(getRegex)
  54. : [getRegex(allowedOrigin)]
  55. return patterns.some(
  56. (pattern) => pattern?.test(origin) || pattern?.test(`${origin}/`),
  57. ) // allowing for trailing '/'
  58. }
  59. export default class Provider<
  60. M extends Meta,
  61. B extends Body,
  62. > extends RequestClient<M, B> {
  63. #refreshingTokenPromise: Promise<void> | undefined
  64. provider: string
  65. id: string
  66. name: string
  67. pluginId: string
  68. tokenKey: string
  69. companionKeysParams?: Record<string, string>
  70. preAuthToken: string | null
  71. supportsRefreshToken: boolean
  72. constructor(uppy: Uppy<M, B>, opts: Opts) {
  73. super(uppy, opts)
  74. this.provider = opts.provider
  75. this.id = this.provider
  76. this.name = this.opts.name || getName(this.id)
  77. this.pluginId = this.opts.pluginId
  78. this.tokenKey = `companion-${this.pluginId}-auth-token`
  79. this.companionKeysParams = this.opts.companionKeysParams
  80. this.preAuthToken = null
  81. this.supportsRefreshToken = opts.supportsRefreshToken ?? true // todo false in next major
  82. }
  83. async headers(): Promise<Record<string, string>> {
  84. const [headers, token] = await Promise.all([
  85. super.headers(),
  86. this.#getAuthToken(),
  87. ])
  88. const authHeaders: Record<string, string> = {}
  89. if (token) {
  90. authHeaders['uppy-auth-token'] = token
  91. }
  92. if (this.companionKeysParams) {
  93. authHeaders['uppy-credentials-params'] = btoa(
  94. JSON.stringify({ params: this.companionKeysParams }),
  95. )
  96. }
  97. return { ...headers, ...authHeaders }
  98. }
  99. onReceiveResponse(response: Response): Response {
  100. super.onReceiveResponse(response)
  101. const plugin = this.#getPlugin()
  102. const oldAuthenticated = plugin.getPluginState().authenticated
  103. const authenticated =
  104. oldAuthenticated ?
  105. response.status !== authErrorStatusCode
  106. : response.status < 400
  107. plugin.setPluginState({ authenticated })
  108. return response
  109. }
  110. async setAuthToken(token: string): Promise<void> {
  111. return this.#getPlugin().storage.setItem(this.tokenKey, token)
  112. }
  113. async #getAuthToken(): Promise<string | null> {
  114. return this.#getPlugin().storage.getItem(this.tokenKey)
  115. }
  116. protected async removeAuthToken(): Promise<void> {
  117. return this.#getPlugin().storage.removeItem(this.tokenKey)
  118. }
  119. #getPlugin() {
  120. const plugin = this.uppy.getPlugin(this.pluginId) as ProviderPlugin<M, B>
  121. if (plugin == null) throw new Error('Plugin was nullish')
  122. return plugin
  123. }
  124. /**
  125. * Ensure we have a preauth token if necessary. Attempts to fetch one if we don't,
  126. * or rejects if loading one fails.
  127. */
  128. async ensurePreAuth(): Promise<void> {
  129. if (this.companionKeysParams && !this.preAuthToken) {
  130. await this.fetchPreAuthToken()
  131. if (!this.preAuthToken) {
  132. throw new Error(
  133. 'Could not load authentication data required for third-party login. Please try again later.',
  134. )
  135. }
  136. }
  137. }
  138. // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars
  139. authQuery(data: unknown): Record<string, string> {
  140. return {}
  141. }
  142. authUrl({
  143. authFormData,
  144. query,
  145. }: {
  146. authFormData: unknown
  147. query: Record<string, string>
  148. }): string {
  149. const params = new URLSearchParams({
  150. ...query,
  151. state: btoa(JSON.stringify({ origin: getOrigin() })),
  152. ...this.authQuery({ authFormData }),
  153. })
  154. if (this.preAuthToken) {
  155. params.set('uppyPreAuthToken', this.preAuthToken)
  156. }
  157. return `${this.hostname}/${this.id}/connect?${params}`
  158. }
  159. protected async loginSimpleAuth({
  160. uppyVersions,
  161. authFormData,
  162. signal,
  163. }: {
  164. uppyVersions: string
  165. authFormData: unknown
  166. signal: AbortSignal
  167. }): Promise<void> {
  168. type Res = { uppyAuthToken: string }
  169. const response = await this.post<Res>(
  170. `${this.id}/simple-auth`,
  171. { form: authFormData },
  172. { qs: { uppyVersions }, signal },
  173. )
  174. this.setAuthToken(response.uppyAuthToken)
  175. }
  176. protected async loginOAuth({
  177. uppyVersions,
  178. authFormData,
  179. signal,
  180. }: {
  181. uppyVersions: string
  182. authFormData: unknown
  183. signal: AbortSignal
  184. }): Promise<void> {
  185. await this.ensurePreAuth()
  186. signal.throwIfAborted()
  187. return new Promise((resolve, reject) => {
  188. const link = this.authUrl({ query: { uppyVersions }, authFormData })
  189. const authWindow = window.open(link, '_blank')
  190. let cleanup: () => void
  191. const handleToken = (e: MessageEvent<any>) => {
  192. if (e.source !== authWindow) {
  193. let jsonData = ''
  194. try {
  195. // TODO improve our uppy logger so that it can take an arbitrary number of arguments,
  196. // each either objects, errors or strings,
  197. // then we don’t have to manually do these things like json stringify when logging.
  198. // the logger should never throw an error.
  199. jsonData = JSON.stringify(e.data)
  200. } catch (err) {
  201. // in case JSON.stringify fails (ignored)
  202. }
  203. this.uppy.log(
  204. `ignoring event from unknown source ${jsonData}`,
  205. 'warning',
  206. )
  207. return
  208. }
  209. const { companionAllowedHosts } = this.#getPlugin().opts
  210. if (!isOriginAllowed(e.origin, companionAllowedHosts)) {
  211. reject(
  212. new Error(
  213. `rejecting event from ${e.origin} vs allowed pattern ${companionAllowedHosts}`,
  214. ),
  215. )
  216. return
  217. }
  218. // Check if it's a string before doing the JSON.parse to maintain support
  219. // for older Companion versions that used object references
  220. const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
  221. if (data.error) {
  222. const { uppy } = this
  223. const message = uppy.i18n('authAborted')
  224. uppy.info({ message }, 'warning', 5000)
  225. reject(new Error('auth aborted'))
  226. return
  227. }
  228. if (!data.token) {
  229. reject(new Error('did not receive token from auth window'))
  230. return
  231. }
  232. cleanup()
  233. resolve(this.setAuthToken(data.token))
  234. }
  235. cleanup = () => {
  236. authWindow?.close()
  237. window.removeEventListener('message', handleToken)
  238. signal.removeEventListener('abort', cleanup)
  239. }
  240. signal.addEventListener('abort', cleanup)
  241. window.addEventListener('message', handleToken)
  242. })
  243. }
  244. async login({
  245. uppyVersions,
  246. authFormData,
  247. signal,
  248. }: {
  249. uppyVersions: string
  250. authFormData: unknown
  251. signal: AbortSignal
  252. }): Promise<void> {
  253. return this.loginOAuth({ uppyVersions, authFormData, signal })
  254. }
  255. refreshTokenUrl(): string {
  256. return `${this.hostname}/${this.id}/refresh-token`
  257. }
  258. fileUrl(id: string): string {
  259. return `${this.hostname}/${this.id}/get/${id}`
  260. }
  261. protected async request<ResBody extends Record<string, unknown>>(
  262. ...args: Parameters<RequestClient<M, B>['request']>
  263. ): Promise<ResBody> {
  264. await this.#refreshingTokenPromise
  265. try {
  266. // to test simulate access token expired (leading to a token token refresh),
  267. // see mockAccessTokenExpiredError in companion/drive.
  268. // If you want to test refresh token *and* access token invalid, do this for example with Google Drive:
  269. // While uploading, go to your google account settings,
  270. // "Third-party apps & services", then click "Companion" and "Remove access".
  271. return await super.request<ResBody>(...args)
  272. } catch (err) {
  273. if (!this.supportsRefreshToken) throw err
  274. // only handle auth errors (401 from provider), and only handle them if we have a (refresh) token
  275. const authTokenAfter = await this.#getAuthToken()
  276. if (!err.isAuthError || !authTokenAfter) throw err
  277. if (this.#refreshingTokenPromise == null) {
  278. // Many provider requests may be starting at once, however refresh token should only be called once.
  279. // Once a refresh token operation has started, we need all other request to wait for this operation (atomically)
  280. this.#refreshingTokenPromise = (async () => {
  281. try {
  282. this.uppy.log(
  283. `[CompanionClient] Refreshing expired auth token`,
  284. 'info',
  285. )
  286. const response = await super.request<{ uppyAuthToken: string }>({
  287. path: this.refreshTokenUrl(),
  288. method: 'POST',
  289. })
  290. await this.setAuthToken(response.uppyAuthToken)
  291. } catch (refreshTokenErr) {
  292. if (refreshTokenErr.isAuthError) {
  293. // if refresh-token has failed with auth error, delete token, so we don't keep trying to refresh in future
  294. await this.removeAuthToken()
  295. }
  296. throw err
  297. } finally {
  298. this.#refreshingTokenPromise = undefined
  299. }
  300. })()
  301. }
  302. await this.#refreshingTokenPromise
  303. // now retry the request with our new refresh token
  304. return super.request(...args)
  305. }
  306. }
  307. async fetchPreAuthToken(): Promise<void> {
  308. if (!this.companionKeysParams) {
  309. return
  310. }
  311. try {
  312. const res = await this.post<{ token: string }>(`${this.id}/preauth/`, {
  313. params: this.companionKeysParams,
  314. })
  315. this.preAuthToken = res.token
  316. } catch (err) {
  317. this.uppy.log(
  318. `[CompanionClient] unable to fetch preAuthToken ${err}`,
  319. 'warning',
  320. )
  321. }
  322. }
  323. list<ResBody extends Record<string, unknown>>(
  324. directory: string | undefined,
  325. options: RequestOptions,
  326. ): Promise<ResBody> {
  327. return this.get<ResBody>(`${this.id}/list/${directory || ''}`, options)
  328. }
  329. async logout<ResBody extends Record<string, unknown>>(
  330. options: RequestOptions,
  331. ): Promise<ResBody> {
  332. const response = await this.get<ResBody>(`${this.id}/logout`, options)
  333. await this.removeAuthToken()
  334. return response
  335. }
  336. static initPlugin(
  337. plugin: ProviderPlugin<any, any>, // any because static methods cannot use class generics
  338. opts: Opts,
  339. defaultOpts: Record<string, unknown>,
  340. ): void {
  341. /* eslint-disable no-param-reassign */
  342. plugin.type = 'acquirer'
  343. plugin.files = []
  344. if (defaultOpts) {
  345. plugin.opts = { ...defaultOpts, ...opts }
  346. }
  347. if (opts.serverUrl || opts.serverPattern) {
  348. throw new Error(
  349. '`serverUrl` and `serverPattern` have been renamed to `companionUrl` and `companionAllowedHosts` respectively in the 0.30.5 release. Please consult the docs (for example, https://uppy.io/docs/instagram/ for the Instagram plugin) and use the updated options.`',
  350. )
  351. }
  352. if (opts.companionAllowedHosts) {
  353. const pattern = opts.companionAllowedHosts
  354. // validate companionAllowedHosts param
  355. if (
  356. typeof pattern !== 'string' &&
  357. !Array.isArray(pattern) &&
  358. !(pattern instanceof RegExp)
  359. ) {
  360. throw new TypeError(
  361. `${plugin.id}: the option "companionAllowedHosts" must be one of string, Array, RegExp`,
  362. )
  363. }
  364. plugin.opts.companionAllowedHosts = pattern
  365. } else if (/^(?!https?:\/\/).*$/i.test(opts.companionUrl)) {
  366. // does not start with https://
  367. plugin.opts.companionAllowedHosts = `https://${opts.companionUrl?.replace(
  368. /^\/\//,
  369. '',
  370. )}`
  371. } else {
  372. plugin.opts.companionAllowedHosts = new URL(opts.companionUrl).origin
  373. }
  374. plugin.storage = plugin.opts.storage || tokenStorage
  375. /* eslint-enable no-param-reassign */
  376. }
  377. }