Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
96 changes: 96 additions & 0 deletions cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { password } from '@inquirer/prompts';
import { __, sprintf } from '@wordpress/i18n';
import { getAuthenticationUrl } from 'common/lib/oauth';
import { AuthCommandLoggerAction as LoggerAction } from 'common/logger-actions';
import { getUserInfo } from 'cli/lib/api';
import {
getAuthToken,
lockAppdata,
readAppdata,
saveAppdata,
unlockAppdata,
} from 'cli/lib/appdata';
import { openBrowser } from 'cli/lib/browser';
import { getAppLocale } from 'cli/lib/i18n';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

const CLI_REDIRECT_URI = `https://developer.wordpress.com/copy-oauth-token`;

export async function runCommand(): Promise< void > {
const logger = new Logger< LoggerAction >();

try {
await getAuthToken();
logger.reportSuccess( __( 'Already authenticated with WordPress.com' ) );
return;
} catch ( error ) {
// Assume the token is invalid and proceed with authentication
}

logger.reportStart( LoggerAction.LOGIN, __( 'Opening browser for authentication…' ) );

const appLocale = await getAppLocale();
const authUrl = getAuthenticationUrl( appLocale, CLI_REDIRECT_URI );

try {
await openBrowser( authUrl );
logger.reportSuccess( __( 'Browser opened successfully' ) );
} catch ( error ) {
// If the browser fails to open, allow users to manually open the URL
const loggerError = new LoggerError(
sprintf( __( 'Failed to open browser. Please open the URL manually: %s' ), authUrl ),
error
);
logger.reportError( loggerError );
}

console.log(
__( 'Please complete authentication in your browser and paste the generated token here.' )
);
console.log( '' );

try {
const accessToken = await password( { message: __( 'Authentication token:' ) } );
const user = await getUserInfo( accessToken );

logger.reportSuccess( __( 'Authentication completed successfully!' ) );

try {
await lockAppdata();
const userData = await readAppdata();

const now = new Date();
const twoWeeksInSeconds = 2 * 7 * 24 * 60 * 60;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just saw that the expiresIn in appData is 1209600 seconds or two weeks, but I always thought the token was valid for months. 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is governed by the back-end. We could look into this further if we think two weeks is too short


userData.authToken = {
accessToken,
id: user.ID,
email: user.email,
displayName: user.display_name,
expiresIn: twoWeeksInSeconds,
expirationTime: now.getTime() + twoWeeksInSeconds * 1000,
};

await saveAppdata( userData );
} finally {
await unlockAppdata();
}
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
logger.reportError( new LoggerError( __( 'Authentication failed' ), error ) );
}
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'login',
describe: __( 'Log in to WordPress.com' ),
handler: async () => {
await runCommand();
},
} );
};
185 changes: 185 additions & 0 deletions cli/commands/auth/tests/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { password } from '@inquirer/prompts';
import { getAuthenticationUrl } from 'common/lib/oauth';
import { getUserInfo } from 'cli/lib/api';
import {
getAuthToken,
lockAppdata,
readAppdata,
saveAppdata,
unlockAppdata,
} from 'cli/lib/appdata';
import { openBrowser } from 'cli/lib/browser';
import { getAppLocale } from 'cli/lib/i18n';
import { Logger, LoggerError } from 'cli/logger';

jest.mock( '@inquirer/prompts' );
jest.mock( 'common/lib/oauth' );
jest.mock( 'cli/lib/api' );
jest.mock( 'cli/lib/appdata', () => ( {
...jest.requireActual( 'cli/lib/appdata' ),
lockAppdata: jest.fn(),
readAppdata: jest.fn(),
saveAppdata: jest.fn(),
unlockAppdata: jest.fn(),
getAuthToken: jest.fn(),
} ) );
jest.mock( 'cli/lib/browser' );
jest.mock( 'cli/lib/i18n' );
jest.mock( 'cli/logger' );

