Skip to content

Commit

Permalink
Merge pull request #177 from Sammyjo20/feature/client-credentials-grant
Browse files Browse the repository at this point in the history
Feature | OAuth2 Client Credentials Grant
  • Loading branch information
Sammyjo20 committed Mar 14, 2023
2 parents 31a2335 + e8e4d70 commit a29a11a
Show file tree
Hide file tree
Showing 11 changed files with 555 additions and 29 deletions.
5 changes: 3 additions & 2 deletions src/Helpers/OAuth2/OAuthConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,11 @@ public function invokeRequestModifier(Request $request): Request
/**
* Validate the OAuth2 config.
*
* @param bool $withRedirectUrl
* @return bool
* @throws \Saloon\Exceptions\OAuthConfigValidationException
*/
public function validate(): bool
public function validate(bool $withRedirectUrl = true): bool
{
if (empty($this->getClientId())) {
throw new OAuthConfigValidationException('The Client ID is empty or has not been provided.');
Expand All @@ -283,7 +284,7 @@ public function validate(): bool
throw new OAuthConfigValidationException('The Client Secret is empty or has not been provided.');
}

if (empty($this->getRedirectUri())) {
if ($withRedirectUrl === true && empty($this->getRedirectUri())) {
throw new OAuthConfigValidationException('The Redirect URI is empty or has not been provided.');
}

Expand Down
67 changes: 67 additions & 0 deletions src/Http/OAuth2/GetClientCredentialsTokenRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace Saloon\Http\OAuth2;

use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Contracts\Body\HasBody;
use Saloon\Traits\Body\HasFormBody;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Traits\Plugins\AcceptsJson;

class GetClientCredentialsTokenRequest extends Request implements HasBody
{
use HasFormBody;
use AcceptsJson;

/**
* Define the method that the request will use.
*
* @var \Saloon\Enums\Method
*/
protected Method $method = Method::POST;

/**
* Define the endpoint for the request.
*
* @return string
*/
public function resolveEndpoint(): string
{
return $this->oauthConfig->getTokenEndpoint();
}

/**
* Requires the authorization code and OAuth 2 config.
*
* @param \Saloon\Helpers\OAuth2\OAuthConfig $oauthConfig
* @param array<string> $scopes
* @param string $scopeSeparator
*/
public function __construct(protected OAuthConfig $oauthConfig, protected array $scopes = [], protected string $scopeSeparator = ' ')
{
//
}

/**
* Register the default data.
*
* @return array{
* grant_type: string,
* client_id: string,
* client_secret: string,
* scope: string,
* }
*/
public function defaultBody(): array
{
return [
'grant_type' => 'client_credentials',
'client_id' => $this->oauthConfig->getClientId(),
'client_secret' => $this->oauthConfig->getClientSecret(),
'scope' => implode($this->scopeSeparator, array_merge($this->oauthConfig->getDefaultScopes(), $this->scopes)),
];
}
}
29 changes: 2 additions & 27 deletions src/Traits/OAuth2/AuthorizationCodeGrant.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use InvalidArgumentException;
use Saloon\Helpers\URLHelper;
use Saloon\Contracts\Response;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Http\OAuth2\GetUserRequest;
use Saloon\Contracts\OAuthAuthenticator;
use Saloon\Exceptions\InvalidStateException;
Expand All @@ -20,12 +19,7 @@

trait AuthorizationCodeGrant
{
/**
* The OAuth2 Config
*
* @var \Saloon\Helpers\OAuth2\OAuthConfig
*/
protected OAuthConfig $oauthConfig;
use HasOAuthConfig;

/**
* The state generated by the getAuthorizationUrl method.
Expand All @@ -34,32 +28,13 @@ trait AuthorizationCodeGrant
*/
protected ?string $state = null;

/**
* Manage the OAuth2 config
*
* @return \Saloon\Helpers\OAuth2\OAuthConfig
*/
public function oauthConfig(): OAuthConfig
{
return $this->oauthConfig ??= $this->defaultOauthConfig();
}

/**
* Define the default Oauth 2 Config.
*
* @return \Saloon\Helpers\OAuth2\OAuthConfig
*/
protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make();
}

/**
* Get the Authorization URL.
*
* @param array<string> $scopes
* @param string|null $state
* @param string $scopeSeparator
* @param array $additionalQueryParameters
* @return string
* @throws \Saloon\Exceptions\OAuthConfigValidationException
*/
Expand Down
83 changes: 83 additions & 0 deletions src/Traits/OAuth2/ClientCredentialsGrant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Saloon\Traits\OAuth2;

use DateTimeImmutable;
use Saloon\Helpers\Date;
use Saloon\Contracts\Response;
use Saloon\Contracts\OAuthAuthenticator;
use Saloon\Http\Auth\AccessTokenAuthenticator;
use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;

trait ClientCredentialsGrant
{
use HasOAuthConfig;

/**
* Get the access token
*
* @template TRequest of \Saloon\Contracts\Request
*
* @param array<string> $scopes
* @param string $scopeSeparator
* @param bool $returnResponse
* @param callable(TRequest): (void)|null $requestModifier
* @return \Saloon\Contracts\OAuthAuthenticator|\Saloon\Contracts\Response
* @throws \ReflectionException
* @throws \Saloon\Exceptions\InvalidResponseClassException
* @throws \Saloon\Exceptions\OAuthConfigValidationException
* @throws \Saloon\Exceptions\PendingRequestException
*/
public function getAccessToken(array $scopes = [], string $scopeSeparator = ' ', bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response
{
$this->oauthConfig()->validate(withRedirectUrl: false);

$request = new GetClientCredentialsTokenRequest($this->oauthConfig(), $scopes, $scopeSeparator);

$request = $this->oauthConfig()->invokeRequestModifier($request);

if (is_callable($requestModifier)) {
$requestModifier($request);
}

$response = $this->send($request);

if ($returnResponse === true) {
return $response;
}

$response->throw();

return $this->createOAuthAuthenticatorFromResponse($response);
}

/**
* Create the OAuthAuthenticator from a response.
*
* @param \Saloon\Contracts\Response $response
* @return \Saloon\Contracts\OAuthAuthenticator
*/
protected function createOAuthAuthenticatorFromResponse(Response $response): OAuthAuthenticator
{
$responseData = $response->object();

$accessToken = $responseData->access_token;
$expiresAt = isset($responseData->expires_in) ? Date::now()->addSeconds($responseData->expires_in)->toDateTime() : null;

return $this->createOAuthAuthenticator($accessToken, $expiresAt);
}

/**
* Create the authenticator.
*
* @param string $accessToken
* @param DateTimeImmutable|null $expiresAt
* @return OAuthAuthenticator
*/
protected function createOAuthAuthenticator(string $accessToken, ?DateTimeImmutable $expiresAt = null): OAuthAuthenticator
{
return new AccessTokenAuthenticator($accessToken, null, $expiresAt);
}
}
37 changes: 37 additions & 0 deletions src/Traits/OAuth2/HasOAuthConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Saloon\Traits\OAuth2;

use Saloon\Helpers\OAuth2\OAuthConfig;

trait HasOAuthConfig
{
/**
* The OAuth2 Config
*
* @var \Saloon\Helpers\OAuth2\OAuthConfig
*/
protected OAuthConfig $oauthConfig;

/**
* Manage the OAuth2 config
*
* @return \Saloon\Helpers\OAuth2\OAuthConfig
*/
public function oauthConfig(): OAuthConfig
{
return $this->oauthConfig ??= $this->defaultOauthConfig();
}

/**
* Define the default Oauth 2 Config.
*
* @return \Saloon\Helpers\OAuth2\OAuthConfig
*/
protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make();
}
}
49 changes: 49 additions & 0 deletions tests/Feature/ConfigTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

use Saloon\Http\Faking\MockResponse;
use Psr\Http\Message\RequestInterface;
use GuzzleHttp\Promise\FulfilledPromise;
use Saloon\Tests\Fixtures\Requests\UserRequest;
use Saloon\Tests\Fixtures\Connectors\TestConnector;

test('default guzzle config options are sent', function () {
$connector = new TestConnector;

$connector->sender()->addMiddleware(function (callable $handler) {
return function (RequestInterface $guzzleRequest, array $options) {
expect($options)->toHaveKey('http_errors', true);
expect($options)->toHaveKey('connect_timeout', 10);
expect($options)->toHaveKey('timeout', 30);

return new FulfilledPromise(MockResponse::make()->getPsrResponse());
};
});

$connector->send(new UserRequest);
});

test('you can pass additional guzzle config options and they are merged from the connector and request', function () {
$connector = new TestConnector();

$connector->config()->add('debug', true);

$connector->sender()->addMiddleware(function (callable $handler) {
return function (RequestInterface $guzzleRequest, array $options) {
expect($options)->toHaveKey('http_errors', true);
expect($options)->toHaveKey('connect_timeout', 10);
expect($options)->toHaveKey('timeout', 30);
expect($options)->toHaveKey('debug', true);
expect($options)->toHaveKey('verify', false);

return new FulfilledPromise(MockResponse::make()->getPsrResponse());
};
});

$request = new UserRequest;

$request->config()->add('verify', false);

$connector->send($request);
});
49 changes: 49 additions & 0 deletions tests/Feature/Oauth2/AuthCodeFlowConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
use Saloon\Http\OAuth2\GetAccessTokenRequest;
use Saloon\Http\Auth\AccessTokenAuthenticator;
use Saloon\Http\OAuth2\GetRefreshTokenRequest;
use Saloon\Exceptions\OAuthConfigValidationException;
use Saloon\Tests\Fixtures\Connectors\OAuth2Connector;
use Saloon\Tests\Fixtures\Connectors\NoConfigAuthCodeConnector;
use Saloon\Tests\Fixtures\Authenticators\CustomOAuthAuthenticator;
use Saloon\Tests\Fixtures\Connectors\CustomResponseOAuth2Connector;

Expand Down Expand Up @@ -338,3 +340,50 @@
GetUserRequest::class,
]);
});

