Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/zoom

src/auth.ts

298 lines · 9.6 KB · TypeScript
import type { ZoomAuthInput, ZoomAuthLaneInfo, ZoomAuthMode, ZoomResolvedAuth } from './types.ts'

export const DEFAULT_ZOOM_API_BASE_URL = 'https://api.zoom.us/v2'
export const DEFAULT_ZOOM_API_HOST = 'api.zoom.us'
export const ZOOM_TOKEN_HOST = 'zoom.us'
export const DEFAULT_ZOOM_INTEGRATION_NAME = 'zoom'
export const DEFAULT_ZOOM_ACCOUNT_ID_SECRET = 'zoomAccountId'
export const DEFAULT_ZOOM_CLIENT_ID_SECRET = 'zoomClientId'
export const DEFAULT_ZOOM_S2S_CLIENT_SECRET = 'zoomS2sClientSecret'

export const ZOOM_OAUTH_AUTHORIZE_URL = 'https://zoom.us/oauth/authorize'
export const ZOOM_OAUTH_TOKEN_URL = 'https://zoom.us/oauth/token'
export const ZOOM_MARKETPLACE_CREATE_URL = 'https://marketplace.zoom.us/develop/create'
export const ZOOM_S2S_DOCS_URL = 'https://developers.zoom.us/docs/internal-apps/s2s-oauth/'
export const ZOOM_OAUTH_DOCS_URL = 'https://developers.zoom.us/docs/integrations/oauth/'

export const SUGGESTED_ZOOM_OAUTH_SCOPES = [
	'user:read:user',
	'meeting:read:list_meetings',
	'meeting:read:meeting',
	'meeting:write:meeting',
	'meeting:update:meeting',
	'meeting:delete:meeting',
	'cloud_recording:read:list_user_recordings',
	'cloud_recording:read:recording',
	'webinar:read:list_webinars',
	'webinar:read:webinar',
	'webinar:write:webinar',
	'webinar:update:webinar',
	'webinar:delete:webinar',
] as const

export const SUGGESTED_ZOOM_S2S_SCOPES = [
	'user:read:user:admin',
	'user:read:list_users:admin',
	'meeting:read:list_meetings:admin',
	'meeting:read:meeting:admin',
	'meeting:write:meeting:admin',
	'meeting:update:meeting:admin',
	'meeting:delete:meeting:admin',
	'cloud_recording:read:list_user_recordings:admin',
	'cloud_recording:read:recording:admin',
	'webinar:read:list_webinars:admin',
	'webinar:read:webinar:admin',
	'webinar:write:webinar:admin',
	'webinar:update:webinar:admin',
	'webinar:delete:webinar:admin',
] as const

export const ZOOM_ALLOWED_HOSTS = `${DEFAULT_ZOOM_API_HOST},${ZOOM_TOKEN_HOST}`

