Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 400 and 401] [Versions 400 and 402] [Versions 400 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      public const METHOD_GET = 'GET';
  19      public const METHOD_POST = 'POST';
  20  
  21      private $cache;
  22      private $client;
  23      private $debuggingMode = false;
  24  
  25      public function __construct(ICache $cache, IHttpClient $client)
  26      {
  27          $this->cache = $cache;
  28          $this->client = $client;
  29      }
  30  
  31      public function setDebuggingMode(bool $enable): void
  32      {
  33          $this->debuggingMode = $enable;
  34      }
  35  
  36      public function getAccessToken(ILtiRegistration $registration, array $scopes)
  37      {
  38          // Get a unique cache key for the access token
  39          $accessTokenKey = $this->getAccessTokenCacheKey($registration, $scopes);
  40          // Get access token from cache if it exists
  41          $accessToken = $this->cache->getAccessToken($accessTokenKey);
  42          if ($accessToken) {
  43              return $accessToken;
  44          }
  45  
  46          // Build up JWT to exchange for an auth token
  47          $clientId = $registration->getClientId();
  48          $jwtClaim = [
  49              'iss' => $clientId,
  50              'sub' => $clientId,
  51              'aud' => $registration->getAuthServer(),
  52              'iat' => time() - 5,
  53              'exp' => time() + 60,
  54              'jti' => 'lti-service-token'.hash('sha256', random_bytes(64)),
  55          ];
  56  
  57          // Sign the JWT with our private key (given by the platform on registration)
  58          $jwt = JWT::encode($jwtClaim, $registration->getToolPrivateKey(), 'RS256', $registration->getKid());
  59  
  60          // Build auth token request headers
  61          $authRequest = [
  62              'grant_type' => 'client_credentials',
  63              'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
  64              'client_assertion' => $jwt,
  65              'scope' => implode(' ', $scopes),
  66          ];
  67  
  68          $url = $registration->getAuthTokenUrl();
  69  
  70          // Get Access
  71          $tokenRequest = new ServiceRequest('POST', $url);
  72          $tokenRequest->setBody(http_build_query($authRequest, '', '&'));
  73          $tokenRequest->setContentType('application/x-www-form-urlencoded');
  74          $tokenRequest->setAccept('application/json');
  75          $response = $this->client->request(
  76              $tokenRequest->getMethod(),
  77              $tokenRequest->getUrl(),
  78              [
  79                  'headers' => $tokenRequest->getPayload()['headers'],
  80                  'body' => $tokenRequest->getPayload()['body']
  81              ]
  82          );
  83          $tokenData = $this->getResponseBody($response);
  84  
  85          // Cache access token
  86          $this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']);
  87  
  88          return $tokenData['access_token'];
  89      }
  90  
  91  
  92      public function makeRequest(IServiceRequest $request)
  93      {
  94          return $this->client->request(
  95              $request->getMethod(),
  96              $request->getUrl(),
  97              $request->getPayload()
  98          );
  99      }
 100  
 101      public function getResponseBody(IHttpResponse $response): ?array
 102      {
 103          $responseBody = (string) $response->getBody();
 104  
 105          return json_decode($responseBody, true);
 106      }
 107  
 108      public function makeServiceRequest(
 109          ILtiRegistration $registration,
 110          array $scopes,
 111          IServiceRequest $request,
 112          bool $shouldRetry = true
 113      ): array {
 114          $request->setAccessToken($this->getAccessToken($registration, $scopes));
 115          try {
 116              $response = $this->makeRequest($request);
 117          } catch (IHttpException $e) {
 118              $status = $e->getResponse()->getStatusCode();
 119              // If the error was due to invalid authentication and the request
 120              // should be retried, clear the access token and retry it.
 121              if ($status === 401 && $shouldRetry) {
 122                  $key = $this->getAccessTokenCacheKey($registration, $scopes);
 123                  $this->cache->clearAccessToken($key);
 124  
 125                  return $this->makeServiceRequest($registration, $scopes, $request, false);
 126              }
 127              throw $e;
 128          }
 129  
 130          $responseHeaders = $response->getHeaders();
 131          $responseBody = $this->getResponseBody($response);
 132  
 133          if ($this->debuggingMode) {
 134              error_log('Syncing grade for this lti_user_id: '.
 135                  json_decode($request->getPayload()['body'])->userId.' '.print_r([
 136                      'request_method' => $request->getMethod(),
 137                      'request_url' => $request->getUrl(),
 138                      'request_body' => $request->getPayload()['body'],
 139                      'response_headers' => $responseHeaders,
 140                      'response_body' => json_encode($responseBody),
 141                  ], true));
 142          }
 143  
 144          return [
 145              'headers' => $responseHeaders,
 146              'body' => $responseBody,
 147              'status' => $response->getStatusCode(),
 148          ];
 149      }
 150  
 151      public function getAll(
 152          ILtiRegistration $registration,
 153          array $scopes,
 154          IServiceRequest $request,
 155          string $key = null
 156      ): array {
 157          if ($request->getMethod() !== static::METHOD_GET) {
 158              throw new \Exception('An invalid method was specified by an LTI service requesting all items.');
 159          }
 160  
 161          $results = [];
 162          $nextUrl = $request->getUrl();
 163  
 164          while ($nextUrl) {
 165              $response = $this->makeServiceRequest($registration, $scopes, $request);
 166  
 167              $page_results = $key === null ? ($response['body'] ?? []) : ($response['body'][$key] ?? []);
 168              $results = array_merge($results, $page_results);
 169  
 170              $nextUrl = $this->getNextUrl($response['headers']);
 171              if ($nextUrl) {
 172                  $request->setUrl($nextUrl);
 173              }
 174          }
 175  
 176          return $results;
 177      }
 178  
 179      private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes)
 180      {
 181          sort($scopes);
 182          $scopeKey = md5(implode('|', $scopes));
 183  
 184          return $registration->getIssuer().$registration->getClientId().$scopeKey;
 185      }
 186  
 187      private function getNextUrl(array $headers)
 188      {
 189          $subject = $headers['Link'] ?? '';
 190          preg_match(LtiServiceConnector::NEXT_PAGE_REGEX, $subject, $matches);
 191  
 192          return $matches[1] ?? null;
 193      }
 194  }