diff --git a/app/plugins/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po index c3452a102..6fdd6cca9 100644 --- a/app/plugins/CoreServer/resources/locales/en_US/core_server.po +++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po @@ -34,6 +34,9 @@ msgstr "{0,plural,=1{SMTP Server} other{SMTP Servers}}" msgid "controller.SqlServers" msgstr "{0,plural,=1{SQL Server} other{SQL Servers}}" +msgid "controller.Oauth2Servers" +msgstr "{0,plural,=1{Oauth2 Server} other{Oauth2 Servers}}" + msgid "enumeration.HttpAuthTypeEnum.BA" msgstr "Basic" @@ -76,6 +79,12 @@ msgstr "Invalid state received in callback" msgid "error.Oauth2Servers.token" msgstr "Error obtaining access token: {0}" +msgid "info.Oauth2Servers.token.ok" +msgstr "Access Token Obtained" + +msgid "info.Oauth2Servers.token.obtain" +msgstr "Obtain New Token" + msgid "field.auth_type" msgstr "Authentication Type" @@ -103,12 +112,15 @@ msgstr "Access Token Grant Type" msgid "field.Oauth2Servers.url" msgstr "Server URL" -msgid "field.Oauth2Servers.callback_endpoint" -msgstr "Callback URI" +msgid "field.Oauth2Servers.redirect_uri" +msgstr "Redirect URI" msgid "field.Oauth2Servers.scope" msgstr "Scopes" +msgid "info.Oauth2Servers.access_token.ok" +msgstr "Access Token Obtained" + msgid "field.SmtpServers.default_from" msgstr "Default From Address" diff --git a/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php b/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php index ad79b2886..b2e8ee9cc 100644 --- a/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php +++ b/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry-plugins - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.2.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -31,6 +31,7 @@ use App\Controller\StandardPluginController; use Cake\Event\EventInterface; +use CoreServer\Lib\Enum\Oauth2GrandTypesEnum; class Oauth2ServersController extends StandardPluginController { @@ -46,17 +47,16 @@ class Oauth2ServersController extends StandardPluginController * * @param EventInterface $event Cake Event * - * @return Response|void * @since COmanage Registry v5.2.0 */ public function beforeRender(EventInterface $event) { - // Generate the callback URL + // Generate the Redirect URI if ($this->getRequest()->getParam('action') === 'edit') { $id = $this->getRequest()->getParam('pass')[0] ?? null; // Assuming $id comes from passed arguments - $this->set('vv_callback_endpoint', $this->Oauth2Servers->redirectUri($id)); + $this->set('vv_redirect_uri', $this->Oauth2Servers->redirectUri($id)); } return parent::beforeRender($event); @@ -69,29 +69,90 @@ public function beforeRender(EventInterface $event) * @since COmanage Registry v5.2.0 */ - public function callback($id) + public function callback($id): void { + $this->autoRender = false; // We have to look in $_GET because what we get back isn't a Cake style named parameter // (ie: code=foo, not code:foo) try { - if (empty($_GET['code']) || empty($_GET['state'])) { - throw new RuntimeException(__d('core_server', 'error.Oauth2Servers.callback')); + $request = $this->getRequest(); + $code = $request->getQuery('code'); + $state = $request->getQuery('state'); + + if (empty($code) || empty($state)) { + throw new \RuntimeException(__d('core_server', 'error.Oauth2Servers.callback')); } // Verify that state is our hashed session ID, as per RFC6749 ยง10.12 // recommendations to prevent CSRF. // https://tools.ietf.org/html/rfc6749#section-10.12 - if ($_GET['state'] != hash('sha256', session_id())) { - throw new RuntimeException(__d('core_server', 'error.Oauth2Servers.state')); + // Access session from the request object + $sessionId = $request->getSession()->id(); + + if ($state != hash('sha256', $sessionId)) { + throw new \RuntimeException(__d('core_server', 'error.Oauth2Servers.state')); } - $response = $this->Oauth2Servers->exchangeCode($id, $_GET['code'], $this->Oauth2Server->redirectUri($id)); + $response = $this->Oauth2Servers->exchangeCode( + $id, + $code, + $this->Oauth2Servers->redirectUri((int)$id), + ); + + $this->Flash->success(__d('core_server', 'info.Oauth2Servers.access_token.ok')); + } catch (\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + $this->performRedirect(); + } + + /** + * Obtain an access token for a Oauth2Server. + * + * @since COmanage Registry v5.2.0 + * @param integer $id Oauth2Server ID + */ + + public function token($id): void + { + // Pull our configuration, initially to find out what type of grant type we need + $osrvr = $this->Oauth2Servers->get($id); + + if(!$osrvr) { + $this->Flash->error(__d('error', 'notfound', [__d('core_server', 'controller.Oauth2Servers')])); + $this->performRedirect(); + } - $this->Flash->set(_txt('rs.server.oauth2.token.ok'), array('key' => 'success')); - } catch (Exception $e) { - $this->Flash->set($e->getMessage(), array('key' => 'error')); + try { + switch($osrvr->access_grant_type) { + case Oauth2GrandTypesEnum::AuthorizationCode: + // Issue a redirect to the server + $targetUrl = $osrvr->url + . '/authorize?response_type=code' + . '&client_id=' . $osrvr->clientid + . '&redirect_uri=' . urlencode($this->Oauth2Servers->redirectUri($id)) + . '&state=' . hash('sha256', session_id()); + // Scope is optional + if(!empty($osrvr->scope)) { + $targetUrl .= '&scope='. str_replace(' ', '%20', $osrvr->scope); + } + + $this->redirect($targetUrl); + break; + case Oauth2GrandTypesEnum::ClientCredentials: + // Make a direct call to the server + $this->Oauth2Servers->obtainToken((int)$id, 'client_credentials'); + $this->Flash->success(__d('core_server', 'info.Oauth2Servers.access_token.ok')); + break; + default: + // No other flows currently supported + throw new LogicException('NOT IMPLEMENTED'); + } + } catch(\Exception $e) { + $this->Flash->error($e->getMessage()); } $this->performRedirect(); @@ -119,8 +180,8 @@ function performRedirect(): void $target['?'] = [ 'co_id' => $this->getCOID() ]; - - $this->redirect($target); } + + $this->redirect($target); } } diff --git a/app/plugins/CoreServer/src/Lib/Enum/Oauth2GrandTypesEnum.php b/app/plugins/CoreServer/src/Lib/Enum/Oauth2GrandTypesEnum.php index a8bd939b2..a82e51111 100644 --- a/app/plugins/CoreServer/src/Lib/Enum/Oauth2GrandTypesEnum.php +++ b/app/plugins/CoreServer/src/Lib/Enum/Oauth2GrandTypesEnum.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry-plugins - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.2.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ diff --git a/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php index a86ea0db2..d21953283 100644 --- a/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php +++ b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php @@ -34,6 +34,8 @@ use CoreServer\Lib\Enum\Oauth2GrandTypesEnum; class Oauth2ServersTable extends HttpServersTable { + use \App\Lib\Traits\PrimaryLinkTrait; + /** * Perform Cake Model initialization. * @@ -57,6 +59,8 @@ public function initialize(array $config): void { $this->setDisplayField('hostname'); $this->setPrimaryLink('server_id'); + $this->setAllowLookupPrimaryLink(['token', 'callback']); + $this->setRequiresCO(true); $this->setAutoViewVars([ @@ -71,24 +75,122 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => false, // Delete the pluggable object instead 'edit' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin'] + 'view' => ['platformAdmin', 'coAdmin'], + 'token' => ['platformAdmin', 'coAdmin'], + 'callback' => true, ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], - 'index' => ['platformAdmin', 'coAdmin'] + 'index' => ['platformAdmin', 'coAdmin'], ] ]); } - + /** + * Exchange an authorization code for an access and refresh token. + * + * @param int|string $id Oauth2Server ID + * @param string $code Access code returned by call to /oauth/authorize + * @param string $redirectUri Callback URL used for initial request + * @return mixed Object of data as returned by server, including access and refresh token + * @throws RuntimeException + *@since COmanage Registry v5.2.0 + */ + + public function exchangeCode(int|string $id, string $code, string $redirectUri, $store=true): mixed + { + return $this->obtainToken((int)$id, 'authorization_code', $code, $redirectUri, $store); + } + + /** + * Obtain an OAuth token. + * + * @param Integer $id Oauth2Server ID + * @param String $grantType OAuth grant type + * @param String|null $code Access code returned by call to /oauth/authorize, for authorization_code grant + * @param String|null $redirectUri Callback URL used for initial request, for authorization_code grant + * @param Boolean $store If true, store the retrieved tokens in the Oauth2Server configuration + * @return mixed Object of data as returned by server, including access and refresh token + * @throws RuntimeException + *@since COmanage Registry v5.2.0 + */ + + public function obtainToken(int $id, string $grantType, string $code=null, string $redirectUri=null, bool $store=true): mixed + { + // Pull our configuration + $srvr = $this->get($id); + + $httpClient = $this->createHttpClient($id); + + $postData = [ + 'client_id' => $srvr->clientid, + 'client_secret' => $srvr->client_secret, + 'grant_type' => $grantType + ]; + + if($grantType == 'refresh_token') { + $postData['refresh_token'] = $srvr->refresh_token; + $postData['format'] = 'json'; + } elseif($grantType == 'authorization_code' && $code) { + $postData['code'] = $code; + $postData['redirect_uri'] = $redirectUri; + } else { + $postData['scope'] = str_replace(' ', '%20', $srvr->scope); + } + + $postUrl = $srvr->url . "/token"; + + $results = $httpClient->post($postUrl, $postData); + + $json = json_decode($results->getStringBody()); + + if($results->getStatusCode() != 200) { + // There should be an error in the response + throw new \RuntimeException(__d('core_server', 'error.Oauth2Servers.token', [$json->error . ": " . $json->error_description])); + } + + if($store) { + // Save the fields we want to keep + $data = [ + 'id' => $id, + 'access_token' => $json->access_token, + // Store the raw result in case the server has added some custom attributes + 'token_response' => json_encode($json) + ]; + + // We shouldn't have a new refresh token on a refresh_token grant + // (which just gets us a new access token). Additionally, section + // 4.4.3 of RFC 6749 explains that the server should NOT return + // a refresh token for a client credentials grant. + if($grantType != 'refresh_token' && property_exists($json, 'refresh_token')) { + $data['refresh_token'] = $json->refresh_token; + } + + // If the Oauth2 server returned `expires_in` use it to set the + // access token expiration time. See section 5.1 of RFC 6749. + if(property_exists($json, 'expires_in')) { + $data['access_token_exp'] = time() + $json->expires_in; + } + + // Update the dataset + $srvr = $this->patchEntity($srvr, $data); + if (!$this->save($srvr)) { + throw new \RuntimeException(__d('error', 'save' [__d('core_server', 'field.Oauth2Servers.access_token')])); + } + } + + return $json; + } + + /** * Generate a redirect URI for the given server ID. * * @param int|string $id The unique identifier of the OAuth2 server * @return string The full URL of the redirect URI */ - public function redirectUri($id): string + public function redirectUri(int|string $id): string { $callback = [ 'plugin' => 'CoreServer', @@ -101,6 +203,22 @@ public function redirectUri($id): string } + /** + * Refresh the OAuth access token using the stored refresh token. + * + * @param int|string $id The unique identifier of the OAuth2 server + * @return string The new access token + * @throws RuntimeException + * @since COmanage Registry v5.2.0 + */ + public function refreshToken(int|string $id):string + { + $json = $this->obtainToken((int)$id, 'refresh_token'); + + return $json->access_token; + } + + /** * Set validation rules. * @@ -152,13 +270,8 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('access_token'); - $validator->add('access_token_exp', [ - 'content' => [ - 'rule' => 'validateNotBlank', - 'provider' => 'table' - ] - ]); - $validator->allowEmptyString('access_token_exp'); + $validator->integer('access_token_exp') + ->allowEmptyString('access_token_exp'); return $validator; } diff --git a/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc b/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc index ee6928f81..580d27591 100644 --- a/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc +++ b/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc @@ -30,14 +30,13 @@ if($vv_action !== 'edit') { return; } -// Render the callback endpoint - +// Render the Redirect URI print $this->element('form/listItem', [ 'arguments' => [ - 'fieldName' => 'callback_endpoint', + 'fieldName' => 'redirect_uri', 'fieldOptions' => [ 'readOnly' => true, - 'default' => $vv_callback_endpoint + 'default' => $vv_redirect_uri ] ]]); @@ -74,7 +73,25 @@ print $this->element('form/listItem', [ ], ]]); +$generateLink = []; +if(!empty($vv_obj->id)) { + $generateLink = [ + 'url' => [ + 'plugin' => 'CoreServer', + 'controller' => 'Oauth2Servers', + 'action' => 'token', + $vv_obj->id + ], + 'label' => __d('core_server', 'info.Oauth2Servers.token.obtain'), + 'class' => 'provisionbutton nospin btn btn-primary btn-sm', + ]; +} + print $this->element('form/listItem', [ 'arguments' => [ - 'fieldName' => 'access_token' - ]]); \ No newline at end of file + 'fieldName' => 'access_token', + 'status' => !empty($vv_obj->access_token) ? __d('enumeration', 'SetBooleanEnum.1') : __d('enumeration', 'SetBooleanEnum.0'), + 'link' => $generateLink, + 'labelIsTextOnly' => true + ] +]); \ No newline at end of file