diff --git a/.env.example b/.env.example index c9e997a9b..b014ce0e6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ BUGSNAG_KEY=abcd1234 AMPLITUDE_KEY=acbd1234 ACLI_VERSION=1.0.0 +ACLI_DEVICE_CLIENT_ID=0oa1example2client3id +ACLI_AUTH_DOMAIN=example.acquia.com +ACLI_AUTH_SERVER_ID=ausExample1234 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb13c8b63..13c6d474d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: touch .env echo BUGSNAG_KEY=${{ secrets.BUGSNAG_KEY }} >> .env echo AMPLITUDE_KEY=${{ secrets.AMPLITUDE_KEY }} >> .env + echo ACLI_DEVICE_CLIENT_ID=${{ secrets.ACLI_DEVICE_CLIENT_ID }} >> .env + echo ACLI_AUTH_DOMAIN=${{ secrets.ACLI_AUTH_DOMAIN }} >> .env + echo ACLI_AUTH_SERVER_ID=${{ secrets.ACLI_AUTH_SERVER_ID }} >> .env echo ACLI_VERSION=${{ steps.acli-version.outputs.ACLI_VERSION }} >> .env echo ACLI_BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") >> .env - name: 'Extract additional API specs from secrets' diff --git a/config/prod/services.yml b/config/prod/services.yml index 5e5c41921..c9f0cc920 100644 --- a/config/prod/services.yml +++ b/config/prod/services.yml @@ -73,6 +73,8 @@ services: Acquia\Cli\ApiCredentialsInterface: alias: Acquia\Cli\CloudApi\CloudCredentials + Acquia\Cli\CloudApi\DeviceTokenRefresher: ~ + cloud.credentials: class: Acquia\Cli\CloudApi\CloudCredentials @@ -100,8 +102,10 @@ services: secret: '@=service("cloud.credentials").getCloudSecret()' accessToken: '@=service("cloud.credentials").getCloudAccessToken()' accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()' + deviceAccessToken: '@=service("cloud.credentials").getCloudDeviceAccessToken()' $baseUri: '@=service("cloud.credentials").getBaseUri()' $accountsUri: '@=service("cloud.credentials").getAccountsUri()' + $deviceTokenRefresher: '@Acquia\Cli\CloudApi\DeviceTokenRefresher' # v3 (MEO) connector factory — same credentials as v2, prod gateway at # https://api.acquia.com/v3. Override with ACLI_CLOUD_API_V3_BASE_URI for dev/stage. @@ -113,8 +117,10 @@ services: secret: '@=service("cloud.credentials").getCloudSecret()' accessToken: '@=service("cloud.credentials").getCloudAccessToken()' accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()' + deviceAccessToken: '@=service("cloud.credentials").getCloudDeviceAccessToken()' $baseUri: '@=service("cloud.credentials").getV3BaseUri()' $accountsUri: '@=service("cloud.credentials").getAccountsUri()' + $deviceTokenRefresher: '@Acquia\Cli\CloudApi\DeviceTokenRefresher' Acquia\Cli\CloudApi\V3ClientService: arguments: diff --git a/src/AcsfApi/AcsfCredentials.php b/src/AcsfApi/AcsfCredentials.php index 971e98bdf..21ab5df72 100644 --- a/src/AcsfApi/AcsfCredentials.php +++ b/src/AcsfApi/AcsfCredentials.php @@ -76,4 +76,9 @@ public function getBaseUri(): ?string return null; } + + public function getCloudDeviceAccessToken(): ?string + { + return null; + } } diff --git a/src/ApiCredentialsInterface.php b/src/ApiCredentialsInterface.php index 1224e1db0..e0e1c3866 100644 --- a/src/ApiCredentialsInterface.php +++ b/src/ApiCredentialsInterface.php @@ -11,4 +11,6 @@ public function getBaseUri(): ?string; public function getCloudKey(): ?string; public function getCloudSecret(): ?string; + + public function getCloudDeviceAccessToken(): ?string; } diff --git a/src/CloudApi/AccessTokenConnector.php b/src/CloudApi/AccessTokenConnector.php index 54a343c64..81d0c8c15 100644 --- a/src/CloudApi/AccessTokenConnector.php +++ b/src/CloudApi/AccessTokenConnector.php @@ -21,8 +21,12 @@ class AccessTokenConnector extends Connector /** * @param array $config */ - public function __construct(array $config, ?string $baseUri = null, ?string $urlAccessToken = null) - { + public function __construct( + array $config, + ?string $baseUri = null, + ?string $urlAccessToken = null, + private ?DeviceTokenRefresher $deviceTokenRefresher = null, + ) { $this->accessToken = new AccessToken(['access_token' => $config['access_token']]); parent::__construct($config, $baseUri, $urlAccessToken); } @@ -30,10 +34,18 @@ public function __construct(array $config, ?string $baseUri = null, ?string $url public function createRequest(string $verb, string $path): RequestInterface { if ($file = getenv('ACLI_ACCESS_TOKEN_FILE')) { + // File token takes priority — skip device token refresh to avoid + // burning refresh token rotations for a token nothing uses. if (!file_exists($file)) { throw new AcquiaCliException('Access token file not found at {file}', ['file' => $file]); } $this->accessToken = new AccessToken(['access_token' => trim(file_get_contents($file), "\"\n")]); + } elseif ($this->deviceTokenRefresher !== null) { + $freshToken = $this->deviceTokenRefresher->getValidAccessToken(); + if ($freshToken === null) { + throw new AcquiaCliException('Device token session expired. Run acli auth:login to re-authenticate.'); + } + $this->accessToken = new AccessToken(['access_token' => $freshToken]); } return $this->provider->getAuthenticatedRequest( $verb, diff --git a/src/CloudApi/AuthConfig.php b/src/CloudApi/AuthConfig.php new file mode 100644 index 000000000..22dd18422 --- /dev/null +++ b/src/CloudApi/AuthConfig.php @@ -0,0 +1,34 @@ +clientId() !== '' + && $this->domain() !== '' + && $this->authServerId() !== ''; + } +} diff --git a/src/CloudApi/ClientService.php b/src/CloudApi/ClientService.php index 357dda551..6fb8f2c0d 100644 --- a/src/CloudApi/ClientService.php +++ b/src/CloudApi/ClientService.php @@ -7,8 +7,10 @@ use Acquia\Cli\ApiCredentialsInterface; use Acquia\Cli\Application; use Acquia\Cli\ConnectorFactoryInterface; +use Acquia\Cli\Helpers\TelemetryHelper; use AcquiaCloudApi\Connector\Client; use AcquiaCloudApi\Connector\ConnectorInterface; +use loophp\phposinfo\OsInfo; /** * Factory producing Acquia Cloud Api clients. @@ -54,7 +56,13 @@ public function getClient(): Client protected function configureClient(Client $client): void { - $userAgent = sprintf("acli/%s", $this->application->getVersion()); + $provider = TelemetryHelper::getEnvironmentProvider(); + $userAgent = sprintf( + 'Acquia CLI (%s, %s%s)', + $this->application->getVersion(), + OsInfo::uuid(), + $provider !== null ? ', ' . $provider : '', + ); $customHeaders = [ 'User-Agent' => [$userAgent], ]; @@ -77,7 +85,8 @@ protected function checkAuthentication(): bool { return ( $this->credentials->getCloudAccessToken() || - ($this->credentials->getCloudKey() && $this->credentials->getCloudSecret()) + ($this->credentials->getCloudKey() && $this->credentials->getCloudSecret()) || + ($this->credentials->getCloudDeviceAccessToken() !== null) ); } } diff --git a/src/CloudApi/CloudCredentials.php b/src/CloudApi/CloudCredentials.php index 79c906354..8120d9f3d 100644 --- a/src/CloudApi/CloudCredentials.php +++ b/src/CloudApi/CloudCredentials.php @@ -10,11 +10,15 @@ class CloudCredentials implements ApiCredentialsInterface { - /** - * CloudCredentials constructor. - */ - public function __construct(private CloudDataStore $datastoreCloud) + public function __construct( + private CloudDataStore $datastoreCloud, + ) { + } + + public function getCloudDeviceAccessToken(): ?string { + $stored = $this->datastoreCloud->get('device_token'); + return $stored['access_token'] ?? null; } public function getCloudAccessToken(): ?string diff --git a/src/CloudApi/ConnectorFactory.php b/src/CloudApi/ConnectorFactory.php index 74cd33fe5..3f3f60c2d 100644 --- a/src/CloudApi/ConnectorFactory.php +++ b/src/CloudApi/ConnectorFactory.php @@ -14,8 +14,12 @@ class ConnectorFactory implements ConnectorFactoryInterface /** * @param array $config */ - public function __construct(protected array $config, protected ?string $baseUri = null, protected ?string $accountsUri = null) - { + public function __construct( + protected array $config, + protected ?string $baseUri = null, + protected ?string $accountsUri = null, + private ?DeviceTokenRefresher $deviceTokenRefresher = null, + ) { } public function createConnector(): ConnectorInterface @@ -40,6 +44,15 @@ private function buildConnector(): ConnectorInterface return new Connector($this->config, $this->baseUri, $this->accountsUri); } + // Device code token path. + if (!empty($this->config['deviceAccessToken'])) { + return new AccessTokenConnector([ + 'access_token' => $this->config['deviceAccessToken'], + 'key' => null, + 'secret' => null, + ], $this->baseUri, $this->accountsUri, $this->deviceTokenRefresher); + } + // Fall back to a valid access token. if ($this->config['accessToken']) { $accessToken = $this->createAccessToken(); diff --git a/src/CloudApi/DeviceTokenRefresher.php b/src/CloudApi/DeviceTokenRefresher.php new file mode 100644 index 000000000..07868f958 --- /dev/null +++ b/src/CloudApi/DeviceTokenRefresher.php @@ -0,0 +1,203 @@ +datastore->get('device_token'); + if (!$stored || empty($stored['access_token'])) { + return null; + } + + if ($this->hasLifeLeft($stored)) { + return $stored['access_token']; + } + + // Presenting a rotated refresh token invalidates the whole session, so only + // one invocation may refresh a given token. + $lock = $this->acquireLock(); + try { + return $this->refresh(); + } finally { + if ($lock !== null) { + flock($lock, LOCK_UN); + fclose($lock); + } + } + } + + /** + * Performs the refresh, assuming the caller holds the lock. + */ + private function refresh(): ?string + { + $stored = $this->readTokenFromDisk(); + if (!$stored || empty($stored['access_token'])) { + return null; + } + if ($this->hasLifeLeft($stored)) { + return $stored['access_token']; + } + + $refreshToken = $stored['refresh_token'] ?? null; + $clientId = $stored['client_id'] ?? null; + $domain = $this->authConfig->domain(); + $authServer = $this->authConfig->authServerId(); + + if (!$refreshToken || !$clientId || !$domain || !$authServer) { + return null; + } + + try { + $response = $this->httpClient->post( + sprintf('https://%s/oauth2/%s/v1/token', $domain, $authServer), + [ + 'form_params' => [ + 'client_id' => $clientId, + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ], + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ] + ); + $new = json_decode((string) $response->getBody(), true); + } catch (ClientException $e) { + $status = $e->getResponse()->getStatusCode(); + $body = json_decode((string) $e->getResponse()->getBody(), true); + $error = is_array($body) ? ($body['error'] ?? '') : ''; + + if ($error === 'invalid_grant') { + $this->logger->warning('Device token refresh failed: the refresh token is expired or revoked. Run `acli auth:login` to re-authenticate.'); + return null; + } + if ($status === 400 || $status === 401) { + $this->logger->warning('Device token refresh failed (HTTP {status}). Run `acli auth:login` to re-authenticate.', ['status' => $status]); + } + return null; + } catch (GuzzleException) { + return null; + } + + if (empty($new['access_token'])) { + return null; + } + + $this->persistToken([ + 'access_token' => $new['access_token'], + 'client_id' => $clientId, + 'expiry' => isset($new['expires_in']) ? time() + (int) $new['expires_in'] : 0, + 'refresh_token' => $new['refresh_token'] ?? $refreshToken, + ]); + + return $new['access_token']; + } + + /** + * @param array $token + */ + private function hasLifeLeft(array $token): bool + { + $accessToken = new AccessToken([ + 'access_token' => $token['access_token'], + 'expires' => $token['expiry'] ?? 0, + ]); + + return ($accessToken->getExpires() - time()) > self::PRE_EXPIRY_WINDOW_SECONDS; + } + + /** + * Reads `device_token` from disk, bypassing the in-memory datastore. + * + * @return array|null + */ + private function readTokenFromDisk(): ?array + { + $contents = @file_get_contents($this->datastore->filepath); + if ($contents === false) { + return null; + } + + $decoded = json_decode($contents, true); + if (!is_array($decoded) || !isset($decoded['device_token']) || !is_array($decoded['device_token'])) { + return null; + } + + return $decoded['device_token']; + } + + /** + * Writes `device_token` without disturbing anything else in the file. + * + * @param array $token + */ + private function persistToken(array $token): void + { + $path = $this->datastore->filepath; + $contents = @file_get_contents($path); + $data = $contents === false ? null : json_decode($contents, true); + + if (!is_array($data)) { + $this->datastore->set('device_token', $token); + return; + } + + $data['device_token'] = $token; + file_put_contents($path, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + chmod($path, 0600); + $this->datastore->syncInMemory('device_token', $token); + } + + /** + * Takes an exclusive lock on a sidecar file beside the datastore. + * + * @return resource|null + */ + private function acquireLock() + { + $handle = @fopen($this->datastore->filepath . '.lock', 'c'); + if ($handle === false) { + $this->logger->warning('Could not open the device token lock file; refreshing without serialisation.'); + return null; + } + + if (!flock($handle, LOCK_EX)) { + fclose($handle); + $this->logger->warning('Could not lock the device token lock file; refreshing without serialisation.'); + return null; + } + + return $handle; + } +} diff --git a/src/Command/Auth/AuthLoginCommand.php b/src/Command/Auth/AuthLoginCommand.php index 28118ff62..9896c9a71 100644 --- a/src/Command/Auth/AuthLoginCommand.php +++ b/src/Command/Auth/AuthLoginCommand.php @@ -4,9 +4,24 @@ namespace Acquia\Cli\Command\Auth; +use Acquia\Cli\ApiCredentialsInterface; +use Acquia\Cli\CloudApi\AuthConfig; +use Acquia\Cli\CloudApi\ClientService; use Acquia\Cli\Command\CommandBase; +use Acquia\Cli\DataStore\AcquiaCliDatastore; +use Acquia\Cli\DataStore\CloudDataStore; use Acquia\Cli\Exception\AcquiaCliException; +use Acquia\Cli\Helpers\LocalMachineHelper; +use Acquia\Cli\Helpers\SshHelper; +use Acquia\Cli\Helpers\TelemetryHelper; use AcquiaCloudApi\Endpoints\Account; +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; +use Psr\Log\LoggerInterface; +use SelfUpdate\SelfUpdateManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -16,16 +31,233 @@ #[AsCommand(name: 'auth:login', description: 'Register Cloud Platform API credentials', aliases: ['login'])] final class AuthLoginCommand extends CommandBase { + private const REQUEST_TIMEOUT_SECONDS = 30; + + public function __construct( + public LocalMachineHelper $localMachineHelper, + protected CloudDataStore $datastoreCloud, + protected AcquiaCliDatastore $datastoreAcli, + protected ApiCredentialsInterface $cloudCredentials, + protected TelemetryHelper $telemetryHelper, + protected string $projectDir, + protected ClientService $cloudApiClientService, + public SshHelper $sshHelper, + protected string $sshDir, + LoggerInterface $logger, + public SelfUpdateManager $selfUpdateManager, + private GuzzleClient $httpClient = new GuzzleClient(), + private AuthConfig $authConfig = new AuthConfig(), + ) { + parent::__construct($this->localMachineHelper, $this->datastoreCloud, $this->datastoreAcli, $this->cloudCredentials, $this->telemetryHelper, $this->projectDir, $this->cloudApiClientService, $this->sshHelper, $this->sshDir, $logger, $this->selfUpdateManager); + } + protected function configure(): void { $this ->addOption('key', 'k', InputOption::VALUE_REQUIRED, 'Your Cloud Platform API key') ->addOption('secret', 's', InputOption::VALUE_REQUIRED, 'Your Cloud Platform API secret') + ->addOption('use-legacy-auth', null, InputOption::VALUE_NONE, 'Force API key/secret authentication instead of device code flow') ->addOption('environment', null, InputOption::VALUE_REQUIRED, 'Cloud Platform API environment', 'prod') - ->setHelp('Acquia CLI can store multiple sets of credentials in case you have multiple Cloud Platform accounts. However, only a single account can be active at a time. This command allows you to activate a new or existing set of credentials.'); + ->setHelp('Authenticates ACLI with Acquia Cloud Platform. Uses API key/secret when credentials are already stored or passed via --key/--secret. Otherwise uses device code flow (no API key required).'); } protected function execute(InputInterface $input, OutputInterface $output): int + { + // Explicit legacy flag or key/secret passed on the command line — always legacy. + if ($input->getOption('use-legacy-auth') || $input->getOption('key') || $input->getOption('secret')) { + return $this->executeLegacyAuth($input, $output); + } + + $keys = $this->datastoreCloud->get('keys'); + $activeKey = $this->datastoreCloud->get('acli_key'); + $deviceToken = $this->datastoreCloud->get('device_token'); + + if (!$this->authConfig->isConfigured()) { + $output->writeln('Device code sign-in is not configured; falling back to API key authentication.'); + return $this->executeLegacyAuth($input, $output); + } + + if ($activeKey && $keys) { + $label = $keys[$activeKey]['label'] ?? $activeKey; + $output->writeln("Already authenticated as $label (API key)"); + + if ($input->isInteractive() && $this->io->confirm('Sign in with device code instead?', false)) { + return $this->executeDeviceCodeFlowWithFallback($input, $output); + } + return $this->executeLegacyAuth($input, $output); + } + + if ($deviceToken) { + $output->writeln('Already authenticated via device code'); + + if ($input->isInteractive()) { + $reauth = $this->io->confirm('Re-authenticate?', false); + if (!$reauth) { + return Command::SUCCESS; + } + } + return $this->executeDeviceCodeFlowWithFallback($input, $output); + } + + return $this->executeDeviceCodeFlowWithFallback($input, $output); + } + + private function executeDeviceCodeFlowWithFallback(InputInterface $input, OutputInterface $output): int + { + $result = $this->executeDeviceCodeFlow($output); + if ( + $result !== Command::SUCCESS + && $input->isInteractive() + && $this->io->confirm('Fall back to API key authentication?', false) + ) { + return $this->executeLegacyAuth($input, $output); + } + return $result; + } + + + + private function executeDeviceCodeFlow(OutputInterface $output): int + { + $clientId = $this->authConfig->clientId(); + $domain = $this->authConfig->domain(); + $authServer = $this->authConfig->authServerId(); + + $baseUrl = sprintf('https://%s/oauth2/%s/v1', $domain, $authServer); + + // Step 1 — initiate: get device_code + user_code. + try { + $response = $this->httpClient->post("$baseUrl/device/authorize", [ + 'form_params' => [ + 'client_id' => $clientId, + 'scope' => 'openid profile email offline_access', + ], + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ]); + } catch (GuzzleException $e) { + $output->writeln('Failed to initiate device code flow: ' . $e->getMessage() . ''); + return Command::FAILURE; + } + + $data = json_decode((string) $response->getBody(), true); + if (!is_array($data) || !isset($data['device_code'], $data['user_code'], $data['verification_uri'])) { + $output->writeln('Unexpected response from device authorization endpoint.'); + return Command::FAILURE; + } + + $deviceCode = $data['device_code']; + $userCode = $data['user_code']; + $verifyUrl = $data['verification_uri']; + $verifyUrlComplete = $data['verification_uri_complete'] ?? $verifyUrl; + $expiresIn = $data['expires_in'] ?? 600; + $interval = $data['interval'] ?? 5; + + // Step 2 — surface the code and URL for the human. + $output->writeln(''); + $output->writeln('Sign in to Acquia ID in your browser:'); + $output->writeln(''); + $output->writeln(" $verifyUrl"); + $output->writeln(''); + $output->writeln('Then enter this code when prompted:'); + $output->writeln(''); + $output->writeln(" $userCode"); + $output->writeln(''); + + if ($this->localMachineHelper->isBrowserAvailable() && !getenv('SSH_CONNECTION')) { + $this->localMachineHelper->startBrowser($verifyUrlComplete); + $output->writeln('Confirm the code above matches what appears in your browser before approving.'); + $output->writeln(''); + } + + $output->writeln(sprintf('Waiting for authorization... (code expires in %d minutes)', (int) ceil($expiresIn / 60))); + + // Step 3 — poll until approved, denied, or expired. + $deadline = time() + $expiresIn; + while (time() < $deadline) { + sleep($interval); + + try { + $tokenResponse = $this->httpClient->post("$baseUrl/token", [ + 'form_params' => [ + 'client_id' => $clientId, + 'device_code' => $deviceCode, + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + ], + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ]); + $token = json_decode((string) $tokenResponse->getBody(), true); + } catch (ClientException $e) { + // HTTP 4xx — parse the structured error body. + $token = json_decode((string) $e->getResponse()->getBody(), true); + } catch (ConnectException | RequestException $e) { + // Transport error (DNS, timeout, no response). Back off exponentially + // and retry; only abort when the device code itself has expired. + $interval = min($interval * 2, 30); + continue; + } + + $error = is_array($token) ? ($token['error'] ?? '') : ''; + + switch ($error) { + case '': + if (!is_array($token) || empty($token['access_token'])) { + $output->writeln('Unexpected response from the token endpoint: no access token returned.'); + return Command::FAILURE; + } + // Success — store token and exit. + $this->storeDeviceToken($token, $clientId); + $output->writeln(''); + $output->writeln('✓ Authenticated successfully.'); + return Command::SUCCESS; + + case 'authorization_pending': + // Normal — human hasn't approved yet. Keep polling. + break; + + case 'slow_down': + // Okta asked us to back off. + $interval += 5; + break; + + case 'access_denied': + $output->writeln('Authorization denied.'); + return Command::FAILURE; + + case 'expired_token': + $output->writeln('Code expired. Run acli login again.'); + return Command::FAILURE; + + default: + $output->writeln("Unexpected error: $error"); + return Command::FAILURE; + } + } + + $output->writeln('Timed out waiting for authorization.'); + return Command::FAILURE; + } + + private function storeDeviceToken(array $token, string $clientId): void + { + $expiry = isset($token['expires_in']) + ? time() + (int) $token['expires_in'] + : 0; + + $this->datastoreCloud->set('device_token', [ + 'access_token' => $token['access_token'], + 'client_id' => $clientId, + 'expiry' => $expiry, + 'refresh_token' => $token['refresh_token'] ?? null, + ]); + + // The key itself stays on + // disk and can be reactivated with --use-legacy-auth. + if ($this->datastoreCloud->get('acli_key')) { + $this->datastoreCloud->remove('acli_key'); + } + } + + private function executeLegacyAuth(InputInterface $input, OutputInterface $output): int { $env = $input->getOption('environment'); [$baseUri, $accountsUri] = $this->getUrisForEnvironment($env); diff --git a/src/Command/Auth/AuthLogoutCommand.php b/src/Command/Auth/AuthLogoutCommand.php index 35e48f431..04a30855e 100644 --- a/src/Command/Auth/AuthLogoutCommand.php +++ b/src/Command/Auth/AuthLogoutCommand.php @@ -22,22 +22,34 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $keys = $this->datastoreCloud->get('keys'); $activeKey = $this->datastoreCloud->get('acli_key'); - if (!$activeKey) { - throw new AcquiaCliException('There is no active Cloud Platform API key'); + $deviceToken = $this->datastoreCloud->get('device_token'); + + if (!$activeKey && !$deviceToken) { + throw new AcquiaCliException('There are no active Cloud Platform credentials'); + } + + // Remove device token session if present. + if ($deviceToken) { + $this->datastoreCloud->remove('device_token'); + $output->writeln('Device code session removed.'); } - $activeKeyLabel = $keys[$activeKey]['label']; - $output->writeln("The key $activeKeyLabel will be deactivated on this machine. However, the credentials will remain on disk and can be reactivated by running acli auth:login unless you also choose to delete them."); - $delete = $this->determineOption('delete', false, null, null, false); - $this->datastoreCloud->remove('acli_key'); - $action = 'deactivated'; - if ($delete) { - $this->datastoreCloud->remove("keys.$activeKey"); - $action = 'deleted'; + + // Remove legacy API key if active. + if ($activeKey) { + $keys = $this->datastoreCloud->get('keys'); + $activeKeyLabel = $keys[$activeKey]['label']; + $output->writeln("The key $activeKeyLabel will be deactivated on this machine. However, the credentials will remain on disk and can be reactivated by running acli auth:login unless you also choose to delete them."); + $delete = $this->determineOption('delete', false, null, null, false); + $this->datastoreCloud->remove('acli_key'); + $action = 'deactivated'; + if ($delete) { + $this->datastoreCloud->remove("keys.$activeKey"); + $action = 'deleted'; + } + $output->writeln("The active Cloud Platform API credentials were $action"); } - $output->writeln("The active Cloud Platform API credentials were $action"); - $output->writeln('No Cloud Platform API key is active. Run acli auth:login to continue using the Cloud Platform API.'); + $output->writeln('No Cloud Platform credentials are active. Run acli auth:login to authenticate.'); return Command::SUCCESS; } diff --git a/src/Config/CloudDataConfig.php b/src/Config/CloudDataConfig.php index c4eef2c40..9d259fd9e 100644 --- a/src/Config/CloudDataConfig.php +++ b/src/Config/CloudDataConfig.php @@ -66,6 +66,14 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->scalarNode('acsf_active_factory')->end() + ->arrayNode('device_token') + ->children() + ->scalarNode('access_token')->end() + ->scalarNode('refresh_token')->end() + ->scalarNode('expiry')->end() + ->scalarNode('client_id')->end() + ->end() + ->end() ->end() ->validate() ->ifTrue(function ($config) { diff --git a/src/DataStore/Datastore.php b/src/DataStore/Datastore.php index de8fa0bba..6deb4af93 100644 --- a/src/DataStore/Datastore.php +++ b/src/DataStore/Datastore.php @@ -54,6 +54,17 @@ public function remove(string $key): void $this->dump(); } + /** + * Updates the in-memory copy of a value without writing to disk. + * + * @param string $key The key to update in the in-memory datastore. + * @param mixed $value The value to set for the given key in memory. + */ + public function syncInMemory(string $key, mixed $value): void + { + $this->data->set($key, $value); + } + public function exists(string $key): bool { return $this->data->has($key); diff --git a/src/Helpers/TelemetryHelper.php b/src/Helpers/TelemetryHelper.php index d3b84df10..ad0c572bd 100644 --- a/src/Helpers/TelemetryHelper.php +++ b/src/Helpers/TelemetryHelper.php @@ -338,6 +338,9 @@ public static function getProviders(): array 'platformsh' => ['PLATFORM_ENVIRONMENT'], 'teamcity' => ['TEAMCITY_VERSION'], 'travis' => ['TRAVIS'], + 'claude-code' => ['CLAUDE_CODE'], + 'cursor' => ['CURSOR_TRACE_ID'], + 'copilot' => ['GITHUB_COPILOT_LANGUAGE_SERVER_VERSION'], ]; } } diff --git a/tests/phpunit/src/AcsfApi/AcsfCredentialsTest.php b/tests/phpunit/src/AcsfApi/AcsfCredentialsTest.php new file mode 100644 index 000000000..e02cf3095 --- /dev/null +++ b/tests/phpunit/src/AcsfApi/AcsfCredentialsTest.php @@ -0,0 +1,20 @@ +datastoreCloud); + + $this->assertNull($credentials->getCloudDeviceAccessToken()); + } +} diff --git a/tests/phpunit/src/AcsfApi/AcsfServiceTest.php b/tests/phpunit/src/AcsfApi/AcsfServiceTest.php index 363bf3402..b2891e5c7 100644 --- a/tests/phpunit/src/AcsfApi/AcsfServiceTest.php +++ b/tests/phpunit/src/AcsfApi/AcsfServiceTest.php @@ -56,4 +56,26 @@ public function testIsMachineAuthenticated(array $envVars, bool $isAuthenticated $this->assertEquals($isAuthenticated, $clientService->isMachineAuthenticated()); self::unsetEnvVars($envVars); } + + public function testACloudPlatformDeviceTokenDoesNotAuthenticateAcsf(): void + { + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode([ + 'device_token' => [ + 'access_token' => 'cloud-platform-token', + 'client_id' => 'client-123', + 'expiry' => time() + 300, + 'refresh_token' => 'cloud-platform-refresh-token', + ], + 'send_telemetry' => false, + ])); + $this->createDataStores(); + $clientService = new AcsfClientService(new AcsfConnectorFactory([ + 'key' => null, + 'secret' => null, + ]), $this->prophet->prophesize(Application::class) + ->reveal(), new AcsfCredentials($this->datastoreCloud)); + + $this->assertFalse($clientService->isMachineAuthenticated()); + } } diff --git a/tests/phpunit/src/CloudApi/AccessTokenConnectorTest.php b/tests/phpunit/src/CloudApi/AccessTokenConnectorTest.php index 3dba738ae..fe59eebab 100644 --- a/tests/phpunit/src/CloudApi/AccessTokenConnectorTest.php +++ b/tests/phpunit/src/CloudApi/AccessTokenConnectorTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\CloudApi\AccessTokenConnector; use Acquia\Cli\CloudApi\ClientService; use Acquia\Cli\CloudApi\ConnectorFactory; +use Acquia\Cli\CloudApi\DeviceTokenRefresher; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; use Acquia\Cli\Tests\TestBase; @@ -14,6 +15,7 @@ use AcquiaCloudApi\Connector\ConnectorInterface; use League\OAuth2\Client\Provider\GenericProvider; use League\OAuth2\Client\Token\AccessTokenInterface; +use loophp\phposinfo\OsInfo; use org\bovigo\vfs\vfsStream; use Prophecy\Argument; use Psr\Http\Message\RequestInterface; @@ -177,11 +179,55 @@ public function testConnectorConfig(): void $clientService = new ClientService($connectorFactory, $this->application, $this->cloudCredentials); $client = $clientService->getClient(); $options = $client->getOptions(); - $this->assertEquals(['User-Agent' => [0 => 'acli/UNKNOWN']], $options['headers']); + $this->assertStringStartsWith( + sprintf('Acquia CLI (UNKNOWN, %s', OsInfo::uuid()), + $options['headers']['User-Agent'][0], + ); + + $this->prophet->checkPredictions(); + } + + public function testDeviceTokenRefreshOnRequest(): void + { + $refresherProphecy = $this->prophet->prophesize(DeviceTokenRefresher::class); + $refresherProphecy->getValidAccessToken()->willReturn('fresh-device-token')->shouldBeCalled(); + + $connector = new AccessTokenConnector([ + 'access_token' => 'stale-token', + 'key' => null, + 'secret' => null, + ], null, null, $refresherProphecy->reveal()); + + $mockProvider = $this->prophet->prophesize(GenericProvider::class); + $mockProvider->getAuthenticatedRequest('get', ConnectorInterface::BASE_URI . 'api', Argument::that( + fn (AccessTokenInterface $t) => $t->getToken() === 'fresh-device-token' + ))->willReturn($this->prophet->prophesize(RequestInterface::class)->reveal())->shouldBeCalled(); + $connector->setProvider($mockProvider->reveal()); + $request = $connector->createRequest('get', 'api'); + $this->assertInstanceOf(RequestInterface::class, $request); $this->prophet->checkPredictions(); } + public function testDeviceTokenExpiredThrowsOnRequest(): void + { + $refresherProphecy = $this->prophet->prophesize(DeviceTokenRefresher::class); + $refresherProphecy->getValidAccessToken()->willReturn(null); + + $connector = new AccessTokenConnector([ + 'access_token' => 'stale-token', + 'key' => null, + 'secret' => null, + ], null, null, $refresherProphecy->reveal()); + + $mockProvider = $this->prophet->prophesize(GenericProvider::class); + $connector->setProvider($mockProvider->reveal()); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Device token session expired'); + $connector->createRequest('get', 'api'); + } + public function testIdeHeader(): void { self::setAccessTokenEnvVars(); @@ -197,7 +243,7 @@ public function testIdeHeader(): void $client = $clientService->getClient(); $options = $client->getOptions(); $this->assertEquals([ - 'User-Agent' => [0 => 'acli/UNKNOWN'], + 'User-Agent' => [0 => sprintf('Acquia CLI (UNKNOWN, %s, acquia)', OsInfo::uuid())], 'X-Cloud-IDE-UUID' => IdeHelper::$remoteIdeUuid, ], $options['headers']); diff --git a/tests/phpunit/src/CloudApi/AuthConfigTest.php b/tests/phpunit/src/CloudApi/AuthConfigTest.php new file mode 100644 index 000000000..510264957 --- /dev/null +++ b/tests/phpunit/src/CloudApi/AuthConfigTest.php @@ -0,0 +1,85 @@ + */ + private array $savedEnvVars = []; + + protected function setUp(): void + { + parent::setUp(); + foreach (['ACLI_DEVICE_CLIENT_ID', 'ACLI_AUTH_DOMAIN', 'ACLI_AUTH_SERVER_ID'] as $var) { + $this->savedEnvVars[$var] = getenv($var); + putenv($var); + } + } + + protected function tearDown(): void + { + foreach ($this->savedEnvVars as $var => $value) { + putenv($value === false ? $var : $var . '=' . $value); + } + parent::tearDown(); + } + + public function testResolvesValuesFromTheEnvironment(): void + { + putenv('ACLI_DEVICE_CLIENT_ID=env-client'); + putenv('ACLI_AUTH_DOMAIN=env.acquia.com'); + putenv('ACLI_AUTH_SERVER_ID=ausEnv'); + $config = new AuthConfig(); + + $this->assertSame('env-client', $config->clientId()); + $this->assertSame('env.acquia.com', $config->domain()); + $this->assertSame('ausEnv', $config->authServerId()); + } + + public function testReturnsEmptyStringsWhenNothingIsSet(): void + { + $config = new AuthConfig(); + + $this->assertSame('', $config->clientId()); + $this->assertSame('', $config->domain()); + $this->assertSame('', $config->authServerId()); + } + + public function testIsConfiguredIsFalseWhenNothingIsSet(): void + { + $this->assertFalse((new AuthConfig())->isConfigured()); + } + + public function testIsConfiguredIsFalseWhenOnlySomeValuesAreSet(): void + { + putenv('ACLI_DEVICE_CLIENT_ID=env-client'); + putenv('ACLI_AUTH_DOMAIN=env.acquia.com'); + + $this->assertFalse((new AuthConfig())->isConfigured()); + } + + public function testIsConfiguredIsTrueWhenAllValuesResolve(): void + { + putenv('ACLI_DEVICE_CLIENT_ID=env-client'); + putenv('ACLI_AUTH_DOMAIN=env.acquia.com'); + putenv('ACLI_AUTH_SERVER_ID=ausEnv'); + + $this->assertTrue((new AuthConfig())->isConfigured()); + } + + public function testIsConfiguredIsFalseWhenAValueIsSetButEmpty(): void + { + putenv('ACLI_DEVICE_CLIENT_ID=env-client'); + putenv('ACLI_AUTH_DOMAIN=env.acquia.com'); + putenv('ACLI_AUTH_SERVER_ID='); + + $this->assertFalse((new AuthConfig())->isConfigured()); + } +} diff --git a/tests/phpunit/src/CloudApi/ClientServiceTest.php b/tests/phpunit/src/CloudApi/ClientServiceTest.php index 3851449de..52d3e60e4 100644 --- a/tests/phpunit/src/CloudApi/ClientServiceTest.php +++ b/tests/phpunit/src/CloudApi/ClientServiceTest.php @@ -10,10 +10,33 @@ use Acquia\Cli\CloudApi\V3ClientService; use Acquia\Cli\DataStore\CloudDataStore; use Acquia\Cli\Tests\TestBase; +use loophp\phposinfo\OsInfo; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; class ClientServiceTest extends TestBase { + #[Group('serial')] + public function testUserAgentCarriesVersionTelemetryIdAndProvider(): void + { + putenv('LANDO=1'); + try { + $clientService = new ClientService( + new ConnectorFactory(['accessToken' => null, 'key' => null, 'secret' => null]), + $this->application, + $this->cloudCredentials, + ); + $options = $clientService->getClient()->getOptions(); + + $this->assertSame( + sprintf('Acquia CLI (UNKNOWN, %s, lando)', OsInfo::uuid()), + $options['headers']['User-Agent'][0], + ); + } finally { + putenv('LANDO'); + } + } + /** * @return array */ @@ -69,6 +92,26 @@ public function testIsMachineAuthenticated(array $envVars, bool $isAuthenticated self::unsetEnvVars($envVars); } + public function testASubclassOverridesTheAuthenticationCheck(): void + { + $envVars = [ + 'ACLI_ACCESS_TOKEN' => null, + 'ACLI_KEY' => 'key', + 'ACLI_SECRET' => 'secret', + ]; + self::setEnvVars($envVars); + $cloudDatastore = $this->prophet->prophesize(CloudDataStore::class); + $clientService = new TestOverridingClientService(new ConnectorFactory([ + 'accessToken' => null, + 'key' => null, + 'secret' => null, + ]), $this->application, new CloudCredentials($cloudDatastore->reveal())); + + $this->assertTrue($clientService->isMachineAuthenticated()); + $this->assertTrue($clientService->overrideWasCalled); + self::unsetEnvVars($envVars); + } + public function testV3ClientServiceConstructs(): void { $cloudDatastore = $this->prophet->prophesize(CloudDataStore::class); diff --git a/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php b/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php index 1af9873ba..d32e7e4df 100644 --- a/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php +++ b/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php @@ -4,6 +4,7 @@ namespace Acquia\Cli\Tests\CloudApi; +use Acquia\Cli\CloudApi\AccessTokenConnector; use Acquia\Cli\CloudApi\ConnectorFactory; use Acquia\Cli\CloudApi\PathRewriteConnector; use AcquiaCloudApi\Connector\Connector; @@ -46,6 +47,57 @@ public function testCreateConnectorFactoryBehavior(?string $envValue, string $ex $this->assertInstanceOf($expectedClass, $connector); } + public function testBuildsAccessTokenConnectorForAStaleDeviceTokenWithARefreshToken(): void + { + putenv('AH_CODEBASE_UUID'); + $factory = new ConnectorFactory([ + 'accessToken' => null, + 'deviceAccessToken' => 'expired-but-present', + 'key' => null, + 'secret' => null, + ], 'https://api.example.com'); + + $this->assertInstanceOf(AccessTokenConnector::class, $factory->createConnector()); + } + + public function testStoredKeyAndSecretOutrankADeviceToken(): void + { + putenv('AH_CODEBASE_UUID'); + $factory = new ConnectorFactory([ + 'accessToken' => null, + 'deviceAccessToken' => 'device-token', + 'key' => 'k', + 'secret' => 's', + ], 'https://api.example.com'); + + $connector = $factory->createConnector(); + + $this->assertInstanceOf(Connector::class, $connector); + $this->assertNotInstanceOf(AccessTokenConnector::class, $connector); + } + + public function testDeviceTokenOutranksTheAccessTokenEnvVar(): void + { + putenv('AH_CODEBASE_UUID'); + // An expired env token makes the branches distinguishable: alone it fails the + // expiry check, so an AccessTokenConnector here can only be the device path. + $expiredEnvToken = [ + 'accessToken' => 'env-access-token', + 'accessTokenExpiry' => time() - 300, + 'key' => null, + 'secret' => null, + ]; + + $withoutDeviceToken = (new ConnectorFactory($expiredEnvToken, 'https://api.example.com'))->createConnector(); + $this->assertNotInstanceOf(AccessTokenConnector::class, $withoutDeviceToken); + + $withDeviceToken = (new ConnectorFactory( + $expiredEnvToken + ['deviceAccessToken' => 'device-token'], + 'https://api.example.com', + ))->createConnector(); + $this->assertInstanceOf(AccessTokenConnector::class, $withDeviceToken); + } + /** * Data provider for testCreateConnectorFactoryBehavior() test. * diff --git a/tests/phpunit/src/CloudApi/DeviceTokenRefresherTest.php b/tests/phpunit/src/CloudApi/DeviceTokenRefresherTest.php new file mode 100644 index 000000000..072bbfe47 --- /dev/null +++ b/tests/phpunit/src/CloudApi/DeviceTokenRefresherTest.php @@ -0,0 +1,327 @@ + */ + private array $savedEnvVars = []; + + protected function setUp(): void + { + parent::setUp(); + foreach (['ACLI_AUTH_DOMAIN', 'ACLI_AUTH_SERVER_ID'] as $var) { + $this->savedEnvVars[$var] = getenv($var); + putenv($var); + } + $this->removeMockCloudConfigFile(); + } + + protected function tearDown(): void + { + foreach ($this->savedEnvVars as $var => $value) { + putenv($value === false ? $var : $var . '=' . $value); + } + parent::tearDown(); + } + + private function givenAuthIsConfigured(): void + { + putenv('ACLI_AUTH_DOMAIN=example.acquia.com'); + putenv('ACLI_AUTH_SERVER_ID=ausTest'); + } + + /** + * Writes the credential file. + * + * Call before createRefresher() to seed what its datastore loads; call after + * to simulate another invocation writing while this one holds a stale + * in-memory snapshot. + * + * @param array $config + */ + private function writeCloudConfig(array $config): void + { + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode($config + ['send_telemetry' => false])); + } + + /** + * @return array + */ + private function readCloudConfig(): array + { + return json_decode(file_get_contents($this->cloudConfigFilepath), true); + } + + /** + * @param array $responses + */ + private function createRefresher(array $responses = [], ?callable $handler = null): DeviceTokenRefresher + { + $client = new GuzzleClient([ + 'handler' => $handler ?? HandlerStack::create(new MockHandler($responses)), + ]); + + return new DeviceTokenRefresher( + new CloudDataStore($this->localMachineHelper, new CloudDataConfig(), $this->cloudConfigFilepath), + $this->logger, + $client, + new AuthConfig(), + ); + } + + /** + * @return array + */ + private function expiredToken(): array + { + return [ + 'access_token' => 'expired-token', + 'client_id' => 'client-123', + 'expiry' => time() - 300, + 'refresh_token' => 'old-refresh-token', + ]; + } + + public function testReturnsNullWhenNoTokenStored(): void + { + $this->writeCloudConfig([]); + + $this->assertNull($this->createRefresher()->getValidAccessToken()); + } + + public function testReturnsStoredTokenWhenNotExpired(): void + { + $this->writeCloudConfig(['device_token' => [ + 'access_token' => 'valid-token', + 'client_id' => 'client-123', + 'expiry' => time() + 300, + 'refresh_token' => 'refresh-123', + ], + ]); + + $this->assertSame('valid-token', $this->createRefresher()->getValidAccessToken()); + } + + public function testReturnsNullWhenExpiredAndRefreshTokenMissing(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => [ + 'access_token' => 'expired-token', + 'client_id' => 'client-123', + 'expiry' => time() - 300, + ], + ]); + + $this->assertNull($this->createRefresher()->getValidAccessToken()); + } + + public function testReturnsNullWhenExpiredAndAuthConfigMissing(): void + { + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + + $this->assertNull($this->createRefresher()->getValidAccessToken()); + } + + public function testRefreshesExpiredTokenAndPersistsIt(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new Response(200, [], json_encode([ + 'access_token' => 'new-access-token', + 'expires_in' => 3600, + 'refresh_token' => 'new-refresh-token', + ])), + ]); + + $this->assertSame('new-access-token', $refresher->getValidAccessToken()); + + $stored = $this->readCloudConfig()['device_token']; + $this->assertSame('new-access-token', $stored['access_token']); + $this->assertSame('new-refresh-token', $stored['refresh_token']); + $this->assertSame('client-123', $stored['client_id']); + $this->assertGreaterThan(time(), $stored['expiry']); + } + + public function testKeepsOldRefreshTokenWhenResponseOmitsNewOne(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new Response(200, [], json_encode(['access_token' => 'new-access-token', 'expires_in' => 3600])), + ]); + + $refresher->getValidAccessToken(); + + $this->assertSame('old-refresh-token', $this->readCloudConfig()['device_token']['refresh_token']); + } + + public function testUsesATokenAnotherInvocationWroteWhileThisOneWasStale(): void + { + // The datastore loads an expired token; another invocation then writes a + // fresh one. Spending a rotation here would kill the session for both. + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new Response(500, [], 'the refresher must not reach Okta'), + ]); + + $this->writeCloudConfig(['device_token' => [ + 'access_token' => 'written-by-another-process', + 'client_id' => 'client-123', + 'expiry' => time() + 3600, + 'refresh_token' => 'their-refresh-token', + ], + ]); + + $this->assertSame('written-by-another-process', $refresher->getValidAccessToken()); + } + + public function testDoesNotClobberCredentialsWrittenSinceTheProcessStarted(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new Response(200, [], json_encode(['access_token' => 'new-access-token', 'expires_in' => 3600])), + ]); + + $this->writeCloudConfig([ + 'acli_key' => 'added-later', + 'device_token' => $this->expiredToken(), + 'keys' => ['added-later' => ['label' => 'Later Key', 'secret' => 's', 'uuid' => 'added-later']], + ]); + + $refresher->getValidAccessToken(); + + $config = $this->readCloudConfig(); + $this->assertSame('added-later', $config['acli_key']); + $this->assertArrayHasKey('added-later', $config['keys']); + $this->assertSame('new-access-token', $config['device_token']['access_token']); + } + + public function testReturnsNullOnInvalidGrant(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new Response(400, [], json_encode(['error' => 'invalid_grant'])), + ]); + + $this->assertNull($refresher->getValidAccessToken()); + $this->assertSame('expired-token', $this->readCloudConfig()['device_token']['access_token']); + } + + public function testReturnsNullOnA400ThatIsNotInvalidGrant(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new Response(400, [], json_encode(['error' => 'invalid_client'])), + ]); + + $this->assertNull($refresher->getValidAccessToken()); + } + + public function testReturnsNullOnATransportFailure(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new ConnectException('cURL error 6: Could not resolve host', new Request('POST', 'https://example.acquia.com')), + ]); + + $this->assertNull($refresher->getValidAccessToken()); + } + + public function testReturnsNullWhenRefreshResponseHasNoAccessToken(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new Response(200, [], json_encode(['token_type' => 'Bearer'])), + ]); + + $this->assertNull($refresher->getValidAccessToken()); + } + + public function testRefreshRequestCarriesAnExplicitTimeout(): void + { + // GuzzleHttp\Client is autowired, so the container injects one built + // without options; a hung /token would block every API request. + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $captured = []; + $refresher = $this->createRefresher([], function ($request, array $options) use (&$captured) { + $captured[] = $options; + return Create::promiseFor(new Response(200, [], json_encode([ + 'access_token' => 'new-access-token', + 'expires_in' => 3600, + ]))); + }); + + $refresher->getValidAccessToken(); + + $this->assertNotEmpty($captured); + $this->assertArrayHasKey('timeout', $captured[0]); + $this->assertGreaterThan(0, $captured[0]['timeout']); + } + + public function testDoesNotClobberTheRefreshWithALaterUnrelatedSet(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $datastore = new CloudDataStore($this->localMachineHelper, new CloudDataConfig(), $this->cloudConfigFilepath); + $client = new GuzzleClient([ + 'handler' => HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'access_token' => 'new-access-token', + 'expires_in' => 3600, + 'refresh_token' => 'new-refresh-token', + ])), + ])), + ]); + $refresher = new DeviceTokenRefresher($datastore, $this->logger, $client, new AuthConfig()); + + $this->assertSame('new-access-token', $refresher->getValidAccessToken()); + + $datastore->set('user', ['uuid' => 'test-uuid']); + + $stored = $this->readCloudConfig()['device_token']; + $this->assertSame('new-access-token', $stored['access_token']); + $this->assertSame('new-refresh-token', $stored['refresh_token']); + } + + public function testTakesTheLockBesideTheCredentialFile(): void + { + $this->givenAuthIsConfigured(); + $this->writeCloudConfig(['device_token' => $this->expiredToken()]); + $refresher = $this->createRefresher([ + new Response(200, [], json_encode(['access_token' => 'new-access-token', 'expires_in' => 3600])), + ]); + + $refresher->getValidAccessToken(); + + $this->assertFileExists($this->cloudConfigFilepath . '.lock'); + } +} diff --git a/tests/phpunit/src/CloudApi/TestOverridingClientService.php b/tests/phpunit/src/CloudApi/TestOverridingClientService.php new file mode 100644 index 000000000..e179aa356 --- /dev/null +++ b/tests/phpunit/src/CloudApi/TestOverridingClientService.php @@ -0,0 +1,22 @@ +overrideWasCalled = true; + return parent::checkAuthentication(); + } +} diff --git a/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php b/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php index 13ae91c0e..2fd15122f 100644 --- a/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php +++ b/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php @@ -10,23 +10,654 @@ use Acquia\Cli\Config\CloudDataConfig; use Acquia\Cli\DataStore\CloudDataStore; use Acquia\Cli\Exception\AcquiaCliException; +use Acquia\Cli\Helpers\LocalMachineHelper; use Acquia\Cli\Tests\CommandTestBase; use AcquiaCloudApi\Connector\Connector; use Generator; +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Validator\Exception\ValidatorException; /** * @property AuthLoginCommand $command */ +#[Group('serial')] class AuthLoginCommandTest extends CommandTestBase { + /** @var array */ + private array $savedDeviceCodeEnvVars = []; + + protected function setUp(): void + { + parent::setUp(); + foreach (['ACLI_DEVICE_CLIENT_ID', 'ACLI_AUTH_DOMAIN', 'ACLI_AUTH_SERVER_ID'] as $var) { + $this->savedDeviceCodeEnvVars[$var] = getenv($var); + putenv($var); + } + } + + protected function tearDown(): void + { + parent::tearDown(); + foreach ($this->savedDeviceCodeEnvVars as $var => $value) { + putenv($value === false ? $var : $var . '=' . $value); + } + } + protected function createCommand(): CommandBase { return $this->injectCommand(AuthLoginCommand::class); } + private function enableDeviceCodeConfig(): void + { + putenv('ACLI_DEVICE_CLIENT_ID=test-client-id'); + putenv('ACLI_AUTH_DOMAIN=example.acquia.com'); + putenv('ACLI_AUTH_SERVER_ID=ausTest'); + } + + /** + * @param array<\GuzzleHttp\Psr7\Response> $responses + */ + private function createDeviceCodeCommand(array $responses, ?ObjectProphecy $localMachineHelperProphecy = null): AuthLoginCommand + { + $mock = new MockHandler($responses); + $client = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + if ($localMachineHelperProphecy === null) { + $localMachineHelperProphecy = $this->prophet->prophesize(LocalMachineHelper::class); + $localMachineHelperProphecy->isBrowserAvailable()->willReturn(false); + } + $localMachineHelper = $localMachineHelperProphecy->reveal(); + + return new AuthLoginCommand( + $localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $client, + ); + } + + private function givenFreshCloudConfigWithTelemetryDisabled(): void + { + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode(['send_telemetry' => false])); + $this->createDataStores(); + } + + /** + * A LocalMachineHelper double for tests that do not exercise browser opening. + */ + private function headlessLocalMachineHelper(): ObjectProphecy + { + $prophecy = $this->prophet->prophesize(LocalMachineHelper::class); + $prophecy->useTty()->willReturn(false); + $prophecy->isBrowserAvailable()->willReturn(false); + + return $prophecy; + } + + private function deviceAuthorizeResponse(): Response + { + return new Response(200, [], json_encode([ + 'device_code' => 'test-device-code', + 'expires_in' => 600, + 'interval' => 0, + 'user_code' => 'ABCD1234', + 'verification_uri' => 'https://example.acquia.com/activate', + ])); + } + + // ------------------------------------------------------------------------- + // Device code flow tests + // ------------------------------------------------------------------------- + public function testDeviceCodeFlowSuccess(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode([ + 'access_token' => 'test-access-token', + 'expires_in' => 300, + 'refresh_token' => 'test-refresh-token', + ])), + ]); + + $this->executeCommand([], []); + $output = $this->getDisplay(); + + $this->assertStringContainsString('https://example.acquia.com/activate', $output); + $this->assertStringContainsString('ABCD1234', $output); + $this->assertStringContainsString('Authenticated successfully', $output); + + $config = new CloudDataStore($this->localMachineHelper, new CloudDataConfig(), $this->cloudConfigFilepath); + $deviceToken = $config->get('device_token'); + $this->assertEquals('test-access-token', $deviceToken['access_token']); + $this->assertEquals('test-refresh-token', $deviceToken['refresh_token']); + $this->assertEquals('test-client-id', $deviceToken['client_id']); + } + + public function testDeviceCodeFlowAuthorizationPendingThenSuccess(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode(['error' => 'authorization_pending'])), + new Response(200, [], json_encode([ + 'access_token' => 'test-access-token', + 'expires_in' => 300, + 'refresh_token' => 'test-refresh-token', + ])), + ]); + + $this->executeCommand([], []); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Authenticated successfully', $output); + } + + public function testDeviceCodeFlowAccessDenied(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode(['error' => 'access_denied'])), + ]); + + $this->executeCommand([], ['no']); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Authorization denied', $output); + } + + public function testDeviceCodeFlowExpiredToken(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode(['error' => 'expired_token'])), + ]); + + $this->executeCommand([], ['no']); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Code expired', $output); + } + + public function testDeviceCodeFlowInitiateFailure(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + new Response(400, [], json_encode(['error' => 'invalid_client'])), + ]); + + $this->executeCommand([], ['no']); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Failed to initiate device code flow', $output); + } + + public function testDeviceCodeFlowFallsBackToLegacyOnFailure(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->mockRequest('getAccount'); + $this->clientServiceProphecy->setConnector(Argument::type(Connector::class)) + ->shouldBeCalled(); + $this->clientServiceProphecy->isMachineAuthenticated() + ->willReturn(false); + + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $this->command = $this->createDeviceCodeCommand([ + new Response(400, [], json_encode(['error' => 'invalid_client'])), + ], $localMachineHelperProphecy); + + // inputs: 'yes' = fallback confirm, 'no' = open browser to create token, then key + secret. + $this->executeCommand([], ['yes', 'no', self::$key, self::$secret]); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Failed to initiate device code flow', $output); + $this->assertStringContainsString('Saved credentials', $output); + } + + public function testDeviceCodeFlowOpensBrowserWhenAvailable(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $localMachineHelperProphecy = $this->prophet->prophesize(LocalMachineHelper::class); + $localMachineHelperProphecy->isBrowserAvailable()->willReturn(true); + $localMachineHelperProphecy->startBrowser('https://example.acquia.com/activate') + ->shouldBeCalled() + ->willReturn(true); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode([ + 'access_token' => 'test-access-token', + 'expires_in' => 300, + ])), + ], $localMachineHelperProphecy); + + $this->executeCommand([], []); + $this->assertStringContainsString('Confirm the code above matches', $this->getDisplay()); + } + + public function testDeviceCodeFlowDoesNotOpenBrowserWhenUnavailable(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $localMachineHelperProphecy = $this->prophet->prophesize(LocalMachineHelper::class); + $localMachineHelperProphecy->isBrowserAvailable()->willReturn(false); + $localMachineHelperProphecy->startBrowser(Argument::any()) + ->shouldNotBeCalled(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode([ + 'access_token' => 'test-access-token', + 'expires_in' => 300, + ])), + ], $localMachineHelperProphecy); + + $this->executeCommand([], []); + } + + public function testDeviceCodeFlowDoesNotOpenBrowserOverSsh(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $savedSsh = getenv('SSH_CONNECTION'); + putenv('SSH_CONNECTION=127.0.0.1 12345'); + $localMachineHelperProphecy = $this->prophet->prophesize(LocalMachineHelper::class); + $localMachineHelperProphecy->isBrowserAvailable()->willReturn(true); + $localMachineHelperProphecy->startBrowser(Argument::any()) + ->shouldNotBeCalled(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode([ + 'access_token' => 'test-access-token', + 'expires_in' => 300, + ])), + ], $localMachineHelperProphecy); + + try { + $this->executeCommand([], []); + } finally { + putenv($savedSsh === false ? 'SSH_CONNECTION' : 'SSH_CONNECTION=' . $savedSsh); + } + } + + public function testSmartRoutingDeviceTokenReauthDeclined(): void + { + $this->enableDeviceCodeConfig(); + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode([ + 'device_token' => [ + 'access_token' => 'existing-token', + 'client_id' => 'test-client-id', + 'expiry' => time() + 300, + 'refresh_token' => 'existing-refresh-token', + ], + 'send_telemetry' => false, + ])); + $this->createDataStores(); + $this->command = $this->createDeviceCodeCommand([]); + + $this->executeCommand([], ['no']); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Already authenticated via', $output); + $this->assertStringNotContainsString('Authenticated successfully', $output); + } + + public function testNoInteractionReauthenticatesAnExistingDeviceSessionWithoutPrompting(): void + { + $this->enableDeviceCodeConfig(); + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode([ + 'device_token' => [ + 'access_token' => 'existing-token', + 'client_id' => 'test-client-id', + 'expiry' => time() + 300, + 'refresh_token' => 'existing-refresh-token', + ], + 'send_telemetry' => false, + ])); + $this->createDataStores(); + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode([ + 'access_token' => 'new-access-token', + 'expires_in' => 300, + 'refresh_token' => 'new-refresh-token', + ])), + ], $localMachineHelperProphecy); + + $this->executeCommand([], [], OutputInterface::VERBOSITY_VERY_VERBOSE, false); + $output = $this->getDisplay(); + + $this->assertStringNotContainsString('Re-authenticate?', $output); + $this->assertStringContainsString('Authenticated successfully', $output); + } + + public function testNoInteractionWithAnActiveApiKeyRoutesToLegacyWithoutPrompting(): void + { + $this->enableDeviceCodeConfig(); + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $localMachineHelperProphecy->startBrowser(Argument::any())->willReturn(true); + $this->command = $this->createDeviceCodeCommand([], $localMachineHelperProphecy); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Enter your Cloud Platform API key'); + + $this->executeCommand([], [], OutputInterface::VERBOSITY_VERY_VERBOSE, false); + } + + public function testNoInteractionDoesNotOfferLegacyFallbackOnDeviceCodeFailure(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $this->command = $this->createDeviceCodeCommand([ + new Response(400, [], json_encode(['error' => 'invalid_client'])), + ], $localMachineHelperProphecy); + + $this->executeCommand([], [], OutputInterface::VERBOSITY_VERY_VERBOSE, false); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Failed to initiate device code flow', $output); + $this->assertStringNotContainsString('Fall back to API key authentication?', $output); + $this->assertStringNotContainsString('Saved credentials', $output); + } + + public function testDeviceCodeFlowBacksOffOnSlowDownThenSucceeds(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode(['error' => 'slow_down'])), + new Response(200, [], json_encode([ + 'access_token' => 'access-token-123', + 'expires_in' => 300, + 'refresh_token' => 'refresh-token-123', + ])), + ], $localMachineHelperProphecy); + + $this->executeCommand([], []); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Authenticated successfully', $output); + $this->assertStringNotContainsString('slow_down', $output); + $this->assertStringNotContainsString('Unexpected error', $output); + } + + public function testDeviceCodeFlowRetriesAfterTransportFailureInPollLoop(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new ConnectException('cURL error 28: Operation timed out', new Request('POST', 'https://example.acquia.com')), + new Response(200, [], json_encode([ + 'access_token' => 'access-token-123', + 'expires_in' => 300, + 'refresh_token' => 'refresh-token-123', + ])), + ], $localMachineHelperProphecy); + + $this->executeCommand([], []); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Authenticated successfully', $output); + $this->assertStringNotContainsString('Timed out waiting for authorization', $output); + } + + public function testUsesDeviceCodeAfterLogoutEvenWhenKeysRemainOnDisk(): void + { + // Login routes on the *active* key, which auth:logout clears while leaving + // `keys` on disk. This is the migration path off API keys. + $this->enableDeviceCodeConfig(); + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode([ + 'keys' => [ + 'key1' => ['label' => 'Test Key', 'secret' => 'secret', 'uuid' => 'key1'], + ], + 'send_telemetry' => false, + ])); + $this->createDataStores(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode([ + 'access_token' => 'access-token-123', + 'expires_in' => 300, + 'refresh_token' => 'refresh-token-123', + ])), + ], $this->headlessLocalMachineHelper()); + + $this->executeCommand([], []); + $output = $this->getDisplay(); + + $this->assertStringNotContainsString('Already authenticated as', $output); + $this->assertStringContainsString('Sign in to Acquia ID in your browser', $output); + $this->assertStringContainsString('Authenticated successfully', $output); + $this->assertArrayHasKey('device_token', json_decode(file_get_contents($this->cloudConfigFilepath), true)); + } + + + public function testDeviceTokenBranchRequiresAuthConfig(): void + { + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode([ + 'device_token' => [ + 'access_token' => 'existing-token', + 'client_id' => 'client-123', + 'expiry' => time() + 300, + 'refresh_token' => 'existing-refresh-token', + ], + 'send_telemetry' => false, + ])); + $this->createDataStores(); + $this->mockRequest('getAccount'); + $this->clientServiceProphecy->setConnector(Argument::type(Connector::class))->shouldBeCalled(); + $this->clientServiceProphecy->isMachineAuthenticated()->willReturn(false); + $this->command = $this->createDeviceCodeCommand([], $this->headlessLocalMachineHelper()); + + $this->executeCommand([], ['no', self::$key, self::$secret]); + $output = $this->getDisplay(); + + $this->assertStringContainsString('not configured', $output); + $this->assertStringNotContainsString('Unable to parse URI', $output); + } + + public function testOffersDeviceCodeToAKeyHolderAndDeactivatesTheKeyOnSuccess(): void + { + $this->enableDeviceCodeConfig(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode([ + 'access_token' => 'access-token-123', + 'expires_in' => 300, + 'refresh_token' => 'refresh-token-123', + ])), + ], $this->headlessLocalMachineHelper()); + + $this->executeCommand([], ['yes']); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Sign in with device code instead?', $output); + $this->assertStringContainsString('Authenticated successfully', $output); + + // Without clearing acli_key the token sits unreachable behind + // ConnectorFactory priority (2) and the login changes nothing. + $config = json_decode(file_get_contents($this->cloudConfigFilepath), true); + $this->assertArrayNotHasKey('acli_key', $config); + $this->assertArrayHasKey('device_token', $config); + $this->assertArrayHasKey('keys', $config); + } + + public function testDecliningTheDeviceCodeOfferReachesTheKeyChooser(): void + { + // Declining must not exit: auth:login's documented purpose is switching + // between stored accounts, which is what the chooser is for. + $this->enableDeviceCodeConfig(); + $this->clientServiceProphecy->setConnector(Argument::type(Connector::class))->shouldBeCalled(); + $this->clientServiceProphecy->isMachineAuthenticated()->willReturn(false); + $this->command = $this->createDeviceCodeCommand([], $this->headlessLocalMachineHelper()); + + $this->executeCommand([], ['no', 'Test Key']); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Activate a Cloud Platform API key', $output); + $this->assertStringNotContainsString('Sign in to Acquia ID in your browser', $output); + } + + + public function testDeviceCodeFlowRejectsMalformedAuthorizeResponse(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + new Response(200, [], json_encode(['user_code' => 'ABCD-EFGH'])), + ], $this->headlessLocalMachineHelper()); + + $this->executeCommand([], ['no']); + + $this->assertStringContainsString('Unexpected response from device authorization endpoint', $this->getDisplay()); + } + + public function testDeviceCodeFlowStopsOnAnUnrecognizedTokenError(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode(['error' => 'invalid_scope'])), + ], $this->headlessLocalMachineHelper()); + + $this->executeCommand([], ['no']); + + $this->assertStringContainsString('Unexpected error: invalid_scope', $this->getDisplay()); + } + + public function testDeviceCodeFlowTimesOutWhenTheWindowCloses(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + new Response(200, [], json_encode([ + 'device_code' => 'device-123', + 'expires_in' => 0, + 'interval' => 0, + 'user_code' => 'ABCD-EFGH', + 'verification_uri' => 'https://example.acquia.com/activate', + ])), + ], $this->headlessLocalMachineHelper()); + + $this->executeCommand([], ['no']); + + $this->assertStringContainsString('Timed out waiting for authorization', $this->getDisplay()); + } + + public function testDeviceCodeFlowStoresAZeroExpiryWhenTokenResponseOmitsExpiresIn(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->command = $this->createDeviceCodeCommand([ + $this->deviceAuthorizeResponse(), + new Response(200, [], json_encode(['access_token' => 'access-token-123'])), + ], $this->headlessLocalMachineHelper()); + + $this->executeCommand([], []); + + $this->assertStringContainsString('Authenticated successfully', $this->getDisplay()); + $stored = json_decode(file_get_contents($this->cloudConfigFilepath), true)['device_token']; + $this->assertSame(0, $stored['expiry']); + $this->assertNull($stored['refresh_token']); + } + + public function testDeviceCodeFlowRejectsTokenResponseWithoutAccessToken(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $this->command = $this->createDeviceCodeCommand([ + new Response(200, [], json_encode([ + 'device_code' => 'device-123', + 'expires_in' => 600, + 'interval' => 0, + 'user_code' => 'ABCD-EFGH', + 'verification_uri' => 'https://example.acquia.com/activate', + ])), + new Response(200, [], json_encode(['token_type' => 'Bearer'])), + ], $localMachineHelperProphecy); + + $this->executeCommand([], ['no']); + $output = $this->getDisplay(); + + $this->assertStringNotContainsString('Authenticated successfully', $output); + $this->assertStringContainsString('Unexpected response', $output); + } + + public function testDeviceCodeFlowSurvivesTransportFailureOnInitiation(): void + { + $this->enableDeviceCodeConfig(); + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $this->command = $this->createDeviceCodeCommand([ + new ConnectException('cURL error 6: Could not resolve host', new Request('POST', 'https://example.acquia.com')), + ], $localMachineHelperProphecy); + + $this->executeCommand([], ['no']); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Failed to initiate device code flow', $output); + } + + public function testReportsWhyItFallsBackWhenDeviceCodeIsNotConfigured(): void + { + $this->givenFreshCloudConfigWithTelemetryDisabled(); + $this->mockRequest('getAccount'); + $this->clientServiceProphecy->setConnector(Argument::type(Connector::class)) + ->shouldBeCalled(); + $this->clientServiceProphecy->isMachineAuthenticated() + ->willReturn(false); + + $localMachineHelperProphecy = $this->headlessLocalMachineHelper(); + $this->command = $this->createDeviceCodeCommand([], $localMachineHelperProphecy); + + $this->executeCommand([], ['no', self::$key, self::$secret]); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Device code sign-in is not configured', $output); + $this->assertStringContainsString('Saved credentials', $output); + } + + // ------------------------------------------------------------------------- + // Legacy auth tests + // ------------------------------------------------------------------------- public function testAuthLoginCommand(): void { $this->mockRequest('getAccount'); @@ -60,7 +691,7 @@ public static function providerTestAuthLoginCommandWithStagingEnvironment(): arr ]; } - #[\PHPUnit\Framework\Attributes\DataProvider('providerTestAuthLoginCommandWithStagingEnvironment')] + #[DataProvider('providerTestAuthLoginCommandWithStagingEnvironment')] public function testAuthLoginCommandWithStagingEnvironment(string $environment): void { $this->mockRequest('getAccount'); @@ -162,7 +793,7 @@ public function testAuthLoginInteractiveSelectsExistingEnvironmentKey(): void $this->executeCommand( ['--environment' => 'staging'], - ['Staging Key'], + ['yes', 'Staging Key'], ); $output = $this->getDisplay(); diff --git a/tests/phpunit/src/Commands/Auth/AuthLoginRoutingTest.php b/tests/phpunit/src/Commands/Auth/AuthLoginRoutingTest.php new file mode 100644 index 000000000..147087405 --- /dev/null +++ b/tests/phpunit/src/Commands/Auth/AuthLoginRoutingTest.php @@ -0,0 +1,118 @@ + + */ + private array $oktaResponses = []; + + /** + * Whether the stubbed AuthConfig reports device code as configured. + */ + private bool $authConfigured = true; + + protected function createCommand(): CommandBase + { + $localMachineHelper = $this->prophet->prophesize(LocalMachineHelper::class); + $localMachineHelper->useTty()->willReturn(false); + $localMachineHelper->isBrowserAvailable()->willReturn(false); + + $authConfig = $this->prophet->prophesize(AuthConfig::class); + $authConfig->isConfigured()->willReturn($this->authConfigured); + $authConfig->clientId()->willReturn('client-123'); + $authConfig->domain()->willReturn('example.acquia.com'); + $authConfig->authServerId()->willReturn('ausTest'); + + return new AuthLoginCommand( + $localMachineHelper->reveal(), + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + new GuzzleClient(['handler' => HandlerStack::create(new MockHandler($this->oktaResponses))]), + $authConfig->reveal(), + ); + } + + /** + * @return array, array}> + */ + public static function providerLegacyRoutingOptions(): array + { + return [ + 'key alone' => [['--key' => self::$key], ['no', self::$secret]], + 'secret alone' => [['--secret' => self::$secret], ['no', self::$key]], + + 'use-legacy-auth alone' => [['--use-legacy-auth' => true], ['no', self::$key, self::$secret]], + ]; + } + + /** + * @param array $args + * @param array $inputs + */ + #[DataProvider('providerLegacyRoutingOptions')] + public function testCommandLineOptionsRouteToLegacyEvenWhenDeviceCodeIsConfigured(array $args, array $inputs): void + { + $this->mockRequest('getAccount'); + $this->clientServiceProphecy->setConnector(Argument::type(Connector::class))->shouldBeCalled(); + $this->clientServiceProphecy->isMachineAuthenticated()->willReturn(false); + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode(['send_telemetry' => false])); + $this->createDataStores(); + $this->command = $this->createCommand(); + + $this->executeCommand($args, $inputs); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Saved credentials', $output); + $this->assertStringNotContainsString('Sign in to Acquia ID in your browser', $output); + } + + public function testNoCommandLineOptionsSkipsTheLegacyGuard(): void + { + $this->authConfigured = false; + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode(['send_telemetry' => false])); + $this->createDataStores(); + $this->mockRequest('getAccount'); + $this->clientServiceProphecy->setConnector(Argument::type(Connector::class))->shouldBeCalled(); + $this->clientServiceProphecy->isMachineAuthenticated()->willReturn(false); + $this->command = $this->createCommand(); + + $this->executeCommand([], ['no', self::$key, self::$secret]); + + $this->assertStringContainsString('Device code sign-in is not configured', $this->getDisplay()); + } +} diff --git a/tests/phpunit/src/Commands/Auth/AuthLogoutCommandTest.php b/tests/phpunit/src/Commands/Auth/AuthLogoutCommandTest.php index a915c6a4c..1e1a71292 100644 --- a/tests/phpunit/src/Commands/Auth/AuthLogoutCommandTest.php +++ b/tests/phpunit/src/Commands/Auth/AuthLogoutCommandTest.php @@ -29,6 +29,84 @@ public function testAuthLogoutCommand(): void $this->assertStringContainsString('The active Cloud Platform API credentials were deactivated', $output); } + public function testAuthLogoutWithDeviceTokenOnly(): void + { + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode([ + 'device_token' => [ + 'access_token' => 'existing-token', + 'client_id' => 'client-123', + 'expiry' => time() + 300, + 'refresh_token' => 'existing-refresh-token', + ], + 'send_telemetry' => false, + ])); + $this->createDataStores(); + $this->command = $this->createCommand(); + + $this->executeCommand(); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Device code session removed', $output); + $this->assertStringContainsString('No Cloud Platform credentials are active', $output); + $this->assertStringNotContainsString('will be deactivated on this machine', $output); + $this->assertArrayNotHasKey('device_token', json_decode(file_get_contents($this->cloudConfigFilepath), true)); + } + + public function testAuthLogoutRemovesBothCredentialTypes(): void + { + $this->executeCommandWithDeviceTokenAlongsideKey(); + $output = $this->getDisplay(); + + $this->assertStringContainsString('Device code session removed', $output); + $this->assertStringContainsString('The key Test Key will be deactivated on this machine.', $output); + $this->assertStringContainsString('The active Cloud Platform API credentials were deactivated', $output); + + $config = json_decode(file_get_contents($this->cloudConfigFilepath), true); + $this->assertArrayNotHasKey('device_token', $config); + $this->assertArrayNotHasKey('acli_key', $config); + } + + public function testAuthLogoutWithNoCredentialsAtAll(): void + { + $this->removeMockCloudConfigFile(); + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode(['send_telemetry' => false])); + $this->createDataStores(); + $this->command = $this->createCommand(); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('There are no active Cloud Platform credentials'); + + $this->executeCommand(); + } + + private function executeCommandWithDeviceTokenAlongsideKey(): void + { + $config = json_decode(file_get_contents($this->cloudConfigFilepath), true); + $config['device_token'] = [ + 'access_token' => 'existing-token', + 'client_id' => 'client-123', + 'expiry' => time() + 300, + 'refresh_token' => 'existing-refresh-token', + ]; + $this->fs->dumpFile($this->cloudConfigFilepath, json_encode($config)); + $this->createDataStores(); + $this->command = $this->createCommand(); + + $this->executeCommand(); + } + + public function testAuthLogoutDeletesCredentialsWhenAsked(): void + { + $this->executeCommand(['--delete' => true]); + $output = $this->getDisplay(); + + $this->assertStringContainsString('The active Cloud Platform API credentials were deleted', $output); + $config = json_decode(file_get_contents($this->cloudConfigFilepath), true); + $this->assertArrayNotHasKey('acli_key', $config); + $this->assertSame([], $config['keys'] ?? []); + } + public function testAuthLogoutInvalidDatastore(): void { $this->clientServiceProphecy->isMachineAuthenticated()