Skip to content

Commit

Permalink
Merge pull request #11 from packbackbooks/cache-auth-tokens
Browse files Browse the repository at this point in the history
PODA-7: Update the LTI 1.3 Library to cache auth tokens
  • Loading branch information
cophaso authored Jun 24, 2021
2 parents f555dce + 63c4e77 commit 0d37803
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 54 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
],
"require": {
"firebase/php-jwt": "^5.2",
"guzzlehttp/oauth-subscriber": "^0.5.0",
"phpseclib/phpseclib": "^2.0"
},
"require-dev": {
Expand Down
15 changes: 15 additions & 0 deletions src/ImsStorage/ImsCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ public function checkNonce($nonce)
return true;
}

public function cacheAccessToken($key, $accessToken)
{
$this->cache[$key] = $accessToken;
$this->saveCache();

return $this;
}

public function getAccessToken($key)
{
$this->loadCache();

return $this->cache[$key];
}

private function loadCache()
{
$cache = file_get_contents(sys_get_temp_dir().'/lti_cache.txt');
Expand Down
4 changes: 4 additions & 0 deletions src/Interfaces/ICache.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ public function cacheLaunchData($key, $jwtBody);
public function cacheNonce($nonce);

public function checkNonce($nonce);

public function cacheAccessToken($key, $accessToken);

public function getAccessToken($key);
}
4 changes: 2 additions & 2 deletions src/LtiAssignmentsGradesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function putGrade(LtiGrade $grade, LtiLineitem $lineitem = null)
$this->service_data['scope'],
LtiServiceConnector::METHOD_POST,
$score_url,
strval($grade),
$grade,
'application/vnd.ims.lis.v1.score+json'
);
}
Expand Down Expand Up @@ -78,7 +78,7 @@ public function findOrCreateLineitem(LtiLineitem $new_line_item)
$this->service_data['scope'],
LtiServiceConnector::METHOD_POST,
$this->service_data['lineitems'],
strval($new_line_item),
$new_line_item,
'application/vnd.ims.lis.v2.lineitem+json',
'application/vnd.ims.lis.v2.lineitem+json'
);
Expand Down
112 changes: 64 additions & 48 deletions src/LtiServiceConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace Packback\Lti1p3;

use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ILtiRegistration;
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;

Expand All @@ -13,90 +15,104 @@ class LtiServiceConnector implements ILtiServiceConnector
const METHOD_GET = 'GET';
const METHOD_POST = 'POST';

private $cache;
private $client;
private $registration;
private $access_tokens = [];

public function __construct(ILtiRegistration $registration)
public function __construct(ILtiRegistration $registration, ICache $cache, Client $client)
{
$this->registration = $registration;
$this->cache = $cache;
$this->client = $client;
}

public function getAccessToken(array $scopes)
{
// Don't fetch the same key more than once.
sort($scopes);
$scope_key = md5(implode('|', $scopes));
if (isset($this->access_tokens[$scope_key])) {
return $this->access_tokens[$scope_key];
// Build up JWT to exchange for an auth token
$clientId = $this->registration->getClientId();

// Store access token with a unique key
$accessTokenKey = $this->getAccessTokenCacheKey($scopes);

// Get Access Token from cache if it exists and is not expired.
if ($this->cache->getAccessToken($accessTokenKey)) {
return $this->cache->getAccessToken($accessTokenKey);
}

// Build up JWT to exchange for an auth token
$client_id = $this->registration->getClientId();
$jwt_claim = [
'iss' => $client_id,
'sub' => $client_id,
$jwtClaim = [
'iss' => $clientId,
'sub' => $clientId,
'aud' => $this->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($jwt_claim, $this->registration->getToolPrivateKey(), 'RS256', $this->registration->getKid());
$jwt = JWT::encode($jwtClaim, $this->registration->getToolPrivateKey(), 'RS256', $this->registration->getKid());

// Build auth token request headers
$auth_request = [
$authRequest = [
'grant_type' => 'client_credentials',
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => $jwt,
'scope' => implode(' ', $scopes),
];

// Make request to get auth token
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->registration->getAuthTokenUrl());
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($auth_request));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$resp = curl_exec($ch);
$token_data = json_decode($resp, true);
curl_close($ch);

return $this->access_tokens[$scope_key] = $token_data['access_token'];
$url = $this->registration->getAuthTokenUrl();

// Get Access
$response = $this->client->post($url, [
'form_params' => $authRequest,
]);

$tokenData = json_decode($response->getBody()->__toString(), true);

// Cache access token
$this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']);

return $tokenData['access_token'];
}

public function makeServiceRequest(array $scopes, $method, $url, $body = null, $contentType = 'application/json', $accept = 'application/json')
{
$ch = curl_init();
$headers = [
'Authorization: Bearer '.$this->getAccessToken($scopes),
'Accept:'.$accept,
'Authorization' => 'Bearer '.$this->getAccessToken($scopes),
'Accept' => $accept,
];
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, strval($body));
$headers[] = 'Content-Type: '.$contentType;
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
if (curl_errno($ch)) {
echo 'Request Error:'.curl_error($ch);

switch (strtoupper($method)) {
case 'POST':
$headers = array_merge($headers, ['Content-Type' => $contentType]);
$response = $this->client->request($method, $url, [
'headers' => $headers,
'json' => $body,
]);
break;
default:
$response = $this->client->request($method, $url, [
'headers' => $headers,
]);
break;
}
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);

$resp_headers = substr($response, 0, $header_size);
$resp_body = substr($response, $header_size);
$respHeaders = $response->getHeaders();
$respBody = $response->getBody();

return [
'headers' => array_filter(explode("\r\n", $resp_headers)),
'body' => json_decode($resp_body, true),
'headers' => array_filter(explode("\r\n", $respHeaders)),
'body' => json_decode($respBody, true),
];
}

private function getAccessTokenCacheKey(array $scopes)
{
// Don't fetch the same key more than once.
sort($scopes);

$scopeKey = md5(implode('|', $scopes));

return $this->registration->getIssuer().$this->registration->getClientId().$scopeKey;
}
}
12 changes: 12 additions & 0 deletions tests/Certification/Lti13CertificationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ public function checkNonce($nonce)
{
return $this->nonce === $nonce;
}

