Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
99 changes: 99 additions & 0 deletions cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { password } from '@inquirer/prompts';
import { __ } from '@wordpress/i18n';
import { SupportedLocale } from 'common/lib/locale';
import { getAuthenticationUrl } from 'common/lib/oauth';
import { AuthCommandLoggerAction as LoggerAction } from 'common/logger-actions';
import wpcomFactory from 'src/lib/wpcom-factory';
import wpcomXhrRequest from 'src/lib/wpcom-xhr-request-factory';
import { z } from 'zod';
import { validateAccessToken } from 'cli/lib/api';
import { lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata';
import { openBrowser } from 'cli/lib/browser';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

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

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

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

try {
const existingData = await readAppdata();
if ( existingData.authToken?.accessToken ) {
await validateAccessToken( existingData.authToken.accessToken );
logger.reportSuccess( __( 'Already authenticated with WordPress.com' ) );
return;
}

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

const authUrl = getAuthenticationUrl( locale, CLI_REDIRECT_URI );
await openBrowser( authUrl );
logger.reportSuccess( __( 'Browser opened successfully' ) );

console.log( __( 'Please complete authentication in your browser.' ) );
console.log( '' );

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

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

const wpcom = wpcomFactory( accessToken, wpcomXhrRequest );
const rawResponse = await wpcom.req.get( '/me', { fields: 'ID,login,email,display_name' } );
const user = meResponseSchema.parse( rawResponse );

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

try {
await lockAppdata();
const userData = await readAppdata();
userData.authToken = {
accessToken,
id: user.ID,
email: user.email,
displayName: user.display_name,
expiresIn: twoWeeksInSeconds,
expirationTime: now.getTime() + twoWeeksInSeconds,
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

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

The expirationTime calculation is incorrect. twoWeeksInSeconds is in seconds (1209600), but getTime() returns milliseconds. This should be now.getTime() + twoWeeksInSeconds * 1000 to convert seconds to milliseconds, or the calculation should use twoWeeksInSeconds * 1000 directly.

Suggested change
expirationTime: now.getTime() + twoWeeksInSeconds,
expirationTime: now.getTime() + twoWeeksInSeconds * 1000,

Copilot uses AI. Check for mistakes.
};
await saveAppdata( userData );
} finally {
await unlockAppdata();
}

logger.reportKeyValuePair( 'email', user.email );
logger.reportKeyValuePair( 'display_name', user.display_name );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
logger.reportError( new LoggerError( __( 'Authentication failed' ), error ) );
}
throw error;
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'login',
describe: __( 'Log in to WordPress.com' ),
builder: ( yargs ) => {
return yargs.option( 'locale', {
type: 'string',
default: 'en',
description: __( 'Locale for the authentication flow' ),
} );
},
handler: async ( argv ) => {
await runCommand( argv.locale as SupportedLocale );
},
} );
};
5 changes: 5 additions & 0 deletions cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'cli/polyfills/browser-globals.js';
import path from 'node:path';
import { __ } from '@wordpress/i18n';
import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login';
import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning';
import { StatsGroup, StatsMetric } from 'common/types/stats';
import yargs from 'yargs';
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions 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(),
expirationTime: z.number(),
id: z.number().optional(),
email: z.string(),
displayName: z.string().default( '' ),
} )
.passthrough()
.optional(),
Expand Down
53 changes: 53 additions & 0 deletions cli/lib/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { spawn } from 'child_process';
import { __ } from '@wordpress/i18n';
import { LoggerError } from 'cli/logger';

Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

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

Unused import LoggerError.

Suggested change
import { LoggerError } from 'cli/logger';

Copilot uses AI. Check for mistakes.
/**
* Opens the default browser with the specified URL
*/
export async function openBrowser( url: string ): Promise< void > {
const platform = process.platform;
let cmd: string;
let args: string[];
Comment on lines +9 to +10
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

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

Variables cmd and args are not initialized and will be undefined if process.platform doesn't match 'darwin', 'win32', or 'linux'. This will cause the spawn() call on line 37 to fail. Add a default case to the switch statement that throws an error for unsupported platforms.

Copilot uses AI. Check for mistakes.

switch ( platform ) {
case 'darwin':
cmd = 'open';
args = [ url ];
break;
case 'win32':
cmd = 'PowerShell';
args = [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
'Start',
`"${ url }"`,
];
Comment on lines +18 to +27
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have yet to test this. This was taken from Sindre Sorhus's open module.

break;
case 'linux':
cmd = 'xdg-open';
args = [ url ];
break;
}

return new Promise( ( resolve, reject ) => {
const child = spawn( cmd, args );

child.on( 'error', ( error ) => {
reject(
new LoggerError( __( 'Failed to open browser. Please open the URL manually.' ), error )
);
} );

child.on( 'exit', ( code ) => {
if ( code === 0 ) {
resolve();
} else {
reject( new LoggerError( __( 'Failed to open browser. Please open the URL manually.' ) ) );
}
} );
} );
}
7 changes: 5 additions & 2 deletions common/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import { SupportedLocale } from 'common/lib/locale';
const SCOPES = 'global';
const REDIRECT_URI = `${ PROTOCOL_PREFIX }://auth`;

export function getAuthenticationUrl( locale: SupportedLocale ): string {
export function getAuthenticationUrl(
locale: SupportedLocale,
redirectUri = REDIRECT_URI
): string {
const url = new URL( 'https://public-api.wordpress.com/oauth2/authorize' );
url.searchParams.set( 'response_type', 'token' );
url.searchParams.set( 'client_id', CLIENT_ID );
url.searchParams.set( 'redirect_uri', REDIRECT_URI );
url.searchParams.set( 'redirect_uri', redirectUri );
url.searchParams.set( 'scope', SCOPES );
url.searchParams.set( 'locale', locale );

Expand Down
5 changes: 5 additions & 0 deletions common/logger-actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// Store the actions in a separate file to avoid Webpack issues when importing them in the Studio
// source code

export enum AuthCommandLoggerAction {
LOGIN = 'login',
LOGOUT = 'logout',
Copy link
Member

Choose a reason for hiding this comment

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

Nit: We don't really need the logout at this point.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll move this to #2027

}

export enum PreviewCommandLoggerAction {
VALIDATE = 'validate',
ARCHIVE = 'archive',
Expand Down
Loading