Skip to content
14 changes: 14 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/.actrc export-ignore
/.distignore export-ignore
/.editorconfig export-ignore
/.github export-ignore
/.gitignore export-ignore
/.typos.toml export-ignore
/AGENTS.md export-ignore
/behat.yml export-ignore
/features export-ignore
/phpcs.xml.dist export-ignore
/phpstan.neon.dist export-ignore
/phpunit.xml.dist export-ignore
/tests export-ignore
/wp-cli.yml export-ignore
70 changes: 62 additions & 8 deletions inc/Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,13 @@ public static function load_remote_commands() {
WP_CLI::error( "Couldn't find index data from {$api_url}." );
}
assert( is_array( $api_index ) );
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
$bits = parse_url( $http );
$auth = array();
if ( ! empty( $bits['user'] ) ) {
$auth['type'] = 'basic';
$auth['username'] = $bits['user'];
$auth['password'] = ! empty( $bits['pass'] ) ? $bits['pass'] : '';
}

$auth = self::resolve_auth( $http, WP_CLI::get_runner()->config );

if ( ! isset( $api_index['routes'] ) || ! is_array( $api_index['routes'] ) ) {
WP_CLI::error( "No routes found in API index from {$api_url}." );
}

/** @var array<string, array<string, mixed>> $routes */
$routes = $api_index['routes'];
foreach ( $routes as $route => $route_data ) {
Expand Down Expand Up @@ -177,6 +173,64 @@ private static function get_api_index( $api_url ) {
return $index;
}

/**
* Resolve HTTP Basic Auth credentials from the available sources.
*
* Priority (highest wins):
* 1. Credentials embedded in the URL (user:pass@host).
* 2. WP_REST_CLI_AUTH_USER / WP_REST_CLI_AUTH_PASSWORD environment variables.
* 3. http_user / http_password keys in the WP-CLI config.
*
* @param string $http The URL passed to --http.
* @param array<string, mixed> $config WP-CLI config array (e.g. WP_CLI::get_runner()->config).
* @return array<string, string> Auth array with 'type', 'username', 'password' keys, or empty array.
*/
public static function resolve_auth( $http, array $config = array() ) {
$username = null;
$password = '';

// Lowest priority: wp-cli config (http_user / http_password).
if ( ! empty( $config['http_user'] ) ) {
$username = $config['http_user'];
$password = ! empty( $config['http_password'] ) ? $config['http_password'] : '';
}

// Medium priority: environment variables.
// An empty username is not valid for authentication, so we skip if it is empty.
// An empty password is allowed (e.g. passwordless setups), consistent with URL embedding.
$env_user = getenv( 'WP_REST_CLI_AUTH_USER' );
if ( false !== $env_user && '' !== $env_user ) {
$username = $env_user;
$env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' );
$password = ( false !== $env_password ) ? $env_password : '';
}

// Highest priority: credentials embedded in the URL.
// Ensure the URL has a scheme so parse_url() can extract user:pass correctly.
if ( false === stripos( $http, 'http://' ) && false === stripos( $http, 'https://' ) ) {
$http = 'http://' . $http;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
$bits = parse_url( $http );
if ( ! empty( $bits['user'] ) ) {
$username = $bits['user'];
$password = ! empty( $bits['pass'] ) ? $bits['pass'] : '';
}

if ( null === $username ) {
return array();
}

assert( is_string( $username ) );
assert( is_string( $password ) );

return array(
'type' => 'basic',
'username' => $username,
'password' => $password,
);
}

/**
* Register WP-CLI commands for all endpoints on a route
*
Expand Down
162 changes: 162 additions & 0 deletions tests/Runner_Resolve_Auth_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

use WP_CLI\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class Runner_Resolve_Auth_Test extends TestCase {

private $saved_env = array();

public function set_up() {
foreach ( array( 'WP_REST_CLI_AUTH_USER', 'WP_REST_CLI_AUTH_PASSWORD' ) as $var ) {
$val = getenv( $var );
$this->saved_env[ $var ] = false === $val ? false : $val;
putenv( $var );
}
}

public function tear_down() {
foreach ( $this->saved_env as $var => $val ) {
if ( false === $val ) {
putenv( $var );
} else {
putenv( "{$var}={$val}" );
}
}
}

public function test_no_auth_when_nothing_set() {
$auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' );
$this->assertSame( array(), $auth );
}

public function test_auth_from_config() {
$auth = \WP_REST_CLI\Runner::resolve_auth(
'example.com',
array(
'http_user' => 'admin',
'http_password' => 'secret',
)
);
$this->assertSame(
array(
'type' => 'basic',
'username' => 'admin',
'password' => 'secret',
),
$auth
);
}

public function test_config_allows_empty_password() {
$auth = \WP_REST_CLI\Runner::resolve_auth(
'example.com',
array( 'http_user' => 'admin' )
);
$this->assertSame(
array(
'type' => 'basic',
'username' => 'admin',
'password' => '',
),
$auth
);
}

public function test_env_vars_override_config() {
putenv( 'WP_REST_CLI_AUTH_USER=envuser' );
putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' );
$auth = \WP_REST_CLI\Runner::resolve_auth(
'example.com',
array(
'http_user' => 'cfguser',
'http_password' => 'cfgpass',
)
);
$this->assertSame(
array(
'type' => 'basic',
'username' => 'envuser',
'password' => 'envpass',
),
$auth
);
}

public function test_env_user_without_env_password_uses_empty_password() {
putenv( 'WP_REST_CLI_AUTH_USER=envuser' );
$auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' );
$this->assertSame(
array(
'type' => 'basic',
'username' => 'envuser',
'password' => '',
),
$auth
);
}

public function test_empty_env_user_skips_env_auth() {
putenv( 'WP_REST_CLI_AUTH_USER=' );
putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' );
$auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' );
$this->assertSame( array(), $auth );
}

public function test_url_credentials_override_env_vars() {
putenv( 'WP_REST_CLI_AUTH_USER=envuser' );
putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' );
$auth = \WP_REST_CLI\Runner::resolve_auth( 'http://urluser:urlpass@example.com' );
$this->assertSame(
array(
'type' => 'basic',
'username' => 'urluser',
'password' => 'urlpass',
),
$auth
);
}

/**
* @dataProvider provide_url_credentials
*/
#[DataProvider( 'provide_url_credentials' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound
public function test_url_credentials( $url, $expected_user, $expected_pass ) {
$auth = \WP_REST_CLI\Runner::resolve_auth( $url );
$this->assertSame(
array(
'type' => 'basic',
'username' => $expected_user,
'password' => $expected_pass,
),
$auth
);
}

public static function provide_url_credentials() {
return array(
'no scheme' => array( 'urluser:urlpass@example.com', 'urluser', 'urlpass' ),
'https scheme' => array( 'https://urluser:urlpass@example.com', 'urluser', 'urlpass' ),
'user only' => array( 'urluser@example.com', 'urluser', '' ),
'user only, https' => array( 'https://urluser@example.com', 'urluser', '' ),
);
}

public function test_url_credentials_override_config() {
$auth = \WP_REST_CLI\Runner::resolve_auth(
'http://urluser:urlpass@example.com',
array(
'http_user' => 'cfguser',
'http_password' => 'cfgpass',
)
);
$this->assertSame(
array(
'type' => 'basic',
'username' => 'urluser',
'password' => 'urlpass',
),
$auth
);
}
}