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'));
+ }
}