Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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

   1  <?php
   2  
   3  namespace Packback\Lti1p3;
   4  
   5  use Exception;
   6  use Firebase\JWT\JWT;
   7  use GuzzleHttp\Client;
   8  use GuzzleHttp\Exception\ClientException;
   9  use GuzzleHttp\Psr7\Response;
  10  use Packback\Lti1p3\Interfaces\ICache;
  11  use Packback\Lti1p3\Interfaces\ILtiRegistration;
  12  use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
  13  use Packback\Lti1p3\Interfaces\IServiceRequest;
  14  
  15  class LtiServiceConnector implements ILtiServiceConnector
  16  {
  17      public const NEXT_PAGE_REGEX = '/<([^>]*)>; ?rel="next"/i';
  18  
  19      private $cache;
  20      private $client;
  21      private $debuggingMode = false;
  22  
  23      public function __construct(
  24          ICache $cache,
  25          Client $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          // Get Access
  69          $request = new ServiceRequest(
  70              ServiceRequest::METHOD_POST,
  71              $registration->getAuthTokenUrl(),
  72              ServiceRequest::TYPE_AUTH
  73          );
  74          $request->setPayload(['form_params' => $authRequest]);
  75          $response = $this->makeRequest($request);
  76  
  77          $tokenData = $this->getResponseBody($response);
  78  
  79          // Cache access token
  80          $this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']);
  81  
  82          return $tokenData['access_token'];
  83      }
  84  
  85      public function makeRequest(IServiceRequest $request)
  86      {
  87          $response = $this->client->request(
  88              $request->getMethod(),
  89              $request->getUrl(),
  90              $request->getPayload()
  91          );
  92  
  93          if ($this->debuggingMode) {
  94              $this->logRequest(
  95                  $request,
  96                  $this->getResponseHeaders($response),
  97                  $this->getResponseBody($response)
  98              );
  99          }
 100  
 101          return $response;
 102      }
 103  
 104      public function getResponseHeaders(Response $response): ?array
 105      {
 106          $responseHeaders = $response->getHeaders();
 107          array_walk($responseHeaders, function (&$value) {
 108              $value = $value[0];
 109          });
 110  
 111          return $responseHeaders;
 112      }
 113  
 114      public function getResponseBody(Response $response): ?array
 115      {
 116          $responseBody = (string) $response->getBody();
 117  
 118          return json_decode($responseBody, true);
 119      }
 120  
 121      public function makeServiceRequest(
 122          ILtiRegistration $registration,
 123          array $scopes,
 124          IServiceRequest $request,
 125          bool $shouldRetry = true
 126      ): array {
 127          $request->setAccessToken($this->getAccessToken($registration, $scopes));
 128  
 129          try {
 130              $response = $this->makeRequest($request);
 131          } catch (ClientException $e) {
 132              $status = $e->getResponse()->getStatusCode();
 133  
 134              // If the error was due to invalid authentication and the request
 135              // should be retried, clear the access token and retry it.
 136              if ($status === 401 && $shouldRetry) {
 137                  $key = $this->getAccessTokenCacheKey($registration, $scopes);
 138                  $this->cache->clearAccessToken($key);
 139  
 140                  return $this->makeServiceRequest($registration, $scopes, $request, false);
 141              }
 142  
 143              throw $e;
 144          }
 145  
 146          return [
 147              'headers' => $this->getResponseHeaders($response),
 148              'body' => $this->getResponseBody($response),
 149              'status' => $response->getStatusCode(),
 150          ];
 151      }
 152  
 153      public function getAll(
 154          ILtiRegistration $registration,
 155          array $scopes,
 156          IServiceRequest $request,
 157          string $key = null
 158      ): array {
 159          if ($request->getMethod() !== ServiceRequest::METHOD_GET) {
 160              throw new Exception('An invalid method was specified by an LTI service requesting all items.');
 161          }
 162  
 163          $results = [];
 164          $nextUrl = $request->getUrl();
 165  
 166          while ($nextUrl) {
 167              $response = $this->makeServiceRequest($registration, $scopes, $request);
 168  
 169              $page_results = $key === null ? ($response['body'] ?? []) : ($response['body'][$key] ?? []);
 170              $results = array_merge($results, $page_results);
 171  
 172              $nextUrl = $this->getNextUrl($response['headers']);
 173              if ($nextUrl) {
 174                  $request->setUrl($nextUrl);
 175              }
 176          }
 177  
 178          return $results;
 179      }
 180  
 181      private function logRequest(
 182          IServiceRequest $request,
 183          array $responseHeaders,
 184          ?array $responseBody
 185      ): void {
 186          $contextArray = [
 187              'request_method' => $request->getMethod(),
 188              'request_url' => $request->getUrl(),
 189              'response_headers' => $responseHeaders,
 190              'response_body' => json_encode($responseBody),
 191          ];
 192  
 193          $requestBody = $request->getPayload()['body'] ?? null;
 194  
 195          if (!empty($requestBody)) {
 196              $contextArray['request_body'] = $requestBody;
 197          }
 198  
 199          error_log(implode(' ', array_filter([
 200              $request->getErrorPrefix(),
 201              json_decode($requestBody)->userId ?? null,
 202              print_r($contextArray, true),
 203          ])));
 204      }
 205  
 206      private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes)
 207      {
 208          sort($scopes);
 209          $scopeKey = md5(implode('|', $scopes));
 210  
 211          return $registration->getIssuer().$registration->getClientId().$scopeKey;
 212      }
 213  
 214      private function getNextUrl(array $headers)
 215      {
 216          $subject = $headers['Link'] ?? '';
 217          preg_match(static::NEXT_PAGE_REGEX, $subject, $matches);
 218  
 219          return $matches[1] ?? null;
 220      }
 221  }