diff --git a/.bumpy/1password-use-cli-with-service-account.md b/.bumpy/1password-use-cli-with-service-account.md new file mode 100644 index 000000000..4ef02b424 --- /dev/null +++ b/.bumpy/1password-use-cli-with-service-account.md @@ -0,0 +1,5 @@ +--- +"@varlock/1password-plugin": minor +--- + +add useCliWithServiceAccount param to use op CLI instead of WASM SDK when a service account token is provided, enabling headless auth in memory-constrained environments diff --git a/.gitignore b/.gitignore index 208212aff..ab02868f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist +dist-test *.ignore ignore .DS_Store diff --git a/bun.lock b/bun.lock index 35485aff2..9cb9c3ffb 100644 --- a/bun.lock +++ b/bun.lock @@ -162,6 +162,7 @@ "@env-spec/utils": "workspace:^", "@types/node": "catalog:", "import-meta-resolve": "^4.2.0", + "outdent": "catalog:", "tsup": "catalog:", "varlock": "workspace:^", "vitest": "catalog:", diff --git a/framework-tests/bun.lock b/framework-tests/bun.lock index 02252c73f..972ec5029 100644 --- a/framework-tests/bun.lock +++ b/framework-tests/bun.lock @@ -5,7 +5,7 @@ "": { "name": "varlock-framework-tests", "devDependencies": { - "@types/node": "^22.0.0", + "@types/node": "^24.0.0", "typescript": "^5.9.3", "vitest": "^3.2.4", }, @@ -122,7 +122,7 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -208,7 +208,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], diff --git a/packages/plugins/1password/README.md b/packages/plugins/1password/README.md index cd4e1c6cb..7e9d5be21 100644 --- a/packages/plugins/1password/README.md +++ b/packages/plugins/1password/README.md @@ -63,6 +63,28 @@ OP_TOKEN= Vault access rules cannot be edited after creation. If your vault setup changes, you'll need to create a new service account. ::: +### CLI-based service account auth (memory-constrained environments) + +By default, service account tokens use the 1Password JavaScript SDK (which includes a WASM module). In memory-constrained environments (e.g., 512 MB containers), the SDK's memory footprint may be prohibitive. + +Set `useCliWithServiceAccount=true` to use the `op` CLI binary instead of the SDK while still authenticating via `OP_SERVICE_ACCOUNT_TOKEN`: + +```env-spec +# @plugin(@varlock/1password-plugin) +# @initOp(token=$OP_TOKEN, useCliWithServiceAccount=true) +# --- + +# @type=opServiceAccountToken @sensitive +OP_TOKEN= +``` + +**Requirements:** + +1. Install the `op` CLI: [Installation guide](https://developer.1password.com/docs/cli/get-started/) +2. The `OP_SERVICE_ACCOUNT_TOKEN` (i.e. `$OP_TOKEN` above) must resolve to a valid service account token at load time. + +The `op` binary is significantly lighter than the WASM SDK. `op` authentication is handled headlessly via the token — no desktop app or interactive sign-in is needed. + ### Desktop app auth (for local dev) During local development, you can use the 1Password desktop app instead of a service account: diff --git a/packages/plugins/1password/package.json b/packages/plugins/1password/package.json index d220a44d0..41f9dae2c 100644 --- a/packages/plugins/1password/package.json +++ b/packages/plugins/1password/package.json @@ -19,6 +19,7 @@ "scripts": { "dev": "bun run copy-wasm && tsup --watch", "build": "tsup && bun run copy-wasm", + "build:test": "tsup --config tsup.test.config.ts", "copy-wasm": "mkdir -p dist || true && cp node_modules/@1password/sdk-core/nodejs/core_bg.wasm ./dist", "test": "vitest", "typecheck": "tsc --noEmit" @@ -49,6 +50,7 @@ "@env-spec/utils": "workspace:^", "@types/node": "catalog:", "import-meta-resolve": "^4.2.0", + "outdent": "catalog:", "tsup": "catalog:", "varlock": "workspace:^", "vitest": "catalog:" diff --git a/packages/plugins/1password/src/cli-helper.ts b/packages/plugins/1password/src/cli-helper.ts index 0b0495351..725bdf21e 100644 --- a/packages/plugins/1password/src/cli-helper.ts +++ b/packages/plugins/1password/src/cli-helper.ts @@ -1,12 +1,9 @@ import { ExecError, spawnAsync } from '@env-spec/utils/exec-helpers'; -import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; import { plugin } from 'varlock/plugin-lib'; const { debug } = plugin; const { ResolutionError } = plugin.ERRORS; -const ENABLE_BATCHING = true; - const OP_CLI_CACHE: Record = {}; /** Proxy env vars that must be forwarded so `op` can reach 1Password through HTTP/SOCKS proxies @@ -22,7 +19,7 @@ const PROXY_ENV_KEYS = [ 'NO_PROXY', ] as const; -function pickProxyEnv(): Record { +export function pickProxyEnv(): Record { const env: Record = {}; for (const key of PROXY_ENV_KEYS) { if (process.env[key]) env[key] = process.env[key]!; @@ -30,46 +27,7 @@ function pickProxyEnv(): Record { return env; } -// for now we'll just use a single 1pass account for all requests -// but we'll likely want to support multiple accounts in the future -// note that the SDK does not currently support this - but service accounts are already limited to an account -let lockCliToOpAccount: string | undefined; - -/* - ! IMPORTANT INFO ON CLI AUTH - - Because we trigger multiple requests in parallel, if the app/cli is not unlocked, it will show multiple auth popups. - In a big project this is super awkward because you may need to scan your finger over and over again. - - To work around this, we track if we are currently making the first op cli command, and if so acquire a mutex in the form of - a deferred promise that other requests can then wait on. We also use the additional trick of checking `op whoami` so that - if the app is already unlocked, we dont have to actually wait for the first request to finish to proceed with the rest. - - Ideally 1Password will fix this issue at some point and we can remove this extra logic. - - NOTE - We don't currently do anything special to handle if the user denies the login, or is logged into the wrong account. -*/ - -// use a singleton within the module to track op cli auth state as a mutex / deferred promise -let opAuthDeferred: DeferredPromise | undefined; - -/** Called after each `op` invocation so parallel waiters can proceed; no-op when this caller only waited on the mutex. */ -type OpAuthCompletedFn = (success: boolean) => void; - -async function checkOpCliAuth(): Promise { - if (opAuthDeferred) { - // Wait for the in-flight first `op` call to finish (or an earlier batch to settle the mutex). - await opAuthDeferred.promise; - // Mutex is already resolved — still return a callable so callers can always invoke authCompletedFn(success). - return (_success: boolean) => undefined; - } - // First caller creates the mutex and must call the returned fn when its `op` run completes. - opAuthDeferred = createDeferredPromise(); - return opAuthDeferred.resolve; -} - - -export async function execOpCliCommand(cmdArgs: Array) { +export async function execOpCliCommand(cmdArgs: Array, serviceAccountToken?: string) { // very simple in-memory cache, will persist between runs in watch mode // but need to think through how a user can opt out // and interact with this cache from the web UI when we add it for the regular cache @@ -81,20 +39,22 @@ export async function execOpCliCommand(cmdArgs: Array) { const startAt = new Date(); - const authCompletedFn = await checkOpCliAuth(); try { // uses system-installed copy of `op` debug('op cli command args', cmdArgs); // strip OP_SERVICE_ACCOUNT_TOKEN from env so the CLI doesn't auto-detect it - // when the user hasn't explicitly wired it into their schema + // when the user hasn't explicitly wired it into their schema. + // When useCliWithServiceAccount is enabled the caller passes the token explicitly. const { OP_SERVICE_ACCOUNT_TOKEN: _, ...cleanEnv } = process.env; - const cliResult = await spawnAsync('op', cmdArgs, { env: cleanEnv }); - authCompletedFn?.(true); + const cliResult = await spawnAsync('op', cmdArgs, { + env: serviceAccountToken + ? { ...cleanEnv, OP_SERVICE_ACCOUNT_TOKEN: serviceAccountToken } + : cleanEnv, + }); debug(`> took ${+new Date() - +startAt}ms`); // OP_CLI_CACHE[cacheKey] = cliResult; return cliResult; } catch (err) { - authCompletedFn?.(false); // eslint-disable-next-line no-use-before-define throw processOpCliError(err); } @@ -105,7 +65,7 @@ export async function execOpCliCommand(cmdArgs: Array) { * this is all fairly brittle though because it depends on the error messages * luckily it should only _improve_ the experience, and is not critical */ -function processOpCliError(err: Error | any) { +export function processOpCliError(err: Error | any) { if (err instanceof ExecError) { let errMessage = err.data; // get rid of "[ERROR] 2024/01/23 12:34:56 " before actual message @@ -205,176 +165,17 @@ function processOpCliError(err: Error | any) { } } - -let opReadBatch: Record> }> | undefined; -const BATCH_READ_TIMEOUT = 50; - -async function executeReadBatch(batchToExecute: NonNullable) { - debug('execute op read batch', Object.keys(batchToExecute)); - const envMap = {} as Record; - let i = 1; - Object.keys(batchToExecute).forEach((opReference) => { - envMap[`VARLOCK_1P_INJECT_${i++}`] = opReference; - }); - const startAt = new Date(); - - const authCompletedFn = await checkOpCliAuth(); - // `env -0` splits values by a null character instead of newlines - // because otherwise we'll have trouble dealing with values that contain newlines - await spawnAsync('op', `run --no-masking ${lockCliToOpAccount ? `--account ${lockCliToOpAccount} ` : ''}-- env -0`.split(' '), { - env: { - // have to pass a few things through at least path so it can find `op` and related config files - PATH: process.env.PATH!, - ...process.env.USER && { USER: process.env.USER }, - ...process.env.HOME && { HOME: process.env.HOME }, - ...process.env.XDG_CONFIG_HOME && { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }, - // proxy env vars so `op` can connect through HTTP/SOCKS proxies - ...pickProxyEnv(), - // this setting actually just enables the CLI + Desktop App integration - // which in some cases op has a hard time detecting via app setting - OP_BIOMETRIC_UNLOCK_ENABLED: 'true', - ...envMap, - }, - }) - .then(async (result) => { - authCompletedFn?.(true); - debug(`batched OP request took ${+new Date() - +startAt}ms`); - - const lines = result.split('\0'); - for (const line of lines) { - const eqPos = line.indexOf('='); - const key = line.substring(0, eqPos); - - if (!envMap[key]) continue; - const val = line.substring(eqPos + 1); - const opRef = envMap[key]; - - // resolve the deferred promises with the value - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.resolve(val); - }); - } - }) - .catch(async (err) => { - authCompletedFn?.(false); - - // have to do special handling of errors because if any IDs are no good, it kills the whole request - const opErr = processOpCliError(err); - debug('batch failed', opErr); - if ((opErr as any).code === 'BAD_VAULT_REFERENCE') { - const badId = (opErr as any).extraMetadata.badVaultId; - debug('skipping failed bad vault id -', badId); - for (const opRef in batchToExecute) { - if (opRef.startsWith(`op://${badId}/`)) { - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.reject(opErr); - }); - delete batchToExecute[opRef]; - } - } - } else if ((opErr as any).code === 'BAD_ITEM_REFERENCE') { - const badId = (opErr as any).extraMetadata.badItemId; - debug('skipping failed bad item id -', badId); - for (const opRef in batchToExecute) { - const itemRef = opRef.split('/')?.[3]; - if (itemRef === badId) { - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.reject(opErr); - }); - delete batchToExecute[opRef]; - } - } - } else if ((opErr as any).code === 'BAD_FIELD_REFERENCE') { - const badId = (opErr as any).extraMetadata.badFieldId; - debug('skipping failed bad field id -', badId); - for (const opRef in batchToExecute) { - const fieldRef = opRef.split('/')?.slice(4).join('/'); - if (fieldRef === badId) { - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.reject(opErr); - }); - delete batchToExecute[opRef]; - } - } - } else { - for (const opRef in batchToExecute) { - batchToExecute[opRef].deferredPromises.forEach((p) => { - p.reject(opErr); - }); - delete batchToExecute[opRef]; - } - } - - if (Object.keys(batchToExecute).length) { - debug('re-executing remainder of batch', Object.keys(batchToExecute)); - await executeReadBatch(batchToExecute); - } - }); -} - -/** - * reads a single value from 1Password by reference (similar to `op read`) - * but internally batches requests and uses `op run` - * */ -export async function opCliRead(opReference: string, account?: string) { - lockCliToOpAccount ||= account; - if (account && lockCliToOpAccount !== account) { - throw new ResolutionError('Cannot use multiple different 1Password accounts when using CLI auth with batching enabled', { - tip: [ - 'When using CLI auth with batching, all references must use the same 1Password account', - 'Consider using service account tokens instead of CLI auth to allow multiple accounts', - ], - }); - } - - if (ENABLE_BATCHING) { - // if no batch exists, we'll create it, and this function will kick it off after a timeout - let shouldExecuteBatch = false; - if (!opReadBatch) { - opReadBatch = {}; - shouldExecuteBatch = true; - } - - // otherwise we'll just add to the existing batch - opReadBatch[opReference] ||= { - deferredPromises: [], - }; - - const deferred = createDeferredPromise(); - opReadBatch[opReference].deferredPromises.push(deferred); - - if (shouldExecuteBatch) { - setTimeout(async () => { - if (!opReadBatch) throw Error('expected to find op read batch!'); - const batchToExecute = opReadBatch; - opReadBatch = undefined; - await executeReadBatch(batchToExecute); - }, BATCH_READ_TIMEOUT); - } - return deferred.promise; - } else { - // fetch each item individually - const result = await execOpCliCommand([ - 'read', - '--force', - '--no-newline', - ...(lockCliToOpAccount ? ['--account', lockCliToOpAccount] : []), - opReference, - ]); - return result; - } -} - export async function opCliEnvironmentRead( environmentId: string, account?: string, + serviceAccountToken?: string, ): Promise { const result = await execOpCliCommand([ 'environment', 'read', environmentId, ...(account ? ['--account', account] : []), - ]); + ], serviceAccountToken); return result; } diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index 808ecc254..d71354c88 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -1,16 +1,182 @@ import { type Resolver, plugin } from 'varlock/plugin-lib'; import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; +import { spawnAsync } from '@env-spec/utils/exec-helpers'; import { Client, createClient } from '@1password/sdk'; -import { opCliRead, opCliEnvironmentRead } from './cli-helper'; +import { + opCliEnvironmentRead, processOpCliError, pickProxyEnv, +} from './cli-helper'; const { ValidationError, SchemaError, ResolutionError } = plugin.ERRORS; +const { debug } = plugin; const PLUGIN_VERSION = plugin.version; const OP_ICON = 'simple-icons:1password'; +/** Called after each `op` invocation so parallel waiters can proceed; no-op when this caller only waited on the mutex. */ +type OpAuthCompletedFn = (success: boolean) => void; + +const CLI_BATCH_READ_TIMEOUT = 50; + +/* + ! IMPORTANT INFO ON CLI APP AUTH + + Because we trigger multiple requests in parallel, if the app/cli is not unlocked, it will show multiple auth popups. + In a big project this is super awkward because you may need to scan your finger over and over again. + + To work around this, we track if we are currently making the first op cli command, and if so acquire a mutex in the form of + a deferred promise that other requests can then wait on. We also use the additional trick of checking `op whoami` so that + if the app is already unlocked, we dont have to actually wait for the first request to finish to proceed with the rest. + + Ideally 1Password will fix this issue at some point and we can remove this extra logic. + + NOTE - We don't currently do anything special to handle if the user denies the login, or is logged into the wrong account. + + IMPORTANT: This shared state is intentional for the `allowAppAuth` path. When authenticating via the local 1Password + desktop app (not a service account), all plugin instances share the same local login context, so we must use a single + shared auth mutex to prevent duplicate auth prompts. Service account instances use per-instance state instead. +*/ + +// Module-level auth mutex shared across all allowAppAuth instances (same local `op` login context). +let appAuthDeferred: DeferredPromise | undefined; +// Module-level read batch shared across all allowAppAuth instances. +let appAuthBatch: Record> }> | undefined; +// Locked account for the shared app auth path (all app-auth instances must use the same account). +let lockCliToOpAccount: string | undefined; + +async function checkAppCliAuth(): Promise { + if (appAuthDeferred) { + await appAuthDeferred.promise; + return () => undefined; + } + appAuthDeferred = createDeferredPromise(); + return appAuthDeferred.resolve; +} + +async function executeAppCliBatch(batchToExecute: NonNullable) { + debug('execute op read batch (app auth)', Object.keys(batchToExecute)); + const envMap = {} as Record; + let i = 1; + Object.keys(batchToExecute).forEach((opReference) => { + envMap[`VARLOCK_1P_INJECT_${i++}`] = opReference; + }); + const startAt = new Date(); + + const authCompletedFn = await checkAppCliAuth(); + // `env -0` splits values by a null character instead of newlines + // because otherwise we'll have trouble dealing with values that contain newlines + await spawnAsync('op', `run --no-masking ${lockCliToOpAccount ? `--account ${lockCliToOpAccount} ` : ''}-- env -0`.split(' '), { + env: { + PATH: process.env.PATH!, + ...process.env.USER && { USER: process.env.USER }, + ...process.env.HOME && { HOME: process.env.HOME }, + ...process.env.XDG_CONFIG_HOME && { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }, + ...pickProxyEnv(), + OP_BIOMETRIC_UNLOCK_ENABLED: 'true', + ...envMap, + }, + }) + .then((result) => { + authCompletedFn?.(true); + debug(`batched OP request took ${+new Date() - +startAt}ms`); + + const lines = result.split('\0'); + for (const line of lines) { + const eqPos = line.indexOf('='); + const key = line.substring(0, eqPos); + if (!envMap[key]) continue; + const val = line.substring(eqPos + 1); + const opRef = envMap[key]; + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.resolve(val); + }); + } + }) + .catch(async (err) => { + authCompletedFn?.(false); + const opErr = processOpCliError(err); + debug('batch failed', opErr); + if ((opErr as any).code === 'BAD_VAULT_REFERENCE') { + const badId = (opErr as any).extraMetadata.badVaultId; + for (const opRef in batchToExecute) { + if (opRef.startsWith(`op://${badId}/`)) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else if ((opErr as any).code === 'BAD_ITEM_REFERENCE') { + const badId = (opErr as any).extraMetadata.badItemId; + for (const opRef in batchToExecute) { + if (opRef.split('/')?.[3] === badId) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else if ((opErr as any).code === 'BAD_FIELD_REFERENCE') { + const badId = (opErr as any).extraMetadata.badFieldId; + for (const opRef in batchToExecute) { + if (opRef.split('/')?.slice(4).join('/') === badId) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else { + for (const opRef in batchToExecute) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + if (Object.keys(batchToExecute).length) { + debug('re-executing remainder of batch', Object.keys(batchToExecute)); + await executeAppCliBatch(batchToExecute); + } + }); +} + +/** + * Reads a 1Password reference via the CLI + desktop app auth path. + * Uses a module-level shared batch and auth mutex since all allowAppAuth instances + * share the same local `op` login context. + */ +async function appAuthCliRead(opReference: string, account?: string): Promise { + lockCliToOpAccount ||= account; + if (account && lockCliToOpAccount !== account) { + throw new ResolutionError('Cannot use multiple different 1Password accounts when using CLI auth with batching enabled', { + tip: [ + 'When using CLI auth with batching, all references must use the same 1Password account', + 'Consider using service account tokens instead of CLI auth to allow multiple accounts', + ], + }); + } + + let shouldExecuteBatch = false; + if (!appAuthBatch) { + appAuthBatch = {}; + shouldExecuteBatch = true; + } + appAuthBatch[opReference] ||= { deferredPromises: [] }; + const deferred = createDeferredPromise(); + appAuthBatch[opReference].deferredPromises.push(deferred); + if (shouldExecuteBatch) { + setTimeout(async () => { + if (!appAuthBatch) throw Error('Internal error: app-auth CLI batch was unexpectedly cleared before execution'); + const batchToExecute = appAuthBatch; + appAuthBatch = undefined; + await executeAppCliBatch(batchToExecute); + }, CLI_BATCH_READ_TIMEOUT); + } + return deferred.promise; +} + plugin.name = '1pass'; -const { debug } = plugin; debug('init - version =', plugin.version); plugin.icon = OP_ICON; plugin.standardVars = { @@ -91,6 +257,11 @@ class OpPluginInstance { * (will not be set to true if a token is provided) * */ private allowAppAuth?: boolean; + /** + * if true, use the `op` CLI with the service account token instead of the WASM SDK. + * Useful in memory-constrained environments where the SDK is too heavy. + * */ + private useCliWithServiceAccount?: boolean; /** URL of a 1Password Connect server */ private connectHost?: string; /** API token for authenticating with the Connect server */ @@ -98,6 +269,11 @@ class OpPluginInstance { /** If true, missing items/fields/vaults return undefined instead of throwing */ allowMissing?: boolean; + /** Per-instance auth mutex for the service-account CLI path (each token is an independent auth context). */ + private cliAuthDeferred?: DeferredPromise; + /** Per-instance CLI read batch for the service-account CLI path. */ + private cliBatch?: Record> }>; + constructor( readonly id: string, ) { @@ -110,14 +286,16 @@ class OpPluginInstance { connectHost?: string, connectToken?: string, allowMissing?: boolean, + useCliWithServiceAccount?: boolean, ) { if (token && typeof token === 'string') this.token = token; this.allowAppAuth = allowAppAuth; + this.useCliWithServiceAccount = useCliWithServiceAccount; this.account = account; if (connectHost && typeof connectHost === 'string') this.connectHost = connectHost.replace(/\/+$/, ''); if (connectToken && typeof connectToken === 'string') this.connectToken = connectToken; if (allowMissing !== undefined) this.allowMissing = allowMissing; - debug('op instance', this.id, ' set auth - ', token, allowAppAuth, account, 'connect:', !!connectHost); + debug('op instance', this.id, ' set auth - token:', !!token, 'allowAppAuth:', allowAppAuth, 'account:', account, 'connect:', !!connectHost, 'connectToken:', !!connectToken, 'useCliWithServiceAccount:', useCliWithServiceAccount); } /** Whether this instance is configured for Connect server */ @@ -138,6 +316,157 @@ class OpPluginInstance { // ── Connect REST API helpers ────────────────────────────── + // ── CLI helpers (per-instance, for service account path only) ────── + + private async checkCliAuth(): Promise { + if (this.cliAuthDeferred) { + // Wait for the in-flight first `op` call to finish (or an earlier batch to settle the mutex). + await this.cliAuthDeferred.promise; + // Mutex is already resolved — still return a callable so callers can always invoke authCompletedFn(success). + return () => undefined; + } + // First caller creates the mutex and must call the returned fn when its `op` run completes. + this.cliAuthDeferred = createDeferredPromise(); + return this.cliAuthDeferred.resolve; + } + + /** Executes the per-instance CLI read batch using `op run` with a service account token. */ + private async executeCliBatch(batchToExecute: NonNullable) { + debug('execute op read batch (service account CLI)', Object.keys(batchToExecute)); + const envMap = {} as Record; + let i = 1; + Object.keys(batchToExecute).forEach((opReference) => { + envMap[`VARLOCK_1P_INJECT_${i++}`] = opReference; + }); + const startAt = new Date(); + + const authCompletedFn = await this.checkCliAuth(); + // `env -0` splits values by a null character instead of newlines + // because otherwise we'll have trouble dealing with values that contain newlines + await spawnAsync('op', `run --no-masking ${this.account ? `--account ${this.account} ` : ''}-- env -0`.split(' '), { + env: { + // have to pass a few things through at least path so it can find `op` and related config files + PATH: process.env.PATH!, + ...process.env.USER && { USER: process.env.USER }, + ...process.env.HOME && { HOME: process.env.HOME }, + ...process.env.XDG_CONFIG_HOME && { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }, + // proxy env vars so `op` can connect through HTTP/SOCKS proxies + ...pickProxyEnv(), + // forward service account token when the instance opted into CLI-based service account auth + ...this.useCliWithServiceAccount && this.token && { OP_SERVICE_ACCOUNT_TOKEN: this.token }, + // this setting actually just enables the CLI + Desktop App integration + // which in some cases op has a hard time detecting via app setting + OP_BIOMETRIC_UNLOCK_ENABLED: 'true', + ...envMap, + }, + }) + .then(async (result) => { + authCompletedFn?.(true); + debug(`batched OP request took ${+new Date() - +startAt}ms`); + + const lines = result.split('\0'); + for (const line of lines) { + const eqPos = line.indexOf('='); + const key = line.substring(0, eqPos); + + if (!envMap[key]) continue; + const val = line.substring(eqPos + 1); + const opRef = envMap[key]; + + // resolve the deferred promises with the value + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.resolve(val); + }); + } + }) + .catch(async (err) => { + authCompletedFn?.(false); + + // have to do special handling of errors because if any IDs are no good, it kills the whole request + const opErr = processOpCliError(err); + debug('batch failed', opErr); + if ((opErr as any).code === 'BAD_VAULT_REFERENCE') { + const badId = (opErr as any).extraMetadata.badVaultId; + debug('skipping failed bad vault id -', badId); + for (const opRef in batchToExecute) { + if (opRef.startsWith(`op://${badId}/`)) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else if ((opErr as any).code === 'BAD_ITEM_REFERENCE') { + const badId = (opErr as any).extraMetadata.badItemId; + debug('skipping failed bad item id -', badId); + for (const opRef in batchToExecute) { + const itemRef = opRef.split('/')?.[3]; + if (itemRef === badId) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else if ((opErr as any).code === 'BAD_FIELD_REFERENCE') { + const badId = (opErr as any).extraMetadata.badFieldId; + debug('skipping failed bad field id -', badId); + for (const opRef in batchToExecute) { + const fieldRef = opRef.split('/')?.slice(4).join('/'); + if (fieldRef === badId) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + } else { + for (const opRef in batchToExecute) { + batchToExecute[opRef].deferredPromises.forEach((p) => { + p.reject(opErr); + }); + delete batchToExecute[opRef]; + } + } + + if (Object.keys(batchToExecute).length) { + debug('re-executing remainder of batch', Object.keys(batchToExecute)); + await this.executeCliBatch(batchToExecute); + } + }); + } + + /** + * Reads a single value from 1Password by reference (similar to `op read`) + * but internally batches requests per-instance using `op run`. + */ + private async cliRead(opReference: string): Promise { + // if no batch exists, we'll create it, and this function will kick it off after a timeout + let shouldExecuteBatch = false; + if (!this.cliBatch) { + this.cliBatch = {}; + shouldExecuteBatch = true; + } + + // otherwise we'll just add to the existing batch + this.cliBatch[opReference] ||= { deferredPromises: [] }; + + const deferred = createDeferredPromise(); + this.cliBatch[opReference].deferredPromises.push(deferred); + + if (shouldExecuteBatch) { + setTimeout(async () => { + if (!this.cliBatch) throw Error('Internal error: CLI batch was unexpectedly cleared before execution'); + const batchToExecute = this.cliBatch; + this.cliBatch = undefined; + await this.executeCliBatch(batchToExecute); + }, CLI_BATCH_READ_TIMEOUT); + } + return deferred.promise; + } + + // ── Connect REST API helpers ────────────────────────────── + private async connectRequest(path: string): Promise { const url = `${this.connectHost}/v1${path}`; debug('connect request:', url); @@ -273,6 +602,9 @@ class OpPluginInstance { async readItem(opReference: string) { if (this.isConnect) { return await this.readItemViaConnect(opReference); + } else if (this.token && this.useCliWithServiceAccount) { + // user wants to use the `op` CLI with service account token instead of the WASM SDK + return await this.cliRead(opReference); } else if (this.token) { // using JS SDK client using service account token await this.initSdkClient(); @@ -285,7 +617,7 @@ class OpPluginInstance { } // add item to batch, with deferred promise this.readBatch[opReference] ||= { defers: [] }; - const deferred = createDeferredPromise(); + const deferred = createDeferredPromise(); this.readBatch[opReference].defers.push(deferred); if (triggerBatch) { setImmediate(() => this.executeReadBatch()); @@ -294,8 +626,8 @@ class OpPluginInstance { } } else if (this.allowAppAuth) { // using op CLI to talk to 1Password desktop app - // NOTE - cli helper does its own batching, untethered to a specific op instance - return await opCliRead(opReference, this.account); + // Uses shared module-level batch+mutex since all app-auth instances share the same local `op` login context. + return await appAuthCliRead(opReference, this.account); } else { throw new SchemaError('Unable to authenticate with 1Password', { tip: `Plugin instance (${this.id}) must be provided a service account token, a Connect server, or have app auth enabled (allowAppAuth=true)`, @@ -311,6 +643,10 @@ class OpPluginInstance { 'Use a service account token or desktop app auth instead, or use op() to read individual items.', ], }); + } else if (this.token && this.useCliWithServiceAccount) { + // user wants to use the `op` CLI with service account token instead of the WASM SDK + const cliResult = await opCliEnvironmentRead(environmentId, this.account, this.token); + return parseOpEnvOutput(cliResult); } else if (this.token) { // Use SDK - supports environments since v0.4.1-beta.1 await this.initSdkClient(); @@ -343,7 +679,7 @@ class OpPluginInstance { this.readBatch = undefined; const opReferences = Object.keys(batch || {}); - debug('bulk fetching', opReferences); + debug('bulk fetching (SDK)', opReferences); if (!opReferences.length) return; try { @@ -454,10 +790,12 @@ plugin.registerRootDecorator({ tokenResolver: objArgs.token, allowAppAuthResolver: objArgs.allowAppAuth, connectTokenResolver: objArgs.connectToken, + useCliWithServiceAccountResolver: objArgs.useCliWithServiceAccount, }; }, async execute({ - id, account, connectHost, allowMissingResolver, tokenResolver, allowAppAuthResolver, connectTokenResolver, + id, account, connectHost, allowMissingResolver, tokenResolver, + allowAppAuthResolver, connectTokenResolver, useCliWithServiceAccountResolver, }) { // even if these are empty, we can't throw errors yet // in case the instance is never actually used @@ -465,6 +803,7 @@ plugin.registerRootDecorator({ const enableAppAuth = await allowAppAuthResolver?.resolve(); const connectToken = await connectTokenResolver?.resolve(); const allowMissing = await allowMissingResolver?.resolve(); + const useCliWithServiceAccount = await useCliWithServiceAccountResolver?.resolve(); pluginInstances[id].setAuth( token, !!enableAppAuth, @@ -472,6 +811,7 @@ plugin.registerRootDecorator({ connectHost, connectToken as string | undefined, allowMissing as boolean | undefined, + !!useCliWithServiceAccount, ); }, }); diff --git a/packages/plugins/1password/test/1password.test.ts b/packages/plugins/1password/test/1password.test.ts new file mode 100644 index 000000000..1ddfa150e --- /dev/null +++ b/packages/plugins/1password/test/1password.test.ts @@ -0,0 +1,560 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { + describe, test, beforeAll, afterAll, +} from 'vitest'; +import outdent from 'outdent'; +import { pluginTest } from 'varlock/test-helpers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PLUGIN_PATH = path.join(__dirname, '..'); +const FIXTURES_DIR = path.join(__dirname, 'fixtures'); + +// Fake `op` CLI script — reads canned responses from a JSON config file +// located next to the script itself (op-config.json in the same directory). +const FAKE_OP_SRC = path.join(__dirname, 'fake-op.sh'); +const FAKE_BIN_DIR = path.join(FIXTURES_DIR, 'bin'); +const FAKE_OP = path.join(FAKE_BIN_DIR, 'op'); +const OP_CONFIG_PATH = path.join(FAKE_BIN_DIR, 'op-config.json'); + +// ── SDK mock setup ─────────────────────────────────────────────────── +// The test build (dist-test/plugin.cjs) externalizes @1password/sdk so +// we can pre-populate Node's require cache with a mock before the plugin loads. + +const SDK_TEST_PLUGIN_PATH = path.join(__dirname, '..', 'dist-test'); +const SDK_TEST_PLUGIN_CJS = path.join(SDK_TEST_PLUGIN_PATH, 'plugin.cjs'); + +/** Mutable state the mock SDK reads from — updated per test */ +const sdkMockState = { + responses: {} as Record, + /** Per-ref errors (NOT_FOUND, etc.) */ + itemErrors: {} as Record, + /** If set, resolveAll throws this string (simulates SDK-level crash) */ + sdkThrow: undefined as string | undefined, + /** Map of environment ID → array of { name, value } */ + environments: {} as Record>, + /** If set, getVariables throws with this message */ + envThrow: undefined as string | undefined, +}; + +function resetSdkMockState() { + sdkMockState.responses = {}; + sdkMockState.itemErrors = {}; + sdkMockState.sdkThrow = undefined; + sdkMockState.environments = {}; + sdkMockState.envThrow = undefined; +} + +const mockSdkClient = { + secrets: { + async resolveAll(refs: Array) { + if (sdkMockState.sdkThrow) throw sdkMockState.sdkThrow; + const individualResponses: Record = {}; + for (const ref of refs) { + if (sdkMockState.itemErrors[ref]) { + individualResponses[ref] = { error: { message: sdkMockState.itemErrors[ref] } }; + } else if (ref in sdkMockState.responses) { + individualResponses[ref] = { content: { secret: sdkMockState.responses[ref] } }; + } + // refs not in responses/errors → no entry (triggers "no response returned" error) + } + return { individualResponses }; + }, + }, + environments: { + async getVariables(envId: string) { + if (sdkMockState.envThrow) throw new Error(sdkMockState.envThrow); + const vars = sdkMockState.environments[envId]; + if (!vars) throw new Error(`environment "${envId}" not found`); + return { variables: vars }; + }, + }, +}; + +const mockSdkExports = { + createClient: async () => mockSdkClient, + Client: class {}, +}; + +beforeAll(() => { + fs.mkdirSync(FAKE_BIN_DIR, { recursive: true }); + fs.copyFileSync(FAKE_OP_SRC, FAKE_OP); + fs.chmodSync(FAKE_OP, 0o755); + + // Write a minimal package.json so the env graph can resolve the test plugin + fs.writeFileSync( + path.join(SDK_TEST_PLUGIN_PATH, 'package.json'), + JSON.stringify({ exports: { './plugin': './plugin.cjs' } }), + ); + + // Pre-populate require.cache so the test build's `require('@1password/sdk')` + // returns our mock instead of loading the real WASM-based SDK. + const testRequire = createRequire(SDK_TEST_PLUGIN_CJS); + const sdkResolvedPath = testRequire.resolve('@1password/sdk'); + testRequire.cache[sdkResolvedPath] = { + id: sdkResolvedPath, + filename: sdkResolvedPath, + loaded: true, + exports: mockSdkExports, + children: [], + paths: [], + path: path.dirname(sdkResolvedPath), + } as any; +}); + +afterAll(() => { + fs.rmSync(FIXTURES_DIR, { recursive: true, force: true }); + // Clean up the test package.json + try { + fs.unlinkSync(path.join(SDK_TEST_PLUGIN_PATH, 'package.json')); + } catch { /* may not exist */ } +}); + +// ── Test helper ────────────────────────────────────────────────────── + +type OpConfig = { + /** Map of op:// references to their resolved values */ + responses?: Record; + /** + * Map of op:// references to error messages. + * When any ref in a batch matches, the entire batch fails with this message + * (matching real `op` behaviour). The plugin then retries the remaining refs. + */ + errors?: Record; + /** Map of environment ID to raw env-format output (KEY=value lines) */ + environments?: Record; +}; + +type OpTestOpts = { + opConfig?: OpConfig; + /** Items section of the schema (after `---`). Auto-wrapped with plugin/init boilerplate. */ + schema?: string; + /** Auth mode: 'serviceAccountCli' uses token + CLI, 'appAuth' uses desktop app auth */ + authMode?: 'serviceAccountCli' | 'appAuth'; + /** Extra @initOp params (e.g. `account="my-acct"`) */ + initParams?: string; + /** Full schema override (skips auto-generated boilerplate) */ + fullSchema?: string; +} & Omit[0], 'schema'>; + +/** + * Build a test case that wires up plugin/initOp boilerplate and + * creates a fake op config with the expected responses. + */ +function opTest(opts: OpTestOpts) { + const { + opConfig = {}, + schema, + authMode = 'serviceAccountCli', + initParams = '', + fullSchema: fullSchemaOverride, + ...rest + } = opts; + + return async () => { + // Write the op config for this test + fs.writeFileSync(OP_CONFIG_PATH, JSON.stringify(opConfig)); + + const origPath = process.env.PATH; + process.env.PATH = `${FAKE_BIN_DIR}:${origPath}`; + + try { + let initLine: string; + let tokenLine = ''; + + if (authMode === 'serviceAccountCli') { + initLine = `# @initOp(token=$OP_SA_TOKEN, useCliWithServiceAccount=true${initParams ? `, ${initParams}` : ''})`; + tokenLine = outdent` + # @type=string @sensitive + OP_SA_TOKEN= + `; + } else { + initLine = `# @initOp(allowAppAuth=true${initParams ? `, ${initParams}` : ''})`; + } + + const fullSchema = fullSchemaOverride ?? outdent` + # @plugin(${PLUGIN_PATH}) + ${initLine} + # --- + ${tokenLine} + ${schema} + `; + + await pluginTest({ + ...rest, + schema: fullSchema, + ...(authMode === 'serviceAccountCli' + ? { injectValues: { OP_SA_TOKEN: 'ops_fake_test_token', ...rest.injectValues } } + : {}), + })(); + } finally { + process.env.PATH = origPath; + } + }; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe('1password plugin', () => { + // ── Service account CLI path ────────────────────────────── + describe('service account CLI path (useCliWithServiceAccount)', () => { + test('resolves a single secret', opTest({ + opConfig: { + responses: { 'op://vault/item/field': 'my-secret-value' }, + }, + schema: 'SECRET=op("op://vault/item/field")', + expectValues: { SECRET: 'my-secret-value' }, + })); + + test('resolves multiple secrets in a batch', opTest({ + opConfig: { + responses: { + 'op://vault/item/username': 'admin', + 'op://vault/item/password': 's3cret', + 'op://vault/other/api-key': 'ak-12345', + }, + }, + schema: outdent` + DB_USER=op("op://vault/item/username") + DB_PASS=op("op://vault/item/password") + API_KEY=op("op://vault/other/api-key") + `, + expectValues: { DB_USER: 'admin', DB_PASS: 's3cret', API_KEY: 'ak-12345' }, + })); + + test('handles values containing special characters', opTest({ + opConfig: { + responses: { 'op://vault/item/field': 'p@ss=w0rd&foo' }, + }, + schema: 'SECRET=op("op://vault/item/field")', + expectValues: { SECRET: 'p@ss=w0rd&foo' }, + })); + + test('bad vault reference rejects that item and retries the rest', opTest({ + opConfig: { + responses: { 'op://good-vault/item/field': 'good-value' }, + errors: { 'op://bad-vault/item/field': '"bad-vault" isn\'t a vault in this account. Specify the vault' }, + }, + schema: outdent` + GOOD=op("op://good-vault/item/field") + BAD=op("op://bad-vault/item/field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + + test('bad item reference rejects that item and retries the rest', opTest({ + opConfig: { + responses: { 'op://vault/good-item/field': 'good-value' }, + errors: { 'op://vault/bad-item/field': 'could not find item bad-item in vault vault' }, + }, + schema: outdent` + GOOD=op("op://vault/good-item/field") + BAD=op("op://vault/bad-item/field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + + test('bad field reference rejects that item and retries the rest', opTest({ + opConfig: { + responses: { 'op://vault/item/good-field': 'good-value' }, + errors: { 'op://vault/item/bad-field': "item 'vault/item' does not have a field 'bad-field'" }, + }, + schema: outdent` + GOOD=op("op://vault/item/good-field") + BAD=op("op://vault/item/bad-field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + }); + + // ── App auth CLI path ───────────────────────────────────── + describe('app auth CLI path (allowAppAuth)', () => { + test('resolves a single secret', opTest({ + authMode: 'appAuth', + opConfig: { + responses: { 'op://vault/item/field': 'app-auth-secret' }, + }, + schema: 'SECRET=op("op://vault/item/field")', + expectValues: { SECRET: 'app-auth-secret' }, + })); + + test('resolves multiple secrets in a batch', opTest({ + authMode: 'appAuth', + opConfig: { + responses: { + 'op://vault/item/user': 'admin', + 'op://vault/item/pass': 'hunter2', + }, + }, + schema: outdent` + DB_USER=op("op://vault/item/user") + DB_PASS=op("op://vault/item/pass") + `, + expectValues: { DB_USER: 'admin', DB_PASS: 'hunter2' }, + })); + + test('bad vault reference rejects that item and retries the rest', opTest({ + authMode: 'appAuth', + opConfig: { + responses: { 'op://good-vault/item/field': 'good-value' }, + errors: { 'op://bad-vault/item/field': '"bad-vault" isn\'t a vault in this account. Specify the vault' }, + }, + schema: outdent` + GOOD=op("op://good-vault/item/field") + BAD=op("op://bad-vault/item/field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + }); + + // ── opLoadEnvironment ───────────────────────────────────── + describe('opLoadEnvironment', () => { + test('loads environment via service account CLI', opTest({ + opConfig: { + environments: { 'env-abc123': 'DB_HOST=localhost\nDB_PORT=5432\n' }, + }, + schema: 'ALL=opLoadEnvironment("env-abc123")', + expectValues: { ALL: JSON.stringify({ DB_HOST: 'localhost', DB_PORT: '5432' }) }, + })); + + test('loads environment via app auth', opTest({ + authMode: 'appAuth', + opConfig: { + environments: { 'env-xyz789': 'API_URL=https://api.example.com\nAPI_KEY=key123\n' }, + }, + schema: 'ALL=opLoadEnvironment("env-xyz789")', + expectValues: { ALL: JSON.stringify({ API_URL: 'https://api.example.com', API_KEY: 'key123' }) }, + })); + + test('invalid environment ID returns error', opTest({ + opConfig: {}, + schema: 'ALL=opLoadEnvironment("bad-env-id")', + expectValues: { ALL: Error }, + })); + }); + + // ── Named instances ─────────────────────────────────────── + describe('named instances', () => { + test('resolves from a named instance', opTest({ + opConfig: { + responses: { 'op://vault/item/field': 'named-value' }, + }, + fullSchema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp(id=prod, token=$OP_SA_TOKEN, useCliWithServiceAccount=true) + # --- + # @type=string @sensitive + OP_SA_TOKEN= + SECRET=op(prod, "op://vault/item/field") + `, + injectValues: { OP_SA_TOKEN: 'ops_fake_test_token' }, + expectValues: { SECRET: 'named-value' }, + })); + }); + + // ── Data types ──────────────────────────────────────────── + describe('data types', () => { + test('opServiceAccountToken validates ops_ prefix', opTest({ + opConfig: {}, + schema: outdent` + # @type=opServiceAccountToken + MY_TOKEN=ops_valid_token_12345 + `, + expectValues: { MY_TOKEN: 'ops_valid_token_12345' }, + })); + + test('opServiceAccountToken rejects invalid prefix', opTest({ + opConfig: {}, + schema: outdent` + # @type=opServiceAccountToken + MY_TOKEN=invalid_token + `, + expectValues: { MY_TOKEN: Error }, + })); + + test('opServiceAccountToken is marked sensitive', opTest({ + opConfig: {}, + schema: outdent` + # @type=opServiceAccountToken + MY_TOKEN=ops_valid_token_12345 + `, + expectSensitive: { MY_TOKEN: true }, + })); + + test('opConnectToken is marked sensitive', opTest({ + opConfig: {}, + schema: outdent` + # @type=opConnectToken + MY_TOKEN=some_connect_token + `, + expectSensitive: { MY_TOKEN: true }, + })); + }); + + // ── SDK path (service account token, no useCliWithServiceAccount) ── + describe('SDK path (service account token)', () => { + /** + * Helper for SDK-path tests. Uses the test build (dist-test/) which + * externalizes @1password/sdk so it can be mocked via require.cache. + */ + type SdkTestOpts = { + /** Mock responses: op:// ref → resolved secret value */ + mockResponses?: Record; + /** Mock per-ref errors: op:// ref → error message */ + mockItemErrors?: Record; + /** If set, resolveAll() throws this string (simulates SDK-level crash) */ + mockSdkThrow?: string; + /** Mock environments: envId → array of { name, value } */ + mockEnvironments?: Record>; + /** If set, getVariables() throws with this message */ + mockEnvThrow?: string; + } & Omit; + + function sdkTest(opts: SdkTestOpts) { + const { + mockResponses = {}, + mockItemErrors = {}, + mockSdkThrow, + mockEnvironments = {}, + mockEnvThrow, + schema, + initParams = '', + fullSchema: fullSchemaOverride, + ...rest + } = opts; + + return async () => { + // Set up mock state before plugin loads + sdkMockState.responses = mockResponses; + sdkMockState.itemErrors = mockItemErrors; + sdkMockState.sdkThrow = mockSdkThrow; + sdkMockState.environments = mockEnvironments; + sdkMockState.envThrow = mockEnvThrow; + + const initLine = `# @initOp(token=$OP_SA_TOKEN${initParams ? `, ${initParams}` : ''})`; + + const fullSchema = fullSchemaOverride ?? outdent` + # @plugin(${SDK_TEST_PLUGIN_PATH}) + ${initLine} + # --- + # @type=string @sensitive + OP_SA_TOKEN= + ${schema} + `; + + try { + await pluginTest({ + ...rest, + schema: fullSchema, + injectValues: { OP_SA_TOKEN: 'ops_fake_test_token', ...rest.injectValues }, + })(); + } finally { + resetSdkMockState(); + } + }; + } + + test('resolves a single secret', sdkTest({ + mockResponses: { 'op://vault/item/field': 'sdk-secret-value' }, + schema: 'SECRET=op("op://vault/item/field")', + expectValues: { SECRET: 'sdk-secret-value' }, + })); + + test('resolves multiple secrets in a batch', sdkTest({ + mockResponses: { + 'op://vault/item/username': 'admin', + 'op://vault/item/password': 's3cret', + }, + schema: outdent` + DB_USER=op("op://vault/item/username") + DB_PASS=op("op://vault/item/password") + `, + expectValues: { DB_USER: 'admin', DB_PASS: 's3cret' }, + })); + + test('per-ref error marks only that item as failed', sdkTest({ + mockResponses: { 'op://vault/item/field': 'good-value' }, + mockItemErrors: { 'op://vault/missing/field': 'item not found' }, + schema: outdent` + GOOD=op("op://vault/item/field") + BAD=op("op://vault/missing/field") + `, + expectValues: { GOOD: 'good-value', BAD: Error }, + })); + + test('ref with no response entry returns error', sdkTest({ + mockResponses: {}, + schema: 'MISSING=op("op://vault/no-response/field")', + expectValues: { MISSING: Error }, + })); + + test('SDK-level throw rejects all items', sdkTest({ + mockSdkThrow: 'SDK authentication failed', + schema: outdent` + A=op("op://vault/item/a") + B=op("op://vault/item/b") + `, + expectValues: { A: Error, B: Error }, + })); + + test('loads environment via SDK', sdkTest({ + mockEnvironments: { + 'env-sdk-123': [ + { name: 'API_URL', value: 'https://api.test' }, + { name: 'API_KEY', value: 'key-abc' }, + ], + }, + schema: 'ALL=opLoadEnvironment("env-sdk-123")', + expectValues: { ALL: JSON.stringify({ API_URL: 'https://api.test', API_KEY: 'key-abc' }) }, + })); + + test('invalid environment ID via SDK returns error', sdkTest({ + schema: 'ALL=opLoadEnvironment("bad-env-id")', + expectValues: { ALL: Error }, + })); + }); + + // ── Schema errors ───────────────────────────────────────── + describe('schema errors', () => { + test('missing auth config', pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp() + # --- + `, + expectSchemaError: true, + })); + + test('duplicate default instance id', pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp(allowAppAuth=true) + # @initOp(allowAppAuth=true) + # --- + `, + expectSchemaError: true, + })); + + test('duplicate named instance id', pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp(id=prod, allowAppAuth=true) + # @initOp(id=prod, allowAppAuth=true) + # --- + `, + expectSchemaError: true, + })); + + test('unused plugin with no op calls causes no errors', pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initOp(allowAppAuth=true) + # --- + UNRELATED=hello + `, + expectValues: { UNRELATED: 'hello' }, + })); + }); +}); diff --git a/packages/plugins/1password/test/fake-op.sh b/packages/plugins/1password/test/fake-op.sh new file mode 100644 index 000000000..23b919f01 --- /dev/null +++ b/packages/plugins/1password/test/fake-op.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Fake op CLI for integration tests. +# Reads config from op-config.json next to this script. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_FILE="$SCRIPT_DIR/op-config.json" + +if [ ! -f "$CONFIG_FILE" ]; then + echo "[ERROR] fake op: config file not found ($CONFIG_FILE)" >&2 + exit 1 +fi + +case "$1" in + whoami) + echo "Test User " + ;; + run) + # op run --no-masking [--account X] -- env -0 + # Reads VARLOCK_1P_INJECT_* env vars, resolves op:// references from config, + # and outputs null-separated KEY=value pairs (like `env -0`). + node -e " + const cfg = JSON.parse(require('fs').readFileSync('$CONFIG_FILE', 'utf-8')); + const responses = cfg.responses || {}; + const errors = cfg.errors || {}; + const refs = []; + + for (const [key, ref] of Object.entries(process.env)) { + if (!key.startsWith('VARLOCK_1P_INJECT_')) continue; + refs.push({ key, ref }); + } + + // Check for errors first (real op fails entire batch on first error) + for (const { ref } of refs) { + if (errors[ref]) { + process.stderr.write('[ERROR] 2024/01/01 00:00:00 ' + errors[ref] + '\n'); + process.exit(1); + } + } + + // Output resolved values as null-separated pairs + const results = []; + for (const { key, ref } of refs) { + if (ref in responses) { + results.push(key + '=' + responses[ref]); + } + } + process.stdout.write(results.join('\0') + '\0'); + " + ;; + environment) + # op environment read [--account X] + ENV_ID="$3" + node -e " + const cfg = JSON.parse(require('fs').readFileSync('$CONFIG_FILE', 'utf-8')); + const environments = cfg.environments || {}; + const envId = '$ENV_ID'; + if (envId in environments) { + process.stdout.write(environments[envId]); + } else { + process.stderr.write('[ERROR] 2024/01/01 00:00:00 environment not found or invalid\n'); + process.exit(1); + } + " + ;; + *) + echo "[ERROR] fake op: unknown subcommand $1" >&2 + exit 1 + ;; +esac diff --git a/packages/plugins/1password/tsup.test.config.ts b/packages/plugins/1password/tsup.test.config.ts new file mode 100644 index 000000000..3b6286bf0 --- /dev/null +++ b/packages/plugins/1password/tsup.test.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup'; + +/** + * Test-only build config — identical to production except `@1password/sdk` + * is external so tests can mock it via `require.cache`. + */ +export default defineConfig({ + entry: ['src/plugin.ts'], + dts: false, + sourcemap: true, + treeshake: true, + clean: false, + outDir: 'dist-test', + format: ['cjs'], + splitting: false, + target: 'esnext', + external: ['varlock', '@1password/sdk'], +}); diff --git a/packages/plugins/1password/vitest.config.ts b/packages/plugins/1password/vitest.config.ts new file mode 100644 index 000000000..e98196540 --- /dev/null +++ b/packages/plugins/1password/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + conditions: ['ts-src'], + }, + define: { + __VARLOCK_BUILD_TYPE__: JSON.stringify('test'), + __VARLOCK_SEA_BUILD__: 'false', + }, +}); diff --git a/packages/utils/src/defer.ts b/packages/utils/src/defer.ts index 4d0aac087..6385562f6 100644 --- a/packages/utils/src/defer.ts +++ b/packages/utils/src/defer.ts @@ -4,9 +4,9 @@ // should be used sparingly export function createDeferredPromise() { // set to noop, but they will be replaced immediately - let resolve: (value?: T) => void = () => {}; + let resolve: (value: T | PromiseLike) => void = () => {}; let reject: (reason?: unknown) => void = () => {}; - const promise = new Promise((_resolve, _reject) => { + const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); diff --git a/packages/varlock-website/src/content/docs/plugins/1password.mdx b/packages/varlock-website/src/content/docs/plugins/1password.mdx index 6a90613ef..164ea6323 100644 --- a/packages/varlock-website/src/content/docs/plugins/1password.mdx +++ b/packages/varlock-website/src/content/docs/plugins/1password.mdx @@ -76,6 +76,29 @@ Each vault has a toggle to disable service account access _in general_. It is on ::: +### CLI-based service account auth (memory-constrained environments) + +By default, service account tokens use the 1Password JavaScript SDK (which bundles a WASM module, ~400 MB RSS). In memory-constrained environments such as 512 MB containers or VMs, this footprint may be prohibitive. + +Set `useCliWithServiceAccount=true` to use the `op` CLI binary instead of the SDK while still authenticating headlessly via the service account token: + +```diff lang="env-spec" title=".env.schema" add="useCliWithServiceAccount=true" +# @plugin(@varlock/1password-plugin) +# @initOp(token=$OP_TOKEN, useCliWithServiceAccount=true) +# --- + +# @type=opServiceAccountToken @sensitive +OP_TOKEN= +``` + +**Requirements:** + +1. **Install the `op` CLI**: [Installation guide](https://developer.1password.com/docs/cli/get-started/) +2. The token referenced by `token=` must resolve to a valid service account token at load time. + +The `op` binary is significantly lighter than the WASM SDK. Authentication is handled headlessly via `OP_SERVICE_ACCOUNT_TOKEN` — no desktop app or interactive sign-in is needed. Multiple plugin instances configured with different service account tokens are fully isolated from one another. + + ### Desktop app auth (for local dev) During local development, you may find it convenient to skip the service account tokens and instead rely on your local 1Password desktop app (via the [CLI integration](https://developer.1password.com/docs/cli/get-started/#step-2-turn-on-the-1password-desktop-app-integration)), including using its biometric unlocking features. @@ -187,6 +210,7 @@ Initializes an instance of the 1Password plugin - setting up options and authent - `token` (optional): service account token. Should be a reference to a config item of type `opServiceAccountToken`. - `allowAppAuth` (optional): boolean flag to enable authenticating using the local desktop app - `account` (optional): limits the `op` cli to connect to specific 1Password account (shorthand, sign-in address, account ID, or user ID) +- `useCliWithServiceAccount` (optional): when `true`, uses the `op` CLI binary instead of the WASM SDK for service account auth. Useful in memory-constrained environments. Requires the `op` CLI to be installed. ```env-spec "@initOp" # @initOp(id=notProd, token=$OP_TOKEN, allowAppAuth=forEnv(dev), account=acmeco)