test('if you attempt to use the authorization code flow without a client id it will throw an exception', function () {
$mockClient = new MockClient([
MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
]);

$connector = new NoConfigAuthCodeConnector;
$connector->withMockClient($mockClient);

$this->expectException(OAuthConfigValidationException::class);
$this->expectExceptionMessage('The Client ID is empty or has not been provided.');

$connector->getAccessToken('code');
});

test('if you attempt to use the authorization code flow without a secret it will throw an exception', function () {
$mockClient = new MockClient([
MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
]);

$connector = new NoConfigAuthCodeConnector;
$connector->withMockClient($mockClient);

$connector->oauthConfig()->setClientId('hello');

$this->expectException(OAuthConfigValidationException::class);
$this->expectExceptionMessage('The Client Secret is empty or has not been provided.');

$connector->getAccessToken('code');
});

test('if you attempt to use the authorization code flow without a redirect uri it will throw an exception', function () {
$mockClient = new MockClient([
MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
]);

$connector = new NoConfigAuthCodeConnector;
$connector->withMockClient($mockClient);

$connector->oauthConfig()->setClientId('hello');
$connector->oauthConfig()->setClientSecret('secret');

$this->expectException(OAuthConfigValidationException::class);
$this->expectExceptionMessage('The Redirect URI is empty or has not been provided.');

$connector->getAccessToken('code');
});
Loading

0 comments on commit a29a11a

Please sign in to comment.