<?php
require_once("../config/database.php");
require_once("lti.php");
require_once("response.php");
require 'vendor/autoload.php'; // Include the Composer autoloader
require_once 'vendor/firebase/php-jwt/src/JWT.php';

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;

define('LTI_ACCESS_TOKEN_LIFE', 3600);
define('LTI_RSA_KEY', 'RSA_KEY');
define('LTI_JWK_KEYSET', 'JWK_KEYSET');

/** Full access to Gradebook services */
const GRADEBOOKSERVICES_FULL = 2;
/** Scope for full access to Lineitem service */
const SCOPE_GRADEBOOKSERVICES_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem';
/** Scope for full access to Lineitem service */
const SCOPE_GRADEBOOKSERVICES_LINEITEM_READ = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly';
/** Scope for access to Result service */
const SCOPE_GRADEBOOKSERVICES_RESULT_READ = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly';
/** Scope for access to Score service */
const SCOPE_GRADEBOOKSERVICES_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score';

$response = new response();
$lti = new LTI();

$contenttype = isset($_SERVER['CONTENT_TYPE']) ? explode(';', $_SERVER['CONTENT_TYPE'], 2)[0] : '';

$ok = ($_SERVER['REQUEST_METHOD'] === 'POST') && ($contenttype === 'application/x-www-form-urlencoded');
$error = 'invalid_request';

//Get the parameters
$clientassertion = $_REQUEST['client_assertion'];
$clientassertiontype = $_REQUEST['client_assertion_type'];
$granttype = $_REQUEST['grant_type'];
$scope = $_REQUEST['scope'];

if ($ok) {
    $ok = !empty($clientassertion) && !empty($clientassertiontype) &&
        !empty($granttype) && !empty($scope);
}
if ($ok) {
    $ok = ($clientassertiontype === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer') &&
        ($granttype === 'client_credentials');
    $error = 'unsupported_grant_type';
}
if ($ok) {
    $parts = explode('.', $clientassertion);
    $ok = (count($parts) === 3);
    if ($ok) {
        $payload = JWT::urlsafeB64Decode($parts[1]);
        $claims = json_decode($payload, true);
        $ok = !is_null($claims) && !empty($claims['sub']);
    }
    $error = 'invalid_request';
}
if ($ok) {
    $tool = new stdClass();
    $stmt = $lti->get_lti_types_from_clientId($claims['sub']);
    if ($result = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $tool->lti_typename = $result['tool_name'];
        $tool->typeid = $result['lti_types_id'];
        $tool->lti_toolurl = $result['tool_url'];
        $tool->lti_ltiversion = $result['lti_version'];
        $tool->lti_clientid = $result['client_id'];
        $tool->toolproxyid = $result['tool_proxy_id'];
        $tool->lti_parameters = $result['parameter'];
    }

    $typeconfig = array();
    //Fetch LTI Types Config
    $stmt = $lti->get_lti_types_config($tool->typeid);
    while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $typeconfig[$result['name']] = $result['value'];
    }

    if ($tool) {
        try {
            lti_verify_jwt_signature($tool, $typeconfig, $claims['sub'], $clientassertion);
            $ok = true;
        } catch (Exception $e) {
            $error = $e->getMessage();
            $ok = false;
        }
    } else {
        $error = 'invalid_client';
        $ok = false;
    }
}
if ($ok) {
    $scopes = array();
    $requestedscopes = explode(' ', $scope);
    $permittedscopes = get_permitted_scopes($tool, $typeconfig);
    $scopes = array_intersect($requestedscopes, $permittedscopes);
    $ok = !empty($scopes);
    $error = 'invalid_scope';
}
if ($ok) {
    $token = lti_new_access_token($tool->typeid, $scopes, $date_time);
    $expiry = LTI_ACCESS_TOKEN_LIFE;
    $permittedscopes = implode(' ', $scopes);
    $body = <<<EOD
{
  "access_token" : "{$token->token}",
  "token_type" : "Bearer",
  "expires_in" : {$expiry},
  "scope" : "{$permittedscopes}"
}
EOD;
} else {
    $response->set_code(400);
    $body = <<<EOD
{
  "error" : "{$error}",
}
EOD;
}

$response->set_body($body);
// Send response to Provider
$response->send();

function get_permitted_scopes($tool, $typeconfig)
{
    $scopes = array();
    $ok = !empty($tool);
    if ($ok && isset($typeconfig['lti_service_grade_synchronization'])) {
        if (!empty($setting = $typeconfig['lti_service_grade_synchronization'])) {
            $scopes[] = SCOPE_GRADEBOOKSERVICES_LINEITEM_READ;
            $scopes[] = SCOPE_GRADEBOOKSERVICES_RESULT_READ;
            $scopes[] = SCOPE_GRADEBOOKSERVICES_SCORE;
            if ($setting == GRADEBOOKSERVICES_FULL) {
                $scopes[] = SCOPE_GRADEBOOKSERVICES_LINEITEM;
            }
        }
    }
    return $scopes;
}

/**
 * Create a new access token.
 *
 * @param int $typeid Tool type ID
 * @param string[] $scopes Scopes permitted for new token
 *
 * @return stdClass Access token
 */
function lti_new_access_token($typeid, $scopes, $date_time)
{
    $lti = new LTI();
    // Make sure the token doesn't exist (even if it should be almost impossible with the random generation).
    $generatedtoken = "";
    $ok = true;
    while ($ok) {
        $generatedtoken = md5(uniqid(rand(), 1));
        $stmt = $lti->token_record_exists($generatedtoken);
        if ($stmt->rowCount() == 0) {
            $ok = false;
        }
    }

    $newtoken = new stdClass();
    $newtoken->typeid = $typeid;
    $newtoken->scope = json_encode(array_values($scopes));
    $newtoken->token = $generatedtoken;
    $newtoken->timecreated = $date_time;
    $validuntil = strtotime($newtoken->timecreated) + LTI_ACCESS_TOKEN_LIFE;
    $newtoken->validuntil = date('Y-m-d H:i:s', $validuntil);
    $newtoken->lastaccess = null;

    $lti->insert_token($newtoken);

    return $newtoken;
}

