src/LtiServiceConnector.php
<?php
namespace Packback\Lti1p3;
use Exception;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
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 bool $debuggingMode = false;
public function __construct(
private ICache $cache,
private Client $client
) {}
public function setDebuggingMode(bool $enable): self
{
$this->debuggingMode = $enable;
return $this;
}
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 (isset($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),
];
// Get Access
$request = new ServiceRequest(
ServiceRequest::METHOD_POST,
$registration->getAuthTokenUrl(),
ServiceRequest::TYPE_AUTH
);
$request->setPayload(['form_params' => $authRequest])
->setMaskResponseLogs(true);
$response = $this->makeRequest($request);
$tokenData = $this->getResponseBody($response);
// Cache access token
$this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']);
return $tokenData['access_token'];
}
public function makeRequest(IServiceRequest $request): ResponseInterface
{
$response = $this->client->request(
$request->getMethod(),
$request->getUrl(),
$request->getPayload()
);
if ($this->debuggingMode) {
$this->logRequest(
$request,
$this->getResponseHeaders($response),
$this->getResponseBody($response)
);
}
return $response;
}
public function getResponseHeaders(ResponseInterface $response): ?array
{
$responseHeaders = $response->getHeaders();
array_walk($responseHeaders, function (&$value) {
$value = $value[0];
});
return $responseHeaders;
}
public function getResponseBody(ResponseInterface $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 (ClientException $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;
}
return [
'headers' => $this->getResponseHeaders($response),
'body' => $this->getResponseBody($response),
'status' => $response->getStatusCode(),
];
}
public function getAll(
ILtiRegistration $registration,
array $scopes,
IServiceRequest $request,
?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.');
}
$results = [];
$nextUrl = $request->getUrl();
while ($nextUrl) {
$request->setUrl($nextUrl);
$response = $this->makeServiceRequest($registration, $scopes, $request);
$pageResults = $this->getResultsFromResponse($response, $key);
$results = array_merge($results, $pageResults);
$nextUrl = $this->getNextUrl($response['headers']);
}
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 {
error_log(self::getLogMessage($request, $responseHeaders, $responseBody));
}
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));
return $registration->getIssuer().$registration->getClientId().$scopeKey;
}
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);
return $matches[1] ?? null;
}
}