diff --git a/README.md b/README.md index db05c36..9d389b4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OVHcloud APIs lightweight PHP wrapper -[![PHP Wrapper for OVH APIs](https://github.com/ovh/php-ovh/blob/master/img/logo.png)](https://packagist.org/packages/ovh/ovh) +[![PHP Wrapper for OVHcloud APIs](https://github.com/ovh/php-ovh/blob/master/img/logo.png)](https://packagist.org/packages/ovh/ovh) [![Source Code](https://img.shields.io/badge/source-ovh/php--ovh-blue.svg?style=flat-square)](https://github.com/ovh/php-ovh) [![Build Status](https://img.shields.io/github/actions/workflow/status/ovh/php-ovh/ci.yaml?label=CI&logo=github&style=flat-square)](https://github.com/ovh/php-ovh/actions?query=workflow%3ACI) @@ -38,7 +38,7 @@ echo 'Welcome '.$ovh->get('/me')['firstname']; ### Handle exceptions -Under the hood, ```php-ovh``` uses [Guzzle](http://docs.guzzlephp.org/en/latest/quickstart.html) by default to issue API requests. +Under the hood, `php-ovh` uses [Guzzle](http://docs.guzzlephp.org/en/latest/quickstart.html) by default to issue API requests. If everything goes well, it will return the response directly as shown in the examples above. @@ -89,7 +89,6 @@ After allowing access to his account, he will be redirected to your application. See "OVHcloud API authentication" section below for more information about the authorization flow. - ```php use \Ovh\Api; session_start(); @@ -147,9 +146,29 @@ foreach ($servers as $server) { ### More code samples -Do you want to use OVH APIs? Maybe the script you want is already written in the [example part](examples/README.md) of this repository! +Do you want to use OVHcloud APIs? Maybe the script you want is already written in the [example part](examples/README.md) of this repository! + +## OAuth2 authentification + +`php-ovh` supports two forms of authentication: + +* OAuth2, using scopped service accounts, and compatible with OVHcloud IAM +* application key & application secret & consumer key (covered in the next chapter) + +For OAuth2, first, you need to generate a pair of valid `client_id` and `client_secret`: you can proceed by +[following this documentation](https://help.ovhcloud.com/csm/en-manage-service-account?id=kb_article_view&sysparm_article=KB0059343). + +Once you have retrieved your `client_id` and `client_secret`, you can instantiate an API client using: + +```php +use \Ovh\Api; + +$ovh = Api::withOauth2($clientId, $clientSecret, $endpoint); +``` + +Supported endpoints are only `ovh-eu`, `ovh-ca` and `ovh-us`. -## OVHcloud API authentication +## Custom OVHcloud API authentication To use the OVHcloud APIs you need three credentials: @@ -169,7 +188,7 @@ They can also be created together if your application is intended to use only yo ### OVHcloud Europe -* ```$endpoint = 'ovh-eu';``` +* `$endpoint = 'ovh-eu';` * Documentation: * Console: * Create application credentials (generate only application credentials, your app will need to implement an authorization flow): @@ -178,7 +197,7 @@ They can also be created together if your application is intended to use only yo ### OVHcloud US -* ```$endpoint = 'ovh-us';``` +* `$endpoint = 'ovh-us';` * Documentation: * Console: * Create application credentials (generate only application credentials, your app will need to implement an authorization flow): @@ -186,7 +205,7 @@ They can also be created together if your application is intended to use only yo ### OVHcloud North America / Canada -* ```$endpoint = 'ovh-ca';``` +* `$endpoint = 'ovh-ca';` * Documentation: * Console: * Create application credentials (generate only application credentials, your app will need to implement an authorization flow): @@ -195,7 +214,7 @@ They can also be created together if your application is intended to use only yo ### So you Start Europe -* ```$endpoint = 'soyoustart-eu';``` +* `$endpoint = 'soyoustart-eu';` * Documentation: * Console: * Create application credentials (generate only application credentials, your app will need to implement an authorization flow): @@ -204,7 +223,7 @@ They can also be created together if your application is intended to use only yo ### So you Start North America -* ```$endpoint = 'soyoustart-ca';``` +* `$endpoint = 'soyoustart-ca';` * Documentation: * Console: * Create application credentials (generate only application credentials, your app will need to implement an authorization flow): @@ -213,7 +232,7 @@ They can also be created together if your application is intended to use only yo ### Kimsufi Europe -* ```$endpoint = 'kimsufi-eu';``` +* `$endpoint = 'kimsufi-eu';` * Documentation: * Console: * Create application credentials (generate only application credentials, your app will need to implement an authorization flow): @@ -222,7 +241,7 @@ They can also be created together if your application is intended to use only yo ### Kimsufi North America -* ```$endpoint = 'kimsufi-ca';``` +* `$endpoint = 'kimsufi-ca';` * Documentation: * Console: * Create application credentials (generate only application credentials, your app will need to implement an authorization flow): diff --git a/composer.json b/composer.json index a771425..c9f1454 100644 --- a/composer.json +++ b/composer.json @@ -18,13 +18,14 @@ ], "require": { "php": ">=7.4", + "ext-json": "*", "guzzlehttp/guzzle": "^6.0||^7.0", - "ext-json": "*" + "league/oauth2-client": "^2.7" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.3.1", "phpdocumentor/shim": "^3", - "phpunit/phpunit": "^9.5", + "phpunit/phpunit": "^9.6", "squizlabs/php_codesniffer": "^3.6" }, "autoload": { diff --git a/src/Api.php b/src/Api.php index 32a9d57..bfac0f6 100644 --- a/src/Api.php +++ b/src/Api.php @@ -1,5 +1,5 @@ - 'https://api.runabove.com/1.0', ]; + private static $OAUTH2_TOKEN_URLS = [ + "ovh-eu" => "https://www.ovh.com/auth/oauth2/token", + "ovh-ca" => "https://ca.ovh.com/auth/oauth2/token", + "ovh-us" => "https://us.ovhcloud.com/auth/oauth2/token", + ]; + /** * Contain endpoint selected to choose API * @@ -108,6 +116,13 @@ class Api */ private ?Client $http_client; + /** + * OAuth2 wrapper if built with `withOAuth2` + * + * @var \Ovh\OAuth2 + */ + private ?OAuth2 $oauth2; + /** * Construct a new wrapper instance * @@ -154,6 +169,26 @@ public function __construct( $this->application_secret = $application_secret; $this->http_client = $http_client; $this->consumer_key = $consumer_key; + $this->oauth2 = null; + } + + /** + * Alternative constructor to build a client using OAuth2 + * + * @throws Exceptions\InvalidParameterException if one parameter is missing or with bad value + * @return Ovh\Api + */ + public static function withOAuth2($clientId, $clientSecret, $apiEndpoint) + { + if (!array_key_exists($apiEndpoint, self::$OAUTH2_TOKEN_URLS)) { + throw new Exceptions\InvalidParameterException( + "OAuth2 authentication is not compatible with endpoint $apiEndpoint (it can only be used with ovh-eu, ovh-ca and ovh-us)" + ); + } + + $instance = new self("", "", $apiEndpoint); + $instance->oauth2 = new Oauth2($clientId, $clientSecret, self::$OAUTH2_TOKEN_URLS[$apiEndpoint]); + return $instance; } /** @@ -298,22 +333,29 @@ protected function rawCall($method, $path, $content = null, $is_authenticated = } $headers['Content-Type'] = 'application/json; charset=utf-8'; - $headers['X-Ovh-Application'] = $this->application_key ?? ''; if ($is_authenticated) { - if (!isset($this->time_delta)) { - $this->calculateTimeDelta(); - } - $now = time() + $this->time_delta; + if (!is_null($this->oauth2)) { + $headers['Authorization'] = $this->oauth2->getAuthorizationHeader(); + } else { + $headers['X-Ovh-Application'] = $this->application_key ?? ''; + + if (!isset($this->time_delta)) { + $this->calculateTimeDelta(); + } + $now = time() + $this->time_delta; - $headers['X-Ovh-Timestamp'] = $now; + $headers['X-Ovh-Timestamp'] = $now; - if (isset($this->consumer_key)) { - $toSign = $this->application_secret . '+' . $this->consumer_key . '+' . $method - . '+' . $url . '+' . $body . '+' . $now; - $signature = '$1$' . sha1($toSign); - $headers['X-Ovh-Consumer'] = $this->consumer_key; - $headers['X-Ovh-Signature'] = $signature; + if (isset($this->consumer_key)) { + $toSign = $this->application_secret . '+' . $this->consumer_key . '+' . $method + . '+' . $url . '+' . $body . '+' . $now; + $signature = '$1$' . sha1($toSign); + $headers['X-Ovh-Consumer'] = $this->consumer_key; + $headers['X-Ovh-Signature'] = $signature; + } } + } else { + $headers['X-Ovh-Application'] = $this->application_key ?? ''; } /** @var Response $response */ diff --git a/src/Exceptions/OAuth2FailureException.php b/src/Exceptions/OAuth2FailureException.php new file mode 100644 index 0000000..3e252a6 --- /dev/null +++ b/src/Exceptions/OAuth2FailureException.php @@ -0,0 +1,46 @@ +provider = new \League\OAuth2\Client\Provider\GenericProvider([ + 'clientId' => $clientId, + 'clientSecret' => $clientSecret, + # Do not configure `scopes` here as this GenericProvider ignores it when using client credentials flow + 'urlAccessToken' => $tokenUrl, + 'urlAuthorize' => null, # GenericProvider wants it but OVHcloud doesn't provide it, as it's not needed for client credentials flow + 'urlResourceOwnerDetails' => null, # GenericProvider wants it but OVHcloud doesn't provide it, as it's not needed for client credentials flow + ]); + } + + public function getAuthorizationHeader() + { + if (is_null($this->token) || + $this->token->hasExpired() || + $this->token->getExpires() - 10 <= time()) { + try { + $this->token = $this->provider->getAccessToken('client_credentials', ['scope' => 'all']); + } catch (UnexpectedValueException | IdentityProviderException $e) { + throw new Exceptions\OAuth2FailureException('OAuth2 failure: ' . $e->getMessage(), $e->getCode(), $e); + } + } + + return 'Bearer ' . $this->token->getToken(); + } +} diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 585f32e..5433e9b 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -37,6 +37,7 @@ use GuzzleHttp\Psr7\Request; use Ovh\Api; use Ovh\Exceptions\InvalidParameterException; +use Ovh\Exceptions\OAuth2FailureException; use PHPUnit\Framework\TestCase; # Mock values @@ -62,6 +63,39 @@ public function __construct(...$responses) } } +/** +* Get private and protected property to unit test it +* +* @param string $name +* +* @return \ReflectionProperty +*/ +function getPrivateProperty($name) +{ + $class = new \ReflectionClass(\Ovh\Api::class); + $property = $class->getProperty($name); + $property->setAccessible(true); + + return $property; +} + +function mockOauth2HttpClient($api, $client) +{ + $httpClientProperty = getPrivateProperty('http_client'); + $httpClient = $httpClientProperty->setValue($api, $client); + + $oauth2Property = getPrivateProperty('oauth2'); + $oauth2 = $oauth2Property->getValue($api); + + $class = new \ReflectionClass(\Ovh\Oauth2::class); + $providerProperty = $class->getProperty('provider'); + $providerProperty->setAccessible(true); + $provider = $providerProperty->getValue($oauth2); + + $provider->setHttpClient($client); +} + + /** * Test Api class * @@ -70,22 +104,6 @@ public function __construct(...$responses) */ class ApiTest extends TestCase { - /** - * Get private and protected property to unit test it - * - * @param string $name - * - * @return \ReflectionProperty - */ - protected static function getPrivateProperty($name) - { - $class = new \ReflectionClass(\Ovh\Api::class); - $property = $class->getProperty($name); - $property->setAccessible(true); - - return $property; - } - /** * Test missing $application_key */ @@ -169,7 +187,7 @@ public function testTimeDeltaCompute() $api = new Api(MOCK_APPLICATION_KEY, MOCK_APPLICATION_SECRET, 'ovh-eu', MOCK_CONSUMER_KEY, $client); $api->get("/me"); - $property = self::getPrivateProperty('time_delta'); + $property = getPrivateProperty('time_delta'); $time_delta = $property->getValue($api); $this->assertSame('-10', $time_delta); @@ -254,7 +272,6 @@ public function testGetConsumerKey() $this->assertSame(MOCK_CONSUMER_KEY, $api->getConsumerKey()); } - /** * Test GET query args */ @@ -429,7 +446,7 @@ public function testVersionInUrl() { // GET /auth/time $mocks = [new Response(200, [], MOCK_TIME)]; - // GET) x (/1.0/call,/v1/call,/v2/call) + // GET x (/1.0/call,/v1/call,/v2/call) for ($i = 0; $i < 3; $i++) { $mocks[] = new Response(200, [], '{}'); } @@ -487,4 +504,124 @@ public function testEmptyResponseBody() $this->assertSame('POST', $req->getMethod()); $this->assertSame('https://eu.api.ovh.com/1.0/domain/zone/nonexisting.ovh/refresh', $req->getUri()->__toString()); } + + public function testOauth2500() + { + $client = new MockClient( + // POST https://www.ovh.com/auth/oauth2/token + new Response(500, [], '

test

'), + ); + + $api = Api::withOauth2('client_id', 'client_secret', 'ovh-eu'); + mockOauth2HttpClient($api, $client); + + $this->expectException(OAuth2FailureException::class); + $this->expectExceptionMessage('OAuth2 failure: An OAuth server error was encountered that did not contain a JSON body'); + + $api->get('/call'); + } + + public function testOauth2BadJSON() + { + $client = new MockClient( + // POST https://www.ovh.com/auth/oauth2/token + new Response(200, [], '

test

'), + ); + + $api = Api::withOauth2('client_id', 'client_secret', 'ovh-eu'); + mockOauth2HttpClient($api, $client); + + $this->expectException(OAuth2FailureException::class); + $this->expectExceptionMessage('OAuth2 failure: Invalid response received from Authorization Server. Expected JSON.'); + + $api->get('/call'); + } + + public function testOauth2UnknownClient() + { + $client = new MockClient( + // POST https://www.ovh.com/auth/oauth2/token + new Response(200, [], '

test

'), + ); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('OAuth2 authentication is not compatible with endpoint unknown (it can only be used with ovh-eu, ovh-ca and ovh-us)'); + + Api::withOauth2('client_id', 'client_secret', 'unknown'); + } + + public function testOauth2InvalidCredentials() + { + $client = new MockClient( + // POST https://www.ovh.com/auth/oauth2/token + new Response(400, [], '{"error":"invalid_client_credentials","error_description":"client secret invalid"}'), + ); + + $api = Api::withOauth2('client_id', 'client_secret', 'ovh-eu'); + mockOauth2HttpClient($api, $client); + + $this->expectException(OAuth2FailureException::class); + $this->expectExceptionMessage('OAuth2 failure: invalid_client_credentials'); + + $api->get('/call'); + } + + public function testOauth2OK() + { + $client = new MockClient( + // POST https://www.ovh.com/auth/oauth2/token + new Response(200, [], '{"access_token":"cccccccccccccccc", "token_type":"Bearer", "expires_in":11,"scope":"all"}'), + // GET /1.0/call + new Response(200, [], '{}'), + // GET /1.0/call + new Response(200, [], '{}'), + // POST https://www.ovh.com/auth/oauth2/token + new Response(200, [], '{"access_token":"cccccccccccccccd", "token_type":"Bearer", "expires_in":11,"scope":"all"}'), + // GET /1.0/call + new Response(200, [], '{}'), + ); + + $api = Api::withOauth2('client_id', 'client_secret', 'ovh-eu'); + mockOauth2HttpClient($api, $client); + + $api->get('/call'); + + $calls = $client->calls; + $this->assertCount(2, $calls); + + $req = $calls[0]['request']; + $this->assertSame('POST', $req->getMethod()); + $this->assertSame('https://www.ovh.com/auth/oauth2/token', $req->getUri()->__toString()); + + $req = $calls[1]['request']; + $this->assertSame('GET', $req->getMethod()); + $this->assertSame('https://eu.api.ovh.com/1.0/call', $req->getUri()->__toString()); + $this->assertSame('Bearer cccccccccccccccc', $req->getHeaderLine('Authorization')); + + $api->get('/call'); + + $calls = $client->calls; + $this->assertCount(3, $calls); + + $req = $calls[2]['request']; + $this->assertSame('GET', $req->getMethod()); + $this->assertSame('https://eu.api.ovh.com/1.0/call', $req->getUri()->__toString()); + $this->assertSame('Bearer cccccccccccccccc', $req->getHeaderLine('Authorization')); + + sleep(2); + + $api->get('/call'); + + $calls = $client->calls; + $this->assertCount(5, $calls); + + $req = $calls[3]['request']; + $this->assertSame('POST', $req->getMethod()); + $this->assertSame('https://www.ovh.com/auth/oauth2/token', $req->getUri()->__toString()); + + $req = $calls[4]['request']; + $this->assertSame('GET', $req->getMethod()); + $this->assertSame('https://eu.api.ovh.com/1.0/call', $req->getUri()->__toString()); + $this->assertSame('Bearer cccccccccccccccd', $req->getHeaderLine('Authorization')); + } }