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 );