MDL-69542 libraries: Add the LTI 1.3 library
This commit is contained in:
@@ -99,6 +99,7 @@ class core_component {
|
||||
'MatthiasMullie\\Minify' => 'lib/minify/matthiasmullie-minify/src/',
|
||||
'MatthiasMullie\\PathConverter' => 'lib/minify/matthiasmullie-pathconverter/src/',
|
||||
'IMSGlobal\LTI' => 'lib/ltiprovider/src',
|
||||
'Packback\\Lti1p3' => 'lib/lti1p3/src',
|
||||
'Phpml' => 'lib/mlbackend/php/phpml/src/Phpml',
|
||||
'PHPMailer\\PHPMailer' => 'lib/phpmailer/src',
|
||||
'RedeyeVentures\\GeoPattern' => 'lib/geopattern-php/GeoPattern',
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2018 Turnitin, LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,18 @@
|
||||
# LTI 1.3 Tool Library
|
||||
|
||||
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.1.0 of the packbackbooks/lti-1-3-php-library](https://github.com/packbackbooks/lti-1-3-php-library/releases/tag/v5.1.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.
|
||||
* Replace the Guzzle dependency with generic HTTP client interfaces for client, response, exception.
|
||||
* Small fix to http_build_query() calls, which now explicitly include the '&' arg separator param, for compatibility with applications that override PHP's arg_separator.output value via an ini_set() call, like Moodle does.
|
||||
* Upgrade the firebase/php-jwt requirement from ^5.2 to ^6.0, to match Moodle's shipped version of the lib. This change can be dropped when [this upstream issue](https://github.com/packbackbooks/lti-1-3-php-library/pull/46) is merged.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
LTI 1.3 Tool Library import instructions
|
||||
|
||||
This library is a patched for use in Moodle - it requires the following changes be applied on top of its upstream base:
|
||||
1. Removal of phpseclib dependency (replaces a single call with openssl equivalent)
|
||||
2. Removal of Guzzle dependency (replaced with generic http client interfaces which are more compatible with Moodle's curl.)
|
||||
3. Small fix to http_build_query() usages, to make sure the arg separator is explicitly set to '&', so as not to trip up
|
||||
on Moodle's definition of PHP's arg_separator.output which is set to '&' in lib/setup.php.
|
||||
4. Update the firebase/php-jwt requirement from ^5.2 to ^6.0, to match the version shipped with Moodle. This can be removed
|
||||
when the upstream PR is merged: https://github.com/packbackbooks/lti-1-3-php-library/pull/46
|
||||
|
||||
To upgrade to a new version of this library:
|
||||
1. Clone the latest version of the upstream library from github:
|
||||
https://github.com/packbackbooks/lti-1-3-php-library/tags
|
||||
2. Apply the changes mentioned above from the moodle-fixes branch of this repository:
|
||||
https://github.com/snake/lti-1-3-php-library/tree/moodle-fixes
|
||||
Apply these commits on top of the upstream clone.
|
||||
3. Copy the entire src/ directory to lib/lti1p3/
|
||||
4. Copy LICENSE.md to lib/lti1p3/
|
||||
5. Copy README.me to lib/lti1p3/
|
||||
6. Update the dependency note in lib/php-jwt/readme_moodle.txt, listing the correct version of this library there.
|
||||
7. Check the upstream library's release notes and UPGRADES.md for any backwards incompatible changes to names, etc.
|
||||
Moodle's calling code may require updates if changes are breaking - so check this and make any changes if needed.
|
||||
8. Run all unit tests in enrol/lti and auth/lti.
|
||||
9. Regression test Moodle-to-Moodle LTI using LTI Advantage (not legacy) using the relevant MDLQA tests as a guide.
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Helpers;
|
||||
|
||||
class Helpers
|
||||
{
|
||||
/**
|
||||
* @param $value
|
||||
*/
|
||||
public static function checkIfNullValue($value): bool
|
||||
{
|
||||
return !is_null($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\ImsStorage;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\ICache;
|
||||
|
||||
class ImsCache implements ICache
|
||||
{
|
||||
private $cache;
|
||||
|
||||
public function getLaunchData(string $key): ?array
|
||||
{
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\ImsStorage;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\ICookie;
|
||||
|
||||
class ImsCookie implements ICookie
|
||||
{
|
||||
public function getCookie(string $name): ?string
|
||||
{
|
||||
if (isset($_COOKIE[$name])) {
|
||||
return $_COOKIE[$name];
|
||||
}
|
||||
// Look for backup cookie if same site is not supported by the user's browser.
|
||||
if (isset($_COOKIE['LEGACY_'.$name])) {
|
||||
return $_COOKIE['LEGACY_'.$name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function setCookie(string $name, string $value, $exp = 3600, $options = []): void
|
||||
{
|
||||
$cookie_options = [
|
||||
'expires' => 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface ICache
|
||||
{
|
||||
public function getLaunchData(string $key): ?array;
|
||||
|
||||
public function cacheLaunchData(string $key, array $jwtBody): void;
|
||||
|
||||
public function cacheNonce(string $nonce, string $state): void;
|
||||
|
||||
public function checkNonceIsValid(string $nonce, string $state): bool;
|
||||
|
||||
public function cacheAccessToken(string $key, string $accessToken): void;
|
||||
|
||||
public function getAccessToken(string $key): ?string;
|
||||
|
||||
public function clearAccessToken(string $key): void;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface ICookie
|
||||
{
|
||||
public function getCookie(string $name): ?string;
|
||||
|
||||
public function setCookie(string $name, string $value, $exp = 3600, $options = []): void;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface IDatabase
|
||||
{
|
||||
public function findRegistrationByIssuer($iss, $clientId = null);
|
||||
|
||||
public function findDeployment($iss, $deploymentId, $clientId = null);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
Interface IHttpClient
|
||||
{
|
||||
public function request(string $method, string $url, array $options) : IHttpResponse;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface IHttpException extends \Throwable
|
||||
{
|
||||
public function getResponse(): IHttpResponse;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface IHttpResponse
|
||||
{
|
||||
public function getBody();
|
||||
public function getHeaders(): array;
|
||||
public function getStatusCode(): int;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface ILtiRegistration
|
||||
{
|
||||
public function getIssuer();
|
||||
|
||||
public function setIssuer($issuer);
|
||||
|
||||
public function getClientId();
|
||||
|
||||
public function setClientId($clientId);
|
||||
|
||||
public function getKeySetUrl();
|
||||
|
||||
public function setKeySetUrl($keySetUrl);
|
||||
|
||||
public function getAuthTokenUrl();
|
||||
|
||||
public function setAuthTokenUrl($authTokenUrl);
|
||||
|
||||
public function getAuthLoginUrl();
|
||||
|
||||
public function setAuthLoginUrl($authLoginUrl);
|
||||
|
||||
public function getAuthServer();
|
||||
|
||||
public function setAuthServer($authServer);
|
||||
|
||||
public function getToolPrivateKey();
|
||||
|
||||
public function setToolPrivateKey($toolPrivateKey);
|
||||
|
||||
public function getKid();
|
||||
|
||||
public function setKid($kid);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface ILtiServiceConnector
|
||||
{
|
||||
public function getAccessToken(ILtiRegistration $registration, array $scopes);
|
||||
|
||||
public function makeRequest(IServiceRequest $request);
|
||||
|
||||
public function getResponseBody(IHttpResponse $response): ?array;
|
||||
|
||||
public function makeServiceRequest(
|
||||
ILtiRegistration $registration,
|
||||
array $scopes,
|
||||
IServiceRequest $request,
|
||||
bool $shouldRetry = true
|
||||
): array;
|
||||
|
||||
public function getAll(
|
||||
ILtiRegistration $registration,
|
||||
array $scopes,
|
||||
IServiceRequest $request,
|
||||
string $key
|
||||
): array;
|
||||
|
||||
public function setDebuggingMode(bool $enable): void;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface IMessageValidator
|
||||
{
|
||||
public function validate(array $jwtBody);
|
||||
|
||||
public function canValidate(array $jwtBody);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\Interfaces;
|
||||
|
||||
interface IServiceRequest
|
||||
{
|
||||
public function getMethod(): string;
|
||||
|
||||
public function getUrl(): string;
|
||||
|
||||
public function getPayload(): array;
|
||||
|
||||
public function setUrl(string $url): self;
|
||||
|
||||
public function setAccessToken(string $accessToken): self;
|
||||
|
||||
public function setBody(string $body): self;
|
||||
|
||||
public function setAccept(string $accept): self;
|
||||
|
||||
public function setContentType(string $contentType): self;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Packback\Lti1p3\Interfaces\IDatabase;
|
||||
use Packback\Lti1p3\Interfaces\ILtiRegistration;
|
||||
|
||||
class JwksEndpoint
|
||||
{
|
||||
private $keys;
|
||||
|
||||
public function __construct(array $keys)
|
||||
{
|
||||
$this->keys = $keys;
|
||||
}
|
||||
|
||||
public static function new(array $keys)
|
||||
{
|
||||
return new JwksEndpoint($keys);
|
||||
}
|
||||
|
||||
public static function fromIssuer(IDatabase $database, $issuer)
|
||||
{
|
||||
$registration = $database->findRegistrationByIssuer($issuer);
|
||||
|
||||
return new JwksEndpoint([$registration->getKid() => $registration->getToolPrivateKey()]);
|
||||
}
|
||||
|
||||
public static function fromRegistration(ILtiRegistration $registration)
|
||||
{
|
||||
return new JwksEndpoint([$registration->getKid() => $registration->getToolPrivateKey()]);
|
||||
}
|
||||
|
||||
public function getPublicJwks()
|
||||
{
|
||||
$jwks = [];
|
||||
foreach ($this->keys as $kid => $private_key) {
|
||||
$key_res = openssl_pkey_get_private($private_key);
|
||||
$key_details = openssl_pkey_get_details($key_res);
|
||||
$components = [
|
||||
'kty' => 'RSA',
|
||||
'alg' => 'RS256',
|
||||
'use' => 'sig',
|
||||
'e' => JWT::urlsafeB64Encode($key_details['rsa']['e']),
|
||||
'n' => JWT::urlsafeB64Encode($key_details['rsa']['n']),
|
||||
'kid' => $kid,
|
||||
];
|
||||
$jwks[] = $components;
|
||||
}
|
||||
|
||||
return ['keys' => $jwks];
|
||||
}
|
||||
|
||||
public function outputJwks()
|
||||
{
|
||||
echo json_encode($this->getPublicJwks());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\ILtiRegistration;
|
||||
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
|
||||
use Packback\Lti1p3\Interfaces\IServiceRequest;
|
||||
|
||||
abstract class LtiAbstractService
|
||||
{
|
||||
private $serviceConnector;
|
||||
private $registration;
|
||||
private $serviceData;
|
||||
|
||||
public function __construct(
|
||||
ILtiServiceConnector $serviceConnector,
|
||||
ILtiRegistration $registration,
|
||||
array $serviceData)
|
||||
{
|
||||
$this->serviceConnector = $serviceConnector;
|
||||
$this->registration = $registration;
|
||||
$this->serviceData = $serviceData;
|
||||
}
|
||||
|
||||
public function getServiceData(): array
|
||||
{
|
||||
return $this->serviceData;
|
||||
}
|
||||
|
||||
public function setServiceData(array $serviceData): self
|
||||
{
|
||||
$this->serviceData = $serviceData;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
abstract public function getScope(): array;
|
||||
|
||||
protected function makeServiceRequest(IServiceRequest $request): array
|
||||
{
|
||||
return $this->serviceConnector->makeServiceRequest(
|
||||
$this->registration,
|
||||
$this->getScope(),
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
protected function getAll(IServiceRequest $request, string $key = null): array
|
||||
{
|
||||
return $this->serviceConnector->getAll(
|
||||
$this->registration,
|
||||
$this->getScope(),
|
||||
$request,
|
||||
$key
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiAssignmentsGradesService extends LtiAbstractService
|
||||
{
|
||||
public const CONTENTTYPE_SCORE = 'application/vnd.ims.lis.v1.score+json';
|
||||
public const CONTENTTYPE_LINEITEM = 'application/vnd.ims.lis.v2.lineitem+json';
|
||||
public const CONTENTTYPE_LINEITEMCONTAINER = 'application/vnd.ims.lis.v2.lineitemcontainer+json';
|
||||
public const CONTENTTYPE_RESULTCONTAINER = 'application/vnd.ims.lis.v2.resultcontainer+json';
|
||||
|
||||
public function getScope(): array
|
||||
{
|
||||
return $this->getServiceData()['scope'];
|
||||
}
|
||||
|
||||
// https://www.imsglobal.org/spec/lti-ags/v2p0#assignment-and-grade-service-claim
|
||||
// When an LTI message is launching a resource associated to one and only one lineitem,
|
||||
// the claim must include the endpoint URL for accessing the associated line item;
|
||||
// in all other cases, this property must be either blank or not included in the claim.
|
||||
public function getResourceLaunchLineItem(): ?LtiLineitem
|
||||
{
|
||||
$serviceData = $this->getServiceData();
|
||||
if (empty($serviceData['lineitem'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LtiLineitem::new()->setId($serviceData['lineitem']);
|
||||
}
|
||||
|
||||
public function putGrade(LtiGrade $grade, LtiLineitem $lineitem = null)
|
||||
{
|
||||
if (!in_array(LtiConstants::AGS_SCOPE_SCORE, $this->getScope())) {
|
||||
throw new LtiException('Missing required scope', 1);
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
$request = new ServiceRequest(LtiServiceConnector::METHOD_POST, $scoreUrl);
|
||||
$request->setBody($grade);
|
||||
$request->setContentType(static::CONTENTTYPE_SCORE);
|
||||
|
||||
return $this->makeServiceRequest($request);
|
||||
}
|
||||
|
||||
public function findLineItem(LtiLineitem $newLineItem): ?LtiLineitem
|
||||
{
|
||||
$lineitems = $this->getLineItems();
|
||||
|
||||
foreach ($lineitems as $lineitem) {
|
||||
if ($this->isMatchingLineitem($lineitem, $newLineItem)) {
|
||||
return new LtiLineitem($lineitem);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function createLineitem(LtiLineitem $newLineItem): LtiLineitem
|
||||
{
|
||||
$request = new ServiceRequest(LtiServiceConnector::METHOD_POST, $this->getServiceData()['lineitems']);
|
||||
$request->setBody($newLineItem)
|
||||
->setContentType(static::CONTENTTYPE_LINEITEM)
|
||||
->setAccept(static::CONTENTTYPE_LINEITEM);
|
||||
$createdLineItems = $this->makeServiceRequest($request);
|
||||
|
||||
return new LtiLineitem($createdLineItems['body']);
|
||||
}
|
||||
|
||||
public function findOrCreateLineitem(LtiLineitem $newLineItem): LtiLineitem
|
||||
{
|
||||
return $this->findLineItem($newLineItem) ?? $this->createLineitem($newLineItem);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$request = new ServiceRequest(LtiServiceConnector::METHOD_GET, $resultsUrl);
|
||||
$request->setAccept(static::CONTENTTYPE_RESULTCONTAINER);
|
||||
$scores = $this->makeServiceRequest($request);
|
||||
|
||||
return $scores['body'];
|
||||
}
|
||||
|
||||
public function getLineItems(): array
|
||||
{
|
||||
if (!in_array(LtiConstants::AGS_SCOPE_LINEITEM, $this->getScope())) {
|
||||
throw new LtiException('Missing required scope', 1);
|
||||
}
|
||||
|
||||
$request = new ServiceRequest(
|
||||
LtiServiceConnector::METHOD_GET,
|
||||
$this->getServiceData()['lineitems']
|
||||
);
|
||||
$request->setAccept(static::CONTENTTYPE_LINEITEMCONTAINER);
|
||||
|
||||
$lineitems = $this->getAll($request);
|
||||
|
||||
// If there is only one item, then wrap it in an array so the foreach works
|
||||
if (isset($lineitems['body']['id'])) {
|
||||
$lineitems['body'] = [$lineitems['body']];
|
||||
}
|
||||
|
||||
return $lineitems;
|
||||
}
|
||||
|
||||
public function getLineItem(string $url): LtiLineitem
|
||||
{
|
||||
if (!in_array(LtiConstants::AGS_SCOPE_LINEITEM, $this->getScope())) {
|
||||
throw new LtiException('Missing required scope', 1);
|
||||
}
|
||||
|
||||
$request = new ServiceRequest(LtiServiceConnector::METHOD_GET, $url);
|
||||
$request->setAccept(static::CONTENTTYPE_LINEITEM);
|
||||
|
||||
$response = $this->makeServiceRequest($request)['body'];
|
||||
|
||||
return new LtiLineitem($response);
|
||||
}
|
||||
|
||||
private function ensureLineItemExists(LtiLineitem $lineitem = null): LtiLineitem
|
||||
{
|
||||
// If no line item is passed in, attempt to use the one associated with
|
||||
// this launch.
|
||||
if (!isset($lineitem)) {
|
||||
$lineitem = $this->getResourceLaunchLineItem();
|
||||
}
|
||||
|
||||
// If none exists still, create a default line item.
|
||||
if (!isset($lineitem)) {
|
||||
$defaultLineitem = LtiLineitem::new()
|
||||
->setLabel('default')
|
||||
->setScoreMaximum(100);
|
||||
$lineitem = $this->createLineitem($defaultLineitem);
|
||||
}
|
||||
|
||||
// If the line item does not contain an ID, find or create it.
|
||||
if (empty($lineitem->getId())) {
|
||||
$lineitem = $this->findOrCreateLineitem($lineitem);
|
||||
}
|
||||
|
||||
return $lineitem;
|
||||
}
|
||||
|
||||
private function isMatchingLineitem(array $lineitem, LtiLineitem $newLineItem): bool
|
||||
{
|
||||
return $newLineItem->getTag() == ($lineitem['tag'] ?? null) &&
|
||||
$newLineItem->getResourceId() == ($lineitem['resourceId'] ?? null) &&
|
||||
$newLineItem->getResourceLinkId() == ($lineitem['resourceLinkId'] ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiConstants
|
||||
{
|
||||
public const V1_3 = '1.3.0';
|
||||
|
||||
// Required message claims
|
||||
public const MESSAGE_TYPE = 'https://purl.imsglobal.org/spec/lti/claim/message_type';
|
||||
public const VERSION = 'https://purl.imsglobal.org/spec/lti/claim/version';
|
||||
public const DEPLOYMENT_ID = 'https://purl.imsglobal.org/spec/lti/claim/deployment_id';
|
||||
public const TARGET_LINK_URI = 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri';
|
||||
public const RESOURCE_LINK = 'https://purl.imsglobal.org/spec/lti/claim/resource_link';
|
||||
public const ROLES = 'https://purl.imsglobal.org/spec/lti/claim/roles';
|
||||
public const FOR_USER = 'https://purl.imsglobal.org/spec/lti/claim/for_user';
|
||||
|
||||
// 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 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';
|
||||
|
||||
// LTI DL
|
||||
public const DL_CONTENT_ITEMS = 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items';
|
||||
public const DL_DATA = 'https://purl.imsglobal.org/spec/lti-dl/claim/data';
|
||||
public const DL_DEEP_LINK_SETTINGS = 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings';
|
||||
|
||||
// LTI NRPS
|
||||
public const NRPS_CLAIM_SERVICE = 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice';
|
||||
public const NRPS_SCOPE_MEMBERSHIP_READONLY = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
|
||||
|
||||
// LTI AGS
|
||||
public const AGS_CLAIM_ENDPOINT = 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint';
|
||||
public const AGS_SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem';
|
||||
public const AGS_SCOPE_LINEITEM_READONLY = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly';
|
||||
public const AGS_SCOPE_RESULT_READONLY = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly';
|
||||
public const AGS_SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score';
|
||||
|
||||
// LTI GS
|
||||
public const GS_CLAIM_SERVICE = 'https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice';
|
||||
|
||||
// User Vocab
|
||||
public const SYSTEM_ADMINISTRATOR = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator';
|
||||
public const SYSTEM_NONE = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#None';
|
||||
public const SYSTEM_ACCOUNTADMIN = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#AccountAdmin';
|
||||
public const SYSTEM_CREATOR = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Creator';
|
||||
public const SYSTEM_SYSADMIN = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#SysAdmin';
|
||||
public const SYSTEM_SYSSUPPORT = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#SysSupport';
|
||||
public const SYSTEM_USER = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#User';
|
||||
public const INSTITUTION_ADMINISTRATOR = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator';
|
||||
public const INSTITUTION_FACULTY = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Faculty';
|
||||
public const INSTITUTION_GUEST = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Guest';
|
||||
public const INSTITUTION_NONE = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#None';
|
||||
public const INSTITUTION_OTHER = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Other';
|
||||
public const INSTITUTION_STAFF = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Staff';
|
||||
public const INSTITUTION_STUDENT = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student';
|
||||
public const INSTITUTION_ALUMNI = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Alumni';
|
||||
public const INSTITUTION_INSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor';
|
||||
public const INSTITUTION_LEARNER = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Learner';
|
||||
public const INSTITUTION_MEMBER = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Member';
|
||||
public const INSTITUTION_MENTOR = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Mentor';
|
||||
public const INSTITUTION_OBSERVER = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Observer';
|
||||
public const INSTITUTION_PROSPECTIVESTUDENT = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#ProspectiveStudent';
|
||||
public const MEMBERSHIP_ADMINISTRATOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator';
|
||||
public const MEMBERSHIP_CONTENTDEVELOPER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper';
|
||||
public const MEMBERSHIP_INSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor';
|
||||
public const MEMBERSHIP_LEARNER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner';
|
||||
public const MEMBERSHIP_MENTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Mentor';
|
||||
public const MEMBERSHIP_MANAGER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Manager';
|
||||
public const MEMBERSHIP_MEMBER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Member';
|
||||
public const MEMBERSHIP_OFFICER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Officer';
|
||||
// Context sub-roles
|
||||
public const MEMBERSHIP_EXTERNALINSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#ExternalInstructor';
|
||||
public const MEMBERSHIP_GRADER = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#Grader';
|
||||
public const MEMBERSHIP_GUESTINSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#GuestInstructor';
|
||||
public const MEMBERSHIP_LECTURER = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#Lecturer';
|
||||
public const MEMBERSHIP_PRIMARYINSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#PrimaryInstructor';
|
||||
public const MEMBERSHIP_SECONDARYINSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#SecondaryInstructor';
|
||||
public const MEMBERSHIP_TA = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant';
|
||||
public const MEMBERSHIP_TAGROUP = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantGroup';
|
||||
public const MEMBERSHIP_TAOFFERING = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantOffering';
|
||||
public const MEMBERSHIP_TASECTION = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantSection';
|
||||
public const MEMBERSHIP_TASECTIONASSOCIATION = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantSectionAssociation';
|
||||
public const MEMBERSHIP_TATEMPLATE = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantTemplate';
|
||||
|
||||
// Context Vocab
|
||||
public const COURSE_TEMPLATE = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseTemplate';
|
||||
public const COURSE_OFFERING = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering';
|
||||
public const COURSE_SECTION = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection';
|
||||
public const COURSE_GROUP = 'http://purl.imsglobal.org/vocab/lis/v2/course#Group';
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiCourseGroupsService extends LtiAbstractService
|
||||
{
|
||||
public const CONTENTTYPE_CONTEXTGROUPCONTAINER = 'application/vnd.ims.lti-gs.v1.contextgroupcontainer+json';
|
||||
|
||||
public function getScope(): array
|
||||
{
|
||||
return $this->getServiceData()['scope'];
|
||||
}
|
||||
|
||||
public function getGroups(): array
|
||||
{
|
||||
$request = new ServiceRequest(
|
||||
LtiServiceConnector::METHOD_GET,
|
||||
$this->getServiceData()['context_groups_url']
|
||||
);
|
||||
$request->setAccept(static::CONTENTTYPE_CONTEXTGROUPCONTAINER);
|
||||
|
||||
return $this->getAll($request, 'groups');
|
||||
}
|
||||
|
||||
public function getSets(): array
|
||||
{
|
||||
// Sets are optional.
|
||||
if (!isset($this->getServiceData()['context_group_sets_url'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$request = new ServiceRequest(
|
||||
LtiServiceConnector::METHOD_GET,
|
||||
$this->getServiceData()['context_group_sets_url']
|
||||
);
|
||||
$request->setAccept(static::CONTENTTYPE_CONTEXTGROUPCONTAINER);
|
||||
|
||||
return $this->getAll($request, 'sets');
|
||||
}
|
||||
|
||||
public function getGroupsBySet()
|
||||
{
|
||||
$groups = $this->getGroups();
|
||||
$sets = $this->getSets();
|
||||
|
||||
$groupsBySet = [];
|
||||
$unsetted = [];
|
||||
|
||||
foreach ($sets as $key => $set) {
|
||||
$groupsBySet[$set['id']] = $set;
|
||||
$groupsBySet[$set['id']]['groups'] = [];
|
||||
}
|
||||
|
||||
foreach ($groups as $key => $group) {
|
||||
if (isset($group['set_id']) && isset($groupsBySet[$group['set_id']])) {
|
||||
$groupsBySet[$group['set_id']]['groups'][$group['id']] = $group;
|
||||
} else {
|
||||
$unsetted[$group['id']] = $group;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($unsetted)) {
|
||||
$groupsBySet['none'] = [
|
||||
'name' => 'None',
|
||||
'id' => 'none',
|
||||
'groups' => $unsetted,
|
||||
];
|
||||
}
|
||||
|
||||
return $groupsBySet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
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 getResponseJwt($resources)
|
||||
{
|
||||
$message_jwt = [
|
||||
'iss' => $this->registration->getClientId(),
|
||||
'aud' => [$this->registration->getIssuer()],
|
||||
'exp' => time() + 600,
|
||||
'iat' => time(),
|
||||
'nonce' => LtiOidcLogin::secureRandomString('nonce-'),
|
||||
LtiConstants::DEPLOYMENT_ID => $this->deployment_id,
|
||||
LtiConstants::MESSAGE_TYPE => 'LtiDeepLinkingResponse',
|
||||
LtiConstants::VERSION => LtiConstants::V1_3,
|
||||
LtiConstants::DL_CONTENT_ITEMS => array_map(function ($resource) { return $resource->toArray(); }, $resources),
|
||||
];
|
||||
|
||||
// https://www.imsglobal.org/spec/lti-dl/v2p0/#deep-linking-request-message
|
||||
// 'data' is an optional property which, if it exists, must be returned by the tool
|
||||
if (isset($this->deep_link_settings['data'])) {
|
||||
$message_jwt[LtiConstants::DL_DATA] = $this->deep_link_settings['data'];
|
||||
}
|
||||
|
||||
return JWT::encode($message_jwt, $this->registration->getToolPrivateKey(), 'RS256', $this->registration->getKid());
|
||||
}
|
||||
|
||||
public function outputResponseForm($resources)
|
||||
{
|
||||
$jwt = $this->getResponseJwt($resources);
|
||||
/*
|
||||
* @todo Fix this
|
||||
*/ ?>
|
||||
<form id="auto_submit" action="<?php echo $this->deep_link_settings['deep_link_return_url']; ?>" method="POST">
|
||||
<input type="hidden" name="JWT" value="<?php echo $jwt; ?>" />
|
||||
<input type="submit" name="Go" />
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('auto_submit').submit();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiDeepLinkResource
|
||||
{
|
||||
private $type = 'ltiResourceLink';
|
||||
private $title;
|
||||
private $text;
|
||||
private $url;
|
||||
private $lineitem;
|
||||
private $custom_params = [];
|
||||
private $target = 'iframe';
|
||||
|
||||
public static function new()
|
||||
{
|
||||
return new LtiDeepLinkResource();
|
||||
}
|
||||
|
||||
public function getType()
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType($value)
|
||||
{
|
||||
$this->type = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle()
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle($value)
|
||||
{
|
||||
$this->title = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getText()
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
public function setText($value)
|
||||
{
|
||||
$this->text = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUrl()
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrl($value)
|
||||
{
|
||||
$this->url = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLineitem()
|
||||
{
|
||||
return $this->lineitem;
|
||||
}
|
||||
|
||||
public function setLineitem(LtiLineitem $value)
|
||||
{
|
||||
$this->lineitem = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCustomParams()
|
||||
{
|
||||
return $this->custom_params;
|
||||
}
|
||||
|
||||
public function setCustomParams($value)
|
||||
{
|
||||
$this->custom_params = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTarget()
|
||||
{
|
||||
return $this->target;
|
||||
}
|
||||
|
||||
public function setTarget($value)
|
||||
{
|
||||
$this->target = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray()
|
||||
{
|
||||
$resource = [
|
||||
'type' => $this->type,
|
||||
'title' => $this->title,
|
||||
'text' => $this->text,
|
||||
'url' => $this->url,
|
||||
'presentation' => [
|
||||
'documentTarget' => $this->target,
|
||||
],
|
||||
'custom' => $this->custom_params,
|
||||
];
|
||||
if ($this->lineitem !== null) {
|
||||
$resource['lineItem'] = [
|
||||
'scoreMaximum' => $this->lineitem->getScoreMaximum(),
|
||||
'label' => $this->lineitem->getLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiDeployment
|
||||
{
|
||||
private $deployment_id;
|
||||
|
||||
public static function new()
|
||||
{
|
||||
return new LtiDeployment();
|
||||
}
|
||||
|
||||
public function getDeploymentId()
|
||||
{
|
||||
return $this->deployment_id;
|
||||
}
|
||||
|
||||
public function setDeploymentId($deployment_id)
|
||||
{
|
||||
$this->deployment_id = $deployment_id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Exception;
|
||||
|
||||
class LtiException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiGrade
|
||||
{
|
||||
private $score_given;
|
||||
private $score_maximum;
|
||||
private $comment;
|
||||
private $activity_progress;
|
||||
private $grading_progress;
|
||||
private $timestamp;
|
||||
private $user_id;
|
||||
private $submission_review;
|
||||
|
||||
public function __construct(array $grade = null)
|
||||
{
|
||||
$this->score_given = $grade['scoreGiven'] ?? null;
|
||||
$this->score_maximum = $grade['scoreMaximum'] ?? null;
|
||||
$this->comment = $grade['comment'] ?? null;
|
||||
$this->activity_progress = $grade['activityProgress'] ?? null;
|
||||
$this->grading_progress = $grade['gradingProgress'] ?? null;
|
||||
$this->timestamp = $grade['timestamp'] ?? null;
|
||||
$this->user_id = $grade['userId'] ?? null;
|
||||
$this->submission_review = $grade['submissionReview'] ?? null;
|
||||
$this->canvas_extension = $grade['https://canvas.instructure.com/lti/submission'] ?? null;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
// Additionally, includes the call back to filter out only NULL values
|
||||
$request = array_filter([
|
||||
'scoreGiven' => $this->score_given,
|
||||
'scoreMaximum' => $this->score_maximum,
|
||||
'comment' => $this->comment,
|
||||
'activityProgress' => $this->activity_progress,
|
||||
'gradingProgress' => $this->grading_progress,
|
||||
'timestamp' => $this->timestamp,
|
||||
'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()
|
||||
{
|
||||
return new LtiGrade();
|
||||
}
|
||||
|
||||
public function getScoreGiven()
|
||||
{
|
||||
return $this->score_given;
|
||||
}
|
||||
|
||||
public function setScoreGiven($value)
|
||||
{
|
||||
$this->score_given = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getScoreMaximum()
|
||||
{
|
||||
return $this->score_maximum;
|
||||
}
|
||||
|
||||
public function setScoreMaximum($value)
|
||||
{
|
||||
$this->score_maximum = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComment()
|
||||
{
|
||||
return $this->comment;
|
||||
}
|
||||
|
||||
public function setComment($comment)
|
||||
{
|
||||
$this->comment = $comment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActivityProgress()
|
||||
{
|
||||
return $this->activity_progress;
|
||||
}
|
||||
|
||||
public function setActivityProgress($value)
|
||||
{
|
||||
$this->activity_progress = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGradingProgress()
|
||||
{
|
||||
return $this->grading_progress;
|
||||
}
|
||||
|
||||
public function setGradingProgress($value)
|
||||
{
|
||||
$this->grading_progress = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTimestamp()
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
public function setTimestamp($value)
|
||||
{
|
||||
$this->timestamp = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserId()
|
||||
{
|
||||
return $this->user_id;
|
||||
}
|
||||
|
||||
public function setUserId($value)
|
||||
{
|
||||
$this->user_id = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubmissionReview()
|
||||
{
|
||||
return $this->submission_review;
|
||||
}
|
||||
|
||||
public function setSubmissionReview($value)
|
||||
{
|
||||
$this->submission_review = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCanvasExtension()
|
||||
{
|
||||
return $this->canvas_extension;
|
||||
}
|
||||
|
||||
// Custom Extension for Canvas.
|
||||
// https://documentation.instructure.com/doc/api/score.html
|
||||
public function setCanvasExtension($value)
|
||||
{
|
||||
$this->canvas_extension = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiGradeSubmissionReview
|
||||
{
|
||||
private $reviewable_status;
|
||||
private $label;
|
||||
private $url;
|
||||
private $custom;
|
||||
|
||||
public function __construct(array $gradeSubmission = null)
|
||||
{
|
||||
$this->reviewable_status = $gradeSubmission['reviewableStatus'] ?? null;
|
||||
$this->label = $gradeSubmission['label'] ?? null;
|
||||
$this->url = $gradeSubmission['url'] ?? null;
|
||||
$this->custom = $gradeSubmission['custom'] ?? null;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
// Additionally, includes the call back to filter out only NULL values
|
||||
return json_encode(array_filter([
|
||||
'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()
|
||||
{
|
||||
return new LtiGradeSubmissionReview();
|
||||
}
|
||||
|
||||
public function getReviewableStatus()
|
||||
{
|
||||
return $this->reviewable_status;
|
||||
}
|
||||
|
||||
public function setReviewableStatus($value)
|
||||
{
|
||||
$this->reviewable_status = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel()
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel($value)
|
||||
{
|
||||
$this->label = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUrl()
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrl($url)
|
||||
{
|
||||
$this->url = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCustom()
|
||||
{
|
||||
return $this->custom;
|
||||
}
|
||||
|
||||
public function setCustom($value)
|
||||
{
|
||||
$this->custom = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiLineitem
|
||||
{
|
||||
private $id;
|
||||
private $score_maximum;
|
||||
private $label;
|
||||
private $resource_id;
|
||||
private $resource_link_id;
|
||||
private $tag;
|
||||
private $start_date_time;
|
||||
private $end_date_time;
|
||||
|
||||
public function __construct(array $lineitem = null)
|
||||
{
|
||||
$this->id = $lineitem['id'] ?? null;
|
||||
$this->score_maximum = $lineitem['scoreMaximum'] ?? null;
|
||||
$this->label = $lineitem['label'] ?? null;
|
||||
$this->resource_id = $lineitem['resourceId'] ?? null;
|
||||
$this->resource_link_id = $lineitem['resourceLinkId'] ?? null;
|
||||
$this->tag = $lineitem['tag'] ?? null;
|
||||
$this->start_date_time = $lineitem['startDateTime'] ?? null;
|
||||
$this->end_date_time = $lineitem['endDateTime'] ?? null;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
// Additionally, includes the call back to filter out only NULL values
|
||||
return json_encode(array_filter([
|
||||
'id' => $this->id,
|
||||
'scoreMaximum' => $this->score_maximum,
|
||||
'label' => $this->label,
|
||||
'resourceId' => $this->resource_id,
|
||||
'resourceLinkId' => $this->resource_link_id,
|
||||
'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();
|
||||
}
|
||||
|
||||
public function getId()
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId($value)
|
||||
{
|
||||
$this->id = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel()
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel($value)
|
||||
{
|
||||
$this->label = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getScoreMaximum()
|
||||
{
|
||||
return $this->score_maximum;
|
||||
}
|
||||
|
||||
public function setScoreMaximum($value)
|
||||
{
|
||||
$this->score_maximum = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResourceId()
|
||||
{
|
||||
return $this->resource_id;
|
||||
}
|
||||
|
||||
public function setResourceId($value)
|
||||
{
|
||||
$this->resource_id = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResourceLinkId()
|
||||
{
|
||||
return $this->resource_link_id;
|
||||
}
|
||||
|
||||
public function setResourceLinkId($value)
|
||||
{
|
||||
$this->resource_link_id = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTag()
|
||||
{
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
public function setTag($value)
|
||||
{
|
||||
$this->tag = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartDateTime()
|
||||
{
|
||||
return $this->start_date_time;
|
||||
}
|
||||
|
||||
public function setStartDateTime($value)
|
||||
{
|
||||
$this->start_date_time = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEndDateTime()
|
||||
{
|
||||
return $this->end_date_time;
|
||||
}
|
||||
|
||||
public function setEndDateTime($value)
|
||||
{
|
||||
$this->end_date_time = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Packback\Lti1p3\Interfaces\ICache;
|
||||
use Packback\Lti1p3\Interfaces\ICookie;
|
||||
use Packback\Lti1p3\Interfaces\IDatabase;
|
||||
use Packback\Lti1p3\Interfaces\IHttpException;
|
||||
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
|
||||
use Packback\Lti1p3\MessageValidators\DeepLinkMessageValidator;
|
||||
use Packback\Lti1p3\MessageValidators\ResourceMessageValidator;
|
||||
use Packback\Lti1p3\MessageValidators\SubmissionReviewMessageValidator;
|
||||
|
||||
class LtiMessageLaunch
|
||||
{
|
||||
public const TYPE_DEEPLINK = 'LtiDeepLinkingRequest';
|
||||
public const TYPE_SUBMISSIONREVIEW = 'LtiSubmissionReviewRequest';
|
||||
public const TYPE_RESOURCELINK = 'LtiResourceLinkRequest';
|
||||
|
||||
public const ERR_FETCH_PUBLIC_KEY = 'Failed to fetch public key.';
|
||||
public const ERR_NO_PUBLIC_KEY = 'Unable to find public key.';
|
||||
public const ERR_STATE_NOT_FOUND = 'State not found. Please make sure you have cookies enabled in this browser.';
|
||||
public const ERR_MISSING_ID_TOKEN = 'Missing id_token.';
|
||||
public const ERR_INVALID_ID_TOKEN = 'Invalid id_token, JWT must contain 3 parts';
|
||||
public const ERR_MISSING_NONCE = 'Missing Nonce.';
|
||||
public const ERR_INVALID_NONCE = 'Invalid Nonce.';
|
||||
public const ERR_MISSING_REGISTRATION = 'Registration not found. Please have your admin confirm your Issuer URL, client ID, and deployment ID.';
|
||||
public const ERR_CLIENT_NOT_REGISTERED = 'Client id not registered for this issuer.';
|
||||
public const ERR_NO_KID = 'No KID specified in the JWT Header.';
|
||||
public const ERR_INVALID_SIGNATURE = 'Invalid signature on id_token';
|
||||
public const ERR_MISSING_DEPLOYEMENT_ID = 'No deployment ID was specified';
|
||||
public const ERR_NO_DEPLOYMENT = 'Unable to find deployment.';
|
||||
public const ERR_INVALID_MESSAGE_TYPE = 'Invalid message type';
|
||||
public const ERR_VALIDATOR_CONFLICT = 'Validator conflict.';
|
||||
public const ERR_UNRECOGNIZED_MESSAGE_TYPE = 'Unrecognized message type.';
|
||||
public const ERR_INVALID_MESSAGE = 'Message validation failed.';
|
||||
|
||||
private $db;
|
||||
private $cache;
|
||||
private $cookie;
|
||||
private $serviceConnector;
|
||||
private $request;
|
||||
private $jwt;
|
||||
private $registration;
|
||||
private $launch_id;
|
||||
|
||||
/**
|
||||
* 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
|
||||
) {
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @throws LtiException will throw an LtiException if validation fails or launch cannot be found
|
||||
*
|
||||
* @return LtiMessageLaunch a populated and validated LtiMessageLaunch
|
||||
*/
|
||||
public static function fromCache($launch_id,
|
||||
IDatabase $database,
|
||||
ICache $cache = null,
|
||||
ILtiServiceConnector $serviceConnector = null)
|
||||
{
|
||||
$new = new LtiMessageLaunch($database, $cache, null, $serviceConnector);
|
||||
$new->launch_id = $launch_id;
|
||||
$new->jwt = ['body' => $new->cache->getLaunchData($launch_id)];
|
||||
|
||||
return $new->validateRegistration();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @throws LtiException will throw an LtiException if validation fails
|
||||
*
|
||||
* @return LtiMessageLaunch will return $this if validation is successful
|
||||
*/
|
||||
public function validate(array $request = null)
|
||||
{
|
||||
if ($request === null) {
|
||||
$request = $_POST;
|
||||
}
|
||||
$this->request = $request;
|
||||
|
||||
return $this->validateState()
|
||||
->validateJwtFormat()
|
||||
->validateNonce()
|
||||
->validateRegistration()
|
||||
->validateJwtSignature()
|
||||
->validateDeployment()
|
||||
->validateMessage()
|
||||
->cacheLaunchData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
return !empty($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()
|
||||
{
|
||||
return new LtiNamesRolesProvisioningService(
|
||||
$this->serviceConnector,
|
||||
$this->registration,
|
||||
$this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
return !empty($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()
|
||||
{
|
||||
return new LtiCourseGroupsService(
|
||||
$this->serviceConnector,
|
||||
$this->registration,
|
||||
$this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
return !empty($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()
|
||||
{
|
||||
return new LtiAssignmentsGradesService(
|
||||
$this->serviceConnector,
|
||||
$this->registration,
|
||||
$this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
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()
|
||||
{
|
||||
return new LtiDeepLink(
|
||||
$this->registration,
|
||||
$this->jwt['body'][LtiConstants::DEPLOYMENT_ID],
|
||||
$this->jwt['body'][LtiConstants::DL_DEEP_LINK_SETTINGS]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
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()
|
||||
{
|
||||
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()
|
||||
{
|
||||
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()
|
||||
{
|
||||
return $this->launch_id;
|
||||
}
|
||||
|
||||
private function getPublicKey()
|
||||
{
|
||||
$keySetUrl = $this->registration->getKeySetUrl();
|
||||
$request = new ServiceRequest(LtiServiceConnector::METHOD_GET, $keySetUrl);
|
||||
|
||||
// Download key set
|
||||
try {
|
||||
$response = $this->serviceConnector->makeRequest($request);
|
||||
} catch (IHttpException $e) {
|
||||
throw new LtiException(static::ERR_NO_PUBLIC_KEY);
|
||||
}
|
||||
$publicKeySet = $this->serviceConnector->getResponseBody($response);
|
||||
|
||||
if (empty($publicKeySet)) {
|
||||
// Failed to fetch public keyset from URL.
|
||||
throw new LtiException(static::ERR_FETCH_PUBLIC_KEY);
|
||||
}
|
||||
|
||||
// Find key used to sign the JWT (matches the KID in the header)
|
||||
foreach ($publicKeySet['keys'] as $key) {
|
||||
if ($key['kid'] == $this->jwt['header']['kid']) {
|
||||
try {
|
||||
$keySet = JWK::parseKeySet([
|
||||
'keys' => [$key],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
if (isset($keySet[$key['kid']])) {
|
||||
return $keySet[$key['kid']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Could not find public key with a matching kid and alg.
|
||||
throw new LtiException(static::ERR_NO_PUBLIC_KEY);
|
||||
}
|
||||
|
||||
private function cacheLaunchData()
|
||||
{
|
||||
$this->cache->cacheLaunchData($this->launch_id, $this->jwt['body']);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function validateState()
|
||||
{
|
||||
// Check State for OIDC.
|
||||
if ($this->cookie->getCookie(LtiOidcLogin::COOKIE_PREFIX.$this->request['state']) !== $this->request['state']) {
|
||||
// Error if state doesn't match
|
||||
throw new LtiException(static::ERR_STATE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function validateJwtFormat()
|
||||
{
|
||||
$jwt = $this->request['id_token'] ?? null;
|
||||
|
||||
if (empty($jwt)) {
|
||||
throw new LtiException(static::ERR_MISSING_ID_TOKEN);
|
||||
}
|
||||
|
||||
// Get parts of JWT.
|
||||
$jwt_parts = explode('.', $jwt);
|
||||
|
||||
if (count($jwt_parts) !== 3) {
|
||||
// Invalid number of parts in JWT.
|
||||
throw new LtiException(static::ERR_INVALID_ID_TOKEN);
|
||||
}
|
||||
|
||||
// Decode JWT headers.
|
||||
$this->jwt['header'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[0]), true);
|
||||
// Decode JWT Body.
|
||||
$this->jwt['body'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[1]), true);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function validateNonce()
|
||||
{
|
||||
if (!isset($this->jwt['body']['nonce'])) {
|
||||
throw new LtiException(static::ERR_MISSING_NONCE);
|
||||
}
|
||||
if (!$this->cache->checkNonceIsValid($this->jwt['body']['nonce'], $this->request['state'])) {
|
||||
throw new LtiException(static::ERR_INVALID_NONCE);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function validateRegistration()
|
||||
{
|
||||
// Find registration.
|
||||
$client_id = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud'];
|
||||
$this->registration = $this->db->findRegistrationByIssuer($this->jwt['body']['iss'], $client_id);
|
||||
|
||||
if (empty($this->registration)) {
|
||||
throw new LtiException(static::ERR_MISSING_REGISTRATION);
|
||||
}
|
||||
|
||||
// Check client id.
|
||||
if ($client_id !== $this->registration->getClientId()) {
|
||||
// Client not registered.
|
||||
throw new LtiException(static::ERR_CLIENT_NOT_REGISTERED);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function validateJwtSignature()
|
||||
{
|
||||
if (!isset($this->jwt['header']['kid'])) {
|
||||
throw new LtiException(static::ERR_NO_KID);
|
||||
}
|
||||
|
||||
// Fetch public key.
|
||||
$public_key = $this->getPublicKey();
|
||||
|
||||
// Validate JWT signature
|
||||
try {
|
||||
JWT::decode($this->request['id_token'], $public_key);
|
||||
} catch (ExpiredException $e) {
|
||||
// Error validating signature.
|
||||
throw new LtiException(static::ERR_INVALID_SIGNATURE);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function validateDeployment()
|
||||
{
|
||||
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);
|
||||
|
||||
if (empty($deployment)) {
|
||||
// deployment not recognized.
|
||||
throw new LtiException(static::ERR_NO_DEPLOYMENT);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function validateMessage()
|
||||
{
|
||||
if (empty($this->jwt['body'][LtiConstants::MESSAGE_TYPE])) {
|
||||
// Unable to identify message type.
|
||||
throw new LtiException(static::ERR_INVALID_MESSAGE_TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo Fix this nonsense
|
||||
*/
|
||||
|
||||
// Create instances of all validators
|
||||
$validators = [
|
||||
new DeepLinkMessageValidator(),
|
||||
new ResourceMessageValidator(),
|
||||
new SubmissionReviewMessageValidator(),
|
||||
];
|
||||
|
||||
$message_validator = false;
|
||||
foreach ($validators as $validator) {
|
||||
if ($validator->canValidate($this->jwt['body'])) {
|
||||
if ($message_validator !== false) {
|
||||
// Can't have more than one validator apply at a time.
|
||||
throw new LtiException(static::ERR_VALIDATOR_CONFLICT);
|
||||
}
|
||||
$message_validator = $validator;
|
||||
}
|
||||
}
|
||||
|
||||
if ($message_validator === false) {
|
||||
throw new LtiException(static::ERR_UNRECOGNIZED_MESSAGE_TYPE);
|
||||
}
|
||||
|
||||
if (!$message_validator->validate($this->jwt['body'])) {
|
||||
throw new LtiException(static::ERR_INVALID_MESSAGE);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
class LtiNamesRolesProvisioningService extends LtiAbstractService
|
||||
{
|
||||
public const CONTENTTYPE_MEMBERSHIPCONTAINER = 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json';
|
||||
|
||||
public function getScope(): array
|
||||
{
|
||||
return [LtiConstants::NRPS_SCOPE_MEMBERSHIP_READONLY];
|
||||
}
|
||||
|
||||
public function getMembers(): array
|
||||
{
|
||||
$request = new ServiceRequest(
|
||||
LtiServiceConnector::METHOD_GET,
|
||||
$this->getServiceData()['context_memberships_url']
|
||||
);
|
||||
$request->setAccept(static::CONTENTTYPE_MEMBERSHIPCONTAINER);
|
||||
|
||||
return $this->getAll($request, 'members');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\ICache;
|
||||
use Packback\Lti1p3\Interfaces\ICookie;
|
||||
use Packback\Lti1p3\Interfaces\IDatabase;
|
||||
|
||||
class LtiOidcLogin
|
||||
{
|
||||
public const COOKIE_PREFIX = 'lti1p3_';
|
||||
|
||||
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';
|
||||
public const ERROR_MSG_REGISTRATION = 'Could not find registration details';
|
||||
|
||||
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. If non is provided launch data will be store in $_SESSION.
|
||||
* @param ICookie $cookie Instance of the Cookie interface used to set and read cookies. Will default to using $_COOKIE and setcookie.
|
||||
*/
|
||||
public function __construct(IDatabase $database, ICache $cache = null, ICookie $cookie = null)
|
||||
{
|
||||
$this->db = $database;
|
||||
$this->cache = $cache;
|
||||
$this->cookie = $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)
|
||||
{
|
||||
return new LtiOidcLogin($database, $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)
|
||||
{
|
||||
if ($request === null) {
|
||||
$request = $_REQUEST;
|
||||
}
|
||||
|
||||
if (empty($launch_url)) {
|
||||
throw new OidcException(static::ERROR_MSG_LAUNCH_URL, 1);
|
||||
}
|
||||
|
||||
// Validate Request Data.
|
||||
$registration = $this->validateOidcLogin($request);
|
||||
|
||||
/*
|
||||
* Build OIDC Auth Response.
|
||||
*/
|
||||
|
||||
// Generate State.
|
||||
// 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 = [
|
||||
'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.
|
||||
'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'];
|
||||
}
|
||||
|
||||
$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.
|
||||
$registration = $this->db->findRegistrationByIssuer($request['iss'], $request['client_id'] ?? null);
|
||||
|
||||
// Check we got something.
|
||||
if (empty($registration)) {
|
||||
throw new OidcException(static::ERROR_MSG_REGISTRATION, 1);
|
||||
}
|
||||
|
||||
// Return Registration.
|
||||
return $registration;
|
||||
}
|
||||
|
||||
public static function secureRandomString(string $prefix = ''): string
|
||||
{
|
||||
return $prefix.hash('sha256', random_bytes(64));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
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;
|
||||
|
||||
public function __construct(array $registration = [])
|
||||
{
|
||||
$this->issuer = $registration['issuer'] ?? null;
|
||||
$this->clientId = $registration['clientId'] ?? null;
|
||||
$this->keySetUrl = $registration['keySetUrl'] ?? null;
|
||||
$this->authTokenUrl = $registration['authTokenUrl'] ?? null;
|
||||
$this->authLoginUrl = $registration['authLoginUrl'] ?? null;
|
||||
$this->authServer = $registration['authServer'] ?? null;
|
||||
$this->toolPrivateKey = $registration['toolPrivateKey'] ?? null;
|
||||
$this->kid = $registration['kid'] ?? null;
|
||||
}
|
||||
|
||||
public static function new(array $registration = [])
|
||||
{
|
||||
return new LtiRegistration($registration);
|
||||
}
|
||||
|
||||
public function getIssuer()
|
||||
{
|
||||
return $this->issuer;
|
||||
}
|
||||
|
||||
public function setIssuer($issuer)
|
||||
{
|
||||
$this->issuer = $issuer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientId()
|
||||
{
|
||||
return $this->clientId;
|
||||
}
|
||||
|
||||
public function setClientId($clientId)
|
||||
{
|
||||
$this->clientId = $clientId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getKeySetUrl()
|
||||
{
|
||||
return $this->keySetUrl;
|
||||
}
|
||||
|
||||
public function setKeySetUrl($keySetUrl)
|
||||
{
|
||||
$this->keySetUrl = $keySetUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthTokenUrl()
|
||||
{
|
||||
return $this->authTokenUrl;
|
||||
}
|
||||
|
||||
public function setAuthTokenUrl($authTokenUrl)
|
||||
{
|
||||
$this->authTokenUrl = $authTokenUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthLoginUrl()
|
||||
{
|
||||
return $this->authLoginUrl;
|
||||
}
|
||||
|
||||
public function setAuthLoginUrl($authLoginUrl)
|
||||
{
|
||||
$this->authLoginUrl = $authLoginUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthServer()
|
||||
{
|
||||
return empty($this->authServer) ? $this->authTokenUrl : $this->authServer;
|
||||
}
|
||||
|
||||
public function setAuthServer($authServer)
|
||||
{
|
||||
$this->authServer = $authServer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getToolPrivateKey()
|
||||
{
|
||||
return $this->toolPrivateKey;
|
||||
}
|
||||
|
||||
public function setToolPrivateKey($toolPrivateKey)
|
||||
{
|
||||
$this->toolPrivateKey = $toolPrivateKey;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getKid()
|
||||
{
|
||||
return $this->kid ?? hash('sha256', trim($this->issuer.$this->clientId));
|
||||
}
|
||||
|
||||
public function setKid($kid)
|
||||
{
|
||||
$this->kid = $kid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Packback\Lti1p3\Interfaces\ICache;
|
||||
use Packback\Lti1p3\Interfaces\IHttpClient;
|
||||
use Packback\Lti1p3\Interfaces\IHttpException;
|
||||
use Packback\Lti1p3\Interfaces\IHttpResponse;
|
||||
use Packback\Lti1p3\Interfaces\ILtiRegistration;
|
||||
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
|
||||
use Packback\Lti1p3\Interfaces\IServiceRequest;
|
||||
|
||||
class LtiServiceConnector implements ILtiServiceConnector
|
||||
{
|
||||
public const NEXT_PAGE_REGEX = '/<([^>]*)>; ?rel="next"/i';
|
||||
|
||||
public const METHOD_GET = 'GET';
|
||||
public const METHOD_POST = 'POST';
|
||||
|
||||
private $cache;
|
||||
private $client;
|
||||
private $debuggingMode = false;
|
||||
|
||||
public function __construct(ICache $cache, IHttpClient $client)
|
||||
{
|
||||
$this->cache = $cache;
|
||||
$this->client = $client;
|
||||
}
|
||||
|
||||
public function setDebuggingMode(bool $enable): void
|
||||
{
|
||||
$this->debuggingMode = $enable;
|
||||
}
|
||||
|
||||
public function getAccessToken(ILtiRegistration $registration, array $scopes)
|
||||
{
|
||||
// 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) {
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
// Build up JWT to exchange for an auth token
|
||||
$clientId = $registration->getClientId();
|
||||
$jwtClaim = [
|
||||
'iss' => $clientId,
|
||||
'sub' => $clientId,
|
||||
'aud' => $registration->getAuthServer(),
|
||||
'iat' => time() - 5,
|
||||
'exp' => time() + 60,
|
||||
'jti' => 'lti-service-token'.hash('sha256', random_bytes(64)),
|
||||
];
|
||||
|
||||
// Sign the JWT with our private key (given by the platform on registration)
|
||||
$jwt = JWT::encode($jwtClaim, $registration->getToolPrivateKey(), 'RS256', $registration->getKid());
|
||||
|
||||
// Build auth token request headers
|
||||
$authRequest = [
|
||||
'grant_type' => 'client_credentials',
|
||||
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
'client_assertion' => $jwt,
|
||||
'scope' => implode(' ', $scopes),
|
||||
];
|
||||
|
||||
$url = $registration->getAuthTokenUrl();
|
||||
|
||||
// Get Access
|
||||
$tokenRequest = new ServiceRequest('POST', $url);
|
||||
$tokenRequest->setBody(http_build_query($authRequest, '', '&'));
|
||||
$tokenRequest->setContentType('application/x-www-form-urlencoded');
|
||||
$tokenRequest->setAccept('application/json');
|
||||
$response = $this->client->request(
|
||||
$tokenRequest->getMethod(),
|
||||
$tokenRequest->getUrl(),
|
||||
[
|
||||
'headers' => $tokenRequest->getPayload()['headers'],
|
||||
'body' => $tokenRequest->getPayload()['body']
|
||||
]
|
||||
);
|
||||
$tokenData = $this->getResponseBody($response);
|
||||
|
||||
// Cache access token
|
||||
$this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']);
|
||||
|
||||
return $tokenData['access_token'];
|
||||
}
|
||||
|
||||
|
||||
public function makeRequest(IServiceRequest $request)
|
||||
{
|
||||
return $this->client->request(
|
||||
$request->getMethod(),
|
||||
$request->getUrl(),
|
||||
$request->getPayload()
|
||||
);
|
||||
}
|
||||
|
||||
public function getResponseBody(IHttpResponse $response): ?array
|
||||
{
|
||||
$responseBody = (string) $response->getBody();
|
||||
|
||||
return json_decode($responseBody, true);
|
||||
}
|
||||
|
||||
public function makeServiceRequest(
|
||||
ILtiRegistration $registration,
|
||||
array $scopes,
|
||||
IServiceRequest $request,
|
||||
bool $shouldRetry = true
|
||||
): array {
|
||||
$request->setAccessToken($this->getAccessToken($registration, $scopes));
|
||||
try {
|
||||
$response = $this->makeRequest($request);
|
||||
} catch (IHttpException $e) {
|
||||
$status = $e->getResponse()->getStatusCode();
|
||||
// If the error was due to invalid authentication and the request
|
||||
// should be retried, clear the access token and retry it.
|
||||
if ($status === 401 && $shouldRetry) {
|
||||
$key = $this->getAccessTokenCacheKey($registration, $scopes);
|
||||
$this->cache->clearAccessToken($key);
|
||||
|
||||
return $this->makeServiceRequest($registration, $scopes, $request, false);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$responseHeaders = $response->getHeaders();
|
||||
$responseBody = $this->getResponseBody($response);
|
||||
|
||||
if ($this->debuggingMode) {
|
||||
error_log('Syncing grade for this lti_user_id: '.
|
||||
json_decode($request->getPayload()['body'])->userId.' '.print_r([
|
||||
'request_method' => $request->getMethod(),
|
||||
'request_url' => $request->getUrl(),
|
||||
'request_body' => $request->getPayload()['body'],
|
||||
'response_headers' => $responseHeaders,
|
||||
'response_body' => json_encode($responseBody),
|
||||
], true));
|
||||
}
|
||||
|
||||
return [
|
||||
'headers' => $responseHeaders,
|
||||
'body' => $responseBody,
|
||||
'status' => $response->getStatusCode(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getAll(
|
||||
ILtiRegistration $registration,
|
||||
array $scopes,
|
||||
IServiceRequest $request,
|
||||
string $key = null
|
||||
): array {
|
||||
if ($request->getMethod() !== static::METHOD_GET) {
|
||||
throw new \Exception('An invalid method was specified by an LTI service requesting all items.');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$nextUrl = $request->getUrl();
|
||||
|
||||
while ($nextUrl) {
|
||||
$response = $this->makeServiceRequest($registration, $scopes, $request);
|
||||
|
||||
$page_results = $key === null ? ($response['body'] ?? []) : ($response['body'][$key] ?? []);
|
||||
$results = array_merge($results, $page_results);
|
||||
|
||||
$nextUrl = $this->getNextUrl($response['headers']);
|
||||
if ($nextUrl) {
|
||||
$request->setUrl($nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes)
|
||||
{
|
||||
sort($scopes);
|
||||
$scopeKey = md5(implode('|', $scopes));
|
||||
|
||||
return $registration->getIssuer().$registration->getClientId().$scopeKey;
|
||||
}
|
||||
|
||||
private function getNextUrl(array $headers)
|
||||
{
|
||||
$subject = $headers['Link'] ?? '';
|
||||
preg_match(LtiServiceConnector::NEXT_PAGE_REGEX, $subject, $matches);
|
||||
|
||||
return $matches[1] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\MessageValidators;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\IMessageValidator;
|
||||
use Packback\Lti1p3\LtiConstants;
|
||||
use Packback\Lti1p3\LtiException;
|
||||
|
||||
class DeepLinkMessageValidator implements IMessageValidator
|
||||
{
|
||||
public function canValidate(array $jwtBody)
|
||||
{
|
||||
return $jwtBody[LtiConstants::MESSAGE_TYPE] === 'LtiDeepLinkingRequest';
|
||||
}
|
||||
|
||||
public function validate(array $jwtBody)
|
||||
{
|
||||
if (empty($jwtBody['sub'])) {
|
||||
throw new LtiException('Must have a user (sub)');
|
||||
}
|
||||
if ($jwtBody[LtiConstants::VERSION] !== LtiConstants::V1_3) {
|
||||
throw new LtiException('Incorrect version, expected 1.3.0');
|
||||
}
|
||||
if (!isset($jwtBody[LtiConstants::ROLES])) {
|
||||
throw new LtiException('Missing Roles Claim');
|
||||
}
|
||||
if (empty($jwtBody[LtiConstants::DL_DEEP_LINK_SETTINGS])) {
|
||||
throw new LtiException('Missing Deep Linking Settings');
|
||||
}
|
||||
$deep_link_settings = $jwtBody[LtiConstants::DL_DEEP_LINK_SETTINGS];
|
||||
if (empty($deep_link_settings['deep_link_return_url'])) {
|
||||
throw new LtiException('Missing Deep Linking Return URL');
|
||||
}
|
||||
if (empty($deep_link_settings['accept_types']) || !in_array('ltiResourceLink', $deep_link_settings['accept_types'])) {
|
||||
throw new LtiException('Must support resource link placement types');
|
||||
}
|
||||
if (empty($deep_link_settings['accept_presentation_document_targets'])) {
|
||||
throw new LtiException('Must support a presentation type');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\MessageValidators;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\IMessageValidator;
|
||||
use Packback\Lti1p3\LtiConstants;
|
||||
use Packback\Lti1p3\LtiException;
|
||||
|
||||
class ResourceMessageValidator implements IMessageValidator
|
||||
{
|
||||
public function canValidate(array $jwtBody)
|
||||
{
|
||||
return $jwtBody[LtiConstants::MESSAGE_TYPE] === 'LtiResourceLinkRequest';
|
||||
}
|
||||
|
||||
public function validate(array $jwtBody)
|
||||
{
|
||||
if (empty($jwtBody['sub'])) {
|
||||
throw new LtiException('Must have a user (sub)');
|
||||
}
|
||||
if (!isset($jwtBody[LtiConstants::VERSION])) {
|
||||
throw new LtiException('Missing LTI Version');
|
||||
}
|
||||
if ($jwtBody[LtiConstants::VERSION] !== LtiConstants::V1_3) {
|
||||
throw new LtiException('Incorrect version, expected 1.3.0');
|
||||
}
|
||||
if (!isset($jwtBody[LtiConstants::ROLES])) {
|
||||
throw new LtiException('Missing Roles Claim');
|
||||
}
|
||||
if (empty($jwtBody[LtiConstants::RESOURCE_LINK]['id'])) {
|
||||
throw new LtiException('Missing Resource Link Id');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3\MessageValidators;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\IMessageValidator;
|
||||
use Packback\Lti1p3\LtiConstants;
|
||||
use Packback\Lti1p3\LtiException;
|
||||
|
||||
class SubmissionReviewMessageValidator implements IMessageValidator
|
||||
{
|
||||
public function canValidate(array $jwtBody)
|
||||
{
|
||||
return $jwtBody[LtiConstants::MESSAGE_TYPE] === 'LtiSubmissionReviewRequest';
|
||||
}
|
||||
|
||||
public function validate(array $jwtBody)
|
||||
{
|
||||
if (empty($jwtBody['sub'])) {
|
||||
throw new LtiException('Must have a user (sub)');
|
||||
}
|
||||
if ($jwtBody[LtiConstants::VERSION] !== LtiConstants::V1_3) {
|
||||
throw new LtiException('Incorrect version, expected 1.3.0');
|
||||
}
|
||||
if (!isset($jwtBody[LtiConstants::ROLES])) {
|
||||
throw new LtiException('Missing Roles Claim');
|
||||
}
|
||||
if (empty($jwtBody[LtiConstants::RESOURCE_LINK]['id'])) {
|
||||
throw new LtiException('Missing Resource Link Id');
|
||||
}
|
||||
if (empty($jwtBody[LtiConstants::FOR_USER])) {
|
||||
throw new LtiException('Missing For User');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\ICookie;
|
||||
|
||||
class Redirect
|
||||
{
|
||||
private $location;
|
||||
private $referer_query;
|
||||
private static $CAN_302_COOKIE = 'LTI_302_Redirect';
|
||||
|
||||
public function __construct(string $location, string $referer_query = null)
|
||||
{
|
||||
$this->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()
|
||||
{
|
||||
?>
|
||||
<a id="try-again" target="_blank">If you are not automatically redirected, click here to continue</a>
|
||||
<script>
|
||||
|
||||
document.getElementById('try-again').href=<?php
|
||||
if (empty($this->referer_query)) {
|
||||
echo 'window.location.href';
|
||||
} else {
|
||||
echo "window.location.origin + window.location.pathname + '?".$this->referer_query."'";
|
||||
} ?>;
|
||||
|
||||
var canAccessCookies = function() {
|
||||
if (!navigator.cookieEnabled) {
|
||||
// We don't have access
|
||||
return false;
|
||||
}
|
||||
// Firefox returns true even if we don't actually have access
|
||||
try {
|
||||
if (!document.cookie || document.cookie == "" || document.cookie.indexOf('<?php echo self::$CAN_302_COOKIE; ?>') === -1) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (canAccessCookies()) {
|
||||
// We have access, continue with redirect
|
||||
window.location = '<?php echo $this->location; ?>';
|
||||
} else {
|
||||
// We don't have access, reopen flow in a new window.
|
||||
var opened = window.open(document.getElementById('try-again').href, '_blank');
|
||||
if (opened) {
|
||||
document.getElementById('try-again').innerText = "New window opened, click to reopen";
|
||||
} else {
|
||||
document.getElementById('try-again').innerText = "Popup blocked, click to open in a new window";
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Packback\Lti1p3;
|
||||
|
||||
use Packback\Lti1p3\Interfaces\IServiceRequest;
|
||||
|
||||
class ServiceRequest implements IServiceRequest
|
||||
{
|
||||
private $method;
|
||||
private $url;
|
||||
private $body;
|
||||
private $accessToken;
|
||||
private $contentType = 'application/json';
|
||||
private $accept = 'application/json';
|
||||
|
||||
public function __construct(string $method, string $url)
|
||||
{
|
||||
$this->method = $method;
|
||||
$this->url = $url;
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return strtoupper($this->method);
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function getPayload(): array
|
||||
{
|
||||
$payload = [
|
||||
'headers' => $this->getHeaders(),
|
||||
];
|
||||
|
||||
$body = $this->getBody();
|
||||
if ($body) {
|
||||
$payload['body'] = $body;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function setUrl(string $url): IServiceRequest
|
||||
{
|
||||
$this->url = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setAccessToken(string $accessToken): IServiceRequest
|
||||
{
|
||||
$this->accessToken = 'Bearer '.$accessToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setBody(string $body): IServiceRequest
|
||||
{
|
||||
$this->body = $body;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setAccept(string $accept): IServiceRequest
|
||||
{
|
||||
$this->accept = $accept;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setContentType(string $contentType): IServiceRequest
|
||||
{
|
||||
$this->contentType = $contentType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function getHeaders(): array
|
||||
{
|
||||
$headers = [
|
||||
'Accept' => $this->accept,
|
||||
];
|
||||
|
||||
if (isset($this->accessToken)) {
|
||||
$headers['Authorization'] = $this->accessToken;
|
||||
}
|
||||
|
||||
if ($this->getMethod() === LtiServiceConnector::METHOD_POST) {
|
||||
$headers['Content-Type'] = $this->contentType;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private function getBody(): ?string
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
Description of php-jwt library import into Moodle
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
- The lib/lti1p3 library currently depends on version 6.0 of php-jwt.
|
||||
- There are usages of this library in mod/lti too. Please check these.
|
||||
|
||||
Instructions
|
||||
------------
|
||||
1. Visit [https://github.com/firebase/php-jwt].
|
||||
2. Click on 'X releases'.
|
||||
3. Download the latest release.
|
||||
4. Unzip it in lib as php-jwt.
|
||||
5. Update entry for this library in lib/thirdpartylibs.xml.
|
||||
|
||||
1. Check dependencies to confirm suitability of the new version of the library (see above).
|
||||
2. Visit [https://github.com/firebase/php-jwt].
|
||||
3. Click on 'X releases'.
|
||||
4. Download the latest release.
|
||||
5. Unzip it in lib as php-jwt.
|
||||
6. Update entry for this library in lib/thirdpartylibs.xml.
|
||||
|
||||
@@ -256,6 +256,13 @@
|
||||
<version>3.0.2</version>
|
||||
<licenseversion>2.0</licenseversion>
|
||||
</library>
|
||||
<library>
|
||||
<location>lti1p3</location>
|
||||
<name>LTI 1.3 Tool Library</name>
|
||||
<license>Apache</license>
|
||||
<version>5.1.0</version>
|
||||
<licenseversion>2.0</licenseversion>
|
||||
</library>
|
||||
<library>
|
||||
<location>amd/src/truncate.js</location>
|
||||
<name>Truncate.js</name>
|
||||
|
||||
Reference in New Issue
Block a user