Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion __tests__/lib/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ( {
Expand All @@ -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' );
Expand All @@ -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();
}
} );
} );
98 changes: 97 additions & 1 deletion __tests__/lib/token.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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' );
Expand Down
1 change: 1 addition & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
28 changes: 25 additions & 3 deletions src/bin/vip.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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,
resolveInternalBinFromArgv,
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' );
Expand All @@ -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() {
Expand Down Expand Up @@ -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( {
Expand Down Expand Up @@ -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' ] );
Expand Down
20 changes: 18 additions & 2 deletions src/lib/logout.ts
Original file line number Diff line number Diff line change
@@ -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' );
};
41 changes: 41 additions & 0 deletions src/lib/token.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 );
Expand Down
Loading