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
184 changes: 184 additions & 0 deletions cli/auth-callback-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env node

import fs from 'fs';
import https from 'https';
import os from 'os';
import path from 'path';
import { URL } from 'url';

interface UserInfo {
ID: number;
email: string;
display_name?: string;
}

interface AuthData {
access_token: string;
expires_in?: number;
}

function getAppdataDirectory(): string {
if ( process.platform === 'win32' ) {
if ( ! process.env.APPDATA ) {
throw new Error( 'Studio config file path not found.' );
}
return path.join( process.env.APPDATA, 'Studio' );
}
return path.join( os.homedir(), 'Library', 'Application Support', 'Studio' );
}

function getAppdataPath(): string {
const appdataDir = getAppdataDirectory();
return path.join( appdataDir, 'appdata-v1.json' );
}

function parseAuthCallbackUrl( callbackUrl: string ): AuthData {
try {
const url = new URL( callbackUrl );
const params = new URLSearchParams( url.hash.replace( '#', '?' ) );

const accessToken = params.get( 'access_token' );
const expiresIn = params.get( 'expires_in' );

if ( ! accessToken ) {
throw new Error( 'Missing access token' );
}

return {
access_token: accessToken,
expires_in: expiresIn ? parseInt( expiresIn, 10 ) : undefined,
};
} catch ( error ) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error( `Invalid authentication callback URL: ${ message }` );
}
}

function fetchUserInfo( accessToken: string ): Promise< UserInfo > {
return new Promise( ( resolve, reject ) => {
const options = {
hostname: 'public-api.wordpress.com',
path: '/rest/v1/me?fields=ID,email,display_name',
method: 'GET',
headers: {
Authorization: `Bearer ${ accessToken }`,
'User-Agent': 'Studio CLI',
},
};

const req = https.request( options, ( res ) => {
let data = '';

res.on( 'data', ( chunk ) => {
data += chunk;
} );

res.on( 'end', () => {
try {
const response = JSON.parse( data );
if ( res.statusCode !== 200 ) {
reject( new Error( `API error: ${ response.message || 'Unknown error' }` ) );
return;
}
resolve( response );
} catch ( error ) {
const message = error instanceof Error ? error.message : 'Unknown error';
reject( new Error( `Failed to parse response: ${ message }` ) );
}
} );
} );

req.on( 'error', ( error ) => {
reject( new Error( `Request failed: ${ error.message }` ) );
} );

req.end();
} );
}

async function saveAuthenticationToken(
accessToken: string,
expiresIn: number | undefined,
userInfo: UserInfo
): Promise< void > {
const appDataPath = getAppdataPath();
const appDataDir = path.dirname( appDataPath );

// Ensure appdata directory exists
await fs.promises.mkdir( appDataDir, { recursive: true } );

let userData: {
newSites: unknown[];
sites: unknown[];
snapshots: unknown[];
version: number;
authToken?: unknown;
};
try {
const fileContent = await fs.promises.readFile( appDataPath, { encoding: 'utf8' } );
userData = JSON.parse( fileContent );
} catch {
userData = {
newSites: [],
sites: [],
snapshots: [],
version: 1,
};
}

const authToken = {
accessToken: accessToken,
id: userInfo.ID,
email: userInfo.email,
displayName: userInfo.display_name || '',
expiresIn: expiresIn,
expirationTime: expiresIn ? Date.now() + expiresIn * 1000 : undefined,
};

userData.authToken = authToken;

const fileContent = JSON.stringify( userData, null, 2 ) + '\n';
await fs.promises.writeFile( appDataPath, fileContent, { encoding: 'utf8' } );
}

async function main(): Promise< void > {
try {
const callbackUrl = process.argv[ 2 ];
if ( ! callbackUrl ) {
console.error( 'Usage: node auth-callback-handler.js <callback-url>' );
process.exit( 1 );
}

console.log( 'Processing authentication callback…' );

// Parse the callback URL to extract auth data
const authData = parseAuthCallbackUrl( callbackUrl );

console.log( 'Fetching user information…' );

// Fetch user info from WordPress.com API
const userInfo = await fetchUserInfo( authData.access_token );

// Save the authentication token to appdata
await saveAuthenticationToken( authData.access_token, authData.expires_in, userInfo );

console.log( 'Authentication token saved successfully' );

// Log user info
if ( userInfo.email ) {
console.log( 'Authenticated as:', userInfo.email );
}
if ( userInfo.display_name ) {
console.log( 'Display name:', userInfo.display_name );
}

// Exit with success code
process.exit( 0 );
} catch ( error ) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error( 'Authentication callback failed:', message );
process.exit( 1 );
}
}

