mirror of
https://github.com/transloadit/uppy.git
synced 2026-07-17 16:50:22 +00:00
167 lines
5.6 KiB
TypeScript
167 lines
5.6 KiB
TypeScript
import type { Request, Response } from 'express'
|
|
import jwt from 'jsonwebtoken'
|
|
import type { CompanionRuntimeOptions } from '../../types/companion-options.js'
|
|
import { isRecord } from './type-guards.js'
|
|
import { decrypt, encrypt } from './utils.js'
|
|
|
|
// The Uppy auth token is an encrypted JWT & JSON encoded container.
|
|
// It used to simply contain an OAuth access_token and refresh_token for a specific provider.
|
|
// However now we allow more data to be stored in it. This allows for storing other state or parameters needed for that
|
|
// specific provider, like username, password, host names etc.
|
|
// The different providers APIs themselves will verify these inner tokens through Provider classes.
|
|
// The expiry of the Uppy auth token should be higher than the expiry of the refresh token.
|
|
// Because some refresh tokens normally never expire, we set the Uppy auth token expiry very high.
|
|
// Chrome has a maximum cookie expiry of 400 days, so we'll use that (we also store the auth token in a cookie)
|
|
//
|
|
// If the Uppy auth token expiry were set too low (e.g. 24hr), we could risk this situation:
|
|
// A user starts an upload, thus creating an auth token. They leave the upload running over night.
|
|
// The upload finishes after a few hours, but with some failed files. Then the next day (say after 25hr)
|
|
// they would retry the failed tiles, however now the Uppy auth token has expired and
|
|
// even though the provider refresh token would still have been accepted and
|
|
// there's no way for them to retry their failed files.
|
|
// With 400 days, there's still a theoretical possibility but very low.
|
|
export const MAX_AGE_REFRESH_TOKEN = 60 * 60 * 24 * 400
|
|
export const MAX_AGE_24H = 60 * 60 * 24
|
|
|
|
type EncryptionSecret = string | Buffer
|
|
|
|
const generateToken = (
|
|
data: unknown,
|
|
secret: EncryptionSecret,
|
|
maxAge: number,
|
|
): string => {
|
|
return jwt.sign({ data }, secret, { expiresIn: maxAge })
|
|
}
|
|
|
|
const verifyJwtToken = (token: string, secret: EncryptionSecret) => {
|
|
const decoded = jwt.verify(token, secret, {})
|
|
if (!decoded || typeof decoded !== 'object' || !('data' in decoded)) {
|
|
throw new Error('Invalid token payload')
|
|
}
|
|
return decoded['data']
|
|
}
|
|
|
|
export const generateEncryptedToken = (
|
|
payload: unknown,
|
|
secret: EncryptionSecret,
|
|
maxAge = MAX_AGE_24H,
|
|
): string => {
|
|
// return payload // for easier debugging
|
|
return encrypt(generateToken(payload, secret, maxAge), secret)
|
|
}
|
|
|
|
export const generateEncryptedAuthToken = (
|
|
payload: unknown,
|
|
secret: EncryptionSecret,
|
|
maxAge?: number,
|
|
): string => {
|
|
return generateEncryptedToken(JSON.stringify(payload), secret, maxAge)
|
|
}
|
|
|
|
export const verifyEncryptedToken = (
|
|
token: string,
|
|
secret: EncryptionSecret,
|
|
) => {
|
|
const ret = verifyJwtToken(decrypt(token, secret), secret)
|
|
if (!ret) throw new Error('No payload')
|
|
return ret
|
|
}
|
|
|
|
export const verifyEncryptedAuthToken = <T extends Record<string, unknown>>(
|
|
token: string,
|
|
secret: EncryptionSecret,
|
|
providerName: string,
|
|
): T => {
|
|
const json = verifyEncryptedToken(token, secret)
|
|
if (typeof json !== 'string') {
|
|
throw new Error('Invalid token payload: expected string')
|
|
}
|
|
const tokens: T = JSON.parse(json)
|
|
if (!isRecord(tokens) || !Object.hasOwn(tokens, providerName))
|
|
throw new Error(`Missing token payload for provider ${providerName}`)
|
|
return tokens
|
|
}
|
|
|
|
function getCommonCookieOptions({
|
|
companionOptions,
|
|
}: {
|
|
companionOptions: CompanionRuntimeOptions
|
|
}): Record<string, unknown> {
|
|
const cookieOptions: Record<string, unknown> = {
|
|
httpOnly: true,
|
|
}
|
|
|
|
// Fix to show thumbnails on Chrome
|
|
// https://community.transloadit.com/t/dropbox-and-box-thumbnails-returning-401-unauthorized/15781/2
|
|
// Note that sameSite cookies also require secure (which needs https), so thumbnails don't work from localhost
|
|
// to test locally, you can manually find the URL of the image and open it in a separate browser tab
|
|
if (companionOptions.server && companionOptions.server.protocol === 'https') {
|
|
cookieOptions['sameSite'] = 'none'
|
|
cookieOptions['secure'] = true
|
|
}
|
|
|
|
const cookieDomain = companionOptions['cookieDomain']
|
|
if (cookieDomain != null) {
|
|
cookieOptions['domain'] = cookieDomain
|
|
}
|
|
|
|
return cookieOptions
|
|
}
|
|
|
|
export const getCookieName = (oauthProvider: string): string =>
|
|
`uppyAuthToken--${oauthProvider}`
|
|
|
|
const addToCookies = ({
|
|
res,
|
|
token,
|
|
companionOptions,
|
|
oauthProvider,
|
|
maxAge = MAX_AGE_24H,
|
|
}: {
|
|
res: Response
|
|
token: string
|
|
companionOptions: CompanionRuntimeOptions
|
|
oauthProvider: string
|
|
maxAge?: number
|
|
}): void => {
|
|
const cookieOptions = {
|
|
...getCommonCookieOptions({ companionOptions }),
|
|
maxAge: maxAge * 1000,
|
|
}
|
|
|
|
// send signed token to client.
|
|
res.cookie(getCookieName(oauthProvider), token, cookieOptions)
|
|
}
|
|
|
|
export const addToCookiesIfNeeded = (
|
|
req: Request,
|
|
res: Response,
|
|
uppyAuthToken: string,
|
|
maxAge?: number,
|
|
): void => {
|
|
// some providers need the token in cookies for thumbnail/image requests
|
|
if (req.companion.provider?.needsCookieAuth) {
|
|
const oauthProvider = req.companion.providerClass?.oauthProvider
|
|
if (oauthProvider == null) return
|
|
const args = {
|
|
res,
|
|
token: uppyAuthToken,
|
|
companionOptions: req.companion.options,
|
|
oauthProvider,
|
|
...(maxAge != null && { maxAge }),
|
|
}
|
|
addToCookies(args)
|
|
}
|
|
}
|
|
|
|
export const removeFromCookies = (
|
|
res: Response,
|
|
companionOptions: CompanionRuntimeOptions,
|
|
oauthProvider: string,
|
|
): void => {
|
|
// options must be identical to those given to res.cookie(), excluding expires and maxAge.
|
|
// https://expressjs.com/en/api.html#res.clearCookie
|
|
const cookieOptions = getCommonCookieOptions({ companionOptions })
|
|
|
|
res.clearCookie(getCookieName(oauthProvider), cookieOptions)
|
|
}
|