From fde083f36f6dd86e96bffc3a8470ea185d9c0a73 Mon Sep 17 00:00:00 2001 From: LukasRos Date: Sun, 10 Nov 2024 23:32:27 +0100 Subject: [PATCH] OAuth2 client credentials flow with cache --- .../SDK/CustomCacheAndLogInterface.php | 17 +++ CloudObjects/SDK/ObjectRetriever.php | 16 ++- CloudObjects/SDK/WebAPI/APIClientFactory.php | 11 +- CloudObjects/SDK/WebAPI/OAuth2AuthServer.php | 114 ++++++++++++------ 4 files changed, 112 insertions(+), 46 deletions(-) create mode 100644 CloudObjects/SDK/CustomCacheAndLogInterface.php diff --git a/CloudObjects/SDK/CustomCacheAndLogInterface.php b/CloudObjects/SDK/CustomCacheAndLogInterface.php new file mode 100644 index 0000000..f8d44ac --- /dev/null +++ b/CloudObjects/SDK/CustomCacheAndLogInterface.php @@ -0,0 +1,17 @@ +client = new Client($options); } - private function logInfoWithTime($message, $ts) { + public function logInfoWithTime($message, $ts) { if (isset($this->logger)) $this->logger->info($message, [ 'elapsed_ms' => round((microtime(true) - $ts) * 1000) ]); } private function getFromCache($id) { return (isset($this->cache) && $this->cache->contains($this->options['cache_prefix'].$id)) - ? $this->cache->fetch($this->options['cache_prefix'].$id) : null; + ? $this->cache->fetch($this->options['cache_prefix'].$this->options['auth_ns'].'/'.$id) : null; } private function putIntoCache($id, $data, $ttl) { if (isset($this->cache)) - $this->cache->save($this->options['cache_prefix'].$id, $data, $ttl); + $this->cache->save($this->options['cache_prefix'].$this->options['auth_ns'].'/'.$id, $data, $ttl); + } + + public function getFromCacheCustom($id) { + return $this->getFromCache('custom/'.$id); + } + + public function putIntoCacheCustom($id, $data, $ttl) { + $this->putIntoCache('custom/'.$id, $data, $ttl); } /** diff --git a/CloudObjects/SDK/WebAPI/APIClientFactory.php b/CloudObjects/SDK/WebAPI/APIClientFactory.php index 2c6408b..5d16185 100644 --- a/CloudObjects/SDK/WebAPI/APIClientFactory.php +++ b/CloudObjects/SDK/WebAPI/APIClientFactory.php @@ -154,16 +154,19 @@ class APIClientFactory { 'timeout' => self::DEFAULT_TIMEOUT ]; - if ($this->reader->hasProperty($api, 'wa:hasAuthorizationServer')) { + if ($this->reader->hasProperty($api, 'oauth2:hasAuthorizationServer')) { // We have an authorization server for this endpoint/API - $authServerCoid = $this->reader->getFirstValueIRI($api, 'wa:hasAuthorizationServer'); + $authServerCoid = $this->reader->getFirstValueIRI($api, 'oauth2:hasAuthorizationServer'); $authServerObject = $this->objectRetriever->getObject($authServerCoid); - if (!isset($authServer)) + if (!isset($authServerObject)) throw new InvalidObjectConfigurationException("Authorization server object <" . (string)$authServerCoid . "> not available."); try { - $authServer = new OAuth2AuthServer($authServerObject); + $authServer = new OAuth2AuthServer($authServerObject, $this->objectRetriever); + } catch (InvalidObjectConfigurationException $e) { + throw new InvalidObjectConfigurationException("Authorization server object <" + . (string)$authServerCoid . "> could not be loaded; error: " . $e->getMessage()); } catch (Exception $e) { throw new InvalidObjectConfigurationException("Authorization server object <" . (string)$authServerCoid . "> could not be loaded. Its definition may be invalid."); diff --git a/CloudObjects/SDK/WebAPI/OAuth2AuthServer.php b/CloudObjects/SDK/WebAPI/OAuth2AuthServer.php index fafed82..e7557c1 100644 --- a/CloudObjects/SDK/WebAPI/OAuth2AuthServer.php +++ b/CloudObjects/SDK/WebAPI/OAuth2AuthServer.php @@ -9,63 +9,77 @@ namespace CloudObjects\SDK\WebAPI; use Exception; use ML\JsonLD\Node; use GuzzleHttp\Client; -use Webmozart\Assert\Assert; -use CloudObjects\SDK\NodeReader; +use Webmozart\Assert\Assert, + Webmozart\Assert\InvalidArgumentException; +use CloudObjects\SDK\NodeReader, + CloudObjects\SDK\CustomCacheAndLogInterface; +use CloudObjects\SDK\Exceptions\InvalidObjectConfigurationException; class OAuth2AuthServer { private $reader; private $authServer; private $consumer; + private $cacheAndLog; private $grantType; private $clientId; private $clientSecret; - public function __construct(Node $authServer) { + public function __construct(Node $authServer, CustomCacheAndLogInterface $cacheAndLog) { $this->reader = new NodeReader([ 'prefixes' => [ 'oauth2' => 'coid://oauth2.co-n.net/' ] ]); - Assert::true($this->reader->hasProperty($authServer, 'oauth2:hasTokenEndpoint'), - "Authorization Server must have a token endpoint."); - Assert::startsWith($this->reader->getFirstValueString($authServer, 'oauth2:hasTokenEndpoint'), - "https://", - "Token endpoint must be an https:// URL."); - Assert::true($this->reader->hasProperty($authServer, 'oauth2:supportsGrantType'), - "Authorization Server must support at least one grant type."); - Assert::true($this->reader->hasProperty($this->authServer, 'oauth2:usesClientIDFrom'), - "Authorization Server must define client ID property."); - Assert::true($this->reader->hasProperty($this->authServer, 'oauth2:usesClientSecretFrom'), - "Authorization Server must define client secret property."); + try { + Assert::true($this->reader->hasProperty($authServer, 'oauth2:hasTokenEndpoint'), + "Authorization Server must have a token endpoint."); + Assert::startsWith($this->reader->getFirstValueString($authServer, 'oauth2:hasTokenEndpoint'), + "https://", + "Token endpoint must be an https:// URL."); + Assert::true($this->reader->hasProperty($authServer, 'oauth2:supportsGrantType'), + "Authorization Server must support at least one grant type."); + Assert::true($this->reader->hasProperty($authServer, 'oauth2:usesClientIDFrom'), + "Authorization Server must define client ID property."); + Assert::true($this->reader->hasProperty($authServer, 'oauth2:usesClientSecretFrom'), + "Authorization Server must define client secret property."); + + } catch (InvalidArgumentException $e) { + throw new InvalidObjectConfigurationException($e->getMessage()); + } $this->authServer = $authServer; - } - - private function assertClientCredentialPropertiesExist() : void { - + $this->cacheAndLog = $cacheAndLog; } public function configureConsumer(Node $consumer) : void { - $this->assertClientCredentialPropertiesExist(); - $clientIDProperty = $this->reader->getFirstValueString($this->authServer, - 'oauth2:usesClientIDFrom'); - $clientSecretProperty = $this->reader->getFirstValueString($this->authServer, - 'oauth2:usesClientSecretFrom'); + try { + Assert::notNull($this->authServer, "Object wasn't initialized correctly."); + Assert::notNull($this->cacheAndLog, "Object wasn't initialized correctly."); + + $clientIDProperty = $this->reader->getFirstValueString($this->authServer, + 'oauth2:usesClientIDFrom'); + $clientSecretProperty = $this->reader->getFirstValueString($this->authServer, + 'oauth2:usesClientSecretFrom'); + + Assert::true($this->reader->hasProperty($consumer, $clientIDProperty), + "Namespace must have Client ID"); + Assert::true($this->reader->hasProperty($consumer, $clientSecretProperty), + "Namespace must have Client Secret"); + + } catch (InvalidArgumentException $e) { + throw new InvalidObjectConfigurationException($e->getMessage()); + } - Assert::true($this->reader->hasProperty($consumer, $clientIDProperty), - "Namespace must have Client ID"); - Assert::true($this->reader->hasProperty($consumer, $clientSecretProperty), - "Namespace must have Client Secret"); - if ($this->reader->hasPropertyValue($this->authServer, - 'oauth2:supportsGrantType', 'oauth2:ClientCredentials')) { + 'oauth2:supportsGrantType', 'oauth2:ClientCredentials')) + { // No additional conditions for "client_credentials" flow $this->grantType = 'client_credentials'; } else { - throw new Exception("No flow/grant_type found."); + throw new InvalidObjectConfigurationException("No flow/grant_type found."); } $this->consumer = $consumer; @@ -74,10 +88,18 @@ class OAuth2AuthServer { } public function getAccessToken() { - Assert::notNull($this->consumer, "Missing consumer."); - Assert::notNull($this->grantType, "Missing grant_type."); - Assert::notNull($this->clientId, "Missing client_id."); - Assert::notNull($this->clientSecret, "Missing client_secret."); + try { + Assert::notNull($this->authServer, "Object wasn't initialized correctly."); + Assert::notNull($this->cacheAndLog, "Object wasn't initialized correctly."); + + Assert::notNull($this->consumer, "Missing consumer."); + Assert::notNull($this->grantType, "Missing grant_type."); + Assert::notNull($this->clientId, "Missing client_id."); + Assert::notNull($this->clientSecret, "Missing client_secret."); + + } catch (InvalidArgumentException $e) { + throw new InvalidObjectConfigurationException($e->getMessage()); + } $client = new Client; $tokenEndpointUrl = $this->reader->getFirstValueString($this->authServer, 'oauth2:hasTokenEndpoint'); @@ -90,15 +112,31 @@ class OAuth2AuthServer { switch ($this->grantType) { case "client_credentials": // no additional params needed + break; default: throw new Exception("No flow/grant_type found."); } - $tokenResponse = json_decode($client->post($tokenEndpointUrl, [ - 'form_params' => $params - ])->getBody(true)); + $grantCacheKey = sha1(json_encode($params)); - Assert::keyExists($tokenResponse, 'access_token'); + $ts = microtime(true); + $tokenResponse = json_decode($this->cacheAndLog->getFromCacheCustom($grantCacheKey)); + + if (isset($tokenResponse)) { + $this->cacheAndLog->logInfoWithTime("Reused access token for <".$this->authServer->getId()."> from cache.", $ts); + } else { + // Nothing cached, fetch from server + $tokenResponse = json_decode($client->post($tokenEndpointUrl, [ + 'form_params' => $params + ])->getBody(true), true); + + Assert::keyExists($tokenResponse, 'access_token'); + + $expiry = isset($tokenResponse['expires_in']) ? $tokenResponse['expires_in'] : 84600; + + $this->cacheAndLog->logInfoWithTime("Retrieved access token for <".$this->authServer->getId()."> from token endpoint.", $ts); + $this->cacheAndLog->putIntoCacheCustom($grantCacheKey, json_encode($tokenResponse), $expiry); + } return $tokenResponse['access_token']; }