Skip to content

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

Package listing

@kentcdodds/cloudflare

src/verify.ts

200 lines · 6.8 KB · TypeScript
import cloudflareApiV4 from './api-v4.ts'
import {
	DEFAULT_CLOUDFLARE_ACCOUNT,
	getAccountInfo,
	pickAuthOptions,
	resolveApiTokenSecretName,
	type CloudflareAuthOptions,
} from './auth.ts'

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

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
	/** Account id used for the account-scoped verify attempt, if any. */
	accountId: string | null
	/** 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 (typeof params.accountId === 'string' && params.accountId.trim()) {
		return params.accountId.trim()
	}
	const account = params.account ?? DEFAULT_CLOUDFLARE_ACCOUNT
	if (account === 'kody') return KODY_CLOUDFLARE_ACCOUNT_ID
	return null
}

/**
 * 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 (`account: 'kody'` defaults to the Kody account id).
 *
 * 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', ... }
 */
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 })
				: {}
		return {
			ok: true,
			kind: 'user',
			tokenId: typeof result.id === 'string' ? result.id : null,
			status: typeof result.status === 'string' ? result.status : null,
			accountId: null,
			secretName,
			httpStatus: userVerify.httpStatus,
			errors: [],
			guidance:
				`Token for secret "${secretName}" verified via /user/tokens/verify ` +
				`(user-scoped / profile token). Alias: ${accountAlias}.`,
		}
	}

	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 })
					: {}
			return {
				ok: true,
				kind: 'account',
				tokenId: typeof result.id === 'string' ? result.id : null,
				status: typeof result.status === 'string' ? result.status : null,
				accountId,
				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,
			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,
		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}.`,
	}
}