public function cacheAccessToken($key, $accessToken)
{
$this->launchData[$key] = $accessToken;

return $this;
}

public function getAccessToken($key)
{
return $this->launchData[$key] ?? null;
}
}

class TestCookie implements ICookie
Expand Down
78 changes: 74 additions & 4 deletions tests/LtiServiceConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,93 @@

namespace Tests;

// use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Mockery;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ILtiRegistration;
use Packback\Lti1p3\LtiServiceConnector;
use PHPUnit\Framework\TestCase;

class LtiServiceConnectorTest extends TestCase
{
public function setUp(): void
{
// $this->jwt = Mockery::mock(JWT::class);
$this->registration = Mockery::mock(ILtiRegistration::class);
$this->cache = Mockery::mock(ICache::class);
$this->client = Mockery::mock(Client::class);

$this->connector = new LtiServiceConnector($this->registration, $this->cache, $this->client);
}

public function testItInstantiates()
{
$registration = Mockery::mock(ILtiRegistration::class);
$this->assertInstanceOf(LtiServiceConnector::class, $this->connector);
}

$connector = new LtiServiceConnector($registration);
public function testItGetsCachedAccessToken()
{
$this->registration->shouldReceive('getClientId')
->once()
->andReturn('client_id');
$this->registration->shouldReceive('getIssuer')
->once()
->andReturn('issuer');
$this->cache->shouldReceive('getAccessToken')
->once()
->andReturn('TokenOfAccess');

$this->assertInstanceOf(LtiServiceConnector::class, $connector);
$result = $this->connector->getAccessToken(['scopeKey']);

$this->assertEquals($result, 'TokenOfAccess');
}

/*
* @todo Finish testing
* @todo Figure out how to test this
*/
// public function testItGetsAccessToken()
// {
// $this->registration->shouldReceive('getClientId')
// ->once()
// ->andReturn('client_id');
// $this->registration->shouldReceive('getIssuer')
// ->once()
// ->andReturn('issuer');
// $this->cache->shouldReceive('getAccessToken')
// ->once()
// ->andReturn();
// $this->registration->shouldReceive('getAuthServer')
// ->once()
// ->andReturn('auth_server');
// $this->registration->shouldReceive('getToolPrivateKey')
// ->once()
// ->andReturn('toolprivatekey');
// $this->registration->shouldReceive('getKid')
// ->once()
// ->andReturn('kid');

// Error: supplied key param cannot be coerced into a private key
// $this->jwt->shouldReceive('encode')
// ->once()
// ->andReturn('jwt');

// $this->registration->shouldReceive('getAuthTokenUrl')
// ->once()
// ->andReturn('auth_token_url');
// $this->client->shouldReceive('post')
// ->once()
// ->andReturn([
// 'body' => [
// 'access_token' => 'accessToken'
// ]
// ]);
// $this->cache->shouldReceive('cacheAccessToken')
// ->once()
// ->andReturn();

// $result = $this->connector->getAccessToken(['scopeKey']);

// $this->assertEquals($result, 'TokenOfAccess');
// }
}

0 comments on commit 0d37803

Please sign in to comment.