function lti_verify_jwt_signature($tool, $typeconfig, $consumerkey, $jwtparam)
{
    // Validate parameters.
    if (!$tool) {
        throw new Exception('errortooltypenotfound');
    }
    if (isset($tool->toolproxyid)) {
        throw new Exception('JWT security not supported with LTI 2');
    }

    $key = $tool->lti_clientid ?? '';

    if ($consumerkey !== $key) {
        throw new Exception('errorincorrectconsumerkey');
    }

    if (empty($typeconfig['keytype']) || $typeconfig['keytype'] === LTI_RSA_KEY) {
        $publickey = $typeconfig['publickey'] ?? '';
        if (empty($publickey)) {
            throw new Exception('No public key configured');
        }
        // Attemps to verify jwt with RSA key.
        JWT::decode($jwtparam, new Key($publickey, 'RS256'));
    } else if ($typeconfig['keytype'] === LTI_JWK_KEYSET) {
        $keyseturl = $typeconfig['publickeyset'] ?? '';
        if (empty($keyseturl)) {
            throw new Exception('No public keyset configured');
        }
        // Attempts to verify jwt with jwk keyset.
        lti_verify_with_keyset($jwtparam, $keyseturl, $tool->lti_clientid);
    } else {
        throw new Exception('Invalid public key type');
    }

    return $tool;
}

/**
 * Verifies the JWT signature using a JWK keyset.
 *
 * @param string $jwtparam JWT parameter value.
 * @param string $keyseturl The tool keyseturl.
 * @param string $clientid The tool client id.
 *
 * @return object The JWT's payload as a PHP object
 */
function lti_verify_with_keyset($jwtparam, $keyseturl, $clientid)
{
    //download the content from $keyseturl
    try {
        $keyset = download_file_content($keyseturl);
        if (empty($keyset)) {
            throw new Exception('errornocachedkeysetfound');
        }

        $keysetarr = json_decode($keyset, true);

        // Fix for firebase/php-jwt's dependency on the optional 'alg' property in the JWK.
        $keysetarr = fix_jwks_alg($keysetarr, $jwtparam);
        // JWK::parseKeySet uses RS256 algorithm by default.
        $keys = JWK::parseKeySet($keysetarr);
        $jwt = JWT::decode($jwtparam, $keys);
    } catch (Exception $e) {
        throw new Exception('Error verifying keyset');
    }
    return $jwt;
}

/**
 * Take an array of JWKS keys and infer the 'alg' property for a single key, if missing, based on an input JWT.
 *
 * This only sets the 'alg' property for a single key when all the following conditions are met:
 * - The key's 'kid' matches the 'kid' provided in the JWT's header.
 * - The key's 'alg' is missing.
 * - The JWT's header 'alg' matches the algorithm family of the key (the key's kty).
 * - The JWT's header 'alg' matches one of the approved LTI asymmetric algorithms.
 *
 * Keys not matching the above are left unchanged.
 *
 * @param array $jwks the keyset array.
 * @param string $jwt the JWT string.
 * @return array the fixed keyset array.
 */
function fix_jwks_alg(array $jwks, string $jwt): array
{
    /**
     *
     * See https://www.imsglobal.org/spec/security/v1p1#approved-jwt-signing-algorithms.
     * @var string[]
     */
    $ltisupportedalgs = [
        'RS256' => 'RSA',
        'RS384' => 'RSA',
        'RS512' => 'RSA',
        'ES256' => 'EC',
        'ES384' => 'EC',
        'ES512' => 'EC'
    ];

    $jwtparts = explode('.', $jwt);
    $jwtheader = json_decode(JWT::urlsafeB64Decode($jwtparts[0]), true);
    if (!isset($jwtheader['kid'])) {
        throw new Exception('Error: kid must be provided in JWT header.');
    }

    foreach ($jwks['keys'] as $index => $key) {
        // Only fix the key being referred to in the JWT.
        if ($jwtheader['kid'] != $key['kid']) {
            continue;
        }

        // Only fix the key if the alg is missing.
        if (!empty($key['alg'])) {
            continue;
        }

        // The header alg must match the key type (family) specified in the JWK's kty.
        if (
            !isset($ltisupportedalgs[$jwtheader['alg']]) || $ltisupportedalgs[$jwtheader['alg']] != $key['kty']
        ) {
            throw new Exception('Error: Alg specified in the JWT header is incompatible with the JWK key type');
        }

        $jwks['keys'][$index]['alg'] = $jwtheader['alg'];
    }

    return $jwks;
}

/**
 * Fetches content of file from Internet (using proxy if defined). Uses cURL extension if present.
 * Due to security concerns only downloads from http(s) sources are supported.
 *
 * @category files
 * @param string $url file url starting with http(s)://
 * @return stdClass|string|bool stdClass object if $fullresponse is true, false if request failed, true
 *   if file downloaded into $tofile successfully or the file content as a string.
 */
function download_file_content($url)
{
    $ch = curl_init();
    // IMPORTANT: the below line is a security risk, read https://paragonie.com/blog/2017/10/certainty-automated-cacert-pem-management-for-php-software
    // in most cases, you should set it to true
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_URL, $url);
    $result = curl_exec($ch);
    curl_close($ch);

    return $result;
}