From 00cd9241a35fa47a19fb27dfe35815ef1610a00d Mon Sep 17 00:00:00 2001 From: Spenser Hale Date: Thu, 9 Jul 2026 19:41:44 -0700 Subject: [PATCH] feat: support VIP_CLI_TOKEN environment variable for keychain-free authentication Add a VIP_CLI_TOKEN environment variable that, when set (non-empty after trimming), authenticates VIP-CLI directly and bypasses the OS keychain entirely. This unblocks headless, SSH, and CI sessions where the keychain is unavailable, and makes scripting easier. It mirrors the existing WPVIP_DEPLOY_TOKEN precedent. The env token takes precedence over stored credentials: a malformed value throws an EnvTokenError with an actionable message naming the variable and token URL, surfaced cleanly instead of crashing. The login flow warns that the env var overrides any token you log in with, and logout skips the server-side call so it never invalidates a user-managed token, while still purging local state and reminding the user to unset the variable. --- __tests__/lib/logout.test.ts | 25 ++++++++- __tests__/lib/token.js | 98 +++++++++++++++++++++++++++++++++++- docs/SETUP.md | 1 + src/bin/vip.js | 28 +++++++++-- src/lib/logout.ts | 20 +++++++- src/lib/token.ts | 41 +++++++++++++++ 6 files changed, 206 insertions(+), 7 deletions(-) diff --git a/__tests__/lib/logout.test.ts b/__tests__/lib/logout.test.ts index 6532786b6..9b0050e87 100644 --- a/__tests__/lib/logout.test.ts +++ b/__tests__/lib/logout.test.ts @@ -17,7 +17,9 @@ jest.mock( '../../src/lib/token', () => ( { __esModule: true, default: { purge: jest.fn( () => Promise.resolve( true ) ), + isEnvTokenSet: jest.fn( () => false ), }, + ENV_TOKEN_NAME: 'VIP_CLI_TOKEN', } ) ); jest.mock( '../../src/lib/rechallenge/token-cache', () => ( { @@ -35,17 +37,22 @@ jest.mock( '../../src/lib/tracker', () => ( { } ) ); const mockHttpApiFn = jest.mocked( http ); -// eslint-disable-next-line @typescript-eslint/unbound-method +/* eslint-disable @typescript-eslint/unbound-method */ const mockTokenPurgeFn = jest.mocked( Token.purge ); +const mockIsEnvTokenSetFn = jest.mocked( Token.isEnvTokenSet ); +/* eslint-enable @typescript-eslint/unbound-method */ describe( 'logout', () => { beforeEach( () => { jest.clearAllMocks(); + mockTokenPurgeFn.mockResolvedValue( true ); + mockIsEnvTokenSetFn.mockReturnValue( false ); } ); it( 'purges primary token, clears elevated-token cache, and emits telemetry', async () => { mockHttpApiFn.mockResolvedValueOnce( { ok: true } as unknown as Response ); await logout(); + expect( mockHttpApiFn ).toHaveBeenCalledTimes( 1 ); expect( mockTokenPurgeFn ).toHaveBeenCalledTimes( 1 ); expect( tokenCache.clearAll ).toHaveBeenCalledTimes( 1 ); expect( trackEvent ).toHaveBeenCalledWith( 'logout_command_execute' ); @@ -57,4 +64,20 @@ describe( 'logout', () => { expect( mockTokenPurgeFn ).toHaveBeenCalledTimes( 1 ); expect( tokenCache.clearAll ).toHaveBeenCalledTimes( 1 ); } ); + + it( 'does not invalidate a user-managed VIP_CLI_TOKEN server-side', async () => { + const consoleLogSpy = jest.spyOn( console, 'log' ).mockImplementation( () => undefined ); + mockIsEnvTokenSetFn.mockReturnValue( true ); + try { + await logout(); + expect( mockHttpApiFn ).not.toHaveBeenCalled(); + expect( mockTokenPurgeFn ).toHaveBeenCalledTimes( 1 ); + expect( tokenCache.clearAll ).toHaveBeenCalledTimes( 1 ); + expect( consoleLogSpy ).toHaveBeenCalledWith( + expect.stringContaining( 'VIP_CLI_TOKEN is still set' ) + ); + } finally { + consoleLogSpy.mockRestore(); + } + } ); } ); diff --git a/__tests__/lib/token.js b/__tests__/lib/token.js index 3fef26c5a..c16cd2ca2 100644 --- a/__tests__/lib/token.js +++ b/__tests__/lib/token.js @@ -1,4 +1,11 @@ -import Token, { SERVICE } from '../../src/lib/token'; +import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals'; + +import * as keychain from '../../src/lib/keychain'; +import Token, { SERVICE, ENV_TOKEN_NAME, EnvTokenError } from '../../src/lib/token'; + +// A valid, non-expiring token (id: 7). +const VALID_RAW_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWQiOjcsImlhdCI6MTUxNjIzOTAyMn0.RTJMXHhhiaCxQberZ5Pre7SBU3Ci8EvCyaOXoqG3pNA'; describe( 'token tests', () => { it( 'should correctly validate token', () => { @@ -59,6 +66,95 @@ describe( 'token tests', () => { } ); } ); + describe( 'get() with VIP_CLI_TOKEN', () => { + const originalEnvToken = process.env[ ENV_TOKEN_NAME ]; + let getKeychainSpy; + + beforeEach( () => { + delete process.env[ ENV_TOKEN_NAME ]; + + // token.ts is loaded during the jest setup files (via apiConfig), so + // it has already bound the real keychain module. Spying on the shared + // module object still intercepts its calls because token.ts reads + // getKeychain at call time. A plain jest.mock() would not, since it + // only rebinds future imports. + getKeychainSpy = jest.spyOn( keychain, 'getKeychain' ).mockResolvedValue( { + getPassword: jest.fn( () => Promise.resolve( null ) ), + setPassword: jest.fn( () => Promise.resolve( true ) ), + deletePassword: jest.fn( () => Promise.resolve( true ) ), + } ); + } ); + + afterEach( () => { + getKeychainSpy.mockRestore(); + + if ( originalEnvToken === undefined ) { + delete process.env[ ENV_TOKEN_NAME ]; + } else { + process.env[ ENV_TOKEN_NAME ] = originalEnvToken; + } + } ); + + it( 'returns a token built from the env var without touching the keychain', async () => { + process.env[ ENV_TOKEN_NAME ] = VALID_RAW_TOKEN; + + const token = await Token.get(); + + expect( token.raw ).toBe( VALID_RAW_TOKEN ); + expect( token.valid() ).toBe( true ); + expect( getKeychainSpy ).not.toHaveBeenCalled(); + } ); + + it( 'trims surrounding whitespace on the env var token', async () => { + process.env[ ENV_TOKEN_NAME ] = ` ${ VALID_RAW_TOKEN } `; + + const token = await Token.get(); + + expect( token.raw ).toBe( VALID_RAW_TOKEN ); + expect( getKeychainSpy ).not.toHaveBeenCalled(); + } ); + + it( 'throws an EnvTokenError naming the variable for a malformed env token', async () => { + process.env[ ENV_TOKEN_NAME ] = 'not-a-jwt'; + + await expect( Token.get() ).rejects.toThrow( EnvTokenError ); + await expect( Token.get() ).rejects.toThrow( ENV_TOKEN_NAME ); + expect( getKeychainSpy ).not.toHaveBeenCalled(); + } ); + + it( 'falls back to the keychain when the env var is unset', async () => { + await Token.get(); + + expect( getKeychainSpy ).toHaveBeenCalled(); + } ); + + it( 'falls back to the keychain when the env var is empty', async () => { + process.env[ ENV_TOKEN_NAME ] = ''; + + await Token.get(); + + expect( getKeychainSpy ).toHaveBeenCalled(); + } ); + + it( 'falls back to the keychain when the env var is only whitespace', async () => { + process.env[ ENV_TOKEN_NAME ] = ' '; + + await Token.get(); + + expect( getKeychainSpy ).toHaveBeenCalled(); + } ); + + it( 'reports whether the env var is set via isEnvTokenSet()', () => { + expect( Token.isEnvTokenSet() ).toBe( false ); + + process.env[ ENV_TOKEN_NAME ] = ' '; + expect( Token.isEnvTokenSet() ).toBe( false ); + + process.env[ ENV_TOKEN_NAME ] = VALID_RAW_TOKEN; + expect( Token.isEnvTokenSet() ).toBe( true ); + } ); + } ); + describe( 'getServiceName()', () => { // TODO how do we test this when it comes from env var, which we've already overridden? it.todo( 'should return default service name for default API_HOST' ); diff --git a/docs/SETUP.md b/docs/SETUP.md index d1a5dafaa..3209a7139 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -107,6 +107,7 @@ TODO: Update description of the variables. - `SOCKS_PROXY`: - `HTTP_PROXY`: - `NO_PROXY`: +- `VIP_CLI_TOKEN`: A [Personal Access Token](https://dashboard.wpvip.com/me/cli/token) used to authenticate directly, bypassing OS keychain storage. Useful for SSH sessions, CI, and other non-graphical environments. Takes precedence over stored credentials. - `VIP_PROXY`: [For internal VIP use](TESTING.md#local-testing). - `VIP_RECHALLENGE_WAIT`: set to `1` to print the verification URL and wait for rechallenge completion on another device. - `VIP_USE_SYSTEM_PROXY`: diff --git a/src/bin/vip.js b/src/bin/vip.js index 713f7eaa8..1f11cdfa4 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -8,6 +8,7 @@ import { prompt } from 'enquirer'; import command, { containsAppEnvArgument } from '../lib/cli/command'; import config from '../lib/cli/config'; +import * as exit from '../lib/cli/exit'; import { loadInternalBin } from '../lib/cli/internal-bin-loader'; import { rewriteArgvForInternalBin, @@ -15,7 +16,7 @@ import { isSeaRuntime, } from '../lib/cli/sea-dispatch'; import tokenCache from '../lib/rechallenge/token-cache'; -import Token from '../lib/token'; +import Token, { EnvTokenError, ENV_TOKEN_NAME, TOKEN_URL } from '../lib/token'; import { aliasUser, trackEvent } from '../lib/tracker'; const debug = debugLib( '@automattic/vip:bin:vip' ); @@ -28,7 +29,7 @@ if ( config && config.environment !== 'production' ) { } // Config -const tokenURL = 'https://dashboard.wpvip.com/me/cli/token'; +const tokenURL = TOKEN_URL; const customDeployToken = process.env.WPVIP_DEPLOY_TOKEN; async function maybeExecuteSeaTargetCommand() { @@ -108,6 +109,16 @@ async function runLoginFlow() { ); console.log(); + if ( Token.isEnvTokenSet() ) { + console.log( + chalk.yellow( + ` Note: ${ ENV_TOKEN_NAME } is set and takes precedence over the stored credentials. ` + + `The token you log in with will not be used until you unset ${ ENV_TOKEN_NAME }.` + ) + ); + console.log(); + } + await trackEvent( 'login_command_execute' ); const answer = await prompt( { @@ -193,7 +204,18 @@ const rootCmd = async function () { } } - let token = await Token.get(); + let token; + try { + token = await Token.get(); + } catch ( err ) { + // A malformed VIP_CLI_TOKEN already carries an actionable message that + // names the env var; surface it directly instead of crashing. + if ( err instanceof EnvTokenError ) { + exit.withError( err.message ); + } + + throw err; + } const isHelpCommand = doesArgvHaveAtLeastOneParam( process.argv, [ 'help', '-h', '--help' ] ); const isVersionCommand = doesArgvHaveAtLeastOneParam( process.argv, [ '-v', '--version' ] ); diff --git a/src/lib/logout.ts b/src/lib/logout.ts index 3011fe422..eb2da4cda 100644 --- a/src/lib/logout.ts +++ b/src/lib/logout.ts @@ -1,15 +1,31 @@ +import chalk from 'chalk'; + import http from '../lib/api/http'; import tokenCache from '../lib/rechallenge/token-cache'; -import Token from '../lib/token'; +import Token, { ENV_TOKEN_NAME } from '../lib/token'; import { trackEvent } from '../lib/tracker'; export default async (): Promise< void > => { try { - await http( '/logout', { method: 'post' } ); + // VIP_CLI_TOKEN is user-managed: logout must not invalidate it server-side. + if ( ! Token.isEnvTokenSet() ) { + await http( '/logout', { method: 'post' } ); + } } finally { await Token.purge(); await tokenCache.clearAll(); } + // Purging the keychain does not clear the env var, so the CLI would remain + // authenticated via VIP_CLI_TOKEN. Tell the user how to fully log out. + if ( Token.isEnvTokenSet() ) { + console.log( + chalk.yellow( + `Note: ${ ENV_TOKEN_NAME } is still set in your environment and continues to authenticate ` + + `VIP-CLI. Unset ${ ENV_TOKEN_NAME } to fully log out.` + ) + ); + } + await trackEvent( 'logout_command_execute' ); }; diff --git a/src/lib/token.ts b/src/lib/token.ts index 512c1239e..b969d3b6c 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -1,9 +1,12 @@ +import debugLib from 'debug'; import { jwtDecode } from 'jwt-decode'; import { randomUUID } from 'node:crypto'; import { API_HOST, PRODUCTION_API_HOST } from './api/constants'; import { getKeychain } from './keychain'; +const debug = debugLib( '@automattic/vip:token' ); + interface Payload { id?: number; iat?: number; @@ -12,6 +15,21 @@ interface Payload { // Config export const SERVICE = 'vip-go-cli'; + +// Environment variable that supplies the auth token directly, bypassing the +// OS keychain. Precedent: gh's GH_TOKEN and this repo's WPVIP_DEPLOY_TOKEN. +export const ENV_TOKEN_NAME = 'VIP_CLI_TOKEN'; + +export const TOKEN_URL = 'https://dashboard.wpvip.com/me/cli/token'; + +/** + * Thrown when the token supplied via the VIP_CLI_TOKEN environment variable + * cannot be decoded. Distinct from a keychain read failure so callers can + * surface an actionable message that names the env var rather than the + * keychain. + */ +export class EnvTokenError extends Error {} + export default class Token { private readonly _raw?: string; private readonly _id?: number; @@ -103,7 +121,30 @@ export default class Token { return keychain.setPassword( service, token ); } + /** + * Returns true when a non-empty VIP_CLI_TOKEN is set in the environment. + * When set, it takes precedence over any keychain-stored token. + */ + public static isEnvTokenSet(): boolean { + return Boolean( process.env[ ENV_TOKEN_NAME ]?.trim() ); + } + public static async get(): Promise< Token > { + const envToken = process.env[ ENV_TOKEN_NAME ]; + if ( envToken?.trim() ) { + // The env var supplies the token directly; never touch the keychain. + try { + return new Token( envToken ); + } catch ( err ) { + debug( 'Failed to decode token from %s: %o', ENV_TOKEN_NAME, err ); + throw new EnvTokenError( + `The token in the ${ ENV_TOKEN_NAME } environment variable is malformed. ` + + `Provide a valid Personal Access Token from ${ TOKEN_URL }, ` + + `or unset ${ ENV_TOKEN_NAME } to use the token stored in the keychain.` + ); + } + } + const service = Token.getServiceName(); const keychain = await getKeychain(); const token = await keychain.getPassword( service );