uppy/packages/@uppy/companion/src/server/helpers/jwt.ts

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)
}