Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]

   1  <?php
   2  
   3  namespace Packback\Lti1p3;
   4  
   5  use Firebase\JWT\JWT;
   6  use Packback\Lti1p3\Interfaces\ICache;
   7  use Packback\Lti1p3\Interfaces\IHttpClient;
   8  use Packback\Lti1p3\Interfaces\IHttpException;
   9  use Packback\Lti1p3\Interfaces\IHttpResponse;
  10  use Packback\Lti1p3\Interfaces\ILtiRegistration;
  11  use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
  12  use Packback\Lti1p3\Interfaces\IServiceRequest;
  13  
  14  class LtiServiceConnector implements ILtiServiceConnector
  15  {
  16      public const NEXT_PAGE_REGEX = '/<([^>]*)>; ?rel="next"/i';
  17  
  18      private $cache;
  19      private $client;
  20      private $debuggingMode = false;
  21  
  22      public function __construct(
  23          ICache $cache,
  24          IHttpClient $client
  25      ) {
  26          $this->cache = $cache;
  27          $this->client = $client;
  28      }
  29  
  30      public function setDebuggingMode(bool $enable): void
  31      {
  32          $this->debuggingMode = $enable;
  33      }
  34  
  35      public function getAccessToken(ILtiRegistration $registration, array $scopes)
  36      {
  37          // Get a unique cache key for the access token
  38          $accessTokenKey = $this->getAccessTokenCacheKey($registration, $scopes);
  39          // Get access token from cache if it exists
  40          $accessToken = $this->cache->getAccessToken($accessTokenKey);
  41          if ($accessToken) {
  42              return $accessToken;
  43          }
  44  
  45          // Build up JWT to exchange for an auth token
  46          $clientId = $registration->getClientId();
  47          $jwtClaim = [
  48              'iss' => $clientId,
  49              'sub' => $clientId,
  50              'aud' => $registration->getAuthServer(),
  51              'iat' => time() - 5,
  52              'exp' => time() + 60,
  53              'jti' => 'lti-service-token'.hash('sha256', random_bytes(64)),
  54          ];
  55  
  56          // Sign the JWT with our private key (given by the platform on registration)
  57          $jwt = JWT::encode($jwtClaim, $registration->getToolPrivateKey(), 'RS256', $registration->getKid());
  58  
  59          // Build auth token request headers
  60          $authRequest = [
  61              'grant_type' => 'client_credentials',
  62              'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
  63              'client_assertion' => $jwt,
  64              'scope' => implode(' ', $scopes),
  65          ];
  66  
  67          // Get Access
  68          $request = new ServiceRequest(
  69              ServiceRequest::METHOD_POST,
  70              $registration->getAuthTokenUrl(),
  71              ServiceRequest::TYPE_AUTH
  72          );
  73          $request->setPayload(['form_params' => $authRequest]);
  74          $response = $this->makeRequest($request);
  75  
  76          $tokenData = $this->getResponseBody($response);
  77  
  78          // Cache access token
  79          $this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']);
  80  
  81          return $tokenData['access_token'];
  82      }
  83  
  84      public function makeRequest(IServiceRequest $request)
  85      {
  86          $response = $this->client->request(
  87              $request->getMethod(),
  88              $request->getUrl(),
  89              $request->getPayload()
  90          );
  91  
  92          if ($this->debuggingMode) {
  93              $this->logRequest(
  94                  $request,
  95                  $this->getResponseHeaders($response),
  96                  $this->getResponseBody($response)
  97              );
  98          }
  99  
 100          return $response;
 101      }
 102  
 103      public function getResponseHeaders(IHttpResponse $response): ?array
 104      {
 105          $responseHeaders = $response->getHeaders();
 106          array_walk($responseHeaders, function (&$value) {
 107              $value = $value[0];
 108          });
 109  
 110          return $responseHeaders;
 111      }
 112  
 113      public function getResponseBody(IHttpResponse $response): ?array
 114      {
 115          $responseBody = (string) $response->getBody();
 116  
 117          return json_decode($responseBody, true);
 118      }
 119  
 120      public function makeServiceRequest(
 121          ILtiRegistration $registration,
 122          array $scopes,
 123          IServiceRequest $request,
 124          bool $shouldRetry = true
 125      ): array {
 126          $request->setAccessToken($this->getAccessToken($registration, $scopes));
 127  
 128          try {
 129              $response = $this->makeRequest($request);
 130          } catch (IHttpException $e) {
 131              $status = $e->getResponse()->getStatusCode();
 132  
 133              // If the error was due to invalid authentication and the request
 134              // should be retried, clear the access token and retry it.
 135              if ($status === 401 && $shouldRetry) {
 136                  $key = $this->getAccessTokenCacheKey($registration, $scopes);
 137                  $this->cache->clearAccessToken($key);
 138  
 139                  return $this->makeServiceRequest($registration, $scopes, $request, false);
 140              }
 141  
 142              throw $e;
 143          }
 144  
 145          return [
 146              'headers' => $this->getResponseHeaders($response),
 147              'body' => $this->getResponseBody($response),
 148              'status' => $response->getStatusCode(),
 149          ];
 150      }
 151  
 152      public function getAll(
 153          ILtiRegistration $registration,
 154          array $scopes,
 155          IServiceRequest $request,
 156          string $key = null
 157      ): array {
 158          if ($request->getMethod() !== ServiceRequest::METHOD_GET) {
 159              throw new \Exception('An invalid method was specified by an LTI service requesting all items.');
 160          }
 161  
 162          $results = [];
 163          $nextUrl = $request->getUrl();
 164  
 165          while ($nextUrl) {
 166              $response = $this->makeServiceRequest($registration, $scopes, $request);
 167  
 168              $page_results = $key === null ? ($response['body'] ?? []) : ($response['body'][$key] ?? []);
 169              $results = array_merge($results, $page_results);
 170  
 171              $nextUrl = $this->getNextUrl($response['headers']);
 172              if ($nextUrl) {
 173                  $request->setUrl($nextUrl);
 174              }
 175          }
 176  
 177          return $results;
 178      }
 179  
 180      private function logRequest(
 181          IServiceRequest $request,
 182          array $responseHeaders,
 183          ?array $responseBody
 184      ): void {
 185          $contextArray = [
 186              'request_method' => $request->getMethod(),
 187              'request_url' => $request->getUrl(),
 188              'response_headers' => $responseHeaders,
 189              'response_body' => json_encode($responseBody),
 190          ];
 191  
 192          $requestBody = $request->getPayload()['body'] ?? null;
 193  
 194          if (!empty($requestBody)) {
 195              $contextArray['request_body'] = $requestBody;
 196          }
 197  
 198          error_log(implode(' ', array_filter([
 199              $request->getErrorPrefix(),
 200              json_decode($requestBody)->userId ?? null,
 201              print_r($contextArray, true),
 202          ])));
 203      }
 204  
 205      private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes)
 206      {
 207          sort($scopes);
 208          $scopeKey = md5(implode('|', $scopes));
 209  
 210          return $registration->getIssuer().$registration->getClientId().$scopeKey;
 211      }
 212  
 213      private function getNextUrl(array $headers)
 214      {
 215          $subject = $headers['Link'] ?? '';
 216          preg_match(static::NEXT_PAGE_REGEX, $subject, $matches);
 217  
 218          return $matches[1] ?? null;
 219      }
 220  }