Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/onepassword

Resolve 1Password Connect item fields for secret-aware fetch with website host allowlisting.

secret-provider.ts

424 lines · 12.4 KB · TypeScript
/**
 * Sealed 1Password Connect canonicalize/resolve handler for secret-aware fetch.
 * Platform fetch boundary only — ordinary execute/invoke/kody:@ of this export is rejected.
 *
 * @param input - action, ref, canonicalRef, door secret name/value, and Connect config
 * @returns For canonicalize, `{ canonicalRef }`. For resolve, `{ value, hosts, canonicalRef? }`.
 *
 * @example
 * // Not callable from execute. Use placeholders instead:
 * // {{secret/1password:i/<item-id>/password}}  // UUID or 26-char Connect id
 */

const UUID_RE =
	/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** 1Password Connect item ids are 26-char lowercase alphanumeric (not always UUID). */
const OP_ID_RE = /^[\da-z]{26}$/

const PURPOSE_BY_FIELD: Record<string, string> = {
	password: 'PASSWORD',
	username: 'USERNAME',
	notes: 'NOTES',
}

const RESOLVE_BUDGET_MS = 7500
const CANONICALIZE_BUDGET_MS = 4500

type SecretProviderInvokeInput = {
	action: 'canonicalize' | 'resolve'
	providerId: string
	ref: string
	canonicalRef: string | null
	doorSecretName: string
	doorSecretValue: string
	config: Record<string, string>
}

type ConnectField = {
	id?: string
	label?: string
	purpose?: string
	type?: string
	value?: string | null
}

type ConnectUrl = {
	href?: string
	label?: string
	primary?: boolean
}

type ConnectItem = {
	id?: string
	title?: string
	fields?: ConnectField[]
	urls?: ConnectUrl[]
}

type ConnectVault = {
	id: string
	name?: string
}

function isItemId(segment: string): boolean {
	return UUID_RE.test(segment) || OP_ID_RE.test(segment)
}

function normalizeFieldSegment(field: string): string {
	// Lowercase so grant keys and purpose shortcuts stay stable across refs.
	return field.trim().toLowerCase()
}

function parseCanonicalIRef(
	raw: string,
): { itemId: string; field: string } | null {
	const trimmed = raw.trim()
	const m = /^i\/([^/]+)\/(.+)$/i.exec(trimmed)
	if (!m) return null
	const itemId = m[1]!
	const field = normalizeFieldSegment(m[2]!)
	if (!isItemId(itemId) || !field) return null
	return { itemId, field }
}

function parseOpRef(
	raw: string,
): { vault: string; item: string; field: string } | null {
	const trimmed = raw.trim()
	const m = /^op:\/\/([^/]+)\/([^/]+)\/(.+)$/i.exec(trimmed)
	if (!m) return null
	const vault = m[1]!.trim()
	const item = m[2]!.trim()
	const field = normalizeFieldSegment(m[3]!)
	if (!vault || !item || !field) return null
	return { vault, item, field }
}

function toCanonicalRef(itemId: string, field: string): string {
	return `i/${itemId}/${normalizeFieldSegment(field)}`
}

function connectBaseUrl(config: Record<string, string>): string {
	const raw =
		config.connectHost?.trim() ||
		config.connectUrl?.trim() ||
		config.baseUrl?.trim() ||
		''
	if (!raw) {
		throw new Error(
			'1Password Connect config missing connectHost (or connectUrl / baseUrl). Bind with non-secret config {"connectHost":"https://your-connect-host"}.',
		)
	}
	let url: URL
	try {
		url = new URL(raw.includes('://') ? raw : `https://${raw}`)
	} catch {
		throw new Error(
			`Invalid Connect host in config: expected an https URL (got a non-URL value). Rebind with {"connectHost":"https://your-connect-host"}.`,
		)
	}
	if (url.protocol !== 'https:' && url.protocol !== 'http:') {
		throw new Error(
			`Invalid Connect host protocol ${url.protocol} — use https://your-connect-host.`,
		)
	}
	return `${url.origin}${url.pathname}`.replace(/\/+$/, '')
}

function assertDoorToken(doorSecretValue: string): void {
	if (!doorSecretValue || !doorSecretValue.trim()) {
		throw new Error(
			'Door secret value is empty. Save the Connect token as the bound user secret (e.g. ONEPASSWORD_CONNECT_TOKEN) and rebind.',
		)
	}
}

async function connectFetch(
	base: string,
	path: string,
	doorSecretValue: string,
	signal: AbortSignal,
): Promise<Response> {
	const url = `${base}${path.startsWith('/') ? path : `/${path}`}`
	return fetch(url, {
		method: 'GET',
		headers: {
			Authorization: `Bearer ${doorSecretValue}`,
			Accept: 'application/json',
		},
		signal,
	})
}

