Skip to content

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

Package listing

@kody/zendesk

src/auth.ts

238 lines · 6.4 KB · TypeScript
import { createAuthenticatedFetch, kody, secretHeaders } from 'kody:runtime'
import { resolveEmail, resolveSubdomain } from './config.ts'
import type { ZendeskAuthInput, ZendeskAuthMode } from './types.ts'
import { requireString } from './types.ts'
import {
	DEFAULT_INTEGRATION_NAME,
	DEFAULT_TOKEN_SECRET,
	DEFAULT_USERNAME_SECRET,
	missingCredentialsMessage,
	resolveIntegrationName,
	resolveSecretName,
	resolveUsernameSecretName,
} from './setup.ts'

const authenticatedFetches = new Map<string, typeof fetch>()

export type ResolvedZendeskAuth = {
	mode: ZendeskAuthMode
	integrationName: string
	secretName: string
	usernameSecretName: string
	subdomain: string
	email?: string
	authorization: string
}

function secretEntries(
	result: unknown,
): Array<{ name?: string; scope?: string }> {
	if (
		result &&
		typeof result === 'object' &&
		Array.isArray((result as { secrets?: unknown }).secrets)
	) {
		return (result as { secrets: Array<{ name?: string; scope?: string }> }).secrets
	}
	return Array.isArray(result) ? result : []
}

export async function listUserSecretNames(): Promise<Set<string>> {
	const listed = await kody.secret_list({ scope: 'user' })
	const names = new Set<string>()
	for (const entry of secretEntries(listed)) {
		if (entry?.name && (entry.scope === 'user' || !entry.scope)) {
			names.add(entry.name)
		}
	}
	return names
}

export async function listZendeskIntegrationNames(): Promise<Array<string>> {
	const listed = await kody.integration_list({})
	const integrations =
		listed && typeof listed === 'object' && Array.isArray((listed as { integrations?: unknown }).integrations)
			? (listed as { integrations: Array<{ name?: string }> }).integrations
			: Array.isArray(listed)
				? listed
				: []
	return integrations
		.map((item) => (typeof item?.name === 'string' ? item.name : ''))
		.filter((name) => name === 'zendesk' || name.startsWith('zendesk-'))
}

export async function resolveZendeskAuth(
	input: ZendeskAuthInput = {},
): Promise<ResolvedZendeskAuth> {
	const integrationName = resolveIntegrationName(input)
	const secretName = resolveSecretName(input)
	const usernameSecretName = resolveUsernameSecretName(input)
	const subdomain = await resolveSubdomain(input)
	const email = await resolveEmail(input)
	const forced = input.auth
	if (forced !== undefined && forced !== 'oauth' && forced !== 'apiToken') {
		throw new Error('auth must be "oauth" or "apiToken".')
	}

	const [secrets, integrations] = await Promise.all([
		listUserSecretNames(),
		listZendeskIntegrationNames(),
	])
	const hasOauth = integrations.includes(integrationName)
	const hasToken = secrets.has(secretName) && secrets.has(usernameSecretName)

	let mode: ZendeskAuthMode
	if (forced === 'oauth') {
		if (!hasOauth) {
			throw new Error(
				missingCredentialsMessage({
					integrationName,
					secretName,
					usernameSecretName,
					subdomain,
					email,
				}),
			)
		}
		mode = 'oauth'
	} else if (forced === 'apiToken') {
		if (!hasToken) {
			throw new Error(
				missingCredentialsMessage({
					integrationName,
					secretName,
					usernameSecretName,
					subdomain,
					email,
				}),
			)
		}
		mode = 'apiToken'
	} else if (hasOauth) {
		mode = 'oauth'
	} else if (hasToken) {
		mode = 'apiToken'
	} else {
		throw new Error(
			missingCredentialsMessage({
				integrationName,
				secretName,
				usernameSecretName,
				subdomain,
				email,
			}),
		)
	}

	switch (mode) {
		case 'oauth':
			return {
				mode,
				integrationName,
				secretName,
				usernameSecretName,
				subdomain,
				email,
				authorization: `[managed ${integrationName} OAuth integration]`,
			}
		case 'apiToken':
			return {
				mode,
				integrationName,
				secretName,
				usernameSecretName,
				subdomain,
				email,
				authorization: secretHeaders.basic({
					usernameSecret: usernameSecretName,
					passwordSecret: secretName,
					scope: 'user',
				}),
			}
		default: {
			const exhaustive: never = mode
			throw new Error(`Unsupported Zendesk auth mode: ${String(exhaustive)}`)
		}
	}
}

async function getOauthFetch(integrationName: string): Promise<typeof fetch> {
	let authedFetch = authenticatedFetches.get(integrationName)
	if (!authedFetch) {
		try {
			authedFetch = await createAuthenticatedFetch(integrationName)
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error)
			throw new Error(
				[
					`Zendesk OAuth integration "${integrationName}" is not usable: ${message}`,
					missingCredentialsMessage({
						integrationName,
						secretName: DEFAULT_TOKEN_SECRET,
						usernameSecretName: DEFAULT_USERNAME_SECRET,
					}),
				].join(' '),
			)
		}
		authenticatedFetches.set(integrationName, authedFetch)
	}
	return authedFetch
}

export async function zendeskFetch(
	auth: ResolvedZendeskAuth,
	url: string,
	init: RequestInit,
): Promise<Response> {
	switch (auth.mode) {
		case 'oauth': {
			const authedFetch = await getOauthFetch(auth.integrationName)
			return authedFetch(url, init)
		}
		case 'apiToken': {
			const headers = new Headers(init.headers)
			headers.set(
				'Authorization',
				secretHeaders.basic({
					usernameSecret: auth.usernameSecretName,
					passwordSecret: auth.secretName,
					scope: 'user',
				}),
			)
			return fetch(url, { ...init, headers })
		}
		default: {
			const exhaustive: never = auth.mode
			throw new Error(`Unsupported Zendesk auth mode: ${String(exhaustive)}`)
		}
	}
}

export function requireAuthMode(value: unknown): ZendeskAuthMode | undefined {
	if (value === undefined) return undefined
	const mode = requireString(value, 'auth')
	if (mode !== 'oauth' && mode !== 'apiToken') {
		throw new Error('auth must be "oauth" or "apiToken".')
	}
	return mode
}

export function parseAuth(input: Record<string, unknown>): ZendeskAuthInput {
	return {
		integrationName: optionalStringSafe(input.integrationName, 'integrationName'),
		integration: optionalStringSafe(input.integration, 'integration'),
		account: optionalStringSafe(input.account, 'account'),
		secretName: optionalStringSafe(input.secretName, 'secretName'),
		usernameSecretName: optionalStringSafe(input.usernameSecretName, 'usernameSecretName'),
		auth: requireAuthMode(input.auth),
		subdomain: optionalStringSafe(input.subdomain, 'subdomain'),
		email: optionalStringSafe(input.email, 'email'),
	}
}

function optionalStringSafe(value: unknown, label: string): string | undefined {
	if (value === undefined || value === null) return undefined
	return requireString(value, label)
}

export { DEFAULT_INTEGRATION_NAME, DEFAULT_TOKEN_SECRET, DEFAULT_USERNAME_SECRET }