dev/core#2141 - APIv4 - Add `OAuthClient.authorizationCode` authentication
authorTim Otten <totten@civicrm.org>
Wed, 28 Oct 2020 09:28:24 +0000 (02:28 -0700)
committerTim Otten <totten@civicrm.org>
Tue, 3 Nov 2020 12:32:48 +0000 (04:32 -0800)
ext/oauth-client/CRM/OAuth/Page/Return.php [new file with mode: 0644]
ext/oauth-client/Civi/Api4/Action/OAuthClient/AbstractGrantAction.php [new file with mode: 0644]
ext/oauth-client/Civi/Api4/Action/OAuthClient/AuthorizationCode.php [new file with mode: 0644]
ext/oauth-client/Civi/Api4/OAuthClient.php
ext/oauth-client/templates/CRM/OAuth/Page/Return.tpl [new file with mode: 0644]
ext/oauth-client/tests/phpunit/api/v4/OAuthClientGrantTest.php [new file with mode: 0644]
ext/oauth-client/xml/Menu/oauth_client.xml [new file with mode: 0644]

diff --git a/ext/oauth-client/CRM/OAuth/Page/Return.php b/ext/oauth-client/CRM/OAuth/Page/Return.php
new file mode 100644 (file)
index 0000000..bf31de1
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+use CRM_OAuth_ExtensionUtil as E;
+
+class CRM_OAuth_Page_Return extends CRM_Core_Page {
+
+  const TTL = 3600;
+
+  public function run() {
+    $state = self::loadState(CRM_Utils_Request::retrieve('state', 'String'));
+
+    if (CRM_Utils_Request::retrieve('error', 'String')) {
+      $error = CRM_Utils_Array::subset($_GET, ['error', 'error_description', 'error_uri']);
+    }
+    elseif ($authCode = CRM_Utils_Request::retrieve('code', 'String')) {
+      $client = \Civi\Api4\OAuthClient::get(0)->addWhere('id', '=', $state['clientId'])->execute()->single();
+      $tokenRecord = Civi::service('oauth2.token')->init([
+        'client' => $client,
+        'scope' => $state['scopes'],
+        'storage' => $state['storage'],
+        'grant_type' => 'authorization_code',
+        'cred' => ['code' => $authCode],
+      ]);
+    }
+    else {
+      throw new \Civi\OAuth\OAuthException("OAuth: Unrecognized return request");
+    }
+
+    $json = function ($d) {
+      return json_encode($d, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+    };
+    $this->assign('state', $json($state));
+    $this->assign('token', $json($tokenRecord ?? NULL));
+    $this->assign('error', $json($error ?? NULL));
+
+    parent::run();
+  }
+
+  /**
+   * @param array $stateData
+   * @return string
+   *   State token / identifier
+   */
+  public static function storeState($stateData):string {
+    $stateId = \CRM_Utils_String::createRandom(20, \CRM_Utils_String::ALPHANUMERIC);
+
+    if (PHP_SAPI === 'cli') {
+      // CLI doesn't have a real session, so we can't defend as deeply. However,
+      // it's also quite uncommon to run authorizationCode in CLI.
+      \Civi::cache('session')->set('OAuthStates_' . $stateId, $stateData, self::TTL);
+      return 'c_' . $stateId;
+    }
+    else {
+      // Storing in the bona fide session binds us to the cookie
+      $session = \CRM_Core_Session::singleton();
+      $session->createScope('OAuthStates');
+      $session->set($stateId, $stateData, 'OAuthStates');
+      return 'w_' . $stateId;
+    }
+  }
+
+  /**
+   * Restore from the $stateId.
+   *
+   * @param string $stateId
+   * @return mixed
+   * @throws \Civi\OAuth\OAuthException
+   */
+  public static function loadState($stateId) {
+    list ($type, $id) = explode('_', $stateId);
+    switch ($type) {
+      case 'w':
+        $state = \CRM_Core_Session::singleton()->get($id, 'OAuthStates');
+        break;
+
+      case 'c':
+        $state = \Civi::cache('session')->get('OAuthStates_' . $id);
+        break;
+
+      default:
+        throw new \Civi\OAuth\OAuthException("OAuth: Received invalid or expired state");
+    }
+
+    if (!isset($state['time']) || $state['time'] + self::TTL < CRM_Utils_Time::getTimeRaw()) {
+      throw new \Civi\OAuth\OAuthException("OAuth: Received invalid or expired state");
+    }
+
+    return $state;
+  }
+
+}
diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthClient/AbstractGrantAction.php b/ext/oauth-client/Civi/Api4/Action/OAuthClient/AbstractGrantAction.php
new file mode 100644 (file)
index 0000000..0de3c9e
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+namespace Civi\Api4\Action\OAuthClient;
+
+use Civi\OAuth\OAuthTokenFacade;
+use Civi\OAuth\OAuthException;
+
+/**
+ * Class AbstractGrantAction
+ * @package Civi\Api4\Action\OAuthClient
+ *
+ * @method $this setStorage(string $storage)
+ * @method string getStorage()
+ */
+abstract class AbstractGrantAction extends \Civi\Api4\Generic\AbstractBatchAction {
+
+  /**
+   * List of permissions to request from the OAuth service.
+   *
+   * If none specified, uses a default based on the client and provider.
+   *
+   * @var array|null
+   */
+  protected $scopes = NULL;
+
+  /**
+   * Where to store tokens once they are received.
+   *
+   * @var string
+   */
+  protected $storage = 'OAuthSysToken';
+
+  /**
+   * The active client definition.
+   *
+   * @var array|null
+   * @see \Civi\Api4\OAuthClient::get()
+   */
+  private $clientDef = NULL;
+
+  public function __construct($entityName, $actionName) {
+    parent::__construct($entityName, $actionName, ['*']);
+  }
+
+  /**
+   * @throws \API_Exception
+   */
+  protected function validate() {
+    if (!preg_match(OAuthTokenFacade::STORAGE_TYPES, $this->storage)) {
+      throw new \API_Exception("Invalid token storage ($this->storage)");
+    }
+  }
+
+  /**
+   * Look up the definition for the desired client.
+   *
+   * @return array
+   *   The OAuthClient details
+   * @see \Civi\Api4\OAuthClient::get()
+   * @throws OAuthException
+   */
+  protected function getClientDef():array {
+    if ($this->clientDef !== NULL) {
+      return $this->clientDef;
+    }
+
+    $records = $this->getBatchRecords();
+    if (count($records) !== 1) {
+      throw new OAuthException(sprintf("OAuth: Failed to locate client. Expected 1 client, but found %d clients.", count($records)));
+    }
+
+    $this->clientDef = array_shift($records);
+    return $this->clientDef;
+  }
+
+  /**
+   * @return \League\OAuth2\Client\Provider\AbstractProvider
+   */
+  protected function createLeagueProvider() {
+    $localOptions = [];
+    if ($this->scopes !== NULL) {
+      $localOptions['scopes'] = $this->scopes;
+    }
+    return \Civi::service('oauth2.league')->createProvider($this->getClientDef(), $localOptions);
+  }
+
+  /**
+   * @return array|null
+   */
+  public function getScopes() {
+    return $this->scopes;
+  }
+
+  /**
+   * @param array|string|null $scopes
+   */
+  public function setScopes($scopes) {
+    $this->scopes = is_string($scopes) ? [$scopes] : $scopes;
+  }
+
+}
diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthClient/AuthorizationCode.php b/ext/oauth-client/Civi/Api4/Action/OAuthClient/AuthorizationCode.php
new file mode 100644 (file)
index 0000000..1f8c517
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+
+namespace Civi\Api4\Action\OAuthClient;
+
+use Civi\Api4\Generic\Result;
+
+/**
+ * Class AuthorizationCode
+ * @package Civi\Api4\Action\OAuthClient
+ *
+ * In this workflow, we seek permission from the browser-user to access
+ * resources on their behalf. The result will be stored as a token.
+ *
+ * This API call merely *initiates* the workflow. It returns a fully-formed `url` for the
+ * authorization service. You should redirect the user to this URL.
+ *
+ * ```
+ * $result = civicrm_api4('OAuthClient', 'authorizationCode', [
+ *   'where' => [['id', '=', 123],
+ * ]);
+ * $startUrl = $result->first()['url'];
+ * CRM_Utils_System::redirect($startUrl);
+ * ```
+ *
+ * @method $this setLandingUrl(string $landingUrl)
+ * @method string getLandingUrl()
+ *
+ * @link https://tools.ietf.org/html/rfc6749#section-4.1
+ */
+class AuthorizationCode extends AbstractGrantAction {
+
+  /**
+   * If a user successfully completes the authentication, where should they go?
+   *
+   * This value will be stored in a way that is bound to the user session and
+   * OAuth-request.
+   *
+   * @var string|null
+   */
+  protected $landingUrl = NULL;
+
+  /**
+   * Tee-up the authorization request.
+   *
+   * @param \Civi\Api4\Generic\Result $result
+   */
+  public function _run(Result $result) {
+    $this->validate();
+
+    /** @var \League\OAuth2\Client\Provider\GenericProvider $provider */
+    $provider = $this->createLeagueProvider();
+
+    // NOTE: If we don't set scopes, then getAuthorizationUrl() would implicitly use getDefaultScopes().
+    // We aim to store the effective list, but the protocol doesn't guarantee a notification of
+    // effective list.
+    $scopes = $this->getScopes() ?: $this->callProtected($provider, 'getDefaultScopes');
+
+    $stateId = \CRM_OAuth_Page_Return::storeState([
+      'time' => \CRM_Utils_Time::getTimeRaw(),
+      'clientId' => $this->getClientDef()['id'],
+      'landingUrl' => $this->getLandingUrl(),
+      'storage' => $this->getStorage(),
+      'scopes' => $scopes,
+    ]);
+    $result[] = [
+      'url' => $provider->getAuthorizationUrl([
+        'state' => $stateId,
+        'scope' => $scopes,
+      ]),
+    ];
+  }
+
+  /**
+   * Call a protected method.
+   *
+   * @param mixed $obj
+   * @param string $method
+   * @param array $args
+   * @return mixed
+   */
+  protected function callProtected($obj, $method, $args = []) {
+    $r = new \ReflectionMethod(get_class($obj), $method);
+    $r->setAccessible(TRUE);
+    return $r->invokeArgs($obj, $args);
+  }
+
+}
index ab34267e4f44cb0794ef489753c026522880291c..f3858bba6869d233f33259c9bd5787270075ee3a 100644 (file)
@@ -23,6 +23,17 @@ class OAuthClient extends Generic\DAOEntity {
     return $action->setCheckPermissions($checkPermissions);
   }
 
+  /**
+   * Initiate the "Authorization Code" workflow.
+   *
+   * @param bool $checkPermissions
+   * @return \Civi\Api4\Action\OAuthClient\AuthorizationCode
+   */
+  public static function authorizationCode($checkPermissions = TRUE) {
+    $action = new \Civi\Api4\Action\OAuthClient\AuthorizationCode(static::class, __FUNCTION__);
+    return $action->setCheckPermissions($checkPermissions);
+  }
+
   public static function permissions() {
     return [
       'meta' => ['access CiviCRM'],
diff --git a/ext/oauth-client/templates/CRM/OAuth/Page/Return.tpl b/ext/oauth-client/templates/CRM/OAuth/Page/Return.tpl
new file mode 100644 (file)
index 0000000..30e6334
--- /dev/null
@@ -0,0 +1,10 @@
+<h3>Welcome back</h3>
+
+<h4>State:</h4>
+<pre>{$state}</pre>
+
+<h4>Token:</h4>
+<pre>{$token}</pre>
+
+<h4>Error</h4>
+<pre>{$error}</pre>
\ No newline at end of file
diff --git a/ext/oauth-client/tests/phpunit/api/v4/OAuthClientGrantTest.php b/ext/oauth-client/tests/phpunit/api/v4/OAuthClientGrantTest.php
new file mode 100644 (file)
index 0000000..9cc4a1f
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+
+use CRM_OAuth_ExtensionUtil as E;
+use Civi\Test\HeadlessInterface;
+use Civi\Test\HookInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * Test the "grant" methods (authorizationCode, clientCredential, etc).
+ *
+ * @group headless
+ */
+class api_v4_OAuthClientGrantTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
+
+  public function setUpHeadless() {
+    // Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
+    // See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
+    return \Civi\Test::headless()->install('oauth-client')->apply();
+  }
+
+  public function setUp() {
+    parent::setUp();
+    $this->assertEquals(0, CRM_Core_DAO::singleValueQuery('SELECT count(*) FROM civicrm_oauth_client'));
+  }
+
+  public function tearDown() {
+    parent::tearDown();
+  }
+
+  /**
+   * Basic sanity check - create, read, and delete a client.
+   */
+  public function testAuthorizationCode() {
+    $usePerms = function($ps) {
+      $base = ['access CiviCRM'];
+      \CRM_Core_Config::singleton()->userPermissionClass->permissions = array_merge($base, $ps);
+    };
+
+    $usePerms(['manage OAuth client']);
+    $client = $this->createClient();
+
+    $usePerms(['manage OAuth client']);
+    $result = Civi\Api4\OAuthClient::authorizationCode()->addWhere('id', '=', $client['id'])->execute();
+    $this->assertEquals(1, $result->count());
+    foreach ($result as $ac) {
+      $url = parse_url($ac['url']);
+      $this->assertEquals('example.com', $url['host']);
+      $this->assertEquals('/one/auth', $url['path']);
+      \parse_str($url['query'], $actualQuery);
+      $this->assertEquals('code', $actualQuery['response_type']);
+      $this->assertRegExp(';^[cs]_[a-zA-Z0-9]+$;', $actualQuery['state']);
+      $this->assertEquals('scope-1-foo,scope-1-bar', $actualQuery['scope']);
+      // ? // $this->assertEquals('auto', $actualQuery['approval_prompt']);
+      $this->assertEquals('example-id', $actualQuery['client_id']);
+      $this->assertRegExp(';civicrm/oauth-client/return;', $actualQuery['redirect_uri']);
+    }
+  }
+
+  private function createClient(): array {
+    $create = Civi\Api4\OAuthClient::create()->setValues([
+      'provider' => 'test_example_1',
+      'guid' => "example-id",
+      'secret' => "example-secret",
+    ])->execute();
+    $this->assertEquals(1, $create->count());
+    $client = $create->first();
+    $this->assertTrue(!empty($client['id']));
+    return $client;
+  }
+
+}
diff --git a/ext/oauth-client/xml/Menu/oauth_client.xml b/ext/oauth-client/xml/Menu/oauth_client.xml
new file mode 100644 (file)
index 0000000..1039478
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<menu>
+  <item>
+    <path>civicrm/oauth-client/return</path>
+    <page_callback>CRM_OAuth_Page_Return</page_callback>
+    <title>Return</title>
+    <access_arguments>access CiviCRM</access_arguments>
+  </item>
+</menu>