describe( 'Auth Login Command', () => {
const mockAccessToken = 'mock-access-token-12345';
const mockAuthUrl = 'https://public-api.wordpress.com/oauth2/authorize?client_id=123';
const mockUserData = {
ID: 12345,
email: '[email protected]',
display_name: 'Test User',
};
const mockAppdata = {
authToken: {
accessToken: 'existing-token',
id: 999,
email: '[email protected]',
displayName: 'Existing User',
expiresIn: 1209600,
expirationTime: Date.now() + 1209600000,
},
};

let mockLogger: {
reportStart: jest.Mock;
reportSuccess: jest.Mock;
reportError: jest.Mock;
reportKeyValuePair: jest.Mock;
};

beforeEach( () => {
jest.clearAllMocks();

mockLogger = {
reportStart: jest.fn(),
reportSuccess: jest.fn(),
reportError: jest.fn(),
reportKeyValuePair: jest.fn(),
};

( Logger as jest.Mock ).mockReturnValue( mockLogger );
( getAuthenticationUrl as jest.Mock ).mockReturnValue( mockAuthUrl );
( getAppLocale as jest.Mock ).mockResolvedValue( 'en' );
( getUserInfo as jest.Mock ).mockResolvedValue( mockUserData );
( openBrowser as jest.Mock ).mockResolvedValue( undefined );
( password as jest.Mock ).mockResolvedValue( mockAccessToken );
( readAppdata as jest.Mock ).mockResolvedValue( mockAppdata );
( getAuthToken as jest.Mock ).mockRejectedValue( new Error( 'Mock error' ) );
( lockAppdata as jest.Mock ).mockResolvedValue( undefined );
( unlockAppdata as jest.Mock ).mockResolvedValue( undefined );
( saveAppdata as jest.Mock ).mockResolvedValue( undefined );
} );

afterEach( () => {
jest.restoreAllMocks();
} );

it( 'should skip login if already authenticated', async () => {
( getAuthToken as jest.Mock ).mockResolvedValue( mockAppdata.authToken );

const { runCommand } = await import( '../login' );
await runCommand();

expect( openBrowser ).not.toHaveBeenCalled();
expect( password ).not.toHaveBeenCalled();
} );

it( 'should complete the login process successfully', async () => {
const { runCommand } = await import( '../login' );
await runCommand();

expect( getAuthenticationUrl ).toHaveBeenCalledWith(
'en',
'https://developer.wordpress.com/copy-oauth-token'
);
expect( openBrowser ).toHaveBeenCalledWith( mockAuthUrl );
expect( password ).toHaveBeenCalledWith( {
message: 'Authentication token:',
} );
expect( getUserInfo ).toHaveBeenCalledWith( mockAccessToken );
expect( lockAppdata ).toHaveBeenCalled();
expect( saveAppdata ).toHaveBeenCalledWith( {
authToken: {
accessToken: mockAccessToken,
id: mockUserData.ID,
email: mockUserData.email,
displayName: mockUserData.display_name,
expiresIn: expect.any( Number ),
expirationTime: expect.any( Number ),
},
} );
expect( unlockAppdata ).toHaveBeenCalled();
} );

it( 'should proceed with login if existing token is invalid', async () => {
const { runCommand } = await import( '../login' );
await runCommand();

expect( openBrowser ).toHaveBeenCalled();
expect( password ).toHaveBeenCalled();
} );

it( 'should handle browser open failure', async () => {
const browserError = new LoggerError( 'Failed to open browser' );
( openBrowser as jest.Mock ).mockRejectedValue( browserError );

const { runCommand } = await import( '../login' );
await runCommand();

expect( password ).toHaveBeenCalled();
} );

it( 'should handle API error when fetching user info', async () => {
const apiError = new LoggerError( 'Failed to fetch user info' );
( getUserInfo as jest.Mock ).mockRejectedValue( apiError );

const { runCommand } = await import( '../login' );
await runCommand();

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
expect( getUserInfo ).toHaveBeenCalled();
} );

it( 'should unlock appdata even if save fails', async () => {
const saveError = new Error( 'Failed to save' );
( saveAppdata as jest.Mock ).mockRejectedValue( saveError );

const { runCommand } = await import( '../login' );
await runCommand();

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
expect( lockAppdata ).toHaveBeenCalled();
expect( unlockAppdata ).toHaveBeenCalled();
} );

it( 'should handle lock appdata failure', async () => {
const lockError = new Error( 'Failed to lock' );
( lockAppdata as jest.Mock ).mockRejectedValue( lockError );

const { runCommand } = await import( '../login' );
await runCommand();

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
} );

it( 'should use provided locale', async () => {
( getAppLocale as jest.Mock ).mockResolvedValue( 'fr' );

const { runCommand } = await import( '../login' );
await runCommand();

expect( getAuthenticationUrl ).toHaveBeenCalledWith(
'fr',
'https://developer.wordpress.com/copy-oauth-token'
);
} );
} );
9 changes: 7 additions & 2 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { __ } from '@wordpress/i18n';
import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning';
import { StatsGroup, StatsMetric } from 'common/types/stats';
import yargs from 'yargs';
import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login';
import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create';
import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete';
import { registerCommand as registerListCommand } from 'cli/commands/preview/list';
Expand All @@ -18,12 +19,12 @@ import { StudioArgv } from 'cli/types';
suppressPunycodeWarning();

