HttpTestTrait - Allow one to easily add Authx JWTs to each request
authorTim Otten <totten@civicrm.org>
Wed, 25 Aug 2021 00:08:19 +0000 (17:08 -0700)
committerTim Otten <totten@civicrm.org>
Thu, 26 Aug 2021 04:31:00 +0000 (21:31 -0700)
```php
// Add JWT credentials to every Guzzle request
$http = $this->createGuzzle([
  'authx_user' => 'admin',
]);
$response = $http->get('route://civicrm/dashboard');

// Add JWT credentials to a single request
$http = $this->createGuzzle();
$response = $http->get('route://civicrm/dashboard', [
  'authx_user' => 'demo',
]);

// Perform a stateful login via JWT
$http = $this->createGuzzle(['cookies' => new CookieJar()]);
$response = $http->get('route://civicrm/authx/login', [
  'authx_contact_id' => 100,
]);
```

CRM/Utils/GuzzleMiddleware.php
Civi/Test/HttpTestTrait.php
ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php

index b6e8fb7e2d4232cf8d1cdbb16c4bee928cd21de3..0c9815361265abe5839d95abfa7c7ade40b66a71 100644 (file)
@@ -8,6 +8,106 @@ use Psr\Http\Message\RequestInterface;
  */
 class CRM_Utils_GuzzleMiddleware {
 
+  /**
+   * The authx middleware sends authenticated requests via JWT.
+   *
+   * To add an authentication token to a specific request, the `$options`
+   * must specify `authx_user` or `authx_contact_id`. Examples:
+   *
+   * $http = new GuzzleHttp\Client(['authx_user' => 'admin']);
+   * $http->post('civicrm/admin', ['authx_user' => 'admin']);
+   *
+   * Supported options:
+   *   - authx_ttl (int): Seconds of validity for JWT's
+   *   - authx_host (string): Only send tokens for the given host.
+   *   - authx_contact_id (int): The CiviCRM contact to authenticate with
+   *   - authx_user (string): The CMS user to authenticate with
+   *   - authx_flow (string): How to format the auth token. One of: 'param', 'xheader', 'header'.
+   *
+   * @return \Closure
+   */
+  public static function authx($defaults = []) {
+    $defaults = array_merge([
+      'authx_ttl' => 60,
+      'authx_host' => parse_url(CIVICRM_UF_BASEURL, PHP_URL_HOST),
+      'authx_contact_id' => NULL,
+      'authx_user' => NULL,
+      'authx_flow' => 'param',
+    ], $defaults);
+    return function(callable $handler) use ($defaults) {
+      return function (RequestInterface $request, array $options) use ($handler, $defaults) {
+        if ($request->getUri()->getHost() !== $defaults['authx_host']) {
+          return $handler($request, $options);
+        }
+
+        $options = array_merge($defaults, $options);
+        if (!empty($options['authx_contact_id'])) {
+          $cid = $options['authx_contact_id'];
+        }
+        elseif (!empty($options['authx_user'])) {
+          $r = civicrm_api3("Contact", "get", ["id" => "@user:" . $options['authx_user']]);
+          foreach ($r['values'] as $id => $value) {
+            $cid = $id;
+            break;
+          }
+          if (empty($cid)) {
+            throw new \RuntimeException("Failed to identify user ({$options['authx_user']})");
+          }
+        }
+        else {
+          $cid = NULL;
+        }
+
+        if ($cid) {
+          if (!CRM_Extension_System::singleton()->getMapper()->isActiveModule('authx')) {
+            throw new \RuntimeException("Authx is not enabled. Authenticated requests will not work.");
+          }
+          $tok = \Civi::service('crypto.jwt')->encode([
+            'exp' => time() + $options['authx_ttl'],
+            'sub' => 'cid:' . $cid,
+            'scope' => 'authx',
+          ]);
+
+          switch ($options['authx_flow']) {
+            case 'header':
+              $request = $request->withHeader('Authorization', "Bearer $tok");
+              break;
+
+            case 'xheader':
+              $request = $request->withHeader('X-Civi-Auth', "Bearer $tok");
+              break;
+
+            case 'param':
+              if ($request->getMethod() === 'POST') {
+                if (!empty($request->getHeader('Content-Type')) && !preg_grep(';application/x-www-form-urlencoded;', $request->getHeader('Content-Type'))) {
+                  throw new \RuntimeException("Cannot append authentication credentials to HTTP POST. Unrecognized content type.");
+                }
+                $query = (string) $request->getBody();
+                $request = $request->withHeader('Content-Type', 'application/x-www-form-urlencoded');
+                $request = new GuzzleHttp\Psr7\Request(
+                  $request->getMethod(),
+                  $request->getUri(),
+                  $request->getHeaders(),
+                  http_build_query(['_authx' => "Bearer $tok"]) . ($query ? '&' : '') . $query
+                );
+              }
+              else {
+                $query = $request->getUri()->getQuery();
+                $request = $request->withUri($request->getUri()->withQuery(
+                  http_build_query(['_authx' => "Bearer $tok"]) . ($query ? '&' : '') . $query
+                ));
+              }
+              break;
+
+            default:
+              throw new \RuntimeException("Unrecognized authx flow: {$options['authx_flow']}");
+          }
+        }
+        return $handler($request, $options);
+      };
+    };
+  }
+
   /**
    * Add this as a Guzzle handler/middleware if you wish to simplify
    * the construction of Civi-related URLs. It enables URL schemes for:
index 80be44515d66060296fc4be5df8b6ce1466c59bb..18f1dad11569fd190d3b43defe95330c0c93e423 100644 (file)
@@ -34,11 +34,20 @@ trait HttpTestTrait {
   /**
    * Create an HTTP client suitable for simulating AJAX requests.
    *
+   * The client may include some mix of these middlewares:
+   *
+   * @see \CRM_Utils_GuzzleMiddleware::authx()
+   * @see \CRM_Utils_GuzzleMiddleware::url()
+   * @see \CRM_Utils_GuzzleMiddleware::curlLog()
+   * @see Middleware::history()
+   * @see Middleware::log()
+   *
    * @param array $options
    * @return \GuzzleHttp\Client
    */
   protected function createGuzzle($options = []) {
     $handler = HandlerStack::create();
+    $handler->unshift(\CRM_Utils_GuzzleMiddleware::authx(), 'civi_authx');
     $handler->unshift(\CRM_Utils_GuzzleMiddleware::url(), 'civi_url');
     $handler->push(Middleware::history($this->httpHistory), 'history');
 
index d2e2ea5f61a274eeeeae95eedb3f8786c443327b..e7792914f41681b2bd970d9957796d114ad9127e 100644 (file)
@@ -423,6 +423,46 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     $this->assertEquals($actualSteps, $planSteps);
   }
 
+  /**
+   * Civi's test suite includes middleware that will add JWT tokens to outgoing requests.
+   *
+   * This test tries a few permutations with different principals ("demo", "Lebowski"),
+   * different identifier fields (authx_user, authx_contact_id), and different
+   * flows (param/header/xheader).
+   *
+   * @throws \CiviCRM_API3_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  public function testJwtMiddleware() {
+    // HTTP GET with a specific user. Choose flow automatically.
+    $response = $this->createGuzzle()->get('civicrm/authx/id', [
+      'authx_user' => $GLOBALS['_CV']['DEMO_USER'],
+    ]);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', 'param', $response);
+
+    // HTTP GET with a specific contact. Choose flow automatically.
+    $response = $this->createGuzzle()->get('civicrm/authx/id', [
+      'authx_contact_id' => $this->getDemoCID(),
+    ]);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', 'param', $response);
+
+    // HTTP POST with a specific contact. Per-client default.
+    $response = $this->createGuzzle([
+      'authx_contact_id' => $this->getLebowskiCID(),
+    ])->post('civicrm/authx/id');
+    $this->assertMyContact($this->getLebowskiCID(), NULL, 'jwt', 'param', $response);
+
+    // Using explicit flow options...
+    foreach (['param', 'xheader', 'header'] as $flowType) {
+      \Civi::settings()->set("authx_{$flowType}_cred", ['jwt']);
+      $response = $this->createGuzzle()->get('civicrm/authx/id', [
+        'authx_contact_id' => $this->getDemoCID(),
+        'authx_flow' => $flowType,
+      ]);
+      $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', $flowType, $response);
+    }
+  }
+
   /**
    * Filter a request, applying the given authentication options
    *