diff --git a/.changeset/brave-doors-connect.md b/.changeset/brave-doors-connect.md new file mode 100644 index 00000000..0cc0e994 --- /dev/null +++ b/.changeset/brave-doors-connect.md @@ -0,0 +1,13 @@ +--- +"@varlock/1password-plugin": patch +--- + +Add support for 1Password Connect server (self-hosted) + +- New auth mode: `connectHost` + `connectToken` parameters in `@initOp()` for connecting to self-hosted 1Password Connect servers +- Direct REST API integration — no `op` CLI or 1Password SDK required for Connect server usage +- New `opConnectToken` data type for Connect server API tokens +- Parses standard `op://vault/item/[section/]field` references and resolves them via the Connect API +- Caches vault and item ID lookups within a session for efficiency +- Clear error when `opLoadEnvironment()` is used with Connect (not supported by the Connect API) +- Updated error messages and tips to include Connect server as an auth option diff --git a/packages/plugins/1password/README.md b/packages/plugins/1password/README.md index 08007a3c..a7226062 100644 --- a/packages/plugins/1password/README.md +++ b/packages/plugins/1password/README.md @@ -8,6 +8,7 @@ This package is a [Varlock](https://varlock.dev) [plugin](https://varlock.dev/gu - **Service account authentication** for CI/CD and production environments - **Desktop app authentication** for local development (with biometric unlock support) +- **Connect server authentication** for self-hosted 1Password infrastructure - **Secret references** using 1Password's standard `op://` format - **Bulk-load environments** with `opLoadEnvironment()` via `@setValuesBulk` - **Multiple vault support** for different environments and access levels @@ -89,6 +90,31 @@ When enabled, if the service account token is empty, the plugin will use the des Keep in mind that this method connects as _YOU_ who likely has more access than a tightly scoped service account. Consider only enabling this for non-production secrets. ::: +### Connect server setup (self-hosted) + +If you are running a self-hosted [1Password Connect server](https://developer.1password.com/docs/connect/), you can authenticate using a Connect server URL and token: + +```env-spec +# @plugin(@varlock/1password-plugin) +# @initOp(connectHost="http://connect-server:8080", connectToken=$OP_CONNECT_TOKEN) +# --- + +# @type=opConnectToken @sensitive +OP_CONNECT_TOKEN= +``` + +**Setup requirements:** + +1. Deploy a [1Password Connect server](https://developer.1password.com/docs/connect/get-started/) +2. Create a Connect token with access to the required vault(s) +3. Set the `OP_CONNECT_TOKEN` environment variable + +This method uses the Connect server REST API directly — no `op` CLI or 1Password SDK is required. + +:::note +The `opLoadEnvironment()` function is not supported with Connect server auth. Use `op()` to read individual items instead. +::: + ### Multiple instances If you need to connect to multiple accounts or vault configurations, register multiple named instances: @@ -173,6 +199,8 @@ Initialize a 1Password plugin instance - setting up options and authentication. - `token?: string` - Service account token. Should be a reference to a config item of type `opServiceAccountToken`. - `allowAppAuth?: boolean` - Enable authenticating using the local desktop app (defaults to `false`) - `account?: string` - Limits the `op` CLI to connect to specific 1Password account (shorthand, sign-in address, account ID, or user ID) +- `connectHost?: string` - URL of a self-hosted 1Password Connect server (e.g., `http://connect-server:8080`) +- `connectToken?: string` - API token for the Connect server. Should be a reference to a config item of type `opConnectToken`. Required when `connectHost` is set. - `id?: string` - Instance identifier for multiple instances (defaults to `_default`) ### Functions @@ -203,6 +231,7 @@ Load all variables from a 1Password environment. Intended for use with `@setValu ### Data Types - `opServiceAccountToken` - 1Password service account token (sensitive, validated format) +- `opConnectToken` - API token for a self-hosted 1Password Connect server (sensitive) --- @@ -262,6 +291,12 @@ Note that [rate limits](https://developer.1password.com/docs/service-accounts/ra - Check that you specified the correct account (run `op account list`) - Try running `op whoami` to debug CLI connection +### Connect server errors +- Verify the Connect server URL is correct and the server is running +- Check that the Connect token has access to the required vault(s) +- Ensure the Connect server version supports the vaults and items you're accessing +- Try accessing `/v1/vaults` directly to verify connectivity + ### Rate limiting - Check your account type's rate limits - Consider implementing caching or reducing request frequency @@ -271,6 +306,7 @@ Note that [rate limits](https://developer.1password.com/docs/service-accounts/ra - [1Password](https://1password.com/) - [Service Accounts](https://developer.1password.com/docs/service-accounts/) +- [1Password Connect](https://developer.1password.com/docs/connect/) - [1Password CLI](https://developer.1password.com/docs/cli/) - [Secret References](https://developer.1password.com/docs/cli/secret-references/) - [Full documentation](https://varlock.dev/plugins/1password/) diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index d8d85b43..7e57edbe 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -16,10 +16,58 @@ plugin.icon = OP_ICON; plugin.standardVars = { initDecorator: '@initOp', params: { - token: { key: 'OP_SERVICE_ACCOUNT_TOKEN' }, + token: { key: ['OP_SERVICE_ACCOUNT_TOKEN', 'OP_CONNECT_TOKEN'] }, }, }; +// ────────────────────────────────────────────────────────────── +// 1Password Connect Server helpers (direct REST API via fetch) +// ────────────────────────────────────────────────────────────── + +interface ConnectField { + id: string; + label?: string; + value?: string; + type?: string; + purpose?: string; + section?: { id: string }; +} + +interface ConnectSection { + id: string; + label?: string; +} + +interface ConnectItem { + id: string; + title?: string; + fields?: Array; + sections?: Array; +} + +interface ConnectVault { + id: string; + name?: string; +} + +/** Parse an `op://vault/item/[section/]field` reference into its parts */ +function parseOpReference(ref: string): { + vault: string; item: string; section?: string; field: string; +} { + const stripped = ref.replace(/^op:\/\//, ''); + const parts = stripped.split('/'); + if (parts.length === 3) { + return { vault: parts[0], item: parts[1], field: parts[2] }; + } else if (parts.length === 4) { + return { + vault: parts[0], item: parts[1], section: parts[2], field: parts[3], + }; + } + throw new ResolutionError(`Invalid op:// reference format: "${ref}"`, { + tip: 'Expected format: op://vault/item/field or op://vault/item/section/field', + }); +} + /** Parse env format output from `op environment read` into a flat {name: value} JSON string */ function parseOpEnvOutput(raw: string): string { const result: Record = {}; @@ -43,19 +91,34 @@ class OpPluginInstance { * (will not be set to true if a token is provided) * */ private allowAppAuth?: boolean; + /** URL of a 1Password Connect server */ + private connectHost?: string; + /** API token for authenticating with the Connect server */ + private connectToken?: string; constructor( readonly id: string, ) { } - setAuth(token: any, allowAppAuth: boolean, account?: string) { + setAuth( + token: any, + allowAppAuth: boolean, + account?: string, + connectHost?: string, + connectToken?: string, + ) { if (token && typeof token === 'string') this.token = token; this.allowAppAuth = allowAppAuth; this.account = account; - debug('op instance', this.id, ' set auth - ', token, allowAppAuth, account); + if (connectHost && typeof connectHost === 'string') this.connectHost = connectHost.replace(/\/+$/, ''); + if (connectToken && typeof connectToken === 'string') this.connectToken = connectToken; + debug('op instance', this.id, ' set auth - ', token, allowAppAuth, account, 'connect:', !!connectHost); } + /** Whether this instance is configured for Connect server */ + get isConnect() { return !!(this.connectHost && this.connectToken); } + opClientPromise: Promise | undefined; async initSdkClient() { if (!this.token) return; @@ -69,10 +132,142 @@ class OpPluginInstance { }); } + // ── Connect REST API helpers ────────────────────────────── + + private async connectRequest(path: string): Promise { + const url = `${this.connectHost}/v1${path}`; + debug('connect request:', url); + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${this.connectToken}`, + 'Content-Type': 'application/json', + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new ResolutionError(`1Password Connect API error (${res.status}): ${body || res.statusText}`, { + tip: [ + `Request: GET ${path}`, + 'Verify your Connect server URL and token are correct', + 'Check that the Connect server is running and reachable', + ], + }); + } + return res.json() as Promise; + } + + /** Cache vault name → ID lookups within a session (Connect only) */ + private connectVaultIdCache = new Map(); + + private async connectResolveVaultId(vaultQuery: string): Promise { + if (this.connectVaultIdCache.has(vaultQuery)) return this.connectVaultIdCache.get(vaultQuery)!; + + // Try direct ID lookup first + try { + const vault = await this.connectRequest(`/vaults/${encodeURIComponent(vaultQuery)}`); + this.connectVaultIdCache.set(vaultQuery, vault.id); + return vault.id; + } catch { + // fall through to title search + } + + // Search by title (escape backslashes then quotes for the filter expression) + const escapedVault = vaultQuery.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const vaults = await this.connectRequest>( + `/vaults?filter=${encodeURIComponent(`name eq "${escapedVault}"`)}`, + ); + if (!vaults.length) { + throw new ResolutionError(`1Password Connect: vault "${vaultQuery}" not found`, { + tip: 'Check the vault name or ID in your op:// reference', + }); + } + this.connectVaultIdCache.set(vaultQuery, vaults[0].id); + return vaults[0].id; + } + + /** Cache item title → ID lookups within a session (Connect only) */ + private connectItemIdCache = new Map(); + + private async connectResolveItemId(vaultId: string, itemQuery: string): Promise { + const cacheKey = `${vaultId}/${itemQuery}`; + if (this.connectItemIdCache.has(cacheKey)) return this.connectItemIdCache.get(cacheKey)!; + + // Try direct ID lookup first + try { + const item = await this.connectRequest(`/vaults/${vaultId}/items/${encodeURIComponent(itemQuery)}`); + this.connectItemIdCache.set(cacheKey, item.id); + return item.id; + } catch { + // fall through to title search + } + + // Search by title (escape backslashes then quotes for the filter expression) + const escapedItem = itemQuery.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const items = await this.connectRequest>( + `/vaults/${vaultId}/items?filter=${encodeURIComponent(`title eq "${escapedItem}"`)}`, + ); + if (!items.length) { + throw new ResolutionError(`1Password Connect: item "${itemQuery}" not found in vault`, { + tip: 'Check the item name or ID in your op:// reference', + }); + } + this.connectItemIdCache.set(cacheKey, items[0].id); + return items[0].id; + } + + private connectExtractField(item: ConnectItem, sectionQuery: string | undefined, fieldQuery: string): string { + const fields = item.fields || []; + const sections = item.sections || []; + + let sectionId: string | undefined; + if (sectionQuery) { + const section = sections.find( + (s) => s.id === sectionQuery || s.label?.toLowerCase() === sectionQuery.toLowerCase(), + ); + if (!section) { + throw new ResolutionError( + `1Password Connect: section "${sectionQuery}" not found in item "${item.title || item.id}"`, + { tip: `Available sections: ${sections.map((s) => s.label || s.id).join(', ') || '(none)'}` }, + ); + } + sectionId = section.id; + } + + const candidates = sectionId + ? fields.filter((f) => f.section?.id === sectionId) + : fields; + + const field = candidates.find( + (f) => f.id === fieldQuery + || f.label?.toLowerCase() === fieldQuery.toLowerCase(), + ); + + if (!field) { + throw new ResolutionError( + `1Password Connect: field "${fieldQuery}" not found in item "${item.title || item.id}"`, + { tip: `Available fields: ${candidates.map((f) => f.label || f.id).join(', ') || '(none)'}` }, + ); + } + + return field.value ?? ''; + } + + private async readItemViaConnect(opReference: string): Promise { + const parsed = parseOpReference(opReference); + const vaultId = await this.connectResolveVaultId(parsed.vault); + const itemId = await this.connectResolveItemId(vaultId, parsed.item); + const fullItem = await this.connectRequest(`/vaults/${vaultId}/items/${itemId}`); + return this.connectExtractField(fullItem, parsed.section, parsed.field); + } + + // ── Core read methods ───────────────────────────────────── + readBatch?: Record> }> | undefined; async readItem(opReference: string) { - if (this.token) { + if (this.isConnect) { + return await this.readItemViaConnect(opReference); + } else if (this.token) { // using JS SDK client using service account token await this.initSdkClient(); if (this.opClientPromise) { @@ -97,13 +292,20 @@ class OpPluginInstance { return await opCliRead(opReference, this.account); } else { throw new SchemaError('Unable to authenticate with 1Password', { - tip: `Plugin instance (${this.id}) must be provided either a service account token or have app auth enabled (allowAppAuth=true)`, + tip: `Plugin instance (${this.id}) must be provided a service account token, a Connect server, or have app auth enabled (allowAppAuth=true)`, }); } } async readEnvironment(environmentId: string): Promise { - if (this.token) { + if (this.isConnect) { + throw new ResolutionError('1Password Environments are not supported with Connect server', { + tip: [ + 'The 1Password Connect server API does not support the Environments feature.', + 'Use a service account token or desktop app auth instead, or use op() to read individual items.', + ], + }); + } else if (this.token) { // Use SDK - supports environments since v0.4.1-beta.1 await this.initSdkClient(); const opClient = await this.opClientPromise; @@ -122,7 +324,7 @@ class OpPluginInstance { return parseOpEnvOutput(cliResult); } else { throw new SchemaError('Unable to authenticate with 1Password', { - tip: `Plugin instance (${this.id}) must be provided either a service account token or have app auth enabled (allowAppAuth=true)`, + tip: `Plugin instance (${this.id}) must be provided a service account token, a Connect server, or have app auth enabled (allowAppAuth=true)`, }); } } @@ -197,33 +399,56 @@ plugin.registerRootDecorator({ } const account = objArgs?.account ? String(objArgs?.account?.staticValue) : undefined; - // user should either be setting token, allowAppAuth, or both - // we will check again later with resovled values - if (!objArgs.token && !objArgs.allowAppAuth) { - throw new SchemaError('Either token or allowAppAuth must be set', { + // connectHost must be static (it's a server URL) + if (objArgs.connectHost && !objArgs.connectHost.isStatic) { + throw new SchemaError('Expected connectHost to be a static value'); + } + const connectHost = objArgs?.connectHost ? String(objArgs?.connectHost?.staticValue) : undefined; + + // user should set one of: token, allowAppAuth, or connectHost+connectToken + // we will check again later with resolved values + if (!objArgs.token && !objArgs.allowAppAuth && !(connectHost && objArgs.connectToken)) { + throw new SchemaError('Either token, allowAppAuth, or connectHost+connectToken must be set', { tip: [ 'Options:', ' 1. Use a service account token: @initOp(token=$OP_SERVICE_ACCOUNT_TOKEN)', ' 2. Use 1Password desktop app auth: @initOp(allowAppAuth=true)', + ' 3. Use a Connect server: @initOp(connectHost="http://connect:8080", connectToken=$OP_CONNECT_TOKEN)', ].join('\n'), }); } + // if connectHost is set, connectToken is required + if (connectHost && !objArgs.connectToken) { + throw new SchemaError('connectToken is required when connectHost is set', { + tip: 'Add connectToken=$OP_CONNECT_TOKEN to your @initOp() call', + }); + } + return { id, account, + connectHost, tokenResolver: objArgs.token, allowAppAuthResolver: objArgs.allowAppAuth, + connectTokenResolver: objArgs.connectToken, }; }, async execute({ - id, account, tokenResolver, allowAppAuthResolver, + id, account, connectHost, tokenResolver, allowAppAuthResolver, connectTokenResolver, }) { // even if these are empty, we can't throw errors yet // in case the instance is never actually used const token = await tokenResolver?.resolve(); const enableAppAuth = await allowAppAuthResolver?.resolve(); - pluginInstances[id].setAuth(token, !!enableAppAuth, account); + const connectToken = await connectTokenResolver?.resolve(); + pluginInstances[id].setAuth( + token, + !!enableAppAuth, + account, + connectHost, + connectToken as string | undefined, + ); }, }); @@ -247,6 +472,19 @@ plugin.registerDataType({ }, }); +plugin.registerDataType({ + name: 'opConnectToken', + sensitive: true, + typeDescription: 'API token used to authenticate with a self-hosted [1Password Connect server](https://developer.1password.com/docs/connect/)', + icon: OP_ICON, + docs: [ + { + description: '1Password Connect', + url: 'https://developer.1password.com/docs/connect/', + }, + ], +}); + plugin.registerResolverFunction({ name: 'op', label: 'Fetch single field value from 1Password',