async function main() {
const locale = await loadTranslations();
const yargsLocale = await loadTranslations();

const studioArgv: StudioArgv = yargs( process.argv.slice( 2 ) )
.scriptName( 'studio' )
.usage( __( 'WordPress Studio CLI' ) )
.locale( locale )
.locale( yargsLocale )
.version( version )
.option( 'avoid-telemetry', {
type: 'boolean',
Expand All @@ -45,6 +46,10 @@ async function main() {
);
}
} )
.command( 'auth', __( 'Manage authentication' ), ( authYargs ) => {
registerAuthLoginCommand( authYargs );
authYargs.demandCommand( 1, __( 'You must provide a valid auth command' ) );
} )
.command( 'preview', __( 'Manage preview sites' ), ( previewYargs ) => {
registerCreateCommand( previewYargs );
registerListCommand( previewYargs );
Expand Down
20 changes: 20 additions & 0 deletions cli/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,23 @@ export async function validateAccessToken( token: string ): Promise< void > {
throw new LoggerError( __( 'Invalid authentication token' ), error );
}
}

const userResponseSchema = z.object( {
ID: z.number(),
email: z.string().email(),
display_name: z.string(),
} );

export async function getUserInfo(
token: string
): Promise< z.infer< typeof userResponseSchema > > {
const wpcom = wpcomFactory( token, wpcomXhrRequest );
try {
const rawResponse = await wpcom.req.get( '/me', {
fields: 'ID,login,email,display_name',
} );
return userResponseSchema.parse( rawResponse );
} catch ( error ) {
throw new LoggerError( __( 'Failed to fetch user info' ), error );
}
}
6 changes: 5 additions & 1 deletion cli/lib/appdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ const userDataSchema = z
authToken: z
.object( {
accessToken: z.string().min( 1, __( 'Access token cannot be empty' ) ),
expiresIn: z.number(), // Seconds
expirationTime: z.number(), // Milliseconds since the Unix epoch
id: z.number().optional(),
email: z.string(),
displayName: z.string().default( '' ),
} )
.passthrough()
.optional(),
Expand Down Expand Up @@ -145,7 +149,7 @@ export async function getAuthToken(): Promise< ValidatedAuthToken > {
try {
const { authToken } = await readAppdata();

if ( ! authToken?.accessToken || ! authToken?.id ) {
if ( ! authToken?.accessToken || ! authToken?.id || Date.now() >= authToken?.expirationTime ) {
throw new Error( 'Authentication required' );
}

Expand Down
Loading
Loading