void main();
88 changes: 88 additions & 0 deletions cli/commands/auth/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { URL } from 'url';
import { __ } from '@wordpress/i18n';
import { saveAuthenticationToken } from 'cli/lib/token-waiter';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

/**
* Parse authentication data from OAuth callback URL
*/
function parseAuthCallbackUrl( callbackUrl: string ) {
try {
const url = new URL( callbackUrl );
const params = new URLSearchParams( url.search );

const accessToken = params.get( 'access_token' );
const userId = params.get( 'user_id' );
const email = params.get( 'email' );
const displayName = params.get( 'display_name' );
const expiresIn = params.get( 'expires_in' );

if ( ! accessToken || ! userId ) {
throw new Error( 'Missing required authentication parameters' );
}

return {
access_token: accessToken,
user_id: parseInt( userId, 10 ),
email: email || undefined,
display_name: displayName || undefined,
expires_in: expiresIn ? parseInt( expiresIn, 10 ) : undefined,
};
} catch ( error ) {
throw new LoggerError( __( 'Invalid authentication callback URL' ), error );
}
}

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

try {
logger.reportStart( 'AUTH_CALLBACK', __( 'Processing authentication callback…' ) );

// Parse the callback URL to extract auth data
const authData = parseAuthCallbackUrl( callbackUrl );

// Save the authentication token to appdata
await saveAuthenticationToken( authData );

logger.reportSuccess( __( 'Authentication token saved successfully' ) );

// Log user info if available
if ( authData.email ) {
console.log( __( 'Authenticated as:' ), authData.email );
}
if ( authData.display_name ) {
console.log( __( 'Display name:' ), authData.display_name );
}

// Exit with success code
process.exit( 0 );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
logger.reportError( new LoggerError( __( 'Authentication callback failed' ), error ) );
}

// Exit with error code
process.exit( 1 );
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'callback <url>',
describe: __( 'Handle OAuth authentication callback (internal use)' ),
builder: ( yargs ) => {
return yargs.positional( 'url', {
type: 'string',
describe: __( 'OAuth callback URL' ),
demandOption: true,
} );
},
handler: async ( argv ) => {
await runCommand( argv.url as string );
},
} );
};
78 changes: 78 additions & 0 deletions cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { __, sprintf } from '@wordpress/i18n';
import { SupportedLocale } from 'common/lib/locale';
import { getAuthenticationUrl } from 'common/lib/oauth';
import { validateAccessToken } from 'cli/lib/api';
import { readAppdata } from 'cli/lib/appdata';
import { openInBrowser } from 'cli/lib/open-in-browser';
import { registerProtocolHandler, unregisterProtocolHandler } from 'cli/lib/protocol-handler';
import { waitForAuthenticationToken, getAuthStartTimestamp } from 'cli/lib/token-waiter';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

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

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

logger.reportStart( 'AUTH_INIT', __( 'Starting authentication flow…' ) );

// Get timestamp before starting auth to detect new tokens
const authStartTime = getAuthStartTimestamp();

// Register CLI as temporary protocol handler
logger.reportStart( 'PROTOCOL_REGISTER', __( 'Registering CLI as protocol handler…' ) );
await registerProtocolHandler();
logger.reportSuccess( __( 'Protocol handler registered' ) );

logger.reportStart( 'BROWSER_OPEN', __( 'Opening browser for authentication…' ) );
const authUrl = getAuthenticationUrl( locale );
await openInBrowser( authUrl );
logger.reportSuccess( __( 'Browser opened successfully' ) );

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

const authToken = await waitForAuthenticationToken( authStartTime, 120000, logger );
await unregisterProtocolHandler();
logger.reportSuccess( __( 'Authentication completed successfully!' ) );
logger.reportKeyValuePair( 'status', __( 'Authenticated' ) );
logger.reportKeyValuePair( 'user_id', authToken.id.toString() );
logger.reportKeyValuePair( 'email', authToken.email || '' );
logger.reportKeyValuePair( 'display_name', authToken.displayName || '' );
} catch ( error ) {
// Clean up protocol registration on error
await unregisterProtocolHandler();

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 );
},
} );
};
Loading