const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/
const ACCOUNT_ID_SECRET_PATTERN = /^zoomAccountId(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const CLIENT_ID_SECRET_PATTERN = /^zoomClientId(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const S2S_CLIENT_SECRET_PATTERN = /^zoomS2sClientSecret(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/

export const ZOOM_OAUTH_CONNECT_URL = buildOauthConnectUrl({
	provider: DEFAULT_ZOOM_INTEGRATION_NAME,
})

export const ZOOM_ACCOUNT_ID_SETUP_URL = buildSecretSetupUrl(
	DEFAULT_ZOOM_ACCOUNT_ID_SECRET,
	'Zoom Server-to-Server account ID',
)
export const ZOOM_CLIENT_ID_SETUP_URL = buildSecretSetupUrl(
	DEFAULT_ZOOM_CLIENT_ID_SECRET,
	'Zoom Server-to-Server client ID',
)
export const ZOOM_S2S_CLIENT_SECRET_SETUP_URL = buildSecretSetupUrl(
	DEFAULT_ZOOM_S2S_CLIENT_SECRET,
	'Zoom Server-to-Server client secret',
)

const zoomAuthLanes = [
	{
		lane: 'oauth',
		default: true,
		integrationName: DEFAULT_ZOOM_INTEGRATION_NAME,
		connectUrl: ZOOM_OAUTH_CONNECT_URL,
		useWhen:
			'Bring-your-own Zoom OAuth app. Pass a distinct integrationName (zoom-work, …) for each extra connected identity.',
		avoidWhen: 'You only have Server-to-Server account credentials and have not connected user OAuth.',
		mutationGuidance:
			'Meeting and webinar create/update/delete require dryRun: true to preview, then confirm: true to apply.',
	},
	{
		lane: 's2s',
		default: false,
		accountIdSecret: DEFAULT_ZOOM_ACCOUNT_ID_SECRET,
		clientIdSecret: DEFAULT_ZOOM_CLIENT_ID_SECRET,
		clientSecretSecret: DEFAULT_ZOOM_S2S_CLIENT_SECRET,
		accountIdSetupUrl: ZOOM_ACCOUNT_ID_SETUP_URL,
		clientIdSetupUrl: ZOOM_CLIENT_ID_SETUP_URL,
		clientSecretSetupUrl: ZOOM_S2S_CLIENT_SECRET_SETUP_URL,
		useWhen:
			'Server-to-Server OAuth app credentials (account ID, client ID, client secret). Pass auth: "s2s" or any S2S secret override.',
		avoidWhen: 'A saved Zoom user OAuth integration already covers the work.',
		mutationGuidance:
			'Meeting and webinar create/update/delete require dryRun: true to preview, then confirm: true to apply.',
	},
] as const satisfies readonly ZoomAuthLaneInfo[]

/**
 * Return the supported Zoom auth lanes and selection guidance.
 */
export function accounts(): readonly ZoomAuthLaneInfo[] {
	return zoomAuthLanes
}

/**
 * Resolve OAuth vs Server-to-Server credentials and the Zoom API base URL.
 */
export function resolveZoomAuth(input: ZoomAuthInput = {}): ZoomResolvedAuth {
	const mode = resolveAuthMode(input)
	switch (mode) {
		case 's2s': {
			const accountIdSecret = resolveNamedSecret(
				input.accountIdSecret,
				DEFAULT_ZOOM_ACCOUNT_ID_SECRET,
				ACCOUNT_ID_SECRET_PATTERN,
				'accountIdSecret',
				input.account,
			)
			const clientIdSecret = resolveNamedSecret(
				input.clientIdSecret,
				DEFAULT_ZOOM_CLIENT_ID_SECRET,
				CLIENT_ID_SECRET_PATTERN,
				'clientIdSecret',
				input.account,
			)
			const clientSecretSecret = resolveNamedSecret(
				input.clientSecretSecret,
				DEFAULT_ZOOM_S2S_CLIENT_SECRET,
				S2S_CLIENT_SECRET_PATTERN,
				'clientSecretSecret',
				input.account,
			)
			return {
				mode: 's2s',
				integrationName: null,
				accountIdSecret,
				clientIdSecret,
				clientSecretSecret,
				label: accountIdSecret,
				apiBaseUrl: DEFAULT_ZOOM_API_BASE_URL,
				apiHost: DEFAULT_ZOOM_API_HOST,
			}
		}
		case 'oauth': {
			const integrationName = resolveIntegrationName(input)
			return {
				mode: 'oauth',
				integrationName,
				accountIdSecret: null,
				clientIdSecret: null,
				clientSecretSecret: null,
				label: integrationName,
				apiBaseUrl: DEFAULT_ZOOM_API_BASE_URL,
				apiHost: DEFAULT_ZOOM_API_HOST,
			}
		}
		default: {
			const exhaustive: never = mode
			throw new Error('Unsupported Zoom auth mode: ' + String(exhaustive))
		}
	}
}

export function resolveAuthMode(input: ZoomAuthInput = {}): ZoomAuthMode {
	const explicit = trimToUndefined(input.auth)
	if (explicit === 'oauth' || explicit === 's2s') return explicit
	if (explicit) {
		throw new Error('auth must be "oauth" or "s2s".')
	}
	if (
		trimToUndefined(input.accountIdSecret) ||
		trimToUndefined(input.clientIdSecret) ||
		trimToUndefined(input.clientSecretSecret)
	) {
		return 's2s'
	}
	return 'oauth'
}

export function resolveIntegrationName(input: ZoomAuthInput = {}): string {
	const explicit = trimToUndefined(input.integrationName)
	if (explicit) return explicit

	const account = trimToUndefined(input.account)
	if (!account || account === 'default') return DEFAULT_ZOOM_INTEGRATION_NAME
	if (account === DEFAULT_ZOOM_INTEGRATION_NAME || account.startsWith('zoom-')) {
		return account
	}
	if (!ACCOUNT_LABEL_PATTERN.test(account)) {
		throw new Error(
			'account must be a short label such as "work" (letters, numbers, _ or -), or a full zoom-* name.',
		)
	}
	return `${DEFAULT_ZOOM_INTEGRATION_NAME}-${account}`
}

export function resolveAccountLabel(account: string | undefined): string | null {
	const trimmed = trimToUndefined(account)
	if (!trimmed || trimmed === 'default' || trimmed === DEFAULT_ZOOM_INTEGRATION_NAME) {
		return null
	}
	if (trimmed.startsWith('zoom-')) return trimmed.slice('zoom-'.length)
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" (letters, numbers, _ or -), or a full zoom-* name.',
		)
	}
	return trimmed
}

export function buildOauthConnectUrl(options: {
	provider?: string
	scopes?: readonly string[]
} = {}): string {
	const provider = trimToUndefined(options.provider) ?? DEFAULT_ZOOM_INTEGRATION_NAME
	const scopes = options.scopes ?? SUGGESTED_ZOOM_OAUTH_SCOPES
	const params = new URLSearchParams({
		provider,
		authorizeUrl: ZOOM_OAUTH_AUTHORIZE_URL,
		tokenUrl: ZOOM_OAUTH_TOKEN_URL,
		flow: 'confidential',
		scopes: scopes.join(' '),
		allowedHosts: ZOOM_ALLOWED_HOSTS,
		apiBaseUrl: DEFAULT_ZOOM_API_BASE_URL,
		dashboardUrl: ZOOM_MARKETPLACE_CREATE_URL,
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function buildSecretSetupUrl(secretName: string, description: string): string {
	const params = new URLSearchParams({
		name: secretName,
		description,
		allowedHosts: ZOOM_ALLOWED_HOSTS,
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function pickAuthInput(params: Record<string, unknown>): ZoomAuthInput {
	const auth = readOptionalString(params.auth)
	return {
		integrationName: readOptionalString(params.integrationName),
		account: readOptionalString(params.account),
		auth: auth === 'oauth' || auth === 's2s' ? auth : undefined,
		accountIdSecret: readOptionalString(params.accountIdSecret),
		clientIdSecret: readOptionalString(params.clientIdSecret),
		clientSecretSecret: readOptionalString(params.clientSecretSecret),
	}
}

export function isDryRun(params: Record<string, unknown>): boolean {
	return params.dryRun === true
}

export function isConfirmed(params: Record<string, unknown>): boolean {
	return params.confirm === true
}

export function trimToUndefined(value: string | undefined): string | undefined {
	if (typeof value !== 'string') return undefined
	const trimmed = value.trim()
	return trimmed.length > 0 ? trimmed : undefined
}

export function readOptionalString(value: unknown): string | undefined {
	return typeof value === 'string' ? value : undefined
}

export function stringifyError(error: unknown): string {
	if (error instanceof Error) return error.message
	return String(error)
}

function resolveNamedSecret(
	override: string | undefined,
	base: string,
	pattern: RegExp,
	label: string,
	account: string | undefined,
): string {
	const explicit = trimToUndefined(override)
	if (explicit) {
		if (!pattern.test(explicit)) {
			throw new Error(
				`${label} must be ${base} or ${base}-<account>. Got ${explicit}.`,
			)
		}
		return explicit
	}
	const suffix = resolveAccountLabel(account)
	return suffix ? `${base}-${suffix}` : base
}