async function listVaults(
	base: string,
	doorSecretValue: string,
	signal: AbortSignal,
): Promise<ConnectVault[]> {
	const res = await connectFetch(base, '/v1/vaults', doorSecretValue, signal)
	if (!res.ok) {
		throw new Error(
			`1Password Connect GET /v1/vaults failed with HTTP ${res.status}. Check the Connect token and connectHost.`,
		)
	}
	const body = (await res.json()) as unknown
	if (!Array.isArray(body)) {
		throw new Error('1Password Connect /v1/vaults returned a non-array body.')
	}
	return body as ConnectVault[]
}

async function getItemAcrossVaults(
	base: string,
	doorSecretValue: string,
	itemId: string,
	signal: AbortSignal,
): Promise<ConnectItem> {
	const vaults = await listVaults(base, doorSecretValue, signal)
	if (vaults.length === 0) {
		throw new Error(
			'1Password Connect returned no vaults for this token. Grant the Connect token access to at least one vault.',
		)
	}
	let lastStatus: number | null = null
	for (const vault of vaults) {
		if (!vault?.id) continue
		const res = await connectFetch(
			base,
			`/v1/vaults/${encodeURIComponent(vault.id)}/items/${encodeURIComponent(itemId)}`,
			doorSecretValue,
			signal,
		)
		if (res.status === 404) {
			lastStatus = 404
			continue
		}
		if (!res.ok) {
			throw new Error(
				`1Password Connect GET item failed with HTTP ${res.status} (vault ${vault.id}). Check token permissions.`,
			)
		}
		return (await res.json()) as ConnectItem
	}
	throw new Error(
		lastStatus === 404
			? `1Password item ${itemId} was not found in any vault visible to this Connect token. Confirm the item id and vault access.`
			: `1Password item ${itemId} could not be loaded from Connect.`,
	)
}

function findField(
	item: ConnectItem,
	fieldRef: string,
): ConnectField {
	const fields = item.fields ?? []
	const needle = fieldRef.trim()
	const byId = fields.find((f) => f.id === needle)
	if (byId) return byId
	const lower = needle.toLowerCase()
	const byLabel = fields.find(
		(f) => typeof f.label === 'string' && f.label.toLowerCase() === lower,
	)
	if (byLabel) return byLabel
	const purpose = PURPOSE_BY_FIELD[lower]
	if (purpose) {
		const byPurpose = fields.find(
			(f) => typeof f.purpose === 'string' && f.purpose.toUpperCase() === purpose,
		)
		if (byPurpose) return byPurpose
	}
	throw new Error(
		`1Password item field "${needle}" was not found (matched against field id, label, or purpose password/username/notes).`,
	)
}

function hostnameFromUrlish(raw: string): string | null {
	const trimmed = raw.trim()
	if (!trimmed) return null
	try {
		const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`
		const hostname = new URL(withScheme).hostname.toLowerCase()
		return hostname || null
	} catch {
		return null
	}
}

function addHost(hosts: string[], seen: Set<string>, hostname: string): void {
	if (!hostname || seen.has(hostname)) return
	seen.add(hostname)
	hosts.push(hostname)
	// Also allowlist apex when the website is www.<apex>
	if (hostname.startsWith('www.') && hostname.length > 4) {
		const apex = hostname.slice(4)
		if (!seen.has(apex)) {
			seen.add(apex)
			hosts.push(apex)
		}
	}
}

function fieldLooksLikeUrl(field: ConnectField): boolean {
	const type = (field.type ?? '').toUpperCase()
	if (type === 'URL') return true
	const label = (field.label ?? '').trim().toLowerCase()
	if (
		label === 'url' ||
		label === 'website' ||
		label === 'website url' ||
		label.endsWith(' url')
	) {
		return true
	}
	const purpose = (field.purpose ?? '').toUpperCase()
	if (purpose === 'URL' || purpose === 'WEBSITE') return true
	return false
}

/**
 * Host allowlist from Connect item.urls websites AND URL-typed/labeled fields.
 * Secure Notes often have empty item.urls but a URL field pointing at the fetch host.
 * Never log or return field values — only hostnames are collected.
 */
function hostsFromItem(item: ConnectItem): string[] {
	const hosts: string[] = []
	const seen = new Set<string>()
	for (const entry of item.urls ?? []) {
		const href = entry?.href
		if (typeof href !== 'string') continue
		const hostname = hostnameFromUrlish(href)
		if (hostname) addHost(hosts, seen, hostname)
	}
	for (const field of item.fields ?? []) {
		if (!fieldLooksLikeUrl(field)) continue
		const value = field.value
		if (typeof value !== 'string') continue
		const hostname = hostnameFromUrlish(value)
		if (hostname) addHost(hosts, seen, hostname)
	}
	return hosts
}

function resolveItemAndField(
	ref: string,
	canonicalRef: string | null,
): { itemId: string; field: string; canonical: string } {
	const fromCanonical = canonicalRef ? parseCanonicalIRef(canonicalRef) : null
	if (fromCanonical) {
		return {
			itemId: fromCanonical.itemId,
			field: fromCanonical.field,
			canonical: toCanonicalRef(fromCanonical.itemId, fromCanonical.field),
		}
	}
	const fromI = parseCanonicalIRef(ref)
	if (fromI) {
		return {
			itemId: fromI.itemId,
			field: fromI.field,
			canonical: toCanonicalRef(fromI.itemId, fromI.field),
		}
	}
	const fromOp = parseOpRef(ref)
	if (fromOp && isItemId(fromOp.item)) {
		return {
			itemId: fromOp.item,
			field: fromOp.field,
			canonical: toCanonicalRef(fromOp.item, fromOp.field),
		}
	}
	throw new Error(
		`Cannot resolve 1Password ref without an item id. Use i/<item-id>/<field> (preferred; UUID or 26-char Connect id) or op://<vault>/<item-id>/<field>. Name-based op://Vault/Item/field refs are not resolved here — copy the item id from 1Password / Connect so grants/lock work. Received ref shape is not locally canonicalizable.`,
	)
}

