--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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);
+ }
+
+}
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'],
--- /dev/null
+<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
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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>