Skip to content

src/verify.ts

235 lines · 8.4 KB · TypeScript
Raw
import { listAccessibleAccounts, type AccessibleAccount } from './accounts-resolve.ts'
import cloudflareApiV4 from './api-v4.ts'
import {
	DEFAULT_CLOUDFLARE_ACCOUNT,
	getAccountInfo,
	KODY_CLOUDFLARE_ACCOUNT_ID,
	pickAuthOptions,
	resolveApiTokenSecretName,
	resolveKnownAccountId,
	type CloudflareAuthOptions,
} from './auth.ts'

/**
 * Production Kody Cloudflare account id. Used as the default target for
 * account-scoped token verification when `account: 'kody'`.
 */
export { KODY_CLOUDFLARE_ACCOUNT_ID }

export type VerifyApiTokenInput = CloudflareAuthOptions & {
	/**
	 * Cloudflare account id for account-scoped token verification
	 * (`GET /accounts/{account_id}/tokens/verify`). Required when the token is
	 * account-scoped and `account` is not `kody` (which defaults to
	 * {@link KODY_CLOUDFLARE_ACCOUNT_ID}).
	 */
	accountId?: string
}

export type VerifyApiTokenResult = {
	/** True when Cloudflare reported the token as usable. */
	ok: boolean
	/** Which verify endpoint succeeded, or null when neither did. */
	kind: 'user' | 'account' | null
	/** Cloudflare token id when returned by a successful verify call. */
	tokenId: string | null
	/** Cloudflare token status (`active`, etc.) when returned. */
	status: string | null
	/**
	 * Cloudflare account id to use with this token: explicit `accountId`, the
	 * alias's known account (`kody`, `pages`), or the only account GET /accounts
	 * returned. `null` when ambiguous — see `accessibleAccounts`.
	 */
	accountId: string | null
	/**
	 * Accounts GET /accounts returned for this token (id + name). Empty for
	 * tokens without account-read access (the `pages` token) even when valid.
	 */
	accessibleAccounts: AccessibleAccount[]
	/** Kody secret name that was used (never the token value). */
	secretName: string
	/** HTTP status from the successful verify, or the last attempt. */
	httpStatus: number | null
	/** Compact error list from failed attempts. */
	errors: Array<{ code?: number; message?: string }>
	/**
	 * Agent-facing guidance. Especially important when `/user/tokens/verify`
	 * returns 401 for a valid account-scoped token.
	 */
	guidance: string
}

type CloudflareError = { code?: number; message?: string }

function asErrors(errors: unknown): Array<CloudflareError> {
	if (!Array.isArray(errors)) return []
	return errors.map((error) => {
		if (!error || typeof error !== 'object') return {}
		const record = error as CloudflareError
		return {
			code: typeof record.code === 'number' ? record.code : undefined,
			message: typeof record.message === 'string' ? record.message : undefined,
		}
	})
}

function resolveAccountIdForVerify(params: VerifyApiTokenInput): string | null {
	if (params.apiTokenSecret && !params.accountId) return null
	return resolveKnownAccountId({ account: params.account, accountId: params.accountId })
}

function pickAccountId(known: string | null, accessible: AccessibleAccount[]): string | null {
	if (known) return known
	return accessible.length === 1 ? accessible[0].id : null
}

function accountsNote(accountId: string | null, accessible: AccessibleAccount[]): string {
	if (accessible.length > 1 && !accountId) {
		return ` Token sees ${accessible.length} accounts (${accessible
			.map((account) => `${account.name} ${account.id}`)
			.join(', ')}); pass accountId to account-scoped helpers.`
	}
	if (accessible.length === 0 && accountId) {
		return ` GET /accounts returned none (normal for narrowly scoped tokens); using known account ${accountId}.`
	}
	return ''
}

/**
 * Verify a Cloudflare API token without misreading account-scoped tokens.
 *
 * **Do not use `/user/tokens/verify` alone.** Account API tokens (Manage
 * Account → API Tokens) often return `401 Invalid API Token` on the user
 * endpoint even when fully valid. Use:
 *
 * - User tokens (My Profile → API Tokens): `GET /user/tokens/verify`
 * - Account tokens: `GET /accounts/{account_id}/tokens/verify`
 *
 * This helper tries the user endpoint first, then the account endpoint when an
 * `accountId` is known (aliases `kody` and `pages` carry their account ids).
 * On success it also lists `accessibleAccounts` (GET /accounts) and returns
 * the `accountId` to use with account-scoped helpers when it is unambiguous.
 *
 * A successful verify only means the token is alive. Permission checks still
 * need resource probes: `403` = lacks permission; `400`/`404` on a mutating
 * call against a bad/missing target = authorized for that operation.
 *
 * @param params.account - Token alias (`default`, `kody`, `pages`).
 * @param params.apiTokenSecret - Explicit Kody secret name when no alias fits.
 * @param params.accountId - Cloudflare account id for account-scoped verify.
 * @returns Compact verification result (never includes the token value).
 * @example
 * import verifyApiToken from 'kody:@kentcdodds/cloudflare/verify'
 * const result = await verifyApiToken({ account: 'kody' })
 * // => { ok: true, kind: 'account', status: 'active', accountId: '…', accessibleAccounts: [{ id, name }], ... }
 */
