← 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)
}
}