diff --git a/README.md b/README.md index b0331d2c..58faccd1 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ $resource = LTI\LTI_Deep_Link_Resource::new() Everything is set to return the resource to the platform. There are two methods of doing this. -The following method will output the html for an aut-posting form for you. +The following method will output the html for an auto-posting form for you. ```php $dl->output_response_form([$resource]); ``` @@ -209,6 +209,12 @@ Alternatively you can just request the signed JWT that will need posting back to $dl->get_response_jwt([$resource]); ``` +If you've created a JWKS endpoint with `LTI\JWKS_Endpoint::new()`, the kid used in the endpoint can be provided as an additional parameter. +```php +$dl->get_response_jwt([$resource], 'a_unique_KID'); + +``` + ## Calling Services ### Names and Roles Service diff --git a/composer.json b/composer.json index 804cdc3b..26135b70 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "imsglobal/lti-1p3-tool", "type": "library", "require": { - "fproject/php-jwt": "^4.0", + "firebase/php-jwt": "^6", "phpseclib/phpseclib": "^2.0" }, "autoload": { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..83ae3127 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,19 @@ +parameters: + parallel: + maximumNumberOfProcesses: 1 + tmpDir: /tmp/phpstan-%env.USER% + ignoreErrors: + - '%Access to constant PUBLIC_FORMAT_PKCS8 on an unknown class phpseclib\\Crypt\\RSA\.%' + - '%Access to property \$modulus on an unknown class phpseclib\\Crypt\\RSA%' + - '%Access to property \$publicExponent on an unknown class phpseclib\\Crypt\\RSA.%' + - '%Access to static property \$leeway on an unknown class Firebase\\JWT\\JWT\.%' + - '%Call to method loadKey\(\) on an unknown class phpseclib\\Crypt\\RSA\.%' + - '%Call to method setHash\(\) on an unknown class phpseclib\\Crypt\\RSA\.%' + - '%Call to method setPublicKey\(\) on an unknown class phpseclib\\Crypt\\RSA\.%' + - '%Call to static method decode\(\) on an unknown class Firebase\\JWT\\JWT\.%' + - '%Call to static method encode\(\) on an unknown class Firebase\\JWT\\JWT\.%' + - '%Call to static method parseKey\(\) on an unknown class Firebase\\JWT\\JWK\.%' + - '%Call to static method urlsafeB64Decode\(\) on an unknown class Firebase\\JWT\\JWT\.%' + - '%Call to static method urlsafeB64Encode\(\) on an unknown class Firebase\\JWT\\JWT\.%' + - '%Instantiated class Firebase\\JWT\\Key not found\.%' + - '%Instantiated class phpseclib\\Crypt\\RSA not found\.%' diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..c9eafc0e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,14 @@ + + + + + src/lti + src/lti/message_validators + + + + + tests + + + diff --git a/src/lti/Cache.php b/src/lti/Cache.php index 0d865711..166a99bc 100644 --- a/src/lti/Cache.php +++ b/src/lti/Cache.php @@ -3,44 +3,57 @@ class Cache { - private $cache; + /** @var array $cache */ + private array $cache; - public function get_launch_data($key) { + public function get_launch_data(string $key): mixed { $this->load_cache(); return $this->cache[$key]; } - public function cache_launch_data($key, $jwt_body) { + /** + * @param array $jwt_body + */ + public function cache_launch_data(string $key, array $jwt_body): self { $this->cache[$key] = $jwt_body; $this->save_cache(); return $this; } - public function cache_nonce($nonce) { - $this->cache['nonce'][$nonce] = true; + public function cache_nonce(string $nonce): self { + $this->cache['nonce'][$nonce] = false; $this->save_cache(); return $this; } - public function check_nonce($nonce) { + public function check_nonce(string $nonce): bool { $this->load_cache(); if (!isset($this->cache['nonce'][$nonce])) { return false; } + if ($this->cache['nonce'][$nonce]) { + return false; + } + $this->cache['nonce'][$nonce] = true; + $this->save_cache(); return true; } - private function load_cache() { + private function load_cache(): void { $cache = file_get_contents(sys_get_temp_dir() . '/lti_cache.txt'); - if (empty($cache)) { + if ($cache === false) { file_put_contents(sys_get_temp_dir() . '/lti_cache.txt', '{}'); $this->cache = []; + return; + } + $data = json_decode($cache, true); + if ($data === false) { + throw new \Exception("Failed to decode cache contents."); } - $this->cache = json_decode($cache, true); + $this->cache = $data; } - private function save_cache() { + private function save_cache(): void { file_put_contents(sys_get_temp_dir() . '/lti_cache.txt', json_encode($this->cache)); } } -?> \ No newline at end of file diff --git a/src/lti/Cookie.php b/src/lti/Cookie.php index e18700b6..97de03f5 100644 --- a/src/lti/Cookie.php +++ b/src/lti/Cookie.php @@ -2,7 +2,10 @@ namespace IMSGlobal\LTI; class Cookie { - public function get_cookie($name) { + /** + * @return string|false + */ + public function get_cookie(string $name) { if (isset($_COOKIE[$name])) { return $_COOKIE[$name]; } @@ -13,7 +16,10 @@ public function get_cookie($name) { return false; } - public function set_cookie($name, $value, $exp = 3600, $options = []) { + /** + * @param array $options + */ + public function set_cookie(string $name, string $value, int $exp = 3600, array $options = []): self { $cookie_options = [ 'expires' => time() + $exp ]; @@ -31,4 +37,3 @@ public function set_cookie($name, $value, $exp = 3600, $options = []) { return $this; } } -?> diff --git a/src/lti/Database.php b/src/lti/Database.php index 945afd67..68348631 100644 --- a/src/lti/Database.php +++ b/src/lti/Database.php @@ -2,8 +2,6 @@ namespace IMSGlobal\LTI; interface Database { - public function find_registration_by_issuer($iss); - public function find_deployment($iss, $deployment_id); + public function find_registration_by_issuer(string $iss, ?string $client_id): ?LTI_Registration; + public function find_deployment(string $iss, string $deployment_id): ?LTI_Deployment; } - -?> \ No newline at end of file diff --git a/src/lti/JWKS_Endpoint.php b/src/lti/JWKS_Endpoint.php index 8e5771b8..e9685b8d 100644 --- a/src/lti/JWKS_Endpoint.php +++ b/src/lti/JWKS_Endpoint.php @@ -6,32 +6,51 @@ class JWKS_Endpoint { - private $keys; + /** @var array $keys */ + private array $keys; + /** + * @param array $keys + */ public function __construct(array $keys) { $this->keys = $keys; } - public static function new($keys) { + /** + * @param array $keys + */ + public static function new(array $keys): self { return new JWKS_Endpoint($keys); } - public static function from_issuer(Database $database, $issuer) { - $registration = $database->find_registration_by_issuer($issuer); - return new JWKS_Endpoint([$registration->get_kid() => $registration->get_tool_private_key()]); + public static function from_issuer( + Database $database, + string $issuer, + string $client_id + ): self { + $registration = $database->find_registration_by_issuer($issuer, $client_id); + if ($registration === null) { + throw new LTI_Exception("Could not find registration"); + } + return new self([$registration->get_kid() => $registration->get_tool_private_key()]); } - public static function from_registration(LTI_Registration $registration) { - return new JWKS_Endpoint([$registration->get_kid() => $registration->get_tool_private_key()]); + public static function from_registration( + LTI_Registration $registration + ): self { + return new self([$registration->get_kid() => $registration->get_tool_private_key()]); } - public function get_public_jwks() { + /** + * @return array + */ + public function get_public_jwks(): array { $jwks = []; foreach ($this->keys as $kid => $private_key) { $key = new RSA(); $key->setHash("sha256"); $key->loadKey($private_key); - $key->setPublicKey(false, RSA::PUBLIC_FORMAT_PKCS8); + $key->setPublicKey("", RSA::PUBLIC_FORMAT_PKCS8); if ( !$key->publicExponent ) { continue; } @@ -48,8 +67,8 @@ public function get_public_jwks() { return ['keys' => $jwks]; } - public function output_jwks() { + public function output_jwks(): void { echo json_encode($this->get_public_jwks()); } -} \ No newline at end of file +} diff --git a/src/lti/LTI_Assignments_Grades_Service.php b/src/lti/LTI_Assignments_Grades_Service.php index ffd1cde0..a06ac6df 100644 --- a/src/lti/LTI_Assignments_Grades_Service.php +++ b/src/lti/LTI_Assignments_Grades_Service.php @@ -2,16 +2,22 @@ namespace IMSGlobal\LTI; class LTI_Assignments_Grades_Service { + private LTI_Service_Connector $service_connector; + /** @var array $service_data */ + private array $service_data; - private $service_connector; - private $service_data; - - public function __construct(LTI_Service_Connector $service_connector, $service_data) { + /** + * @param array $service_data + */ + public function __construct(LTI_Service_Connector $service_connector, array $service_data) { $this->service_connector = $service_connector; $this->service_data = $service_data; } - public function put_grade(LTI_Grade $grade, LTI_Lineitem $lineitem = null) { + /** + * @return array + */ + public function put_grade(LTI_Grade $grade, ?LTI_Lineitem $lineitem = null): array { if (!in_array("https://purl.imsglobal.org/spec/lti-ags/scope/score", $this->service_data['scope'])) { throw new LTI_Exception('Missing required scope', 1); } @@ -24,7 +30,7 @@ public function put_grade(LTI_Grade $grade, LTI_Lineitem $lineitem = null) { } else { $lineitem = LTI_Lineitem::new() ->set_label('default') - ->set_score_maximum(100); + ->set_score_maximum('100'); $lineitem = $this->find_or_create_lineitem($lineitem); $score_url = $lineitem->get_id(); } @@ -41,7 +47,7 @@ public function put_grade(LTI_Grade $grade, LTI_Lineitem $lineitem = null) { ); } - public function find_or_create_lineitem(LTI_Lineitem $new_line_item) { + public function find_or_create_lineitem(LTI_Lineitem $new_line_item): LTI_Lineitem { if (!in_array("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", $this->service_data['scope'])) { throw new LTI_Exception('Missing required scope', 1); } @@ -50,7 +56,7 @@ public function find_or_create_lineitem(LTI_Lineitem $new_line_item) { 'GET', $this->service_data['lineitems'], null, - null, + 'application/json', 'application/vnd.ims.lis.v2.lineitemcontainer+json' ); foreach ($line_items['body'] as $line_item) { @@ -71,7 +77,10 @@ public function find_or_create_lineitem(LTI_Lineitem $new_line_item) { return new LTI_Lineitem($created_line_item['body']); } - public function get_grades(LTI_Lineitem $lineitem) { + /** + * @return array + */ + public function get_grades(LTI_Lineitem $lineitem): array { $lineitem = $this->find_or_create_lineitem($lineitem); // Place '/results' before url params $pos = strpos($lineitem->get_id(), '?'); @@ -81,11 +90,10 @@ public function get_grades(LTI_Lineitem $lineitem) { 'GET', $results_url, null, - null, + 'application/json', 'application/vnd.ims.lis.v2.resultcontainer+json' ); return $scores['body']; } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Course_Groups_Service.php b/src/lti/LTI_Course_Groups_Service.php index b9a878c3..16490d46 100644 --- a/src/lti/LTI_Course_Groups_Service.php +++ b/src/lti/LTI_Course_Groups_Service.php @@ -3,15 +3,22 @@ class LTI_Course_Groups_Service { - private $service_connector; - private $service_data; - - public function __construct(LTI_Service_Connector $service_connector, $service_data) { + private LTI_Service_Connector $service_connector; + /** @var array $service_data */ + private array $service_data; + + /** + * @param array $service_data + */ + public function __construct(LTI_Service_Connector $service_connector, array $service_data) { $this->service_connector = $service_connector; $this->service_data = $service_data; } - public function get_groups() { + /** + * @return array + */ + public function get_groups(): array { $groups = []; @@ -23,7 +30,7 @@ public function get_groups() { 'GET', $next_page, null, - null, + 'application/json', 'application/vnd.ims.lti-gs.v1.contextgroupcontainer+json' ); @@ -41,7 +48,10 @@ public function get_groups() { } - public function get_sets() { + /** + * @return array + */ + public function get_sets(): array { $sets = []; @@ -58,7 +68,7 @@ public function get_sets() { 'GET', $next_page, null, - null, + 'application/json', 'application/vnd.ims.lti-gs.v1.contextgroupcontainer+json' ); @@ -76,6 +86,9 @@ public function get_sets() { } + /** + * @return array + */ public function get_groups_by_set() { $groups = $this->get_groups(); $sets = $this->get_sets(); @@ -107,4 +120,3 @@ public function get_groups_by_set() { return $groups_by_set; } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Deep_Link.php b/src/lti/LTI_Deep_Link.php index c87cb0da..cfcabde7 100644 --- a/src/lti/LTI_Deep_Link.php +++ b/src/lti/LTI_Deep_Link.php @@ -2,22 +2,34 @@ namespace IMSGlobal\LTI; use \Firebase\JWT\JWT; + class LTI_Deep_Link { - private $registration; - private $deployment_id; - private $deep_link_settings; + private LTI_Registration $registration; + private string $deployment_id; + /** @var array $deep_link_settings */ + private array $deep_link_settings; - public function __construct($registration, $deployment_id, $deep_link_settings) { + /** + * @param array $deep_link_settings + */ + public function __construct( + LTI_Registration $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 get_response_jwt($resources) { + /** + * @param array $resources + */ + public function get_response_jwt(array $resources, ?string $kid = null): string { $message_jwt = [ "iss" => $this->registration->get_client_id(), - "aud" => [$this->registration->get_issuer()], + "aud" => $this->registration->get_issuer(), "exp" => time() + 600, "iat" => time(), "nonce" => 'nonce' . hash('sha256', random_bytes(64)), @@ -27,11 +39,20 @@ public function get_response_jwt($resources) { "https://purl.imsglobal.org/spec/lti-dl/claim/content_items" => array_map(function($resource) { return $resource->to_array(); }, $resources), "https://purl.imsglobal.org/spec/lti-dl/claim/data" => $this->deep_link_settings['data'], ]; - return JWT::encode($message_jwt, $this->registration->get_tool_private_key(), 'RS256', $this->registration->get_kid()); + + return JWT::encode( + $message_jwt, + $this->registration->get_tool_private_key(), + 'RS256', + is_null($kid) ? $this->registration->get_kid() : $kid + ); } - public function output_response_form($resources) { - $jwt = $this->get_response_jwt($resources); + /** + * @param array $resources + */ + public function output_response_form(array $resources, ?string $kid = null): void { + $jwt = $this->get_response_jwt($resources, $kid); ?>
@@ -43,4 +64,3 @@ public function output_response_form($resources) { \ No newline at end of file diff --git a/src/lti/LTI_Deep_Link_Resource.php b/src/lti/LTI_Deep_Link_Resource.php index 8abeadd6..182f35fe 100644 --- a/src/lti/LTI_Deep_Link_Resource.php +++ b/src/lti/LTI_Deep_Link_Resource.php @@ -3,72 +3,82 @@ class LTI_Deep_Link_Resource { - private $type = 'ltiResourceLink'; - private $title; - private $url; - private $lineitem; - private $custom_params = []; - private $target = 'iframe'; - - public static function new() { + private string $type = 'ltiResourceLink'; + private string $title; + private string $url; + private ?LTI_Lineitem $lineitem = null; + /** @var array $custom_params */ + private array $custom_params = []; + private string $target = 'iframe'; + + public static function new(): self { return new LTI_Deep_Link_Resource(); } - public function get_type() { + public function get_type(): string { return $this->type; } - public function set_type($value) { + public function set_type(string $value): self { $this->type = $value; return $this; } - public function get_title() { + public function get_title(): string { return $this->title; } - public function set_title($value) { + public function set_title(string $value): self { $this->title = $value; return $this; } - public function get_url() { + public function get_url(): string { return $this->url; } - public function set_url($value) { + public function set_url(string $value): self { $this->url = $value; return $this; } - public function get_lineitem() { + public function get_lineitem(): ?LTI_Lineitem { return $this->lineitem; } - public function set_lineitem($value) { + public function set_lineitem(LTI_Lineitem $value): self { $this->lineitem = $value; return $this; } - public function get_custom_params() { + /** + * @return array + */ + public function get_custom_params(): array { return $this->custom_params; } - public function set_custom_params($value) { + /** + * @param array $value + */ + public function set_custom_params(array $value): self { $this->custom_params = $value; return $this; } - public function get_target() { + public function get_target(): string { return $this->target; } - public function set_target($value) { + public function set_target(string $value): self { $this->target = $value; return $this; } - public function to_array() { + /** + * @return array + */ + public function to_array(): array { $resource = [ "type" => $this->type, "title" => $this->title, @@ -76,8 +86,11 @@ public function to_array() { "presentation" => [ "documentTarget" => $this->target, ], - "custom" => $this->custom_params, ]; + if (count($this->custom_params) > 0) { + $resource["custom"] = $this->custom_params; + } + if ($this->lineitem !== null) { $resource["lineItem"] = [ "scoreMaximum" => $this->lineitem->get_score_maximum(), @@ -87,4 +100,3 @@ public function to_array() { return $resource; } } -?> diff --git a/src/lti/LTI_Deployment.php b/src/lti/LTI_Deployment.php index ad502055..42aa06c8 100644 --- a/src/lti/LTI_Deployment.php +++ b/src/lti/LTI_Deployment.php @@ -3,21 +3,18 @@ class LTI_Deployment { - private $deployment_id; + private string $deployment_id; - public static function new() { + public static function new(): self { return new LTI_Deployment(); } - public function get_deployment_id() { + public function get_deployment_id(): string { return $this->deployment_id; } - public function set_deployment_id($deployment_id) { + public function set_deployment_id(string $deployment_id): self { $this->deployment_id = $deployment_id; return $this; } - } - -?> \ No newline at end of file diff --git a/src/lti/LTI_Exception.php b/src/lti/LTI_Exception.php index b54bf7d3..8cd9f27e 100644 --- a/src/lti/LTI_Exception.php +++ b/src/lti/LTI_Exception.php @@ -4,4 +4,3 @@ class LTI_Exception extends \Exception { } -?> \ No newline at end of file diff --git a/src/lti/LTI_Grade.php b/src/lti/LTI_Grade.php index fabeb6ad..a9359269 100644 --- a/src/lti/LTI_Grade.php +++ b/src/lti/LTI_Grade.php @@ -2,98 +2,98 @@ namespace IMSGlobal\LTI; class LTI_Grade { - private $score_given; - private $score_maximum; - private $comment; - private $activity_progress; - private $grading_progress; - private $timestamp; - private $user_id; - private $submission_review; + private string $score_given = ""; + private string $score_maximum = ""; + private string $comment = ""; + private string $activity_progress = ""; + private string $grading_progress = ""; + private string $timestamp = ""; + private string $user_id = ""; + private string $submission_review = ""; /** * 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 LTI_Grade(); } - public function get_score_given() { + public function get_score_given(): string { return $this->score_given; } - public function set_score_given($value) { + public function set_score_given(string $value): self { $this->score_given = $value; return $this; } - public function get_score_maximum() { + public function get_score_maximum(): string { return $this->score_maximum; } - public function set_score_maximum($value) { + public function set_score_maximum(string $value): self { $this->score_maximum = $value; return $this; } - public function get_comment() { + public function get_comment(): string { return $this->comment; } - public function set_comment($comment) { + public function set_comment(string $comment): self { $this->comment = $comment; return $this; } - public function get_activity_progress() { + public function get_activity_progress(): string { return $this->activity_progress; } - public function set_activity_progress($value) { + public function set_activity_progress(string $value): self { $this->activity_progress = $value; return $this; } - public function get_grading_progress() { + public function get_grading_progress(): string { return $this->grading_progress; } - public function set_grading_progress($value) { + public function set_grading_progress(string $value): self { $this->grading_progress = $value; return $this; } - public function get_timestamp() { + public function get_timestamp(): string { return $this->timestamp; } - public function set_timestamp($value) { + public function set_timestamp(string $value): self { $this->timestamp = $value; return $this; } - public function get_user_id() { + public function get_user_id(): string { return $this->user_id; } - public function set_user_id($value) { + public function set_user_id(string $value): self { $this->user_id = $value; return $this; } - public function get_submission_review() { + public function get_submission_review(): string { return $this->submission_review; } - public function set_submission_review($value) { + public function set_submission_review(string $value): self { $this->submission_review = $value; return $this; } - public function __toString() { - return json_encode(array_filter([ - "scoreGiven" => 0 + $this->score_given, - "scoreMaximum" => 0 + $this->score_maximum, + public function __toString(): string { + return (string)json_encode(array_filter([ + "scoreGiven" => (string)floatval($this->score_given), + "scoreMaximum" => (string)floatval($this->score_maximum), "comment" => $this->comment, "activityProgress" => $this->activity_progress, "gradingProgress" => $this->grading_progress, @@ -103,4 +103,3 @@ public function __toString() { ])); } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Grade_Submission_Review.php b/src/lti/LTI_Grade_Submission_Review.php index 73fc073f..b34a24e7 100644 --- a/src/lti/LTI_Grade_Submission_Review.php +++ b/src/lti/LTI_Grade_Submission_Review.php @@ -2,56 +2,56 @@ namespace IMSGlobal\LTI; class LTI_Grade_Submission_Review { - private $reviewable_status; - private $label; - private $url; - private $custom; + private string $reviewable_status = ""; + private string $label = ""; + private string $url = ""; + private string $custom = ""; /** * 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 LTI_Grade_Submission_Review(); } - public function get_reviewable_status() { + public function get_reviewable_status(): string { return $this->reviewable_status; } - public function set_reviewable_status($value) { + public function set_reviewable_status(string $value): self { $this->reviewable_status = $value; return $this; } - public function get_label() { + public function get_label(): string { return $this->label; } - public function set_label($value) { + public function set_label(string $value): self { $this->label = $value; return $this; } - public function get_url() { + public function get_url(): string { return $this->url; } - public function set_url($url) { + public function set_url(string $url): self { $this->url = $url; return $this; } - public function get_custom() { + public function get_custom(): string { return $this->custom; } - public function set_custom($value) { + public function set_custom(string $value):self { $this->custom = $value; return $this; } - public function __toString() { - return json_encode(array_filter([ + public function __toString(): string { + return (string)json_encode(array_filter([ "reviewableStatus" => $this->reviewable_status, "label" => $this->label, "url" => $this->url, @@ -59,4 +59,3 @@ public function __toString() { ])); } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Lineitem.php b/src/lti/LTI_Lineitem.php index ba8e20a7..fea9e20f 100644 --- a/src/lti/LTI_Lineitem.php +++ b/src/lti/LTI_Lineitem.php @@ -2,15 +2,18 @@ namespace IMSGlobal\LTI; class LTI_Lineitem { - private $id; - private $score_maximum; - private $label; - private $resource_id; - private $tag; - private $start_date_time; - private $end_date_time; - - public function __construct(array $lineitem = null) { + private string $id = ""; + private string $score_maximum = ""; + private string $label = ""; + private string $resource_id = ""; + private string $tag = ""; + private string $start_date_time = ""; + private string $end_date_time = ""; + + /** + * @param array $lineitem + */ + public function __construct(?array $lineitem = null) { if (empty($lineitem)) { return; } @@ -26,75 +29,75 @@ public function __construct(array $lineitem = null) { /** * 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 LTI_Lineitem(); } - public function get_id() { + public function get_id(): string { return $this->id; } - public function set_id($value) { + public function set_id(string $value): self { $this->id = $value; return $this; } - public function get_label() { + public function get_label(): string { return $this->label; } - public function set_label($value) { + public function set_label(string $value): self { $this->label = $value; return $this; } - public function get_score_maximum() { + public function get_score_maximum(): string { return $this->score_maximum; } - public function set_score_maximum($value) { + public function set_score_maximum(string $value): self { $this->score_maximum = $value; return $this; } - public function get_resource_id() { + public function get_resource_id(): string { return $this->resource_id; } - public function set_resource_id($value) { + public function set_resource_id(string $value): self { $this->resource_id = $value; return $this; } - public function get_tag() { + public function get_tag(): string { return $this->tag; } - public function set_tag($value) { + public function set_tag(string $value): self { $this->tag = $value; return $this; } - public function get_start_date_time() { + public function get_start_date_time(): string { return $this->start_date_time; } - public function set_start_date_time($value) { + public function set_start_date_time(string $value): self { $this->start_date_time = $value; return $this; } - public function get_end_date_time() { + public function get_end_date_time(): string { return $this->end_date_time; } - public function set_end_date_time($value) { + public function set_end_date_time(string $value): self { $this->end_date_time = $value; return $this; } - public function __toString() { - return json_encode(array_filter([ + public function __toString(): string { + return (string)json_encode(array_filter([ "id" => $this->id, "scoreMaximum" => $this->score_maximum, "label" => $this->label, @@ -105,4 +108,3 @@ public function __toString() { ])); } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Message_Launch.php b/src/lti/LTI_Message_Launch.php index 7a18195d..06e4e6a5 100644 --- a/src/lti/LTI_Message_Launch.php +++ b/src/lti/LTI_Message_Launch.php @@ -3,18 +3,21 @@ use Firebase\JWT\JWK; use Firebase\JWT\JWT; +use Firebase\JWT\Key; JWT::$leeway = 5; class LTI_Message_Launch { - private $db; - private $cache; - private $request; - private $cookie; - private $jwt; - private $registration; - private $launch_id; + private Database $db; + private Cache $cache; + /** @var array $request */ + private array $request; + protected Cookie $cookie; + /** @var array $jwt */ + private array $jwt; + private LTI_Registration $registration; + private string $launch_id; /** * Constructor @@ -23,7 +26,7 @@ class LTI_Message_Launch { * @param Cache $cache Instance of the Cache interface used to loading and storing launches. If non is provided launch data will be store in $_SESSION. * @param Cookie $cookie Instance of the Cookie interface used to set and read cookies. Will default to using $_COOKIE and setcookie. */ - function __construct(Database $database, Cache $cache = null, Cookie $cookie = null) { + final function __construct(Database $database, ?Cache $cache = null, ?Cookie $cookie = null) { $this->db = $database; $this->launch_id = uniqid("lti1p3_launch_", true); @@ -40,10 +43,11 @@ function __construct(Database $database, Cache $cache = null, Cookie $cookie = n } /** - * Static function to allow for method chaining without having to assign to a variable first. + * Static function to allow for method chaining without having to assign + * to a variable first. */ - public static function new(Database $database, Cache $cache = null, Cookie $cookie = null) { - return new LTI_Message_Launch($database, $cache, $cookie); + public static function new(Database $database, ?Cache $cache = null, ?Cookie $cookie = null): self { + return new static($database, $cache, $cookie); } /** @@ -56,8 +60,8 @@ public static function new(Database $database, Cache $cache = null, Cookie $cook * @throws LTI_Exception Will throw an LTI_Exception if validation fails or launch cannot be found. * @return LTI_Message_Launch A populated and validated LTI_Message_Launch. */ - public static function from_cache($launch_id, Database $database, Cache $cache = null) { - $new = new LTI_Message_Launch($database, $cache, null); + public static function from_cache($launch_id, Database $database, ?Cache $cache = null) { + $new = new static($database, $cache, null); $new->launch_id = $launch_id; $new->jwt = [ 'body' => $new->cache->get_launch_data($launch_id) ]; return $new->validate_registration(); @@ -66,12 +70,12 @@ public static function from_cache($launch_id, Database $database, Cache $cache = /** * 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. + * @param array|null $request An array of post request parameters. If not set will default to $_POST. * * @throws LTI_Exception Will throw an LTI_Exception if validation fails. * @return LTI_Message_Launch Will return $this if validation is successful. */ - public function validate(array $request = null) { + public function validate(?array $request = null) { if ($request === null) { $request = $_POST; @@ -190,9 +194,9 @@ public function is_resource_launch() { /** * 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. + * @return array Returns the decoded json body of the launch as an array. */ - public function get_launch_data() { + public function get_launch_data(): array { return $this->jwt['body']; } @@ -201,29 +205,49 @@ public function get_launch_data() { * * @return string A unique identifier used to re-reference the current launch in subsequent requests. */ - public function get_launch_id() { + public function get_launch_id(): string { return $this->launch_id; } - private function get_public_key() { + /** + * @return array + */ + protected function get_public_key(): array { $key_set_url = $this->registration->get_key_set_url(); // Download key set - $public_key_set = json_decode(file_get_contents($key_set_url), true); + $public_key_set_data = file_get_contents($key_set_url); + if ($public_key_set_data === false) { + throw new LTI_Exception("Failed to fetch public key", 1); + } + $public_key_set = json_decode($public_key_set_data, true); if (empty($public_key_set)) { // Failed to fetch public keyset from URL. - throw new LTI_Exception("Failed to fetch public key", 1); + throw new LTI_Exception("Failed to decode public key", 1); } // Find key used to sign the JWT (matches the KID in the header) foreach ($public_key_set['keys'] as $key) { if ($key['kid'] == $this->jwt['header']['kid']) { - try { - return openssl_pkey_get_details(JWK::parseKey($key)); - } catch(\Exception $e) { - return false; + $result = JWK::parseKey($key, 'RS256'); + if ($result === null) { + throw new LTI_Exception("JWK::parseKey() failed to parse public key", 1); + } + + $result = $result->getKeyMaterial(); + if (!$result instanceof \OpenSSLAsymmetricKey) { + throw new LTI_Exception("Result JWK::parseKey() was an unexpected type", 1); } + + $result = openssl_pkey_get_details($result); + if ($result === false) { + throw new LTI_Exception( + "openssl_pkey_get_details() failed to parse result of JWK::parseKey()", + 1 + ); + } + return $result; } } @@ -231,27 +255,32 @@ private function get_public_key() { throw new LTI_Exception("Unable to find public key", 1); } - private function cache_launch_data() { + private function cache_launch_data(): self { $this->cache->cache_launch_data($this->launch_id, $this->jwt['body']); return $this; } - private function validate_state() { - // Check State for OIDC. + private function validate_state(): self { + // Ensure request contains a state + if (!isset($this->request['state'])) { + throw new LTI_Exception("State not provided in request", 1); + } + + // Check State for OI DC. if ($this->cookie->get_cookie('lti1p3_' . $this->request['state']) !== $this->request['state']) { // Error if state doesn't match - throw new LTI_Exception("State not found", 1); + throw new LTI_Exception("State not found: ".$this->request['state'], 1); } return $this; } - private function validate_jwt_format() { - $jwt = $this->request['id_token']; - - if (empty($jwt)) { + private function validate_jwt_format(): self { + if (!isset($this->request['id_token'])) { throw new LTI_Exception("Missing id_token", 1); } + $jwt = $this->request['id_token']; + // Get parts of JWT. $jwt_parts = explode('.', $jwt); @@ -268,24 +297,42 @@ private function validate_jwt_format() { return $this; } - private function validate_nonce() { + private function validate_nonce(): self { + if (!isset($this->jwt['body']['nonce'])) { + throw new LTI_Exception("No nonce provided", 1); + } if (!$this->cache->check_nonce($this->jwt['body']['nonce'])) { - //throw new LTI_Exception("Invalid Nonce"); + throw new LTI_Exception("Invalid nonce", 1); } return $this; } - private function validate_registration() { - // Find registration. - $this->registration = $this->db->find_registration_by_issuer($this->jwt['body']['iss']); + private function validate_registration(): self { + if (!isset($this->jwt['body']['iss'])) { + throw new LTI_Exception("No iss provided", 1); + } - if (empty($this->registration)) { - throw new LTI_Exception("Registration not found.", 1); + if (!isset($this->jwt['body']['aud'])) { + throw new LTI_Exception("No aud provided", 1); } - // Check client id. + // get client id $client_id = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud']; - if ( $client_id !== $this->registration->get_client_id()) { + + // find registration + $registration = $this->db->find_registration_by_issuer( + $this->jwt['body']['iss'], + $client_id + ); + + if (empty($registration)) { + throw new LTI_Exception("Registration not found", 1); + } + + $this->registration = $registration; + + // Check client id. + if ($client_id !== $this->registration->get_client_id()) { // Client not registered. throw new LTI_Exception("Client id not registered for this issuer", 1); } @@ -293,15 +340,14 @@ private function validate_registration() { return $this; } - private function validate_jwt_signature() { + private function validate_jwt_signature(): self { // Fetch public key. $public_key = $this->get_public_key(); // Validate JWT signature try { - JWT::decode($this->request['id_token'], $public_key['key'], array('RS256')); + JWT::decode($this->request['id_token'], new Key($public_key['key'], 'RS256')); } catch(\Exception $e) { - var_dump($e); // Error validating signature. throw new LTI_Exception("Invalid signature on id_token", 1); } @@ -309,9 +355,16 @@ private function validate_jwt_signature() { return $this; } - private function validate_deployment() { + private function validate_deployment(): self { + if (!isset($this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/deployment_id'])) { + throw new LTI_Exception("No deployment provided", 1); + } + // Find deployment. - $deployment = $this->db->find_deployment($this->jwt['body']['iss'], $this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/deployment_id']); + $deployment = $this->db->find_deployment( + $this->jwt['body']['iss'], + $this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/deployment_id'] + ); if (empty($deployment)) { // deployment not recognized. @@ -321,8 +374,8 @@ private function validate_deployment() { return $this; } - private function validate_message() { - if (empty($this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/message_type'])) { + private function validate_message(): self { + if (!isset($this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/message_type'])) { // Unable to identify message type. throw new LTI_Exception("Invalid message type", 1); } @@ -330,7 +383,11 @@ private function validate_message() { // Do message type validation // Import all validators - foreach (glob(__DIR__ . "/message_validators/*.php") as $filename) { + $filenames = glob(__DIR__ . "/message_validators/*.php"); + if ($filenames === false) { + throw new \Exception("glob() failed."); + } + foreach ($filenames as $filename) { include_once $filename; } @@ -348,6 +405,11 @@ private function validate_message() { $message_validator = false; foreach ($validators as $validator) { + if (!method_exists($validator, 'can_validate')) { + throw new LTI_Exception( + "Validator provided that fails to implement can_validate()." + ); + } if ($validator->can_validate($this->jwt['body'])) { if ($message_validator !== false) { // Can't have more than one validator apply at a time. @@ -361,6 +423,11 @@ private function validate_message() { throw new LTI_Exception("Unrecognized message type.", 1); } + if (!method_exists($message_validator, 'validate')) { + throw new LTI_Exception( + "Validator provided that fails to implement validate()." + ); + } if (!$message_validator->validate($this->jwt['body'])) { throw new LTI_Exception("Message validation failed.", 1); } @@ -369,4 +436,3 @@ private function validate_message() { } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Names_Roles_Provisioning_Service.php b/src/lti/LTI_Names_Roles_Provisioning_Service.php index df2242ea..fc44d26c 100644 --- a/src/lti/LTI_Names_Roles_Provisioning_Service.php +++ b/src/lti/LTI_Names_Roles_Provisioning_Service.php @@ -3,14 +3,25 @@ class LTI_Names_Roles_Provisioning_Service { - private $service_connector; + private LTI_Service_Connector $service_connector; + + /** @var array $service_data */ private $service_data; - public function __construct(LTI_Service_Connector $service_connector, $service_data) { + /** + * @param array $service_data + */ + public function __construct( + LTI_Service_Connector $service_connector, + array $service_data + ) { $this->service_connector = $service_connector; $this->service_data = $service_data; } + /** + * @return array + */ public function get_members() { $members = []; @@ -23,7 +34,7 @@ public function get_members() { 'GET', $next_page, null, - null, + 'application/json', 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json' ); @@ -37,8 +48,7 @@ public function get_members() { } } } - return $members; + return $members; } } -?> diff --git a/src/lti/LTI_OIDC_Login.php b/src/lti/LTI_OIDC_Login.php index 2c135c09..dca252bb 100644 --- a/src/lti/LTI_OIDC_Login.php +++ b/src/lti/LTI_OIDC_Login.php @@ -3,9 +3,9 @@ class LTI_OIDC_Login { - private $db; - private $cache; - private $cookie; + private Database $db; + private Cache $cache; + private Cookie $cookie; /** * Constructor @@ -14,7 +14,11 @@ class LTI_OIDC_Login { * @param Cache $cache Instance of the Cache interface used to loading and storing launches. If non is provided launch data will be store in $_SESSION. * @param Cookie $cookie Instance of the Cookie interface used to set and read cookies. Will default to using $_COOKIE and setcookie. */ - function __construct(Database $database, Cache $cache = null, Cookie $cookie = null) { + function __construct( + Database $database, + ?Cache $cache = null, + ?Cookie $cookie = null + ) { $this->db = $database; if ($cache === null) { $cache = new Cache(); @@ -30,7 +34,11 @@ function __construct(Database $database, Cache $cache = null, Cookie $cookie = n /** * Static function to allow for method chaining without having to assign to a variable first. */ - public static function new(Database $database, Cache $cache = null, Cookie $cookie = null) { + public static function new( + Database $database, + ?Cache $cache = null, + ?Cookie $cookie = null + ): LTI_OIDC_Login { return new LTI_OIDC_Login($database, $cache, $cookie); } @@ -38,11 +46,11 @@ public static function new(Database $database, Cache $cache = null, Cookie $cook * 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. + * @param array|null $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 do_oidc_login_redirect($launch_url, array $request = null) { + public function do_oidc_login_redirect($launch_url, ?array $request = null) { if ($request === null) { $request = $_REQUEST; @@ -94,7 +102,14 @@ public function do_oidc_login_redirect($launch_url, array $request = null) { } - protected function validate_oidc_login($request) { + /** + * Validate an OIDC login and, if valid, return the corresponding LTI_Registration. + * @param array $request Parameters from OIDC Login request. + * @throws OIDC_Exception when request is not valid or when no + * corresponding registration can be found. + * @return LTI_Registration + */ + protected function validate_oidc_login(array $request): LTI_Registration { // Validate Issuer. if (empty($request['iss'])) { @@ -107,7 +122,10 @@ protected function validate_oidc_login($request) { } // Fetch Registration Details. - $registration = $this->db->find_registration_by_issuer($request['iss']); + $registration = $this->db->find_registration_by_issuer( + $request['iss'], + $request['client_id'] + ); // Check we got something. if (empty($registration)) { @@ -117,4 +135,4 @@ protected function validate_oidc_login($request) { // Return Registration. return $registration; } -} \ No newline at end of file +} diff --git a/src/lti/LTI_Registration.php b/src/lti/LTI_Registration.php index 7438253c..c5f47ee7 100644 --- a/src/lti/LTI_Registration.php +++ b/src/lti/LTI_Registration.php @@ -3,91 +3,89 @@ class LTI_Registration { - private $issuer; - private $client_id; - private $key_set_url; - private $auth_token_url; - private $auth_login_url; - private $auth_server; - private $tool_private_key; - private $kid; - - public static function new() { + private string $issuer = ""; + private string $client_id = ""; + private string $key_set_url = ""; + private string $auth_token_url = ""; + private string $auth_login_url = ""; + private string $auth_server = ""; + private string $tool_private_key = ""; + private string $kid = ""; + + public static function new(): self { return new LTI_Registration(); } - public function get_issuer() { + public function get_issuer(): string { return $this->issuer; } - public function set_issuer($issuer) { + public function set_issuer(string $issuer): self { $this->issuer = $issuer; return $this; } - public function get_client_id() { + public function get_client_id(): string { return $this->client_id; } - public function set_client_id($client_id) { + public function set_client_id(string $client_id): self { $this->client_id = $client_id; return $this; } - public function get_key_set_url() { + public function get_key_set_url(): string { return $this->key_set_url; } - public function set_key_set_url($key_set_url) { + public function set_key_set_url(string $key_set_url): self { $this->key_set_url = $key_set_url; return $this; } - public function get_auth_token_url() { + public function get_auth_token_url(): string { return $this->auth_token_url; } - public function set_auth_token_url($auth_token_url) { + public function set_auth_token_url(string $auth_token_url): self { $this->auth_token_url = $auth_token_url; return $this; } - public function get_auth_login_url() { + public function get_auth_login_url(): string { return $this->auth_login_url; } - public function set_auth_login_url($auth_login_url) { + public function set_auth_login_url(string $auth_login_url): self { $this->auth_login_url = $auth_login_url; return $this; } - public function get_auth_server() { + public function get_auth_server(): string { return empty($this->auth_server) ? $this->auth_token_url : $this->auth_server; } - public function set_auth_server($auth_server) { + public function set_auth_server(string $auth_server): self { $this->auth_server = $auth_server; return $this; } - public function get_tool_private_key() { + public function get_tool_private_key(): string { return $this->tool_private_key; } - public function set_tool_private_key($tool_private_key) { + public function set_tool_private_key(string $tool_private_key): self { $this->tool_private_key = $tool_private_key; return $this; } - public function get_kid() { + public function get_kid(): string { return empty($this->kid) ? hash('sha256', trim($this->issuer . $this->client_id)) : $this->kid; } - public function set_kid($kid) { + public function set_kid(string $kid): self { $this->kid = $kid; return $this; } } - -?> \ No newline at end of file diff --git a/src/lti/LTI_Service_Connector.php b/src/lti/LTI_Service_Connector.php index cd2318e5..45240c32 100644 --- a/src/lti/LTI_Service_Connector.php +++ b/src/lti/LTI_Service_Connector.php @@ -7,14 +7,21 @@ class LTI_Service_Connector { const NEXT_PAGE_REGEX = "/^Link:.*<([^>]*)>; ?rel=\"next\"/i"; - private $registration; - private $access_tokens = []; + private LTI_Registration $registration; + + /** @var array $access_tokens */ + private array $access_tokens = []; public function __construct(LTI_Registration $registration) { $this->registration = $registration; } - public function get_access_token($scopes) { + /** + * Get access token for a given set of scopes. + * @param array $scopes Scopes to look for. + * @return string Access token. + */ + public function get_access_token(array $scopes): string { // Don't fetch the same key more than once. sort($scopes); @@ -45,21 +52,52 @@ public function get_access_token($scopes) { 'scope' => implode(' ', $scopes) ]; + $url = $this->registration->get_auth_token_url(); + if (strlen($url) == 0) { + throw new LTI_Exception("Auth token URL was empty."); + } + // Make request to get auth token $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $this->registration->get_auth_token_url()); - curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($auth_request)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); $resp = curl_exec($ch); + if (!is_string($resp)) { + throw new LTI_Exception("Failed to retrieve access token."); + } $token_data = json_decode($resp, true); curl_close ($ch); return $this->access_tokens[$scope_key] = $token_data['access_token']; } - public function make_service_request($scopes, $method, $url, $body = null, $content_type = 'application/json', $accept = 'application/json') { + /** + * Make a service request. + * @param array $scopes Scopes for the request. + * @param string $method HTTP Method to use. + * @param string $url Target URL for the request. + * @param mixed $body Body for the request. + * @param string $content_type Content type (OPTIONAL, defaults to + * application/json). + * @param string $accept Accept type (OPTIONAL, defaults to + * application/json). + * @return array Array with 'headers' and 'body' keys. + */ + public function make_service_request( + array $scopes, + string $method, + string $url, + mixed $body = null, + string $content_type = 'application/json', + string $accept = 'application/json' + ): array { + if (strlen($url) == 0) { + throw new LTI_Exception("Url can not be empty."); + } + $ch = curl_init(); $headers = [ 'Authorization: Bearer ' . $this->get_access_token($scopes), @@ -67,18 +105,19 @@ public function make_service_request($scopes, $method, $url, $body = null, $cont ]; curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HEADER, 1); + curl_setopt($ch, CURLOPT_HEADER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); if ($method === 'POST') { - curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, strval($body)); $headers[] = 'Content-Type: ' . $content_type; } curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); $response = curl_exec($ch); - if (curl_errno($ch)){ - echo 'Request Error:' . curl_error($ch); + if (!is_string($response)){ + throw new LTI_Exception('Request Error:' . curl_error($ch)); } + $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close ($ch); @@ -90,4 +129,3 @@ public function make_service_request($scopes, $method, $url, $body = null, $cont ]; } } -?> \ No newline at end of file diff --git a/src/lti/Message_Validator.php b/src/lti/Message_Validator.php index d12be20b..9f39a81f 100644 --- a/src/lti/Message_Validator.php +++ b/src/lti/Message_Validator.php @@ -2,7 +2,17 @@ namespace IMSGlobal\LTI; interface Message_Validator { - public function validate($jwt_body); - public function can_validate($jwt_body); + /** + * Validate a message. + * @param array $jwt_body Body of the JWT in the message. + * @return bool TRUE for valid messages. + */ + public function validate(array $jwt_body): bool; + + /** + * Determine if a message is sufficiently well-formed that it can be validated. + * @param array $jwt_body Body of the JWT in the message. + * @return bool TRUE for messages that can be validated. + */ + public function can_validate(array $jwt_body): bool; } -?> \ No newline at end of file diff --git a/src/lti/OIDC_Exception.php b/src/lti/OIDC_Exception.php index d492f4d2..9efcce64 100644 --- a/src/lti/OIDC_Exception.php +++ b/src/lti/OIDC_Exception.php @@ -4,4 +4,3 @@ class OIDC_Exception extends \Exception { } -?> \ No newline at end of file diff --git a/src/lti/Redirect.php b/src/lti/Redirect.php index 647e8f44..24362aa0 100644 --- a/src/lti/Redirect.php +++ b/src/lti/Redirect.php @@ -3,36 +3,60 @@ class Redirect { - private $location; - private $referer_query; - private static $CAN_302_COOKIE = 'LTI1p3_302_Redirect'; + private string $location; + private ?string $referer_query = null; + private static string $CAN_302_COOKIE = 'LTI1p3_302_Redirect'; - public function __construct($location, $referer_query = null) { + /** + * Class constructor. + * @param string $location Target location for redirect. + * @param string|null $referer_query Query parameters to add when redirecting (OPTIONAL) + */ + public function __construct( + string $location, + ?string $referer_query = null + ) { $this->location = $location; $this->referer_query = $referer_query; } - public function do_redirect() { + /** + * Perform a 302 redirect. + */ + public function do_redirect(): void { header('Location: ' . $this->location, true, 302); die; } - public function do_hybrid_redirect(Cookie $cookie = null) { + /** + * Redirect using a 302 if possible and falling back to a Javascript + * redirect otherwise. + * @param Cookie|null $cookie Cookie to store redirect information (OPTIONAL). + */ + public function do_hybrid_redirect(?Cookie $cookie = null): void { if ($cookie == null) { $cookie = new Cookie(); } if (!empty($cookie->get_cookie(self::$CAN_302_COOKIE))) { - return $this->do_redirect(); + $this->do_redirect(); + return; } $cookie->set_cookie(self::$CAN_302_COOKIE, "true"); $this->do_js_redirect(); } - public function get_redirect_url() { + /** + * Get redirect url. + * @return string Redirect url. + */ + public function get_redirect_url(): string { return $this->location; } - public function do_js_redirect() { + /** + * Output HTML/JS for a Javascript-based redirect. + */ + public function do_js_redirect(): void { ?> If you are not automatically redirected, click here to continue \ No newline at end of file diff --git a/src/lti/lti.php b/src/lti/lti.php index 67d9a04c..47e501a0 100644 --- a/src/lti/lti.php +++ b/src/lti/lti.php @@ -1,8 +1,16 @@ \ No newline at end of file diff --git a/src/lti/message_validators/deep_link_message_validator.php b/src/lti/message_validators/deep_link_message_validator.php index eefb746a..3456431c 100644 --- a/src/lti/message_validators/deep_link_message_validator.php +++ b/src/lti/message_validators/deep_link_message_validator.php @@ -2,11 +2,11 @@ namespace IMSGlobal\LTI; class Deep_Link_Message_Validator implements Message_Validator { - public function can_validate($jwt_body) { + public function can_validate(array $jwt_body): bool { return $jwt_body['https://purl.imsglobal.org/spec/lti/claim/message_type'] === 'LtiDeepLinkingRequest'; } - public function validate($jwt_body) { + public function validate(array $jwt_body): bool { if (empty($jwt_body['sub'])) { throw new LTI_Exception('Must have a user (sub)'); } @@ -33,4 +33,3 @@ public function validate($jwt_body) { return true; } } -?> \ No newline at end of file diff --git a/src/lti/message_validators/resource_message_validator.php b/src/lti/message_validators/resource_message_validator.php index 22521167..81e52be0 100644 --- a/src/lti/message_validators/resource_message_validator.php +++ b/src/lti/message_validators/resource_message_validator.php @@ -2,11 +2,11 @@ namespace IMSGlobal\LTI; class Resource_Message_Validator implements Message_Validator { - public function can_validate($jwt_body) { + public function can_validate(array $jwt_body): bool { return $jwt_body['https://purl.imsglobal.org/spec/lti/claim/message_type'] === 'LtiResourceLinkRequest'; } - public function validate($jwt_body) { + public function validate(array $jwt_body): bool { if (empty($jwt_body['sub'])) { throw new LTI_Exception('Must have a user (sub)'); } @@ -23,4 +23,3 @@ public function validate($jwt_body) { return true; } } -?> \ No newline at end of file diff --git a/src/lti/message_validators/submission_review_message_validator.php b/src/lti/message_validators/submission_review_message_validator.php index 60cc4914..4ef02214 100644 --- a/src/lti/message_validators/submission_review_message_validator.php +++ b/src/lti/message_validators/submission_review_message_validator.php @@ -2,11 +2,11 @@ namespace IMSGlobal\LTI; class Submission_Review_Message_Validator implements Message_Validator { - public function can_validate($jwt_body) { + public function can_validate(array $jwt_body): bool { return $jwt_body['https://purl.imsglobal.org/spec/lti/claim/message_type'] === 'LtiSubmissionReviewRequest'; } - public function validate($jwt_body) { + public function validate(array $jwt_body): bool { if (empty($jwt_body['sub'])) { throw new LTI_Exception('Must have a user (sub)'); } @@ -26,4 +26,3 @@ public function validate($jwt_body) { return true; } } -?> \ No newline at end of file diff --git a/tests/LTI_Deep_Link_Resource_Test.php b/tests/LTI_Deep_Link_Resource_Test.php new file mode 100644 index 00000000..3350684e --- /dev/null +++ b/tests/LTI_Deep_Link_Resource_Test.php @@ -0,0 +1,61 @@ +assertEquals( + "ltiResourceLink", + $deep_link->get_type() + ); + $this->assertEquals( + 'iframe', + $deep_link->get_target() + ); + $this->assertEquals( + [], + $deep_link->get_custom_params() + ); + $this->assertNull($deep_link->get_lineitem()); + + $deep_link->set_title("Test Title"); + $this->assertEquals( + "Test Title", + $deep_link->get_title() + ); + + $deep_link->set_url("http://issuer.example/test_url"); + $this->assertEquals( + "http://issuer.example/test_url", + $deep_link->get_url() + ); + + $ExpectedArray = [ + "type" => "ltiResourceLink", + "title" => "Test Title", + "url" => "http://issuer.example/test_url", + "presentation" => [ + "documentTarget" => "iframe" + ] + ]; + $this->assertEquals( + $ExpectedArray, + $deep_link->to_array() + ); + + $deep_link->set_custom_params([ + "X-KEY-X" => "X-VAL-X", + ]); + + $ExpectedArray["custom"] = [ + "X-KEY-X" => "X-VAL-X", + ]; + $this->assertEquals( + $ExpectedArray, + $deep_link->to_array() + ); + } +} diff --git a/tests/LTI_Deployment_Test.php b/tests/LTI_Deployment_Test.php new file mode 100644 index 00000000..a16fb427 --- /dev/null +++ b/tests/LTI_Deployment_Test.php @@ -0,0 +1,17 @@ +set_deployment_id("X-DEPLOYMENT-ID-X"); + + $this->assertEquals( + "X-DEPLOYMENT-ID-X", + $deployment->get_deployment_id() + ); + } +} diff --git a/tests/LTI_Grade_Submission_Review_Test.php b/tests/LTI_Grade_Submission_Review_Test.php new file mode 100644 index 00000000..08e982ce --- /dev/null +++ b/tests/LTI_Grade_Submission_Review_Test.php @@ -0,0 +1,38 @@ +set_reviewable_status("status") + ->set_label("label") + ->set_url("https://issuer.example") + ->set_custom("custom"); + + $this->assertEquals( + "status", + $gsr->get_reviewable_status() + ); + $this->assertEquals( + "label", + $gsr->get_label() + ); + $this->assertEquals( + "https://issuer.example", + $gsr->get_url() + ); + $this->assertEquals( + "custom", + $gsr->get_custom() + ); + + $ExpectedString = + '{"reviewableStatus":"status","label":"label",' + .'"url":"https:\/\/issuer.example","custom":"custom"}'; + $this->assertEquals($ExpectedString, (string)$gsr); + } +} diff --git a/tests/LTI_Grade_Test.php b/tests/LTI_Grade_Test.php new file mode 100644 index 00000000..2a6873e9 --- /dev/null +++ b/tests/LTI_Grade_Test.php @@ -0,0 +1,60 @@ +set_score_given("90") + ->set_score_maximum("100") + ->set_comment("boo!") + ->set_activity_progress("activity complete") + ->set_grading_progress("grading complete") + ->set_timestamp("2025-01-01T12:00:00") + ->set_user_id("1000") + ->set_submission_review("review complete"); + + $this->assertEquals( + "90", + $grade->get_score_given() + ); + $this->assertEquals( + "100", + $grade->get_score_maximum() + ); + $this->assertEquals( + "boo!", + $grade->get_comment() + ); + $this->assertEquals( + "activity complete", + $grade->get_activity_progress() + ); + $this->assertEquals( + "grading complete", + $grade->get_grading_progress() + ); + $this->assertEquals( + "2025-01-01T12:00:00", + $grade->get_timestamp() + ); + $this->assertEquals( + "1000", + $grade->get_user_id() + ); + $this->assertEquals( + "review complete", + $grade->get_submission_review() + ); + + $ExpectedString = + '{"scoreGiven":"90","scoreMaximum":"100","comment":"boo!",' + .'"activityProgress":"activity complete","gradingProgress":"grading complete",' + .'"timestamp":"2025-01-01T12:00:00","userId":"1000",' + .'"submissionReview":"review complete"}'; + $this->assertEquals((string)$grade, $ExpectedString); + } +} diff --git a/tests/LTI_Lineitem_Test.php b/tests/LTI_Lineitem_Test.php new file mode 100644 index 00000000..ff9ab77a --- /dev/null +++ b/tests/LTI_Lineitem_Test.php @@ -0,0 +1,55 @@ +set_id("X-ID-X") + ->set_label("X-LABEL-X") + ->set_score_maximum("100") + ->set_resource_id("X-RESOURCE-ID-X") + ->set_tag("X-TAG-X") + ->set_start_date_time("2025-01-01T12:00:00") + ->set_end_date_time("2025-01-02T12:00:00"); + + $this->assertEquals( + "X-ID-X", + $item->get_id() + ); + $this->assertEquals( + "X-LABEL-X", + $item->get_label() + ); + $this->assertEquals( + "100", + $item->get_score_maximum() + ); + $this->assertEquals( + "X-RESOURCE-ID-X", + $item->get_resource_id() + ); + $this->assertEquals( + "X-TAG-X", + $item->get_tag() + ); + $this->assertEquals( + "2025-01-01T12:00:00", + $item->get_start_date_time() + ); + $this->assertEquals( + "2025-01-02T12:00:00", + $item->get_end_date_time() + ); + + $ExpectedString = + '{"id":"X-ID-X","scoreMaximum":"100","label":"X-LABEL-X",' + .'"resourceId":"X-RESOURCE-ID-X","tag":"X-TAG-X",' + .'"startDateTime":"2025-01-01T12:00:00",' + .'"endDateTime":"2025-01-02T12:00:00"}'; + $this->assertEquals($ExpectedString, (string)$item); + } +} diff --git a/tests/LTI_Message_Launch_Test.php b/tests/LTI_Message_Launch_Test.php new file mode 100644 index 00000000..d4e9c051 --- /dev/null +++ b/tests/LTI_Message_Launch_Test.php @@ -0,0 +1,287 @@ +cookie; + } + + public static function setup() { + # create key for tests if we do not yet have one + if (!file_exists(__DIR__."/lms.key")) { + exec("openssl genrsa -out ".__DIR__."/lms.key 2>/dev/null"); + } + + self::$testPrivateKey = openssl_pkey_get_private( + "file://".__DIR__."/lms.key" + ); + + self::$testKid = uniqid("kid_", true); + } + + public static function encodeJWT(array $payload) { + return JWT::encode( + $payload, + self::$testPrivateKey, + "RS256", + self::$testKid + ); + } + + protected function get_public_key(): array { + static $KeyDetails = null; + if (is_null($KeyDetails)) { + $KeyDetails = openssl_pkey_get_details( + self::$testPrivateKey + ); + } + + return $KeyDetails; + } + + private static $testPrivateKey = null; + private static $testKid = null; +} + +class LTI_Message_Launch_Test extends \PHPUnit\Framework\TestCase +{ + public function test() + { + TestMessage::setup(); + + $Cache = new TestCache(); + + $this->Launch = TestMessage::new( + new TestDatabase(), + $Cache, + new TestCookie() + ); + + $State = $this->Launch->get_launch_id(); + + $this->assertIsString($State); + + $this->Launch->getCookie()->set_cookie( + 'lti1p3_'.$State, + $State, + 60 + ); + + $this->validateLaunch( + [], + "State not provided in request" + ); + + $this->validateLaunch( + [ + "state" => "X-INVALID-X" + ], + "State not found: X-INVALID-X" + ); + + + $this->validateLaunch( + [ + "state" => $State + ], + "Missing id_token" + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => "X-INVALID-X", + ], + "Invalid id_token, JWT must contain 3 parts" + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([]), + ], + "No nonce provided" + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => "X-NONCE-X", + ]), + ], + "No iss provided" + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => "X-NONCE-X", + "iss" => "X-ISSUER-X", + ]), + ], + "No aud provided" + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => "X-NONCE-X", + "iss" => "X-ISSUER-X", + "aud" => "X-CLIENT-ID-X", + ]), + ], + "Registration not found", + + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => "X-NONCE-X", + "iss" => "https://issuer.example", + "aud" => "X-INVALID-CLIENT-ID-X", + ]), + ], + "Client id not registered for this issuer" + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => "X-NONCE-X", + "iss" => "https://issuer.example", + "aud" => "X-CLIENT-ID-X", + ])."xx", + ], + "Invalid signature on id_token", + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => "X-NONCE-X", + "iss" => "https://issuer.example", + "aud" => "X-CLIENT-ID-X", + ]), + ], + "No deployment provided", + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => "X-NONCE-X", + "iss" => "https://issuer.example", + "aud" => "X-CLIENT-ID-X", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id" => + "X-INVALID-DEPLOYMENT-ID-X", + ]), + ], + "Unable to find deployment" + ); + + $this->validateLaunch( + [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => "X-NONCE-X", + "iss" => "https://issuer.example", + "aud" => "X-CLIENT-ID-X", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id" => + "X-DEPLOYMENT-ID-X", + ]), + ], + "Invalid message type" + ); + + # construct a valid LtiResourceLinkRequest + $Nonce = uniqid("nonce-", true); + $Message = [ + "state" => $State, + "id_token" => TestMessage::encodeJWT([ + "nonce" => $Nonce, + "iss" => "https://issuer.example", + "aud" => "X-CLIENT-ID-X", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id" => + "X-DEPLOYMENT-ID-X", + "https://purl.imsglobal.org/spec/lti/claim/message_type" => + "LtiResourceLinkRequest", + "sub" => + "X-USER-X", + "https://purl.imsglobal.org/spec/lti/claim/version" => + "1.3.0", + "https://purl.imsglobal.org/spec/lti/claim/roles" => + "X-ROLES-X", + "https://purl.imsglobal.org/spec/lti/claim/resource_link" => [ + "id" => "X-RESOURCE-ID-X", + ] + ]), + ]; + + # should fail if the nonce is unknown + $this->validateLaunch($Message, "Invalid nonce"); + + # should pass if the nonce is known but has not been used + $Cache->cache_nonce($Nonce); + $this->validateLaunch($Message); + + $this->assertTrue( + $this->Launch->is_resource_launch() + ); + $this->assertFalse( + $this->Launch->is_deep_link_launch() + ); + $this->assertFalse( + $this->Launch->is_submission_review_launch() + ); + + $this->assertFalse( + $this->Launch->has_nrps() + ); + $this->assertFalse( + $this->Launch->has_gs() + ); + $this->assertFalse( + $this->Launch->has_ags() + ); + + # replay should fail + $this->validateLaunch($Message, "Invalid nonce"); + } + + private function validateLaunch( + array $Request, + string $ExceptionMessage = null + ): void { + try { + $this->Launch->validate($Request); + if ($ExceptionMessage !== null) { + $this->fail("Exception not thrown when one was expected."); + } + } catch (LTI_Exception $Ex) { + if ($ExceptionMessage === null) { + $this->fail( + "Exception thrown when none was expected." + ); + return; + } + + $this->assertEquals($ExceptionMessage, $Ex->getMessage()); + } + } + + private TestMessage $Launch; +} diff --git a/tests/LTI_OIDC_Login_Test.php b/tests/LTI_OIDC_Login_Test.php new file mode 100644 index 00000000..385dd78d --- /dev/null +++ b/tests/LTI_OIDC_Login_Test.php @@ -0,0 +1,99 @@ +do_oidc_login_redirect("", []); + $this->fail( + "Exception not thrown." + ); + } catch (OIDC_Exception $Ex) { + $this->assertEquals( + "No launch URL configured", + $Ex->getMessage() + ); + } + + try { + $login->do_oidc_login_redirect( + "https://tool.example/launch", + [] + ); + } catch (OIDC_Exception $Ex) { + $this->assertEquals( + "Could not find issuer", + $Ex->getMessage() + ); + } + + try { + $login->do_oidc_login_redirect( + "https://tool.example/launch", + [ + "iss" => "https://issuer.example", + ] + ); + } catch (OIDC_Exception $Ex) { + $this->assertEquals( + "Could not find login hint", + $Ex->getMessage() + ); + } + + try { + $login->do_oidc_login_redirect( + "https://tool.example/launch", + [ + "iss" => "https://issuer.invalid", + "login_hint" => "X-LOGIN-HINT-X", + "client_id" => "X-CLIENT-ID-X", + ] + ); + } catch (OIDC_Exception $Ex) { + $this->assertEquals( + "Could not find registration details", + $Ex->getMessage() + ); + } + + $result = $login->do_oidc_login_redirect( + "https://tool.example/launch", + [ + "iss" => "https://issuer.example", + "login_hint" => "X-LOGIN-HINT-X", + "client_id" => "X-CLIENT-ID-X", + ] + ); + $this->assertInstanceOf( + 'IMSGlobal\LTI\Redirect', + $result + ); + + $result = $login->do_oidc_login_redirect( + "https://tool.example/launch", + [ + "iss" => "https://issuer.example", + "login_hint" => "X-LOGIN-HINT-X", + "client_id" => "X-CLIENT-ID-X", + "lti_message_hint" => "X-LTI-MESSAGE-HINT-X", + ] + ); + $this->assertInstanceOf( + 'IMSGlobal\LTI\Redirect', + $result + ); + } +} diff --git a/tests/LTI_Registration_Test.php b/tests/LTI_Registration_Test.php new file mode 100644 index 00000000..4e837aef --- /dev/null +++ b/tests/LTI_Registration_Test.php @@ -0,0 +1,65 @@ +set_issuer("https://issuer.example") + ->set_client_id("X-TEST-CLIENT-ID-X") + ->set_key_set_url("https://issuer.example/keyset") + ->set_auth_token_url("https://issuer.example/auth_token") + ->set_auth_login_url("https://issuer.example/login") + ->set_tool_private_key("X-TEST-PRIVATE-KEY-X"); + + $this->assertEquals( + "https://issuer.example", + $registration->get_issuer() + ); + $this->assertEquals( + "X-TEST-CLIENT-ID-X", + $registration->get_client_id() + ); + $this->assertEquals( + "https://issuer.example/keyset", + $registration->get_key_set_url() + + ); + $this->assertEquals( + "https://issuer.example/auth_token", + $registration->get_auth_token_url() + ); + $this->assertEquals( + "https://issuer.example/login", + $registration->get_auth_login_url() + ); + $this->assertEquals( + "X-TEST-PRIVATE-KEY-X", + $registration->get_tool_private_key() + ); + $this->assertEquals( + '4a32c86c8b616875153795f79550891b2a0b683dd9c403a1d135bd45b0137ae8', + $registration->get_kid() + ); + + $this->assertEquals( + "https://issuer.example/auth_token", + $registration->get_auth_server() + ); + + $registration->set_auth_server("https://issuer.example/auth_server"); + $this->assertEquals( + "https://issuer.example/auth_server", + $registration->get_auth_server() + ); + + $registration->set_kid("X-TEST-KID-X"); + $this->assertEquals( + "X-TEST-KID-X", + $registration->get_kid() + ); + } +} diff --git a/tests/TestCache.php b/tests/TestCache.php new file mode 100644 index 00000000..37d28594 --- /dev/null +++ b/tests/TestCache.php @@ -0,0 +1,34 @@ +cache[$key]; + } + + public function cache_launch_data(string $key, array $jwt_body): self { + $this->cache[$key] = $jwt_body; + return $this; + } + + public function cache_nonce(string $nonce): self { + $this->cache['nonce'][$nonce] = false; + return $this; + } + + public function check_nonce(string $nonce): bool { + if ($nonce == "X-NONCE-X") { + return true; + } + if (!isset($this->cache['nonce'][$nonce])) { + return false; + } + if ($this->cache['nonce'][$nonce]) { + return false; + } + $this->cache['nonce'][$nonce] = true; + return true; + } +} diff --git a/tests/TestCookie.php b/tests/TestCookie.php new file mode 100644 index 00000000..587a0dbb --- /dev/null +++ b/tests/TestCookie.php @@ -0,0 +1,19 @@ +cookies[$name])) { + return $this->cookies[$name]; + } + return false; + } + + public function set_cookie(string $name, string $value, int $exp = 3600, array $options = []): self { + $this->cookies[$name] = $value; + + return $this; + } + + private $cookies = []; +} diff --git a/tests/TestDatabase.php b/tests/TestDatabase.php new file mode 100644 index 00000000..9133b656 --- /dev/null +++ b/tests/TestDatabase.php @@ -0,0 +1,32 @@ +set_client_id("X-CLIENT-ID-X") + ->set_auth_login_url("https://issuer.example/login"); + return $result; + } + + return null; + } + + public function find_deployment( + string $iss, + string $deployment_id + ): ?LTI_Deployment { + if ($iss == "https://issuer.example" && + $deployment_id == "X-DEPLOYMENT-ID-X") { + return LTI_Deployment::new() + ->set_deployment_id("X-DEPLOYMENT-ID-X"); + } + + return null; + } +} diff --git a/tests/Validator_DeepLink_Test.php b/tests/Validator_DeepLink_Test.php new file mode 100644 index 00000000..0b14a08c --- /dev/null +++ b/tests/Validator_DeepLink_Test.php @@ -0,0 +1,92 @@ +Validator = new Deep_Link_Message_Validator(); + + $Message = [ + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => + "X-INVALID-X", + ]; + $this->assertFalse( + $this->Validator->can_validate($Message) + ); + + $Message = [ + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => + "LtiDeepLinkingRequest", + ]; + $this->assertTrue( + $this->Validator->can_validate($Message) + ); + + $this->validateMessage($Message, "Must have a user (sub)"); + + $Message += [ + 'sub' => + "X-USER-X", + 'https://purl.imsglobal.org/spec/lti/claim/version' => + "X-INVALID-X", + ]; + + $this->validateMessage($Message, "Incorrect version, expected 1.3.0"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/version'] = "1.3.0"; + + $this->validateMessage($Message, "Missing Roles Claim"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/roles'] = + "X-ROLES-X"; + + $this->validateMessage($Message, "Missing Deep Linking Settings"); + + $Message['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'] = [ + 'test' + ]; + + $this->validateMessage($Message, "Missing Deep Linking Return URL"); + + $Message['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'] = [ + 'deep_link_return_url' => 'https://issuer.example/dl_return_url' + ]; + + $this->validateMessage($Message, "Must support resource link placement types"); + $Message['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'] += [ + 'accept_types' => ['ltiResourceLink'], + ]; + $this->validateMessage($Message, "Must support a presentation type"); + + $Message['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'] += [ + 'accept_presentation_document_targets' => "X-DOC-TARGETS-X", + ]; + + $this->assertTrue( + $this->Validator->validate($Message) + ); + + } + + private function validateMessage(array $Message, string $ExceptionMessage = null) + { + try { + $this->Validator->validate($Message); + if ($ExceptionMessage !== null) { + $this->fail("Exception not thrown when one was expected."); + } + } catch (LTI_Exception $Ex) { + if ($ExceptionMessage === null) { + $this->fail( + "Exception thrown when none was expected." + ); + return; + } + + $this->assertEquals($ExceptionMessage, $Ex->getMessage()); + } + } + + private Deep_Link_Message_Validator $Validator; +} diff --git a/tests/Validator_Resource_Test.php b/tests/Validator_Resource_Test.php new file mode 100644 index 00000000..ef45b05e --- /dev/null +++ b/tests/Validator_Resource_Test.php @@ -0,0 +1,74 @@ +Validator = new Resource_Message_Validator(); + + $Message = [ + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => + "X-INVALID-X", + ]; + $this->assertFalse( + $this->Validator->can_validate($Message) + ); + + $Message = [ + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => + "LtiResourceLinkRequest", + ]; + $this->assertTrue( + $this->Validator->can_validate($Message) + ); + + $this->validateMessage($Message, "Must have a user (sub)"); + + $Message += [ + 'sub' => + "X-USER-X", + 'https://purl.imsglobal.org/spec/lti/claim/version' => + "X-INVALID-X", + ]; + + $this->validateMessage($Message, "Incorrect version, expected 1.3.0"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/version'] = "1.3.0"; + + $this->validateMessage($Message, "Missing Roles Claim"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/roles'] = + "X-ROLES-X"; + $this->validateMessage($Message, "Missing Resource Link Id"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/resource_link'] = [ + 'id' => "X-LINK-ID-X", + ]; + $this->assertTrue( + $this->Validator->validate($Message) + ); + + } + + private function validateMessage(array $Message, string $ExceptionMessage = null) + { + try { + $this->Validator->validate($Message); + if ($ExceptionMessage !== null) { + $this->fail("Exception not thrown when one was expected."); + } + } catch (LTI_Exception $Ex) { + if ($ExceptionMessage === null) { + $this->fail( + "Exception thrown when none was expected." + ); + return; + } + + $this->assertEquals($ExceptionMessage, $Ex->getMessage()); + } + } + + private resource_Message_Validator $Validator; +} diff --git a/tests/Validator_SubmissionReview_Test.php b/tests/Validator_SubmissionReview_Test.php new file mode 100644 index 00000000..855a0ab7 --- /dev/null +++ b/tests/Validator_SubmissionReview_Test.php @@ -0,0 +1,81 @@ +Validator = new Submission_Review_Message_Validator(); + + $Message = [ + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => + "X-INVALID-X", + ]; + $this->assertFalse( + $this->Validator->can_validate($Message) + ); + + $Message = [ + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => + "LtiSubmissionReviewRequest", + ]; + $this->assertTrue( + $this->Validator->can_validate($Message) + ); + + $this->validateMessage($Message, "Must have a user (sub)"); + + $Message += [ + 'sub' => + "X-USER-X", + 'https://purl.imsglobal.org/spec/lti/claim/version' => + "X-INVALID-X", + ]; + + $this->validateMessage($Message, "Incorrect version, expected 1.3.0"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/version'] = "1.3.0"; + + $this->validateMessage($Message, "Missing Roles Claim"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/roles'] = + "X-ROLES-X"; + + $this->validateMessage($Message, "Missing Resource Link Id"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/resource_link'] = [ + 'id' => "X-LINK-ID-X", + ]; + + $this->validateMessage($Message, "Missing For User"); + + $Message['https://purl.imsglobal.org/spec/lti/claim/for_user'] = + "X-USER-X"; + + $this->assertTrue( + $this->Validator->validate($Message) + ); + + } + + private function validateMessage(array $Message, string $ExceptionMessage = null) + { + try { + $this->Validator->validate($Message); + if ($ExceptionMessage !== null) { + $this->fail("Exception not thrown when one was expected."); + } + } catch (LTI_Exception $Ex) { + if ($ExceptionMessage === null) { + $this->fail( + "Exception thrown when none was expected." + ); + return; + } + + $this->assertEquals($ExceptionMessage, $Ex->getMessage()); + } + } + + private Submission_Review_Message_Validator $Validator; +}