function canonicalizeLocal(ref: string, canonicalRef: string | null): string {
	if (canonicalRef) {
		const parsed = parseCanonicalIRef(canonicalRef)
		if (parsed) return toCanonicalRef(parsed.itemId, parsed.field)
	}
	const asI = parseCanonicalIRef(ref)
	if (asI) return toCanonicalRef(asI.itemId, asI.field)

	const asOp = parseOpRef(ref)
	if (asOp && isItemId(asOp.item)) {
		return toCanonicalRef(asOp.item, asOp.field)
	}
	if (asOp && !isItemId(asOp.item)) {
		throw new Error(
			`Name-based op:// refs cannot be locked or granted until they use an item id (UUID or 26-char Connect id). Replace the item name with its id (preferred form: i/<item-id>/${asOp.field}). Connect name lookup is not performed by this package so grant/lock stay local.`,
		)
	}
	throw new Error(
		`Unrecognized 1Password secret ref. Prefer i/<item-id>/<field> where item-id is a UUID or 26-char Connect id (e.g. i/uyg4awiieh3gzflnbrv5xv3gmy/password). Also accepted: op://<vault>/<item-id>/<field>.`,
	)
}

export default async function secretProvider(
	input: SecretProviderInvokeInput,
): Promise<
	| { canonicalRef: string }
	| { value: string; hosts: string[]; canonicalRef?: string }
> {
	const action = input?.action
	if (action !== 'canonicalize' && action !== 'resolve') {
		throw new Error(
			`Unknown secret provider action "${String(action)}". Expected "canonicalize" or "resolve".`,
		)
	}

	if (action === 'canonicalize') {
		const controller = new AbortController()
		const timer = setTimeout(() => controller.abort(), CANONICALIZE_BUDGET_MS)
		try {
			// Local-only: UUID or 26-char Connect item id required for lock/grant.
			void controller
			const canonical = canonicalizeLocal(input.ref, input.canonicalRef)
			return { canonicalRef: canonical }
		} finally {
			clearTimeout(timer)
		}
	}

	// resolve
	assertDoorToken(input.doorSecretValue)
	const base = connectBaseUrl(input.config ?? {})
	const { itemId, field, canonical } = resolveItemAndField(
		input.ref,
		input.canonicalRef,
	)

	const controller = new AbortController()
	const timer = setTimeout(() => controller.abort(), RESOLVE_BUDGET_MS)
	try {
		const item = await getItemAcrossVaults(
			base,
			input.doorSecretValue,
			itemId,
			controller.signal,
		)
		const matched = findField(item, field)
		const value = matched.value
		if (typeof value !== 'string' || value.length === 0) {
			throw new Error(
				`1Password field "${field}" on item ${itemId} has no usable value.`,
			)
		}
		const hosts = hostsFromItem(item)
		if (hosts.length === 0) {
			throw new Error(
				`1Password item ${itemId} has no usable websites/URLs. Add a Website on the item, or a URL field whose value points at the fetch host (Secure Notes often use a URL field when websites is empty). Empty hosts fail closed.`,
			)
		}
		return { value, hosts, canonicalRef: canonical }
	} catch (err) {
		if (err instanceof Error && err.name === 'AbortError') {
			throw new Error(
				'1Password Connect resolve timed out. Stay well under the 8s platform budget — check connectHost reachability and vault count.',
			)
		}
		throw err
	} finally {
		clearTimeout(timer)
	}
}