diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 479caf1..df18575 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,10 +17,10 @@ jobs: strategy: matrix: os: [ windows-2022, windows-2025, ubuntu-22.04, ubuntu-24.04 ] - php-version: [8.0, 8.1, 8.2, 8.3] + php-version: [8.0, 8.1, 8.2, 8.3, 8.4] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -31,7 +31,7 @@ jobs: run: composer install - name: Test - run: ./vendor/bin/phpunit tests + run: ./vendor/bin/phpunit tests --testdox -v - uses: Bandwidth/build-notify-slack-action@v2 if: failure() && !github.event.pull_request.draft diff --git a/.gitignore b/.gitignore index f73aa03..c6483f2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ selftests/ config.php /bin composer.lock +composer.phar .idea/ .DS_Store .phpunit.result.cache diff --git a/README.md b/README.md index caa3326..aafb89b 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ PHP Client library for Bandwidth's Phone Number Dashboard (AKA: Dashboard, Iris) ## Supported PHP Versions -| Version | Support Level | -|:--------|:-------------------------| -| 8.0 | Supported | -| 8.1 | Supported | -| 8.2 | Supported | -| 8.3 | Supported | +| Version | Supported? | +|:--------|:-----------| +| 8.0 | Supported | +| 8.1 | Supported | +| 8.2 | Supported | +| 8.3 | Supported | +| 8.4 | Supported | ## Install @@ -22,8 +23,15 @@ composer require bandwidth/iris ## Usage ```PHP +// Basic Auth $client = new \Iris\Client($login, $password, ['url' => 'https://dashboard.bandwidth.com/api/']); +// Bearer Auth with Token +$client = new \Iris\Client(null, null, ['accessToken' => '']); + +// Bearer Auth using Client Credentials +$client = new \Iris\Client(null, null, ['clientId' => '', 'clientSecret' => '']); + ``` ## Run tests diff --git a/composer.phar b/composer.phar deleted file mode 100755 index e44722e..0000000 Binary files a/composer.phar and /dev/null differ diff --git a/core/Client.php b/core/Client.php index 027bd67..14065c2 100644 --- a/core/Client.php +++ b/core/Client.php @@ -135,28 +135,48 @@ final class Client extends iClient */ protected $lastResponseBody = null; + protected $accessToken = null; + + protected $accessTokenExpiration = null; + + protected $clientId = null; + + protected $clientSecret = null; + + protected $clientOptions = []; + public function __construct($login, $password, $options = []) { - if (empty($login) || empty($password)) - { - throw new \Exception("Provide login, password"); + foreach (['accessToken', 'accessTokenExpiration', 'clientId', 'clientSecret'] as $key) { + if (array_key_exists($key, $options)) { + $this->$key = $options[$key]; + unset($options[$key]); + } } - $options['auth'] = [$login, $password]; - $options['base_uri'] = $options['url'] ?: 'https://dashboard.bandwidth.com/api'; + if ($login !== null && $password !== null) { + $this->clientOptions['auth'] = [$login, $password]; + } + + $base = $options['base_uri'] ?? ($options['url'] ?? 'https://dashboard.bandwidth.com/api'); unset($options['url']); - $options['base_uri'] = rtrim($options['base_uri'], '/') . '/'; + $this->clientOptions['base_uri'] = rtrim($base, '/') . '/'; - $client_options = array(); - if(isset($options['handler'])) { - $client_options['handler'] = $options['handler']; + if (isset($options['handler'])) { + $this->clientOptions['handler'] = $options['handler']; + unset($options['handler']); + } else { + $this->clientOptions['handler'] = \GuzzleHttp\HandlerStack::create(); } - $options['headers'] = array( - 'User-Agent' => 'PHP-Bandwidth-Iris' - ); + $headers = $options['headers'] ?? []; + $headers['User-Agent'] = 'PHP-Bandwidth-Iris'; + unset($options['headers']); + $this->clientOptions['headers'] = $headers; + + $this->clientOptions = array_replace($this->clientOptions, $options); - $this->client = new \GuzzleHttp\Client($options); + $this->client = new \GuzzleHttp\Client($this->clientOptions); } /** @@ -289,6 +309,19 @@ public function request($method, $url, $options = [], $parse = true) $this->lastResponseBody = null; try { + // Get token if we don't have one, or if it's expired, and we have client credentials + if ((empty($this->accessToken) || (!empty($this->accessTokenExpiration) && $this->accessTokenExpiration <= time() + 60)) + && !empty($this->clientId) && !empty($this->clientSecret)) { + $this->fetchAccessToken(); + } + + // If we have a valid access token, add it to the request headers + if (!empty($this->accessToken) && (empty($this->accessTokenExpiration) || $this->accessTokenExpiration > time() + 60)) { + $options['headers'] = $options['headers'] ?? []; + if (empty($options['headers']['Authorization'])) { + $options['headers']['Authorization'] = 'Bearer ' . $this->accessToken; + } + } $response = $this->client->request($method, ltrim($url, '/'), $options); if (!$parse) { @@ -302,6 +335,23 @@ public function request($method, $url, $options = [], $parse = true) } } + private function fetchAccessToken() + { + $tokenUrl = 'https://api.bandwidth.com/api/v1/oauth2/token'; + $tokenOptions = [ + 'auth' => [$this->clientId, $this->clientSecret], + 'form_params' => [ + 'grant_type' => 'client_credentials' + ], + ]; + $oauthClient = new \GuzzleHttp\Client($this->clientOptions); + + $tokenResponse = $oauthClient->request('post', $tokenUrl, $tokenOptions); + $tokenData = json_decode((string)$tokenResponse->getBody(), true); + $this->accessToken = $tokenData['access_token'] ?? null; + $this->accessTokenExpiration = isset($tokenData['expires_in']) ? (time() + (int)$tokenData['expires_in']) : null; + } + /** * Returns the XML string received in the last response. * @return string $lastResponseBody diff --git a/tests/OAuthTest.php b/tests/OAuthTest.php new file mode 100644 index 0000000..a04a8b2 --- /dev/null +++ b/tests/OAuthTest.php @@ -0,0 +1,96 @@ +1link18043024183"; + private const TOKEN_RESPONSE = '{"access_token":"abcdef123456","expires_in":3600}'; + + private const TOKEN_REQUEST_AUTH_STRING = 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ='; + private const BEARER_AUTH_STRING = 'Bearer abcdef123456'; + + private function makeAccount(array $responses, array $clientOptions, array &$container, ?string $login = null, ?string $password = null): Iris\Account { + $mock = new MockHandler($responses); + $handler = HandlerStack::create($mock); + $history = Middleware::history($container); + $handler->push($history); + $client = new Iris\Client($login, $password, array_merge(['url' => self::API_URL, 'handler' => $handler], $clientOptions)); + return new Iris\Account(9500249, $client); + } + + private function assertRequest(array $container, int $idx, string $method, string $uri, ?string $authHeader = null): void { + $request = $container[$idx]['request']; + $this->assertSame($uri, (string)$request->getUri()); + $this->assertSame($method, $request->getMethod()); + if ($authHeader !== null) { + $this->assertSame($authHeader, $request->getHeaderLine('Authorization')); + } + } + + public function testBasicAuth(): void { + $container = []; + $account = $this->makeAccount([ + new Response(200, [], self::INSERVICE_RESPONSE), + ], [], $container, 'username', 'password'); + + $account->inserviceNumbers(); + $this->assertRequest($container, 0, 'GET', self::INSERVICE_URL, 'Basic ' . base64_encode('username:password')); + } + + public function testOAuth(): void { + $container = []; + $account = $this->makeAccount([ + new Response(200, [], self::TOKEN_RESPONSE), + new Response(200, [], self::INSERVICE_RESPONSE), + new Response(200, [], self::INSERVICE_RESPONSE), + ], [ + 'clientId' => 'client_id', + 'clientSecret' => 'client_secret', + ], $container); + + $account->inserviceNumbers(); + $account->inserviceNumbers(); + + $this->assertRequest($container, 0, 'POST', self::TOKEN_URL, self::TOKEN_REQUEST_AUTH_STRING); + $this->assertRequest($container, 1, 'GET', self::INSERVICE_URL, self::BEARER_AUTH_STRING); + $this->assertRequest($container, 2, 'GET', self::INSERVICE_URL, self::BEARER_AUTH_STRING); + } + + public function testToken(): void { + $container = []; + $account = $this->makeAccount([ + new Response(200, [], self::INSERVICE_RESPONSE), + ], [ + 'accessToken' => 'access_token', + 'accessTokenExpiration' => time() + 3600, + ], $container); + + $account->inserviceNumbers(); + $this->assertRequest($container, 0, 'GET', self::INSERVICE_URL, 'Bearer access_token'); + } + + public function testExpiredToken(): void { + $container = []; + $account = $this->makeAccount([ + new Response(200, [], self::TOKEN_RESPONSE), + new Response(200, [], self::INSERVICE_RESPONSE), + ], [ + 'accessToken' => 'expired_token', + 'accessTokenExpiration' => time() - 3600, + 'clientId' => 'client_id', + 'clientSecret' => 'client_secret', + ], $container); + + $account->inserviceNumbers(); + $this->assertRequest($container, 0, 'POST', self::TOKEN_URL, self::TOKEN_REQUEST_AUTH_STRING); + $this->assertRequest($container, 1, 'GET', self::INSERVICE_URL, self::BEARER_AUTH_STRING); + } +}