export default async function verifyApiToken(
	params: VerifyApiTokenInput = {},
): Promise<VerifyApiTokenResult> {
	const auth = pickAuthOptions(params)
	const secretName = resolveApiTokenSecretName(auth)
	const accountAlias = params.account ?? DEFAULT_CLOUDFLARE_ACCOUNT
	const accountId = resolveAccountIdForVerify(params)

	const userVerify = await cloudflareApiV4({
		...auth,
		path: '/client/v4/user/tokens/verify',
	})
	if (userVerify.success) {
		const result =
			userVerify.result && typeof userVerify.result === 'object'
				? (userVerify.result as { id?: string; status?: string })
				: {}
		const listed = await listAccessibleAccounts(auth)
		const resolved = pickAccountId(accountId, listed.accounts)
		return {
			ok: true,
			kind: 'user',
			tokenId: typeof result.id === 'string' ? result.id : null,
			status: typeof result.status === 'string' ? result.status : null,
			accountId: resolved,
			accessibleAccounts: listed.accounts,
			secretName,
			httpStatus: userVerify.httpStatus,
			errors: [],
			guidance:
				`Token for secret "${secretName}" verified via /user/tokens/verify ` +
				`(user-scoped / profile token). Alias: ${accountAlias}.` +
				accountsNote(resolved, listed.accounts),
		}
	}

	if (accountId) {
		const accountVerify = await cloudflareApiV4({
			...auth,
			path: `/client/v4/accounts/${accountId}/tokens/verify`,
		})
		if (accountVerify.success) {
			const result =
				accountVerify.result && typeof accountVerify.result === 'object'
					? (accountVerify.result as { id?: string; status?: string })
					: {}
			const listed = await listAccessibleAccounts(auth)
			return {
				ok: true,
				kind: 'account',
				tokenId: typeof result.id === 'string' ? result.id : null,
				status: typeof result.status === 'string' ? result.status : null,
				accountId,
				accessibleAccounts: listed.accounts,
				secretName,
				httpStatus: accountVerify.httpStatus,
				errors: [],
				guidance:
					`Token for secret "${secretName}" verified via ` +
					`/accounts/${accountId}/tokens/verify (account-scoped token). ` +
					`A prior /user/tokens/verify 401 is expected for account tokens and ` +
					`must not be treated as “token invalid.”`,
			}
		}

		return {
			ok: false,
			kind: null,
			tokenId: null,
			status: null,
			accountId,
			accessibleAccounts: [],
			secretName,
			httpStatus: accountVerify.httpStatus,
			errors: [
				...asErrors(userVerify.errors),
				...asErrors(accountVerify.errors),
			],
			guidance:
				`Both verify endpoints failed for secret "${secretName}". ` +
				`User verify HTTP ${String(userVerify.httpStatus)}; account verify ` +
				`HTTP ${String(accountVerify.httpStatus)} against account ${accountId}. ` +
				`The stored secret value is likely wrong, revoked, or expired — not merely under-scoped.`,
		}
	}

	const aliasHint =
		accountAlias === 'kody'
			? ''
			: ` For the Kody account token, call verifyApiToken({ account: 'kody' }). ` +
				`For other account-scoped tokens, pass accountId.`

	return {
		ok: false,
		kind: null,
		tokenId: null,
		status: null,
		accountId: null,
		accessibleAccounts: [],
		secretName,
		httpStatus: userVerify.httpStatus,
		errors: asErrors(userVerify.errors),
		guidance:
			`/user/tokens/verify failed for secret "${secretName}" ` +
			`(HTTP ${String(userVerify.httpStatus)}). This is NOT proof the token is ` +
			`invalid — account-scoped tokens commonly 401 here. Retry with accountId ` +
			`(GET /accounts/{account_id}/tokens/verify).${aliasHint} ` +
			`Alias info: ${getAccountInfo(accountAlias).label}.`,
	}
}