diff --git a/lib/lti1p3/README.md b/lib/lti1p3/README.md index 65b09794b70..b7befbbfa11 100644 --- a/lib/lti1p3/README.md +++ b/lib/lti1p3/README.md @@ -4,12 +4,10 @@ A library used for building IMS-certified LTI 1.3 tool providers in PHP. This library is a fork of the [packbackbooks/lti-1-3-php-library](https://github.com/packbackbooks/lti-1-3-php-library), patched specifically for use in [Moodle](https://github.com/moodle/moodle). -It is currently based on version [5.4.1 of the packbackbooks/lti-1-3-php-library](https://github.com/packbackbooks/lti-1-3-php-library/releases/tag/v5.4.1) library. +It is currently based on version [6.0.0 of the packbackbooks/lti-1-3-php-library](https://github.com/packbackbooks/lti-1-3-php-library/releases/tag/v6.0.0) library. The following changes are included so that the library may be used with Moodle: * Replace the phpseclib dependency with openssl equivalent call in public key generation code. Please see the original [README](https://github.com/packbackbooks/lti-1-3-php-library/blob/master/README.md) for more information about the upstream library. - - diff --git a/lib/lti1p3/src/Concerns/Arrayable.php b/lib/lti1p3/src/Concerns/Arrayable.php new file mode 100644 index 00000000000..c83f345edc3 --- /dev/null +++ b/lib/lti1p3/src/Concerns/Arrayable.php @@ -0,0 +1,15 @@ +getArray()); + } +} diff --git a/lib/lti1p3/src/Concerns/JsonStringable.php b/lib/lti1p3/src/Concerns/JsonStringable.php new file mode 100644 index 00000000000..29242827c01 --- /dev/null +++ b/lib/lti1p3/src/Concerns/JsonStringable.php @@ -0,0 +1,13 @@ +toArray()); + } +} diff --git a/lib/lti1p3/src/DeepLinkResources/DateTimeInterval.php b/lib/lti1p3/src/DeepLinkResources/DateTimeInterval.php new file mode 100644 index 00000000000..e3b2cef2868 --- /dev/null +++ b/lib/lti1p3/src/DeepLinkResources/DateTimeInterval.php @@ -0,0 +1,74 @@ +validateStartAndEnd(); + } + + public static function new(): self + { + return new DateTimeInterval(); + } + + public function getArray(): array + { + if (!isset($this->start) && !isset($this->end)) { + throw new LtiException(self::ERROR_NO_START_OR_END); + } + + $this->validateStartAndEnd(); + + return [ + 'startDateTime' => $this->start?->format(DateTime::ATOM), + 'endDateTime' => $this->end?->format(DateTime::ATOM), + ]; + } + + public function setStart(?DateTime $start): self + { + $this->start = $start; + + return $this; + } + + public function getStart(): ?DateTime + { + return $this->start; + } + + public function setEnd(?DateTime $end): self + { + $this->end = $end; + + return $this; + } + + public function getEnd(): ?DateTime + { + return $this->end; + } + + /** + * @throws LtiException + */ + private function validateStartAndEnd(): void + { + if (isset($this->start) && isset($this->end) && $this->start > $this->end) { + throw new LtiException(self::ERROR_START_GT_END); + } + } +} diff --git a/lib/lti1p3/src/DeepLinkResources/HasDimensions.php b/lib/lti1p3/src/DeepLinkResources/HasDimensions.php new file mode 100644 index 00000000000..ba9431cb0cf --- /dev/null +++ b/lib/lti1p3/src/DeepLinkResources/HasDimensions.php @@ -0,0 +1,30 @@ +width = $width; + + return $this; + } + + public function getWidth(): ?int + { + return $this->width; + } + + public function setHeight(?int $height): self + { + $this->height = $height; + + return $this; + } + + public function getHeight(): ?int + { + return $this->height; + } +} diff --git a/lib/lti1p3/src/DeepLinkResources/Icon.php b/lib/lti1p3/src/DeepLinkResources/Icon.php new file mode 100644 index 00000000000..267c983fc08 --- /dev/null +++ b/lib/lti1p3/src/DeepLinkResources/Icon.php @@ -0,0 +1,43 @@ + $this->url, + 'width' => $this->width, + 'height' => $this->height, + ]; + } + + public function setUrl(string $url): self + { + $this->url = $url; + + return $this; + } + + public function getUrl(): string + { + return $this->url; + } +} diff --git a/lib/lti1p3/src/DeepLinkResources/Iframe.php b/lib/lti1p3/src/DeepLinkResources/Iframe.php new file mode 100644 index 00000000000..039ea8eb5e7 --- /dev/null +++ b/lib/lti1p3/src/DeepLinkResources/Iframe.php @@ -0,0 +1,43 @@ + $this->width, + 'height' => $this->height, + 'src' => $this->src, + ]; + } + + public function setSrc(?string $src): self + { + $this->src = $src; + + return $this; + } + + public function getSrc(): ?string + { + return $this->src; + } +} diff --git a/lib/lti1p3/src/DeepLinkResources/Resource.php b/lib/lti1p3/src/DeepLinkResources/Resource.php new file mode 100644 index 00000000000..5cbb4b2be9a --- /dev/null +++ b/lib/lti1p3/src/DeepLinkResources/Resource.php @@ -0,0 +1,210 @@ + $this->type, + 'title' => $this->title, + 'text' => $this->text, + 'url' => $this->url, + 'icon' => $this->icon?->toArray(), + 'thumbnail' => $this->thumbnail?->toArray(), + 'iframe' => $this->iframe?->toArray(), + 'window' => $this->window?->toArray(), + 'available' => $this->availability_interval?->toArray(), + 'submission' => $this->submission_interval?->toArray(), + ]; + + if (!empty($this->custom_params)) { + $resource['custom'] = $this->custom_params; + } + + if (isset($this->line_item)) { + $resource['lineItem'] = [ + 'scoreMaximum' => $this->line_item->getScoreMaximum(), + 'label' => $this->line_item->getLabel(), + ]; + } + + // Kept for backwards compatibility + if (!isset($this->iframe) && !isset($this->window)) { + $resource['presentation'] = [ + 'documentTarget' => $this->target, + ]; + } + + return $resource; + } + + public function getType(): string + { + return $this->type; + } + + public function setType(string $value): self + { + $this->type = $value; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(?string $value): self + { + $this->title = $value; + + return $this; + } + + public function getText(): ?string + { + return $this->text; + } + + public function setText(?string $value): self + { + $this->text = $value; + + return $this; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $value): self + { + $this->url = $value; + + return $this; + } + + public function getLineItem(): ?LtiLineitem + { + return $this->line_item; + } + + public function setLineItem(?LtiLineitem $value): self + { + $this->line_item = $value; + + return $this; + } + + public function setIcon(?Icon $icon): self + { + $this->icon = $icon; + + return $this; + } + + public function getIcon(): ?Icon + { + return $this->icon; + } + + public function setThumbnail(?Icon $thumbnail): self + { + $this->thumbnail = $thumbnail; + + return $this; + } + + public function getThumbnail(): ?Icon + { + return $this->thumbnail; + } + + public function getCustomParams(): array + { + return $this->custom_params; + } + + public function setCustomParams(array $value): self + { + $this->custom_params = $value; + + return $this; + } + + public function getIframe(): ?Iframe + { + return $this->iframe; + } + + public function setIframe(?Iframe $iframe): self + { + $this->iframe = $iframe; + + return $this; + } + + public function getWindow(): ?Window + { + return $this->window; + } + + public function setWindow(?Window $window): self + { + $this->window = $window; + + return $this; + } + + public function getAvailabilityInterval(): ?DateTimeInterval + { + return $this->availability_interval; + } + + public function setAvailabilityInterval(?DateTimeInterval $availabilityInterval): self + { + $this->availability_interval = $availabilityInterval; + + return $this; + } + + public function getSubmissionInterval(): ?DateTimeInterval + { + return $this->submission_interval; + } + + public function setSubmissionInterval(?DateTimeInterval $submissionInterval): self + { + $this->submission_interval = $submissionInterval; + + return $this; + } +} diff --git a/lib/lti1p3/src/DeepLinkResources/Window.php b/lib/lti1p3/src/DeepLinkResources/Window.php new file mode 100644 index 00000000000..7ee30ba00ce --- /dev/null +++ b/lib/lti1p3/src/DeepLinkResources/Window.php @@ -0,0 +1,57 @@ + $this->target_name, + 'width' => $this->width, + 'height' => $this->height, + 'windowFeatures' => $this->window_features, + ]; + } + + public function setTargetName(?string $targetName): self + { + $this->target_name = $targetName; + + return $this; + } + + public function getTargetName(): ?string + { + return $this->target_name; + } + + public function setWindowFeatures(?string $windowFeatures): self + { + $this->window_features = $windowFeatures; + + return $this; + } + + public function getWindowFeatures(): ?string + { + return $this->window_features; + } +} diff --git a/lib/lti1p3/src/Helpers/Helpers.php b/lib/lti1p3/src/Helpers/Helpers.php index 9c5267ac6c8..ad64d43f778 100644 --- a/lib/lti1p3/src/Helpers/Helpers.php +++ b/lib/lti1p3/src/Helpers/Helpers.php @@ -4,8 +4,23 @@ namespace Packback\Lti1p3\Helpers; class Helpers { - public static function checkIfNullValue($value): bool + public static function filterOutNulls(array $array): array { - return !is_null($value); + return array_filter($array, fn ($value) => !is_null($value)); + } + + public static function buildUrlWithQueryParams(string $url, array $params = []): string + { + if (empty($params)) { + return $url; + } + + if (parse_url($url, PHP_URL_QUERY)) { + $separator = '&'; + } else { + $separator = '?'; + } + + return $url.$separator.http_build_query($params, ''); } } diff --git a/lib/lti1p3/src/ImsStorage/ImsCache.php b/lib/lti1p3/src/ImsStorage/ImsCache.php deleted file mode 100644 index ed38bb44427..00000000000 --- a/lib/lti1p3/src/ImsStorage/ImsCache.php +++ /dev/null @@ -1,79 +0,0 @@ -loadCache(); - - return $this->cache[$key] ?? null; - } - - public function cacheLaunchData(string $key, array $jwtBody): void - { - $this->loadCache(); - - $this->cache[$key] = $jwtBody; - $this->saveCache(); - } - - public function cacheNonce(string $nonce, string $state): void - { - $this->loadCache(); - - $this->cache['nonce'][$nonce] = $state; - $this->saveCache(); - } - - public function checkNonceIsValid(string $nonce, string $state): bool - { - $this->loadCache(); - - return isset($this->cache['nonce'][$nonce]) && - $this->cache['nonce'][$nonce] === $state; - } - - public function cacheAccessToken(string $key, string $accessToken): void - { - $this->loadCache(); - - $this->cache[$key] = $accessToken; - $this->saveCache(); - } - - public function getAccessToken(string $key): ?string - { - $this->loadCache(); - - return $this->cache[$key] ?? null; - } - - public function clearAccessToken(string $key): void - { - $this->loadCache(); - - unset($this->cache[$key]); - $this->saveCache(); - } - - private function loadCache() - { - $cache = file_get_contents(sys_get_temp_dir().'/lti_cache.txt'); - if (empty($cache)) { - file_put_contents(sys_get_temp_dir().'/lti_cache.txt', '{}'); - $this->cache = []; - } - $this->cache = json_decode($cache, true); - } - - private function saveCache() - { - file_put_contents(sys_get_temp_dir().'/lti_cache.txt', json_encode($this->cache)); - } -} diff --git a/lib/lti1p3/src/ImsStorage/ImsCookie.php b/lib/lti1p3/src/ImsStorage/ImsCookie.php deleted file mode 100644 index a98175e2d79..00000000000 --- a/lib/lti1p3/src/ImsStorage/ImsCookie.php +++ /dev/null @@ -1,39 +0,0 @@ - time() + $exp, - ]; - - // SameSite none and secure will be required for tools to work inside iframes - $same_site_options = [ - 'samesite' => 'None', - 'secure' => true, - ]; - - setcookie($name, $value, array_merge($cookie_options, $same_site_options, $options)); - - // Set a second fallback cookie in the event that "SameSite" is not supported - setcookie('LEGACY_'.$name, $value, array_merge($cookie_options, $options)); - } -} diff --git a/lib/lti1p3/src/Interfaces/ICookie.php b/lib/lti1p3/src/Interfaces/ICookie.php index 96021c8a1c1..1590acc551a 100644 --- a/lib/lti1p3/src/Interfaces/ICookie.php +++ b/lib/lti1p3/src/Interfaces/ICookie.php @@ -6,5 +6,5 @@ interface ICookie { public function getCookie(string $name): ?string; - public function setCookie(string $name, string $value, $exp = 3600, $options = []): void; + public function setCookie(string $name, string $value, int $exp = 3600, array $options = []): void; } diff --git a/lib/lti1p3/src/Interfaces/IDatabase.php b/lib/lti1p3/src/Interfaces/IDatabase.php index d1dd99415a6..d863d0e9422 100644 --- a/lib/lti1p3/src/Interfaces/IDatabase.php +++ b/lib/lti1p3/src/Interfaces/IDatabase.php @@ -4,7 +4,7 @@ namespace Packback\Lti1p3\Interfaces; interface IDatabase { - public function findRegistrationByIssuer($iss, $clientId = null); + public function findRegistrationByIssuer(string $iss, ?string $clientId = null): ?ILtiRegistration; - public function findDeployment($iss, $deploymentId, $clientId = null); + public function findDeployment(string $iss, string $deploymentId, ?string $clientId = null): ?ILtiDeployment; } diff --git a/lib/lti1p3/src/Interfaces/ILtiDeployment.php b/lib/lti1p3/src/Interfaces/ILtiDeployment.php new file mode 100644 index 00000000000..a7092f3ff4b --- /dev/null +++ b/lib/lti1p3/src/Interfaces/ILtiDeployment.php @@ -0,0 +1,11 @@ + + */ + public function findLti1p1Keys(LtiMessageLaunch $launch): array; + + /** + * Given an LtiMessageLaunch, return true if this tool should migrate from 1.1 to 1.3 + */ + public function shouldMigrate(LtiMessageLaunch $launch): bool; + + /** + * This method should create a 1.3 deployment in your DB based on the LtiMessageLaunch. + * Previous to this, we validated the oauth_consumer_key_sign to ensure this migration + * can safely occur. + */ + public function migrateFromLti1p1(LtiMessageLaunch $launch): ?ILtiDeployment; +} diff --git a/lib/lti1p3/src/Interfaces/IServiceRequest.php b/lib/lti1p3/src/Interfaces/IServiceRequest.php index 3987eb05a17..d619b159ef2 100644 --- a/lib/lti1p3/src/Interfaces/IServiceRequest.php +++ b/lib/lti1p3/src/Interfaces/IServiceRequest.php @@ -11,15 +11,21 @@ interface IServiceRequest public function getPayload(): array; - public function setUrl(string $url): self; + public function setUrl(string $url): IServiceRequest; - public function setAccessToken(string $accessToken): self; + public function setAccessToken(string $accessToken): IServiceRequest; - public function setBody(string $body): self; + public function setBody(string $body): IServiceRequest; - public function setAccept(string $accept): self; + public function setPayload(array $payload): IServiceRequest; - public function setContentType(string $contentType): self; + public function setAccept(string $accept): IServiceRequest; + + public function setContentType(string $contentType): IServiceRequest; public function getErrorPrefix(): string; + + public function getMaskResponseLogs(): bool; + + public function setMaskResponseLogs(bool $maskResponseLogs): self; } diff --git a/lib/lti1p3/src/JwksEndpoint.php b/lib/lti1p3/src/JwksEndpoint.php index d228c1054eb..312b0241d5b 100644 --- a/lib/lti1p3/src/JwksEndpoint.php +++ b/lib/lti1p3/src/JwksEndpoint.php @@ -8,31 +8,28 @@ use Packback\Lti1p3\Interfaces\ILtiRegistration; class JwksEndpoint { - private $keys; - - public function __construct(array $keys) + public function __construct(private array $keys) { - $this->keys = $keys; } - public static function new(array $keys) + public static function new(array $keys): self { return new JwksEndpoint($keys); } - public static function fromIssuer(IDatabase $database, $issuer) + public static function fromIssuer(IDatabase $database, string $issuer): self { $registration = $database->findRegistrationByIssuer($issuer); return new JwksEndpoint([$registration->getKid() => $registration->getToolPrivateKey()]); } - public static function fromRegistration(ILtiRegistration $registration) + public static function fromRegistration(ILtiRegistration $registration): self { return new JwksEndpoint([$registration->getKid() => $registration->getToolPrivateKey()]); } - public function getPublicJwks() + public function getPublicJwks(): array { $jwks = []; foreach ($this->keys as $kid => $private_key) { @@ -51,9 +48,4 @@ class JwksEndpoint return ['keys' => $jwks]; } - - public function outputJwks() - { - echo json_encode($this->getPublicJwks()); - } } diff --git a/lib/lti1p3/src/Lti1p1Key.php b/lib/lti1p3/src/Lti1p1Key.php new file mode 100644 index 00000000000..091f9fc51ca --- /dev/null +++ b/lib/lti1p3/src/Lti1p1Key.php @@ -0,0 +1,68 @@ +key = $key['key'] ?? null; + $this->secret = $key['secret'] ?? null; + } + + public function getKey(): ?string + { + return $this->key; + } + + public function setKey(string $key): self + { + $this->key = $key; + + return $this; + } + + public function getSecret(): ?string + { + return $this->secret; + } + + public function setSecret(string $secret): self + { + $this->secret = $secret; + + return $this; + } + + /** + * Create a signature using the key and secret + * + * @see https://www.imsglobal.org/spec/lti/v1p3/migr#oauth_consumer_key_sign + */ + public function sign(string $deploymentId, string $iss, string $clientId, string $exp, string $nonce): string + { + $signatureComponents = [ + $this->getKey(), + $deploymentId, + $iss, + $clientId, + $exp, + $nonce, + ]; + + $baseString = implode('&', $signatureComponents); + $utf8String = mb_convert_encoding($baseString, 'utf8', mb_detect_encoding($baseString)); + $hash = hash_hmac('sha256', $utf8String, $this->getSecret(), true); + + return base64_encode($hash); + } + +} diff --git a/lib/lti1p3/src/LtiAbstractService.php b/lib/lti1p3/src/LtiAbstractService.php index 0f33d3adaf1..d0614ba406b 100644 --- a/lib/lti1p3/src/LtiAbstractService.php +++ b/lib/lti1p3/src/LtiAbstractService.php @@ -8,18 +8,11 @@ use Packback\Lti1p3\Interfaces\IServiceRequest; abstract class LtiAbstractService { - private $serviceConnector; - private $registration; - private $serviceData; - public function __construct( - ILtiServiceConnector $serviceConnector, - ILtiRegistration $registration, - array $serviceData + private ILtiServiceConnector $serviceConnector, + private ILtiRegistration $registration, + private array $serviceData ) { - $this->serviceConnector = $serviceConnector; - $this->registration = $registration; - $this->serviceData = $serviceData; } public function getServiceData(): array @@ -39,7 +32,7 @@ abstract class LtiAbstractService protected function validateScopes(array $scopes): void { if (empty(array_intersect($scopes, $this->getScope()))) { - throw new LtiException('Missing required scope', 1); + throw new LtiException('Missing required scope'); } } @@ -52,7 +45,7 @@ abstract class LtiAbstractService ); } - protected function getAll(IServiceRequest $request, string $key = null): array + protected function getAll(IServiceRequest $request, ?string $key = null): array { return $this->serviceConnector->getAll( $this->registration, diff --git a/lib/lti1p3/src/LtiAssignmentsGradesService.php b/lib/lti1p3/src/LtiAssignmentsGradesService.php index 3fffba7af0f..75026e57b18 100644 --- a/lib/lti1p3/src/LtiAssignmentsGradesService.php +++ b/lib/lti1p3/src/LtiAssignmentsGradesService.php @@ -28,17 +28,12 @@ class LtiAssignmentsGradesService extends LtiAbstractService return LtiLineitem::new()->setId($serviceData['lineitem']); } - public function putGrade(LtiGrade $grade, LtiLineitem $lineitem = null) + public function putGrade(LtiGrade $grade, ?LtiLineitem $lineitem = null) { $this->validateScopes([LtiConstants::AGS_SCOPE_SCORE]); $lineitem = $this->ensureLineItemExists($lineitem); - - $scoreUrl = $lineitem->getId(); - - // Place '/scores' before url params - $pos = strpos($scoreUrl, '?'); - $scoreUrl = $pos === false ? $scoreUrl.'/scores' : substr_replace($scoreUrl, '/scores', $pos, 0); + $scoreUrl = $this->appendLineItemPath($lineitem, '/scores'); $request = new ServiceRequest( ServiceRequest::METHOD_POST, @@ -64,11 +59,11 @@ class LtiAssignmentsGradesService extends LtiAbstractService return null; } - public function updateLineitem(LtiLineItem $lineitemToUpdate): LtiLineitem + public function updateLineitem(LtiLineitem $lineitemToUpdate): LtiLineitem { $request = new ServiceRequest( ServiceRequest::METHOD_PUT, - $this->getServiceData()['lineitem'], + $lineitemToUpdate->getId(), ServiceRequest::TYPE_UPDATE_LINEITEM ); @@ -112,14 +107,10 @@ class LtiAssignmentsGradesService extends LtiAbstractService return $this->findLineItem($newLineItem) ?? $this->createLineitem($newLineItem); } - public function getGrades(LtiLineitem $lineitem = null) + public function getGrades(?LtiLineitem $lineitem = null) { $lineitem = $this->ensureLineItemExists($lineitem); - $resultsUrl = $lineitem->getId(); - - // Place '/results' before url params - $pos = strpos($resultsUrl, '?'); - $resultsUrl = $pos === false ? $resultsUrl.'/results' : substr_replace($resultsUrl, '/results', $pos, 0); + $resultsUrl = $this->appendLineItemPath($lineitem, '/results'); $request = new ServiceRequest( ServiceRequest::METHOD_GET, @@ -168,7 +159,7 @@ class LtiAssignmentsGradesService extends LtiAbstractService return new LtiLineitem($response); } - private function ensureLineItemExists(LtiLineitem $lineitem = null): LtiLineitem + private function ensureLineItemExists(?LtiLineitem $lineitem = null): LtiLineitem { // If no line item is passed in, attempt to use the one associated with // this launch. @@ -198,4 +189,18 @@ class LtiAssignmentsGradesService extends LtiAbstractService $newLineItem->getResourceId() == ($lineitem['resourceId'] ?? null) && $newLineItem->getResourceLinkId() == ($lineitem['resourceLinkId'] ?? null); } + + private function appendLineItemPath(LtiLineitem $lineItem, string $suffix): string + { + $url = $lineItem->getId(); + $pos = strpos($url, '?'); + + if ($pos === false) { + $url = $url.$suffix; + } else { + $url = substr_replace($url, $suffix, $pos, 0); + } + + return $url; + } } diff --git a/lib/lti1p3/src/LtiConstants.php b/lib/lti1p3/src/LtiConstants.php index 73ffc08cb2f..120ed8d00e9 100644 --- a/lib/lti1p3/src/LtiConstants.php +++ b/lib/lti1p3/src/LtiConstants.php @@ -17,11 +17,12 @@ class LtiConstants // Optional message claims public const CONTEXT = 'https://purl.imsglobal.org/spec/lti/claim/context'; - public const TOOL_PLATFORM = 'https://purl.imsglobal.org/spec/lti/claim/tool_platform'; - public const ROLE_SCOPE_MENTOR = 'https://purlimsglobal.org/spec/lti/claim/role_scope_mentor'; + public const CUSTOM = 'https://purl.imsglobal.org/spec/lti/claim/custom'; public const LAUNCH_PRESENTATION = 'https://purl.imsglobal.org/spec/lti/claim/launch_presentation'; public const LIS = 'https://purl.imsglobal.org/spec/lti/claim/lis'; - public const CUSTOM = 'https://purl.imsglobal.org/spec/lti/claim/custom'; + public const LTI1P1 = 'https://purl.imsglobal.org/spec/lti/claim/lti1p1'; + public const ROLE_SCOPE_MENTOR = 'https://purlimsglobal.org/spec/lti/claim/role_scope_mentor'; + public const TOOL_PLATFORM = 'https://purl.imsglobal.org/spec/lti/claim/tool_platform'; // LTI DL public const DL_CONTENT_ITEMS = 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items'; diff --git a/lib/lti1p3/src/LtiCourseGroupsService.php b/lib/lti1p3/src/LtiCourseGroupsService.php index 4ce8084c0e3..ec60957dbc9 100644 --- a/lib/lti1p3/src/LtiCourseGroupsService.php +++ b/lib/lti1p3/src/LtiCourseGroupsService.php @@ -40,7 +40,7 @@ class LtiCourseGroupsService extends LtiAbstractService return $this->getAll($request, 'sets'); } - public function getGroupsBySet() + public function getGroupsBySet(): array { $groups = $this->getGroups(); $sets = $this->getSets(); diff --git a/lib/lti1p3/src/LtiDeepLink.php b/lib/lti1p3/src/LtiDeepLink.php index b42a714e0a5..a1fae707e0e 100644 --- a/lib/lti1p3/src/LtiDeepLink.php +++ b/lib/lti1p3/src/LtiDeepLink.php @@ -7,18 +7,14 @@ use Packback\Lti1p3\Interfaces\ILtiRegistration; class LtiDeepLink { - private $registration; - private $deployment_id; - private $deep_link_settings; - - public function __construct(ILtiRegistration $registration, string $deployment_id, array $deep_link_settings) - { - $this->registration = $registration; - $this->deployment_id = $deployment_id; - $this->deep_link_settings = $deep_link_settings; + public function __construct( + private ILtiRegistration $registration, + private string $deployment_id, + private array $deep_link_settings + ) { } - public function getResponseJwt($resources) + public function getResponseJwt(array $resources): string { $message_jwt = [ 'iss' => $this->registration->getClientId(), @@ -42,28 +38,4 @@ class LtiDeepLink return JWT::encode($message_jwt, $this->registration->getToolPrivateKey(), 'RS256', $this->registration->getKid()); } - - /** - * This method builds an auto-submitting HTML form to post the deep linking response message - * back to platform, as per LTI-DL 2.0 specification. The resulting HTML is then written to standard output, - * so calling this method will automatically send an HTTP response to conclude the content selection flow. - * - * @param LtiDeepLinkResource[] $resources The list of selected resources to be sent to the platform - * - * @todo Consider wrapping the content inside a well-formed HTML document, - * and returning it instead of directly writing to standard output - */ - public function outputResponseForm($resources) - { - $jwt = $this->getResponseJwt($resources); - $formActionUrl = $this->deep_link_settings['deep_link_return_url']; - - echo << - - - - -HTML; - } } diff --git a/lib/lti1p3/src/LtiDeepLinkDateTimeInterval.php b/lib/lti1p3/src/LtiDeepLinkDateTimeInterval.php deleted file mode 100644 index b26c6687f69..00000000000 --- a/lib/lti1p3/src/LtiDeepLinkDateTimeInterval.php +++ /dev/null @@ -1,72 +0,0 @@ -start = $start ?? null; - $this->end = $end ?? null; - } - - public static function new(): LtiDeepLinkDateTimeInterval - { - return new LtiDeepLinkDateTimeInterval(); - } - - public function setStart(?DateTime $start): LtiDeepLinkDateTimeInterval - { - $this->start = $start; - - return $this; - } - - public function getStart(): ?DateTime - { - return $this->start; - } - - public function setEnd(?DateTime $end): LtiDeepLinkDateTimeInterval - { - $this->end = $end; - - return $this; - } - - public function getEnd(): ?DateTime - { - return $this->end; - } - - public function toArray(): array - { - if (!isset($this->start) && !isset($this->end)) { - throw new LtiException('At least one of the interval bounds must be specified on the object instance'); - } - - if ($this->start !== null && $this->end !== null && $this->end < $this->start) { - throw new LtiException('Interval start time cannot be greater than end time'); - } - - $dateTimeInterval = []; - - if (isset($this->start)) { - $dateTimeInterval['startDateTime'] = $this->start->format(DateTime::ATOM); - } - if (isset($this->end)) { - $dateTimeInterval['endDateTime'] = $this->end->format(DateTime::ATOM); - } - - return $dateTimeInterval; - } -} diff --git a/lib/lti1p3/src/LtiDeepLinkResource.php b/lib/lti1p3/src/LtiDeepLinkResource.php deleted file mode 100644 index 093f979c84f..00000000000 --- a/lib/lti1p3/src/LtiDeepLinkResource.php +++ /dev/null @@ -1,243 +0,0 @@ -type; - } - - public function setType(string $value): LtiDeepLinkResource - { - $this->type = $value; - - return $this; - } - - public function getTitle(): ?string - { - return $this->title; - } - - public function setTitle(?string $value): LtiDeepLinkResource - { - $this->title = $value; - - return $this; - } - - public function getText(): ?string - { - return $this->text; - } - - public function setText(?string $value): LtiDeepLinkResource - { - $this->text = $value; - - return $this; - } - - public function getUrl(): ?string - { - return $this->url; - } - - public function setUrl(?string $value): LtiDeepLinkResource - { - $this->url = $value; - - return $this; - } - - public function getLineItem(): ?LtiLineitem - { - return $this->line_item; - } - - public function setLineItem(?LtiLineitem $value): LtiDeepLinkResource - { - $this->line_item = $value; - - return $this; - } - - public function setIcon(?LtiDeepLinkResourceIcon $icon): LtiDeepLinkResource - { - $this->icon = $icon; - - return $this; - } - - public function getIcon(): ?LtiDeepLinkResourceIcon - { - return $this->icon; - } - - public function setThumbnail(?LtiDeepLinkResourceIcon $thumbnail): LtiDeepLinkResource - { - $this->thumbnail = $thumbnail; - - return $this; - } - - public function getThumbnail(): ?LtiDeepLinkResourceIcon - { - return $this->thumbnail; - } - - public function getCustomParams(): array - { - return $this->custom_params; - } - - public function setCustomParams(array $value): LtiDeepLinkResource - { - $this->custom_params = $value; - - return $this; - } - - /** - * @deprecated This field maps the "presentation" resource property, which is non-standard. - * Consider using "iframe" and/or "window" instead. - */ - public function getTarget(): string - { - return $this->target; - } - - /** - * @deprecated This field maps the "presentation" resource property, which is non-standard. - * Consider using "iframe" and/or "window" instead. - */ - public function setTarget(string $value): LtiDeepLinkResource - { - $this->target = $value; - - return $this; - } - - public function getIframe(): ?LtiDeepLinkResourceIframe - { - return $this->iframe; - } - - public function setIframe(?LtiDeepLinkResourceIframe $iframe): LtiDeepLinkResource - { - $this->iframe = $iframe; - - return $this; - } - - public function getWindow(): ?LtiDeepLinkResourceWindow - { - return $this->window; - } - - public function setWindow(?LtiDeepLinkResourceWindow $window): LtiDeepLinkResource - { - $this->window = $window; - - return $this; - } - - public function getAvailabilityInterval(): ?LtiDeepLinkDateTimeInterval - { - return $this->availability_interval; - } - - public function setAvailabilityInterval(?LtiDeepLinkDateTimeInterval $availabilityInterval): LtiDeepLinkResource - { - $this->availability_interval = $availabilityInterval; - - return $this; - } - - public function getSubmissionInterval(): ?LtiDeepLinkDateTimeInterval - { - return $this->submission_interval; - } - - public function setSubmissionInterval(?LtiDeepLinkDateTimeInterval $submissionInterval): LtiDeepLinkResource - { - $this->submission_interval = $submissionInterval; - - return $this; - } - - public function toArray(): array - { - $resource = [ - 'type' => $this->type, - ]; - - if (isset($this->title)) { - $resource['title'] = $this->title; - } - if (isset($this->text)) { - $resource['text'] = $this->text; - } - if (isset($this->url)) { - $resource['url'] = $this->url; - } - if (!empty($this->custom_params)) { - $resource['custom'] = $this->custom_params; - } - if (isset($this->icon)) { - $resource['icon'] = $this->icon->toArray(); - } - if (isset($this->thumbnail)) { - $resource['thumbnail'] = $this->thumbnail->toArray(); - } - if ($this->line_item !== null) { - $resource['lineItem'] = [ - 'scoreMaximum' => $this->line_item->getScoreMaximum(), - 'label' => $this->line_item->getLabel(), - ]; - } - - // Kept for backwards compatibility - if (!isset($this->iframe) && !isset($this->window)) { - $resource['presentation'] = [ - 'documentTarget' => $this->target, - ]; - } - - if (isset($this->iframe)) { - $resource['iframe'] = $this->iframe->toArray(); - } - if (isset($this->window)) { - $resource['window'] = $this->window->toArray(); - } - if (isset($this->availability_interval)) { - $resource['available'] = $this->availability_interval->toArray(); - } - if (isset($this->submission_interval)) { - $resource['submission'] = $this->submission_interval->toArray(); - } - - return $resource; - } -} diff --git a/lib/lti1p3/src/LtiDeepLinkResourceIcon.php b/lib/lti1p3/src/LtiDeepLinkResourceIcon.php deleted file mode 100644 index f037910bc15..00000000000 --- a/lib/lti1p3/src/LtiDeepLinkResourceIcon.php +++ /dev/null @@ -1,67 +0,0 @@ -url = $url; - $this->width = $width; - $this->height = $height; - } - - public static function new(string $url, int $width, int $height): LtiDeepLinkResourceIcon - { - return new LtiDeepLinkResourceIcon($url, $width, $height); - } - - public function setUrl(string $url): LtiDeepLinkResourceIcon - { - $this->url = $url; - - return $this; - } - - public function getUrl(): string - { - return $this->url; - } - - public function setWidth(int $width): LtiDeepLinkResourceIcon - { - $this->width = $width; - - return $this; - } - - public function getWidth(): int - { - return $this->width; - } - - public function setHeight(int $height): LtiDeepLinkResourceIcon - { - $this->height = $height; - - return $this; - } - - public function getHeight(): int - { - return $this->height; - } - - public function toArray(): array - { - return [ - 'url' => $this->url, - 'width' => $this->width, - 'height' => $this->height, - ]; - } -} diff --git a/lib/lti1p3/src/LtiDeepLinkResourceIframe.php b/lib/lti1p3/src/LtiDeepLinkResourceIframe.php deleted file mode 100644 index e88b6934d27..00000000000 --- a/lib/lti1p3/src/LtiDeepLinkResourceIframe.php +++ /dev/null @@ -1,58 +0,0 @@ -width = $width ?? null; - $this->height = $height ?? null; - } - - public static function new(): LtiDeepLinkResourceIframe - { - return new LtiDeepLinkResourceIframe(); - } - - public function setWidth(?int $width): LtiDeepLinkResourceIframe - { - $this->width = $width; - - return $this; - } - - public function getWidth(): ?int - { - return $this->width; - } - - public function setHeight(?int $height): LtiDeepLinkResourceIframe - { - $this->height = $height; - - return $this; - } - - public function getHeight(): ?int - { - return $this->height; - } - - public function toArray(): array - { - $iframe = []; - - if (isset($this->width)) { - $iframe['width'] = $this->width; - } - if (isset($this->height)) { - $iframe['height'] = $this->height; - } - - return $iframe; - } -} diff --git a/lib/lti1p3/src/LtiDeepLinkResourceWindow.php b/lib/lti1p3/src/LtiDeepLinkResourceWindow.php deleted file mode 100644 index 323d706c17d..00000000000 --- a/lib/lti1p3/src/LtiDeepLinkResourceWindow.php +++ /dev/null @@ -1,92 +0,0 @@ -target_name = $targetName ?? null; - $this->width = $width ?? null; - $this->height = $height ?? null; - $this->window_features = $windowFeatures ?? null; - } - - public static function new(): LtiDeepLinkResourceWindow - { - return new LtiDeepLinkResourceWindow(); - } - - public function setTargetName(?string $targetName): LtiDeepLinkResourceWindow - { - $this->target_name = $targetName; - - return $this; - } - - public function getTargetName(): ?string - { - return $this->target_name; - } - - public function setWidth(?int $width): LtiDeepLinkResourceWindow - { - $this->width = $width; - - return $this; - } - - public function getWidth(): ?int - { - return $this->width; - } - - public function setHeight(?int $height): LtiDeepLinkResourceWindow - { - $this->height = $height; - - return $this; - } - - public function getHeight(): ?int - { - return $this->height; - } - - public function setWindowFeatures(?string $windowFeatures): LtiDeepLinkResourceWindow - { - $this->window_features = $windowFeatures; - - return $this; - } - - public function getWindowFeatures(): ?string - { - return $this->window_features; - } - - public function toArray(): array - { - $window = []; - - if (isset($this->target_name)) { - $window['targetName'] = $this->target_name; - } - if (isset($this->width)) { - $window['width'] = $this->width; - } - if (isset($this->height)) { - $window['height'] = $this->height; - } - if (isset($this->window_features)) { - $window['windowFeatures'] = $this->window_features; - } - - return $window; - } -} diff --git a/lib/lti1p3/src/LtiDeployment.php b/lib/lti1p3/src/LtiDeployment.php index cbe083aa196..7ac23989ed5 100644 --- a/lib/lti1p3/src/LtiDeployment.php +++ b/lib/lti1p3/src/LtiDeployment.php @@ -2,13 +2,18 @@ namespace Packback\Lti1p3; -class LtiDeployment -{ - private $deployment_id; +use Packback\Lti1p3\Interfaces\ILtiDeployment; - public static function new() +class LtiDeployment implements ILtiDeployment +{ + public function __construct( + private $deployment_id + ) { + } + + public static function new($deployment_id): self { - return new LtiDeployment(); + return new LtiDeployment($deployment_id); } public function getDeploymentId() @@ -16,7 +21,7 @@ class LtiDeployment return $this->deployment_id; } - public function setDeploymentId($deployment_id) + public function setDeploymentId($deployment_id): self { $this->deployment_id = $deployment_id; diff --git a/lib/lti1p3/src/LtiGrade.php b/lib/lti1p3/src/LtiGrade.php index 1f3ff4ee513..bd673b4e0a8 100644 --- a/lib/lti1p3/src/LtiGrade.php +++ b/lib/lti1p3/src/LtiGrade.php @@ -2,8 +2,11 @@ namespace Packback\Lti1p3; +use Packback\Lti1p3\Concerns\JsonStringable; + class LtiGrade { + use JsonStringable; private $score_given; private $score_maximum; private $comment; @@ -14,7 +17,7 @@ class LtiGrade private $submission_review; private $canvas_extension; - public function __construct(array $grade = null) + public function __construct(?array $grade = null) { $this->score_given = $grade['scoreGiven'] ?? null; $this->score_maximum = $grade['scoreMaximum'] ?? null; @@ -27,10 +30,9 @@ class LtiGrade $this->canvas_extension = $grade['https://canvas.instructure.com/lti/submission'] ?? null; } - public function __toString() + public function getArray(): array { - // Additionally, includes the call back to filter out only NULL values - $request = array_filter([ + return [ 'scoreGiven' => $this->score_given, 'scoreMaximum' => $this->score_maximum, 'comment' => $this->comment, @@ -40,15 +42,13 @@ class LtiGrade 'userId' => $this->user_id, 'submissionReview' => $this->submission_review, 'https://canvas.instructure.com/lti/submission' => $this->canvas_extension, - ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue'); - - return json_encode($request); + ]; } /** * Static function to allow for method chaining without having to assign to a variable first. */ - public static function new() + public static function new(): self { return new LtiGrade(); } @@ -58,7 +58,7 @@ class LtiGrade return $this->score_given; } - public function setScoreGiven($value) + public function setScoreGiven($value): self { $this->score_given = $value; @@ -70,7 +70,7 @@ class LtiGrade return $this->score_maximum; } - public function setScoreMaximum($value) + public function setScoreMaximum($value): self { $this->score_maximum = $value; @@ -82,7 +82,7 @@ class LtiGrade return $this->comment; } - public function setComment($comment) + public function setComment($comment): self { $this->comment = $comment; @@ -94,7 +94,7 @@ class LtiGrade return $this->activity_progress; } - public function setActivityProgress($value) + public function setActivityProgress($value): self { $this->activity_progress = $value; @@ -106,7 +106,7 @@ class LtiGrade return $this->grading_progress; } - public function setGradingProgress($value) + public function setGradingProgress($value): self { $this->grading_progress = $value; @@ -118,7 +118,7 @@ class LtiGrade return $this->timestamp; } - public function setTimestamp($value) + public function setTimestamp($value): self { $this->timestamp = $value; @@ -130,7 +130,7 @@ class LtiGrade return $this->user_id; } - public function setUserId($value) + public function setUserId($value): self { $this->user_id = $value; @@ -142,7 +142,7 @@ class LtiGrade return $this->submission_review; } - public function setSubmissionReview($value) + public function setSubmissionReview($value): self { $this->submission_review = $value; @@ -154,9 +154,16 @@ class LtiGrade return $this->canvas_extension; } - // Custom Extension for Canvas. - // https://documentation.instructure.com/doc/api/score.html - public function setCanvasExtension($value) + /** + * Add custom extensions for Canvas. + * + * Disclaimer: You should only set this if your LMS is Canvas. + * Some LMS (e.g. Schoology) include validation logic that will throw if there + * is unexpected data. And, the type of LMS cannot simply be inferred by their URL. + * + * @see https://documentation.instructure.com/doc/api/score.html + */ + public function setCanvasExtension($value): self { $this->canvas_extension = $value; diff --git a/lib/lti1p3/src/LtiGradeSubmissionReview.php b/lib/lti1p3/src/LtiGradeSubmissionReview.php index a185930f2fb..4732d784008 100644 --- a/lib/lti1p3/src/LtiGradeSubmissionReview.php +++ b/lib/lti1p3/src/LtiGradeSubmissionReview.php @@ -2,14 +2,17 @@ namespace Packback\Lti1p3; +use Packback\Lti1p3\Concerns\JsonStringable; + class LtiGradeSubmissionReview { + use JsonStringable; private $reviewable_status; private $label; private $url; private $custom; - public function __construct(array $gradeSubmission = null) + public function __construct(?array $gradeSubmission = null) { $this->reviewable_status = $gradeSubmission['reviewableStatus'] ?? null; $this->label = $gradeSubmission['label'] ?? null; @@ -17,21 +20,20 @@ class LtiGradeSubmissionReview $this->custom = $gradeSubmission['custom'] ?? null; } - public function __toString() + public function getArray(): array { - // Additionally, includes the call back to filter out only NULL values - return json_encode(array_filter([ + return [ 'reviewableStatus' => $this->reviewable_status, 'label' => $this->label, 'url' => $this->url, 'custom' => $this->custom, - ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue')); + ]; } /** * Static function to allow for method chaining without having to assign to a variable first. */ - public static function new() + public static function new(): self { return new LtiGradeSubmissionReview(); } @@ -41,7 +43,7 @@ class LtiGradeSubmissionReview return $this->reviewable_status; } - public function setReviewableStatus($value) + public function setReviewableStatus($value): self { $this->reviewable_status = $value; @@ -53,7 +55,7 @@ class LtiGradeSubmissionReview return $this->label; } - public function setLabel($value) + public function setLabel($value): self { $this->label = $value; @@ -65,7 +67,7 @@ class LtiGradeSubmissionReview return $this->url; } - public function setUrl($url) + public function setUrl($url): self { $this->url = $url; @@ -77,7 +79,7 @@ class LtiGradeSubmissionReview return $this->custom; } - public function setCustom($value) + public function setCustom($value): self { $this->custom = $value; diff --git a/lib/lti1p3/src/LtiLineitem.php b/lib/lti1p3/src/LtiLineitem.php index 8964ce19f3c..c8e662ed586 100644 --- a/lib/lti1p3/src/LtiLineitem.php +++ b/lib/lti1p3/src/LtiLineitem.php @@ -2,8 +2,11 @@ namespace Packback\Lti1p3; +use Packback\Lti1p3\Concerns\JsonStringable; + class LtiLineitem { + use JsonStringable; private $id; private $score_maximum; private $label; @@ -12,8 +15,9 @@ class LtiLineitem private $tag; private $start_date_time; private $end_date_time; + private ?bool $grades_released; - public function __construct(array $lineitem = null) + public function __construct(?array $lineitem = null) { $this->id = $lineitem['id'] ?? null; $this->score_maximum = $lineitem['scoreMaximum'] ?? null; @@ -23,12 +27,20 @@ class LtiLineitem $this->tag = $lineitem['tag'] ?? null; $this->start_date_time = $lineitem['startDateTime'] ?? null; $this->end_date_time = $lineitem['endDateTime'] ?? null; + $this->grades_released = $lineitem['gradesReleased'] ?? null; } - public function __toString() + /** + * Static function to allow for method chaining without having to assign to a variable first. + */ + public static function new(?array $lineItem = null): self { - // Additionally, includes the call back to filter out only NULL values - return json_encode(array_filter([ + return new LtiLineitem($lineItem); + } + + public function getArray(): array + { + return [ 'id' => $this->id, 'scoreMaximum' => $this->score_maximum, 'label' => $this->label, @@ -37,15 +49,8 @@ class LtiLineitem 'tag' => $this->tag, 'startDateTime' => $this->start_date_time, 'endDateTime' => $this->end_date_time, - ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue')); - } - - /** - * Static function to allow for method chaining without having to assign to a variable first. - */ - public static function new() - { - return new LtiLineitem(); + 'gradesReleased' => $this->grades_released, + ]; } public function getId() @@ -53,7 +58,7 @@ class LtiLineitem return $this->id; } - public function setId($value) + public function setId($value): self { $this->id = $value; @@ -65,7 +70,7 @@ class LtiLineitem return $this->label; } - public function setLabel($value) + public function setLabel($value): self { $this->label = $value; @@ -77,7 +82,7 @@ class LtiLineitem return $this->score_maximum; } - public function setScoreMaximum($value) + public function setScoreMaximum($value): self { $this->score_maximum = $value; @@ -89,7 +94,7 @@ class LtiLineitem return $this->resource_id; } - public function setResourceId($value) + public function setResourceId($value): self { $this->resource_id = $value; @@ -101,7 +106,7 @@ class LtiLineitem return $this->resource_link_id; } - public function setResourceLinkId($value) + public function setResourceLinkId($value): self { $this->resource_link_id = $value; @@ -113,7 +118,7 @@ class LtiLineitem return $this->tag; } - public function setTag($value) + public function setTag($value): self { $this->tag = $value; @@ -125,7 +130,7 @@ class LtiLineitem return $this->start_date_time; } - public function setStartDateTime($value) + public function setStartDateTime($value): self { $this->start_date_time = $value; @@ -137,10 +142,22 @@ class LtiLineitem return $this->end_date_time; } - public function setEndDateTime($value) + public function setEndDateTime($value): self { $this->end_date_time = $value; return $this; } + + public function getGradesReleased(): ?bool + { + return $this->grades_released; + } + + public function setGradesReleased(?bool $value): self + { + $this->grades_released = $value; + + return $this; + } } diff --git a/lib/lti1p3/src/LtiMessageLaunch.php b/lib/lti1p3/src/LtiMessageLaunch.php index d325e375068..62f2f794d61 100644 --- a/lib/lti1p3/src/LtiMessageLaunch.php +++ b/lib/lti1p3/src/LtiMessageLaunch.php @@ -6,11 +6,15 @@ use Exception; use Firebase\JWT\ExpiredException; use Firebase\JWT\JWK; use Firebase\JWT\JWT; +use Firebase\JWT\Key; use GuzzleHttp\Exception\TransferException; use Packback\Lti1p3\Interfaces\ICache; use Packback\Lti1p3\Interfaces\ICookie; use Packback\Lti1p3\Interfaces\IDatabase; +use Packback\Lti1p3\Interfaces\ILtiDeployment; +use Packback\Lti1p3\Interfaces\ILtiRegistration; use Packback\Lti1p3\Interfaces\ILtiServiceConnector; +use Packback\Lti1p3\Interfaces\IMigrationDatabase; use Packback\Lti1p3\MessageValidators\DeepLinkMessageValidator; use Packback\Lti1p3\MessageValidators\ResourceMessageValidator; use Packback\Lti1p3\MessageValidators\SubmissionReviewMessageValidator; @@ -45,14 +49,13 @@ class LtiMessageLaunch public const ERR_INVALID_MESSAGE = 'Message validation failed.'; public const ERR_INVALID_ALG = 'Invalid alg was specified in the JWT header.'; public const ERR_MISMATCHED_ALG_KEY = 'The alg specified in the JWT header is incompatible with the JWK key type.'; - private $db; - private $cache; - private $cookie; - private $serviceConnector; - private $request; - private $jwt; - private $registration; - private $launch_id; + public const ERR_OAUTH_KEY_SIGN_NOT_VERIFIED = 'Unable to upgrade from LTI 1.1 to 1.3. No OAuth Consumer Key matched this signature.'; + public const ERR_OAUTH_KEY_SIGN_MISSING = 'Unable to upgrade from LTI 1.1 to 1.3. The oauth_consumer_key_sign was not provided.'; + private array $request; + private array $jwt; + private ?ILtiRegistration $registration; + private ?ILtiDeployment $deployment; + public string $launch_id; // See https://www.imsglobal.org/spec/security/v1p1#approved-jwt-signing-algorithms. private static $ltiSupportedAlgs = [ @@ -64,105 +67,115 @@ class LtiMessageLaunch 'ES512' => 'EC', ]; - /** - * Constructor. - * - * @param IDatabase $database Instance of the database interface used for looking up registrations and deployments - * @param ICache $cache Instance of the Cache interface used to loading and storing launches - * @param ICookie $cookie Instance of the Cookie interface used to set and read cookies - * @param ILtiServiceConnector $serviceConnector Instance of the LtiServiceConnector used to by LTI services to make API requests - */ public function __construct( - IDatabase $database, - ICache $cache = null, - ICookie $cookie = null, - ILtiServiceConnector $serviceConnector = null + private IDatabase $db, + private ICache $cache, + private ICookie $cookie, + private ILtiServiceConnector $serviceConnector ) { - $this->db = $database; - $this->launch_id = uniqid('lti1p3_launch_', true); - - $this->cache = $cache; - $this->cookie = $cookie; - $this->serviceConnector = $serviceConnector; } /** * Static function to allow for method chaining without having to assign to a variable first. */ public static function new( - IDatabase $database, - ICache $cache = null, - ICookie $cookie = null, - ILtiServiceConnector $serviceConnector = null - ) { - return new LtiMessageLaunch($database, $cache, $cookie, $serviceConnector); + IDatabase $db, + ICache $cache, + ICookie $cookie, + ILtiServiceConnector $serviceConnector + ): self { + return new LtiMessageLaunch($db, $cache, $cookie, $serviceConnector); } /** * Load an LtiMessageLaunch from a Cache using a launch id. * - * @param string $launch_id The launch id of the LtiMessageLaunch object that is being pulled from the cache - * @param IDatabase $database Instance of the database interface used for looking up registrations and deployments - * @param ICache $cache Instance of the Cache interface used to loading and storing launches. If non is provided launch data will be store in $_SESSION. - * @return LtiMessageLaunch A populated and validated LtiMessageLaunch - * * @throws LtiException Will throw an LtiException if validation fails or launch cannot be found */ public static function fromCache( - $launch_id, - IDatabase $database, - ICache $cache = null, - ILtiServiceConnector $serviceConnector = null - ) { - $new = new LtiMessageLaunch($database, $cache, null, $serviceConnector); + string $launch_id, + IDatabase $db, + ICache $cache, + ICookie $cookie, + ILtiServiceConnector $serviceConnector + ): self { + $new = new LtiMessageLaunch($db, $cache, $cookie, $serviceConnector); $new->launch_id = $launch_id; $new->jwt = ['body' => $new->cache->getLaunchData($launch_id)]; return $new->validateRegistration(); } + public function setRequest(array $request): self + { + $this->request = $request; + + return $this; + } + + public function initialize(array $request): self + { + return $this->setRequest($request) + ->validate() + ->migrate() + ->cacheLaunchData(); + } + /** * Validates all aspects of an incoming LTI message launch and caches the launch if successful. * - * @param array|string $request An array of post request parameters. If not set will default to $_POST. - * @return LtiMessageLaunch Will return $this if validation is successful - * * @throws LtiException Will throw an LtiException if validation fails */ - public function validate(array $request = null) + public function validate(): self { - if ($request === null) { - $request = $_POST; - } - $this->request = $request; - return $this->validateState() ->validateJwtFormat() ->validateNonce() ->validateRegistration() ->validateJwtSignature() ->validateDeployment() - ->validateMessage() - ->cacheLaunchData(); + ->validateMessage(); + } + + public function migrate(): self + { + if (!$this->shouldMigrate()) { + return $this->ensureDeploymentExists(); + } + + if (!isset($this->jwt['body'][LtiConstants::LTI1P1]['oauth_consumer_key_sign'])) { + throw new LtiException(static::ERR_OAUTH_KEY_SIGN_MISSING); + } + + if (!$this->matchingLti1p1KeyExists()) { + throw new LtiException(static::ERR_OAUTH_KEY_SIGN_NOT_VERIFIED); + } + + $this->deployment = $this->db->migrateFromLti1p1($this); + + return $this->ensureDeploymentExists(); + } + + public function cacheLaunchData(): self + { + $this->cache->cacheLaunchData($this->launch_id, $this->jwt['body']); + + return $this; } /** * Returns whether or not the current launch can use the names and roles service. - * - * @return bool Returns a boolean indicating the availability of names and roles */ - public function hasNrps() + public function hasNrps(): bool { - return !empty($this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]['context_memberships_url']); + return isset($this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]['context_memberships_url']); } /** * Fetches an instance of the names and roles service for the current launch. - * - * @return LtiNamesRolesProvisioningService An instance of the names and roles service that can be used to make calls within the scope of the current launch */ - public function getNrps() + public function getNrps(): LtiNamesRolesProvisioningService { return new LtiNamesRolesProvisioningService( $this->serviceConnector, @@ -173,20 +186,16 @@ class LtiMessageLaunch /** * Returns whether or not the current launch can use the groups service. - * - * @return bool Returns a boolean indicating the availability of groups */ - public function hasGs() + public function hasGs(): bool { - return !empty($this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]['context_groups_url']); + return isset($this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]['context_groups_url']); } /** * Fetches an instance of the groups service for the current launch. - * - * @return LtiCourseGroupsService An instance of the groups service that can be used to make calls within the scope of the current launch */ - public function getGs() + public function getGs(): LtiCourseGroupsService { return new LtiCourseGroupsService( $this->serviceConnector, @@ -197,20 +206,16 @@ class LtiMessageLaunch /** * Returns whether or not the current launch can use the assignments and grades service. - * - * @return bool Returns a boolean indicating the availability of assignments and grades */ - public function hasAgs() + public function hasAgs(): bool { - return !empty($this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]); + return isset($this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]); } /** * Fetches an instance of the assignments and grades service for the current launch. - * - * @return LtiAssignmentsGradesService An instance of the assignments an grades service that can be used to make calls within the scope of the current launch */ - public function getAgs() + public function getAgs(): LtiAssignmentsGradesService { return new LtiAssignmentsGradesService( $this->serviceConnector, @@ -221,20 +226,16 @@ class LtiMessageLaunch /** * Returns whether or not the current launch is a deep linking launch. - * - * @return bool Returns true if the current launch is a deep linking launch */ - public function isDeepLinkLaunch() + public function isDeepLinkLaunch(): bool { return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_DEEPLINK; } /** * Fetches a deep link that can be used to construct a deep linking response. - * - * @return LtiDeepLink An instance of a deep link to construct a deep linking response for the current launch */ - public function getDeepLink() + public function getDeepLink(): LtiDeepLink { return new LtiDeepLink( $this->registration, @@ -245,45 +246,37 @@ class LtiMessageLaunch /** * Returns whether or not the current launch is a submission review launch. - * - * @return bool Returns true if the current launch is a submission review launch */ - public function isSubmissionReviewLaunch() + public function isSubmissionReviewLaunch(): bool { return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_SUBMISSIONREVIEW; } /** * Returns whether or not the current launch is a resource launch. - * - * @return bool Returns true if the current launch is a resource launch */ - public function isResourceLaunch() + public function isResourceLaunch(): bool { return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_RESOURCELINK; } /** * Fetches the decoded body of the JWT used in the current launch. - * - * @return array|object Returns the decoded json body of the launch as an array */ - public function getLaunchData() + public function getLaunchData(): array { return $this->jwt['body']; } /** * Get the unique launch id for the current launch. - * - * @return string A unique identifier used to re-reference the current launch in subsequent requests */ - public function getLaunchId() + public function getLaunchId(): string { return $this->launch_id; } - public static function getMissingRegistrationErrorMsg(string $issuerUrl, string $clientId = null): string + public static function getMissingRegistrationErrorMsg(string $issuerUrl, ?string $clientId = null): string { // Guard against client ID being null if (!isset($clientId)) { @@ -296,7 +289,10 @@ class LtiMessageLaunch return str_replace($search, $replace, static::ERR_MISSING_REGISTRATION); } - private function getPublicKey() + /** + * @throws LtiException + */ + private function getPublicKey(): Key { $request = new ServiceRequest( ServiceRequest::METHOD_GET, @@ -358,22 +354,15 @@ class LtiMessageLaunch throw new LtiException(static::ERR_MISMATCHED_ALG_KEY); } - private function jwtAlgMatchesJwkKty($key): bool + private function jwtAlgMatchesJwkKty(array $key): bool { $jwtAlg = $this->jwt['header']['alg']; - return isset(static::$ltiSupportedAlgs[$jwtAlg]) && - static::$ltiSupportedAlgs[$jwtAlg] === $key['kty']; + return isset(self::$ltiSupportedAlgs[$jwtAlg]) && + self::$ltiSupportedAlgs[$jwtAlg] === $key['kty']; } - private function cacheLaunchData() - { - $this->cache->cacheLaunchData($this->launch_id, $this->jwt['body']); - - return $this; - } - - private function validateState() + protected function validateState(): self { // Check State for OIDC. if ($this->cookie->getCookie(LtiOidcLogin::COOKIE_PREFIX.$this->request['state']) !== $this->request['state']) { @@ -384,16 +373,14 @@ class LtiMessageLaunch return $this; } - private function validateJwtFormat() + protected function validateJwtFormat(): self { - $jwt = $this->request['id_token'] ?? null; - - if (empty($jwt)) { + if (!isset($this->request['id_token'])) { throw new LtiException(static::ERR_MISSING_ID_TOKEN); } // Get parts of JWT. - $jwt_parts = explode('.', $jwt); + $jwt_parts = explode('.', $this->request['id_token']); if (count($jwt_parts) !== 3) { // Invalid number of parts in JWT. @@ -408,7 +395,7 @@ class LtiMessageLaunch return $this; } - private function validateNonce() + protected function validateNonce(): self { if (!isset($this->jwt['body']['nonce'])) { throw new LtiException(static::ERR_MISSING_NONCE); @@ -420,14 +407,14 @@ class LtiMessageLaunch return $this; } - private function validateRegistration() + protected function validateRegistration(): self { // Find registration. - $clientId = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud']; + $clientId = $this->getAud(); $issuerUrl = $this->jwt['body']['iss']; $this->registration = $this->db->findRegistrationByIssuer($issuerUrl, $clientId); - if (empty($this->registration)) { + if (!isset($this->registration)) { throw new LtiException($this->getMissingRegistrationErrorMsg($issuerUrl, $clientId)); } @@ -440,7 +427,7 @@ class LtiMessageLaunch return $this; } - private function validateJwtSignature() + protected function validateJwtSignature(): self { if (!isset($this->jwt['header']['kid'])) { throw new LtiException(static::ERR_NO_KID); @@ -461,27 +448,26 @@ class LtiMessageLaunch return $this; } - private function validateDeployment() + protected function validateDeployment(): self { if (!isset($this->jwt['body'][LtiConstants::DEPLOYMENT_ID])) { throw new LtiException(static::ERR_MISSING_DEPLOYEMENT_ID); } // Find deployment. - $client_id = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud']; - $deployment = $this->db->findDeployment($this->jwt['body']['iss'], $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], $client_id); + $client_id = $this->getAud(); + $this->deployment = $this->db->findDeployment($this->jwt['body']['iss'], $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], $client_id); - if (empty($deployment)) { - // deployment not recognized. - throw new LtiException(static::ERR_NO_DEPLOYMENT); + if (!$this->canMigrate()) { + return $this->ensureDeploymentExists(); } return $this; } - private function validateMessage() + protected function validateMessage(): self { - if (empty($this->jwt['body'][LtiConstants::MESSAGE_TYPE])) { + if (!isset($this->jwt['body'][LtiConstants::MESSAGE_TYPE])) { // Unable to identify message type. throw new LtiException(static::ERR_INVALID_MESSAGE_TYPE); } @@ -513,4 +499,65 @@ class LtiMessageLaunch // There should be 0-1 validators. This will either return the validator, or null if none apply. return array_shift($applicableValidators); } + + private function getAud(): string + { + if (is_array($this->jwt['body']['aud'])) { + return $this->jwt['body']['aud'][0]; + } else { + return $this->jwt['body']['aud']; + } + } + + /** + * @throws LtiException + */ + private function ensureDeploymentExists(): self + { + if (!isset($this->deployment)) { + throw new LtiException(static::ERR_NO_DEPLOYMENT); + } + + return $this; + } + + public function canMigrate(): bool + { + return $this->db instanceof IMigrationDatabase; + } + + private function shouldMigrate(): bool + { + return $this->canMigrate() + && $this->db->shouldMigrate($this); + } + + private function matchingLti1p1KeyExists(): bool + { + $keys = $this->db->findLti1p1Keys($this); + + foreach ($keys as $key) { + if ($this->oauthConsumerKeySignMatches($key)) { + return true; + } + } + + return false; + } + + private function oauthConsumerKeySignMatches(Lti1p1Key $key): bool + { + return $this->jwt['body'][LtiConstants::LTI1P1]['oauth_consumer_key_sign'] === $this->getOauthSignature($key); + } + + private function getOauthSignature(Lti1p1Key $key): string + { + return $key->sign( + $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], + $this->jwt['body']['iss'], + $this->getAud(), + $this->jwt['body']['exp'], + $this->jwt['body']['nonce'] + ); + } } diff --git a/lib/lti1p3/src/LtiNamesRolesProvisioningService.php b/lib/lti1p3/src/LtiNamesRolesProvisioningService.php index afad6d3a132..d3861e20349 100644 --- a/lib/lti1p3/src/LtiNamesRolesProvisioningService.php +++ b/lib/lti1p3/src/LtiNamesRolesProvisioningService.php @@ -2,6 +2,8 @@ namespace Packback\Lti1p3; +use Packback\Lti1p3\Helpers\Helpers; + class LtiNamesRolesProvisioningService extends LtiAbstractService { public const CONTENTTYPE_MEMBERSHIPCONTAINER = 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'; @@ -11,11 +13,16 @@ class LtiNamesRolesProvisioningService extends LtiAbstractService return [LtiConstants::NRPS_SCOPE_MEMBERSHIP_READONLY]; } - public function getMembers(): array + /** + * @param array $options An array of options that can be passed with the context_membership_url such as rlid, since, etc. + */ + public function getMembers(array $options = []): array { + $url = Helpers::buildUrlWithQueryParams($this->getServiceData()['context_memberships_url'], $options); + $request = new ServiceRequest( ServiceRequest::METHOD_GET, - $this->getServiceData()['context_memberships_url'], + $url, ServiceRequest::TYPE_GET_MEMBERSHIPS ); $request->setAccept(static::CONTENTTYPE_MEMBERSHIPCONTAINER); diff --git a/lib/lti1p3/src/LtiOidcLogin.php b/lib/lti1p3/src/LtiOidcLogin.php index 7d12c7a51eb..7b9dc7f8a12 100644 --- a/lib/lti1p3/src/LtiOidcLogin.php +++ b/lib/lti1p3/src/LtiOidcLogin.php @@ -2,9 +2,11 @@ namespace Packback\Lti1p3; +use Packback\Lti1p3\Helpers\Helpers; use Packback\Lti1p3\Interfaces\ICache; use Packback\Lti1p3\Interfaces\ICookie; use Packback\Lti1p3\Interfaces\IDatabase; +use Packback\Lti1p3\Interfaces\ILtiRegistration; class LtiOidcLogin { @@ -12,115 +14,86 @@ class LtiOidcLogin public const ERROR_MSG_LAUNCH_URL = 'No launch URL configured'; public const ERROR_MSG_ISSUER = 'Could not find issuer'; public const ERROR_MSG_LOGIN_HINT = 'Could not find login hint'; - private $db; - private $cache; - private $cookie; - /** - * Constructor. - * - * @param IDatabase $database Instance of the Database interface used for looking up registrations and deployments - * @param ICache $cache instance of the Cache interface used to loading and storing launches - * @param ICookie $cookie instance of the Cookie interface used to set and read cookies - */ - public function __construct(IDatabase $database, ICache $cache = null, ICookie $cookie = null) - { - $this->db = $database; - $this->cache = $cache; - $this->cookie = $cookie; + public function __construct( + public IDatabase $db, + public ICache $cache, + public ICookie $cookie + ) { } /** * Static function to allow for method chaining without having to assign to a variable first. */ - public static function new(IDatabase $database, ICache $cache = null, ICookie $cookie = null) + public static function new(IDatabase $db, ICache $cache, ICookie $cookie): self { - return new LtiOidcLogin($database, $cache, $cookie); + return new LtiOidcLogin($db, $cache, $cookie); } /** * Calculate the redirect location to return to based on an OIDC third party initiated login request. - * - * @param string $launch_url URL to redirect back to after the OIDC login. This URL must match exactly a URL white listed in the platform. - * @param array|string $request An array of request parameters. If not set will default to $_REQUEST. - * @return Redirect returns a redirect object containing the fully formed OIDC login URL */ - public function doOidcLoginRedirect($launch_url, array $request = null) + public function getRedirectUrl(string $launchUrl, array $request): string { - if ($request === null) { - $request = $_REQUEST; - } - - if (empty($launch_url)) { - throw new OidcException(static::ERROR_MSG_LAUNCH_URL, 1); - } - - // Validate Request Data. + // Validate request data. $registration = $this->validateOidcLogin($request); - /* - * Build OIDC Auth Response. - */ + // Build OIDC Auth response. + $authParams = $this->getAuthParams($launchUrl, $registration->getClientId(), $request); - // Generate State. + return Helpers::buildUrlWithQueryParams($registration->getAuthLoginUrl(), $authParams); + } + + public function validateOidcLogin(array $request): ILtiRegistration + { + if (!isset($request['iss'])) { + throw new OidcException(static::ERROR_MSG_ISSUER); + } + + if (!isset($request['login_hint'])) { + throw new OidcException(static::ERROR_MSG_LOGIN_HINT); + } + + // Fetch registration + $clientId = $request['client_id'] ?? null; + $registration = $this->db->findRegistrationByIssuer($request['iss'], $clientId); + + if (!isset($registration)) { + $errorMsg = LtiMessageLaunch::getMissingRegistrationErrorMsg($request['iss'], $clientId); + + throw new OidcException($errorMsg); + } + + return $registration; + } + + public function getAuthParams(string $launchUrl, string $clientId, array $request): array + { // Set cookie (short lived) $state = static::secureRandomString('state-'); $this->cookie->setCookie(static::COOKIE_PREFIX.$state, $state, 60); - // Generate Nonce. $nonce = static::secureRandomString('nonce-'); $this->cache->cacheNonce($nonce, $state); - // Build Response. - $auth_params = [ + $authParams = [ 'scope' => 'openid', // OIDC Scope. 'response_type' => 'id_token', // OIDC response is always an id token. 'response_mode' => 'form_post', // OIDC response is always a form post. 'prompt' => 'none', // Don't prompt user on redirect. - 'client_id' => $registration->getClientId(), // Registered client id. - 'redirect_uri' => $launch_url, // URL to return to after login. + 'client_id' => $clientId, // Registered client id. + 'redirect_uri' => $launchUrl, // URL to return to after login. 'state' => $state, // State to identify browser session. 'nonce' => $nonce, // Prevent replay attacks. 'login_hint' => $request['login_hint'], // Login hint to identify platform session. ]; - // Pass back LTI message hint if we have it. if (isset($request['lti_message_hint'])) { // LTI message hint to identify LTI context within the platform. - $auth_params['lti_message_hint'] = $request['lti_message_hint']; + $authParams['lti_message_hint'] = $request['lti_message_hint']; } - $auth_login_return_url = $registration->getAuthLoginUrl().'?'.http_build_query($auth_params, '', '&'); - - // Return auth redirect. - return new Redirect($auth_login_return_url, http_build_query($request, '', '&')); - } - - public function validateOidcLogin($request) - { - // Validate Issuer. - if (empty($request['iss'])) { - throw new OidcException(static::ERROR_MSG_ISSUER, 1); - } - - // Validate Login Hint. - if (empty($request['login_hint'])) { - throw new OidcException(static::ERROR_MSG_LOGIN_HINT, 1); - } - - // Fetch Registration Details. - $clientId = $request['client_id'] ?? null; - $registration = $this->db->findRegistrationByIssuer($request['iss'], $clientId); - - // Check we got something. - if (empty($registration)) { - $errorMsg = LtiMessageLaunch::getMissingRegistrationErrorMsg($request['iss'], $clientId); - - throw new OidcException($errorMsg, 1); - } - - // Return Registration. - return $registration; + return $authParams; } public static function secureRandomString(string $prefix = ''): string diff --git a/lib/lti1p3/src/LtiRegistration.php b/lib/lti1p3/src/LtiRegistration.php index 21eebb7e300..4805c3bfb99 100644 --- a/lib/lti1p3/src/LtiRegistration.php +++ b/lib/lti1p3/src/LtiRegistration.php @@ -6,16 +6,16 @@ use Packback\Lti1p3\Interfaces\ILtiRegistration; class LtiRegistration implements ILtiRegistration { - private $issuer; - private $clientId; - private $keySetUrl; - private $authTokenUrl; - private $authLoginUrl; - private $authServer; - private $toolPrivateKey; - private $kid; + private ?string $issuer; + private ?string $clientId; + private ?string $keySetUrl; + private ?string $authTokenUrl; + private ?string $authLoginUrl; + private ?string $authServer; + private ?string $toolPrivateKey; + private ?string $kid; - public function __construct(array $registration = []) + public function __construct(?array $registration = null) { $this->issuer = $registration['issuer'] ?? null; $this->clientId = $registration['clientId'] ?? null; @@ -27,7 +27,7 @@ class LtiRegistration implements ILtiRegistration $this->kid = $registration['kid'] ?? null; } - public static function new(array $registration = []) + public static function new(?array $registration = null): self { return new LtiRegistration($registration); } @@ -37,7 +37,7 @@ class LtiRegistration implements ILtiRegistration return $this->issuer; } - public function setIssuer($issuer) + public function setIssuer(string $issuer): self { $this->issuer = $issuer; @@ -49,55 +49,55 @@ class LtiRegistration implements ILtiRegistration return $this->clientId; } - public function setClientId($clientId) + public function setClientId(string $clientId): self { $this->clientId = $clientId; return $this; } - public function getKeySetUrl() + public function getKeySetUrl(): ?string { return $this->keySetUrl; } - public function setKeySetUrl($keySetUrl) + public function setKeySetUrl(?string $keySetUrl): self { $this->keySetUrl = $keySetUrl; return $this; } - public function getAuthTokenUrl() + public function getAuthTokenUrl(): ?string { return $this->authTokenUrl; } - public function setAuthTokenUrl($authTokenUrl) + public function setAuthTokenUrl(?string $authTokenUrl): self { $this->authTokenUrl = $authTokenUrl; return $this; } - public function getAuthLoginUrl() + public function getAuthLoginUrl(): ?string { return $this->authLoginUrl; } - public function setAuthLoginUrl($authLoginUrl) + public function setAuthLoginUrl(?string $authLoginUrl): self { $this->authLoginUrl = $authLoginUrl; return $this; } - public function getAuthServer() + public function getAuthServer(): ?string { - return empty($this->authServer) ? $this->authTokenUrl : $this->authServer; + return $this->authServer ?? $this->authTokenUrl; } - public function setAuthServer($authServer) + public function setAuthServer(?string $authServer): self { $this->authServer = $authServer; @@ -109,7 +109,7 @@ class LtiRegistration implements ILtiRegistration return $this->toolPrivateKey; } - public function setToolPrivateKey($toolPrivateKey) + public function setToolPrivateKey(string $toolPrivateKey): self { $this->toolPrivateKey = $toolPrivateKey; @@ -121,7 +121,7 @@ class LtiRegistration implements ILtiRegistration return $this->kid ?? hash('sha256', trim($this->issuer.$this->clientId)); } - public function setKid($kid) + public function setKid(string $kid): self { $this->kid = $kid; diff --git a/lib/lti1p3/src/LtiServiceConnector.php b/lib/lti1p3/src/LtiServiceConnector.php index ab69986d74a..90869535f6a 100644 --- a/lib/lti1p3/src/LtiServiceConnector.php +++ b/lib/lti1p3/src/LtiServiceConnector.php @@ -6,39 +6,38 @@ use Exception; use Firebase\JWT\JWT; use GuzzleHttp\Client; use GuzzleHttp\Exception\ClientException; -use GuzzleHttp\Psr7\Response; use Packback\Lti1p3\Interfaces\ICache; use Packback\Lti1p3\Interfaces\ILtiRegistration; use Packback\Lti1p3\Interfaces\ILtiServiceConnector; use Packback\Lti1p3\Interfaces\IServiceRequest; +use Psr\Http\Message\ResponseInterface; class LtiServiceConnector implements ILtiServiceConnector { public const NEXT_PAGE_REGEX = '/<([^>]*)>; ?rel="next"/i'; - private $cache; - private $client; - private $debuggingMode = false; + private bool $debuggingMode = false; public function __construct( - ICache $cache, - Client $client + private ICache $cache, + private Client $client ) { - $this->cache = $cache; - $this->client = $client; } - public function setDebuggingMode(bool $enable): void + public function setDebuggingMode(bool $enable): self { $this->debuggingMode = $enable; + + return $this; } - public function getAccessToken(ILtiRegistration $registration, array $scopes) + public function getAccessToken(ILtiRegistration $registration, array $scopes): string { // Get a unique cache key for the access token $accessTokenKey = $this->getAccessTokenCacheKey($registration, $scopes); // Get access token from cache if it exists $accessToken = $this->cache->getAccessToken($accessTokenKey); - if ($accessToken) { + + if (isset($accessToken)) { return $accessToken; } @@ -70,7 +69,8 @@ class LtiServiceConnector implements ILtiServiceConnector $registration->getAuthTokenUrl(), ServiceRequest::TYPE_AUTH ); - $request->setPayload(['form_params' => $authRequest]); + $request->setPayload(['form_params' => $authRequest]) + ->setMaskResponseLogs(true); $response = $this->makeRequest($request); $tokenData = $this->getResponseBody($response); @@ -81,7 +81,7 @@ class LtiServiceConnector implements ILtiServiceConnector return $tokenData['access_token']; } - public function makeRequest(IServiceRequest $request) + public function makeRequest(IServiceRequest $request): ResponseInterface { $response = $this->client->request( $request->getMethod(), @@ -100,7 +100,7 @@ class LtiServiceConnector implements ILtiServiceConnector return $response; } - public function getResponseHeaders(Response $response): ?array + public function getResponseHeaders(ResponseInterface $response): ?array { $responseHeaders = $response->getHeaders(); array_walk($responseHeaders, function (&$value) { @@ -110,7 +110,7 @@ class LtiServiceConnector implements ILtiServiceConnector return $responseHeaders; } - public function getResponseBody(Response $response): ?array + public function getResponseBody(ResponseInterface $response): ?array { $responseBody = (string) $response->getBody(); @@ -153,7 +153,7 @@ class LtiServiceConnector implements ILtiServiceConnector ILtiRegistration $registration, array $scopes, IServiceRequest $request, - string $key = null + ?string $key = null ): array { if ($request->getMethod() !== ServiceRequest::METHOD_GET) { throw new Exception('An invalid method was specified by an LTI service requesting all items.'); @@ -163,46 +163,68 @@ class LtiServiceConnector implements ILtiServiceConnector $nextUrl = $request->getUrl(); while ($nextUrl) { + $request->setUrl($nextUrl); $response = $this->makeServiceRequest($registration, $scopes, $request); - - $page_results = $key === null ? ($response['body'] ?? []) : ($response['body'][$key] ?? []); - $results = array_merge($results, $page_results); - + $pageResults = $this->getResultsFromResponse($response, $key); + $results = array_merge($results, $pageResults); $nextUrl = $this->getNextUrl($response['headers']); - if ($nextUrl) { - $request->setUrl($nextUrl); - } } return $results; } + public static function getLogMessage( + IServiceRequest $request, + array $responseHeaders, + ?array $responseBody + ): string { + if ($request->getMaskResponseLogs()) { + $responseHeaders = self::maskValues($responseHeaders); + $responseBody = self::maskValues($responseBody); + } + + $contextArray = [ + 'request_method' => $request->getMethod(), + 'request_url' => $request->getUrl(), + 'response_headers' => $responseHeaders, + 'response_body' => $responseBody, + ]; + + $requestBody = $request->getPayload()['body'] ?? null; + + if (isset($requestBody)) { + $contextArray['request_body'] = $requestBody; + } + + return implode(' ', array_filter([ + $request->getErrorPrefix(), + json_decode($requestBody)->userId ?? null, + json_encode($contextArray), + ])); + } + private function logRequest( IServiceRequest $request, array $responseHeaders, ?array $responseBody ): void { - $contextArray = [ - 'request_method' => $request->getMethod(), - 'request_url' => $request->getUrl(), - 'response_headers' => $responseHeaders, - 'response_body' => json_encode($responseBody), - ]; - - $requestBody = $request->getPayload()['body'] ?? null; - - if (!empty($requestBody)) { - $contextArray['request_body'] = $requestBody; - } - - error_log(implode(' ', array_filter([ - $request->getErrorPrefix(), - json_decode($requestBody)->userId ?? null, - print_r($contextArray, true), - ]))); + error_log(self::getLogMessage($request, $responseHeaders, $responseBody)); } - private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes) + private static function maskValues(?array $payload): ?array + { + if (!isset($payload) || empty($payload)) { + return $payload; + } + + foreach ($payload as $key => $value) { + $payload[$key] = '***'; + } + + return $payload; + } + + private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes): string { sort($scopes); $scopeKey = md5(implode('|', $scopes)); @@ -210,7 +232,16 @@ class LtiServiceConnector implements ILtiServiceConnector return $registration->getIssuer().$registration->getClientId().$scopeKey; } - private function getNextUrl(array $headers) + private function getResultsFromResponse(array $response, ?string $key = null): array + { + if (isset($key)) { + return $response['body'][$key] ?? []; + } + + return $response['body'] ?? []; + } + + private function getNextUrl(array $headers): ?string { $subject = $headers['Link'] ?? $headers['link'] ?? ''; preg_match(static::NEXT_PAGE_REGEX, $subject, $matches); diff --git a/lib/lti1p3/src/MessageValidators/AbstractMessageValidator.php b/lib/lti1p3/src/MessageValidators/AbstractMessageValidator.php index 7c52929171a..c005196389f 100644 --- a/lib/lti1p3/src/MessageValidators/AbstractMessageValidator.php +++ b/lib/lti1p3/src/MessageValidators/AbstractMessageValidator.php @@ -17,6 +17,9 @@ abstract class AbstractMessageValidator implements IMessageValidator abstract public static function validate(array $jwtBody): void; + /** + * @throws LtiException + */ public static function validateGenericMessage(array $jwtBody): void { if (empty($jwtBody['sub'])) { diff --git a/lib/lti1p3/src/MessageValidators/DeepLinkMessageValidator.php b/lib/lti1p3/src/MessageValidators/DeepLinkMessageValidator.php index e99a79164a5..67e007b007b 100644 --- a/lib/lti1p3/src/MessageValidators/DeepLinkMessageValidator.php +++ b/lib/lti1p3/src/MessageValidators/DeepLinkMessageValidator.php @@ -12,6 +12,9 @@ class DeepLinkMessageValidator extends AbstractMessageValidator return LtiConstants::MESSAGE_TYPE_DEEPLINK; } + /** + * @throws LtiException + */ public static function validate(array $jwtBody): void { static::validateGenericMessage($jwtBody); diff --git a/lib/lti1p3/src/MessageValidators/ResourceMessageValidator.php b/lib/lti1p3/src/MessageValidators/ResourceMessageValidator.php index e24c6b1133f..199e2cff905 100644 --- a/lib/lti1p3/src/MessageValidators/ResourceMessageValidator.php +++ b/lib/lti1p3/src/MessageValidators/ResourceMessageValidator.php @@ -12,6 +12,9 @@ class ResourceMessageValidator extends AbstractMessageValidator return LtiConstants::MESSAGE_TYPE_RESOURCE; } + /** + * @throws LtiException + */ public static function validate(array $jwtBody): void { static::validateGenericMessage($jwtBody); diff --git a/lib/lti1p3/src/MessageValidators/SubmissionReviewMessageValidator.php b/lib/lti1p3/src/MessageValidators/SubmissionReviewMessageValidator.php index 45e63b57a13..6a1ef8922c3 100644 --- a/lib/lti1p3/src/MessageValidators/SubmissionReviewMessageValidator.php +++ b/lib/lti1p3/src/MessageValidators/SubmissionReviewMessageValidator.php @@ -12,6 +12,9 @@ class SubmissionReviewMessageValidator extends AbstractMessageValidator return LtiConstants::MESSAGE_TYPE_SUBMISSIONREVIEW; } + /** + * @throws LtiException + */ public static function validate(array $jwtBody): void { static::validateGenericMessage($jwtBody); diff --git a/lib/lti1p3/src/Redirect.php b/lib/lti1p3/src/Redirect.php deleted file mode 100644 index a87ed5f1274..00000000000 --- a/lib/lti1p3/src/Redirect.php +++ /dev/null @@ -1,84 +0,0 @@ -location = $location; - $this->referer_query = $referer_query; - } - - public function doRedirect() - { - header('Location: '.$this->location, true, 302); - exit; - } - - public function doHybridRedirect(ICookie $cookie) - { - if (!empty($cookie->getCookie(self::$CAN_302_COOKIE))) { - return $this->doRedirect(); - } - $cookie->setCookie(self::$CAN_302_COOKIE, 'true'); - $this->doJsRedirect(); - } - - public function getRedirectUrl() - { - return $this->location; - } - - public function doJsRedirect() - { - ?> - If you are not automatically redirected, click here to continue - - method = $method; - $this->url = $url; - $this->type = $type; + // Other + private $maskResponseLogs = false; + + public function __construct( + private string $method, + private string $url, + private string $type = self::TYPE_UNSUPPORTED + ) { } public function getMethod(): string @@ -120,6 +120,18 @@ class ServiceRequest implements IServiceRequest return $this; } + public function getMaskResponseLogs(): bool + { + return $this->maskResponseLogs; + } + + public function setMaskResponseLogs(bool $shouldMask): IServiceRequest + { + $this->maskResponseLogs = $shouldMask; + + return $this; + } + public function getErrorPrefix(): string { $defaultMessage = 'Logging request data:'; diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml index 8c299d240b2..0af34e5367d 100644 --- a/lib/thirdpartylibs.xml +++ b/lib/thirdpartylibs.xml @@ -437,7 +437,7 @@ All rights reserved. lti1p3 LTI 1.3 Tool Library A library used for building IMS-certified LTI 1.3 tool providers in PHP. - 5.4.1 + 6.0.0 Apache 2.0 https://github.com/packbackbooks/lti-1-3-php-library