MDL-79675 libraries: upgrade lti1p3 to v6.0.0

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