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
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
65 changes: 57 additions & 8 deletions inc/Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,8 @@ public static function load_remote_commands() {
if ( ! $api_index ) {
WP_CLI::error( "Couldn't find index data from {$api_url}." );
}
// 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 );

foreach ( $api_index['routes'] as $route => $route_data ) {
if ( empty( $route_data['schema']['title'] ) ) {
WP_CLI::debug( "No schema title found for {$route}, skipping REST command registration.", 'rest' );
Expand Down Expand Up @@ -129,6 +123,61 @@ private static function get_api_index( $api_url ) {
return json_decode( $response->body, true );
}

/**
* 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 $config WP-CLI config array (e.g. WP_CLI::get_runner()->config).
* @return array 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();
}

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