Skip to content

Commit

Permalink
MBS-9476: Refactor Google OAuth handling to be usable for other subpl…
Browse files Browse the repository at this point in the history
…ugins
  • Loading branch information
PhMemmel committed Dec 2, 2024
1 parent b9b621c commit d1269c3
Show file tree
Hide file tree
Showing 11 changed files with 276 additions and 127 deletions.
96 changes: 96 additions & 0 deletions classes/local/aitool_option_vertexai.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

namespace local_ai_manager\local;

use stdClass;

/**
* Helper class for providing the necessary extension functions to implement the Google OAuth authentication for access to
* Google's Vertex AI.
*
* @package local_ai_manager
* @copyright 2024 ISB Bayern
* @author Philipp Memmel
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class aitool_option_vertexai {

/**
* Extends the form definition of the edit instance form by adding the Vertex AI options.
*
* @param \MoodleQuickForm $mform the mform object
*/
public static function extend_form_definition(\MoodleQuickForm $mform): void {
$mform->freeze('apikey');
$mform->freeze('endpoint');
$mform->addElement('textarea', 'serviceaccountjson',
get_string('serviceaccountjson', 'local_ai_manager'), ['rows' => '20']);
}

/**
* Adds the Vertex AI data to the form data to be passed to the form when loading.
*
* @param string $serviceaccountjson the service account JSON string
* @return stdClass the object to pass to the form when loading
*/
public static function add_vertexai_to_form_data(string $serviceaccountjson): stdClass {
$data = new stdClass();
$data->serviceaccountjson = $serviceaccountjson;
return $data;
}

/**
* Extract the service account JSON and calculate the new endpoint from the form data submitted by the form.
*
* @param stdClass $data the form data after submission
* @return array array of the service account JSON and the calculated endpoint
*/
public static function extract_vertexai_to_store(stdClass $data): array {
$serviceaccountjson = trim($data->serviceaccountjson);
$serviceaccountinfo = json_decode($serviceaccountjson);
$projectid = $serviceaccountinfo->project_id;

$endpoint = 'https://europe-west3-aiplatform.googleapis.com/v1/projects/' . $projectid
. '/locations/europe-west3/publishers/google/models/'
. $data->model . ':predict';
return [$serviceaccountjson, $endpoint];
}

/**
* Validation function for the Vertex AI option when form is being submitted.
*
* @param array $data the data being submitted by the form
* @return array associative array ['mformelementname' => 'error string'] if there are validation errors, otherwise empty array
*/
public static function validate_vertexai(array $data): array {
$errors = [];
if (empty($data['serviceaccountjson'])) {
$errors['serviceaccountjson'] = get_string('error_vertexai_serviceaccountjsonempty', 'local_ai_manager');
return $errors;
}

$serviceaccountinfo = json_decode(trim($data['serviceaccountjson']));
foreach (['private_key_id', 'private_key', 'client_email'] as $field) {
if (!property_exists($serviceaccountinfo, $field)) {
$errors['serviceaccountjson'] = get_string('error_vertexai_serviceaccountjsoninvalid', 'local_ai_manager', $field);
break;
}
}

return $errors;
}
}
143 changes: 143 additions & 0 deletions classes/local/aitool_option_vertexai_authhandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

namespace local_ai_manager\local;

use core\http_client;
use Firebase\JWT\JWT;
use Psr\Http\Client\ClientExceptionInterface;

/**
* Class responsible for handling authentication with Vertex AI using Google's OAuth mechanism.
*/
class aitool_option_vertexai_authhandler {

/**
* Constructor for the auth handler.
*/
public function __construct(
/** @var int The ID of the instance being used. Will be used as key for the cache handling. */
private readonly int $instanceid,
/** @var string The serviceaccountinfo stringified JSON */
private readonly string $serviceaccountinfo
) {
}

/**
* Retrieves a fresh access token from the Google oauth endpoint.
*
* @return array of the form ['access_token' => 'xxx', 'expires' => 1730805678] containing the access token and the time at
* which the token expires. If there has been an error, the array is of the form
* ['error' => 'more detailed info about the error']
*/
public function retrieve_access_token(): array {
$clock = \core\di::get(\core\clock::class);
$serviceaccountinfo = json_decode($this->serviceaccountinfo);
$kid = $serviceaccountinfo->private_key_id;
$privatekey = $serviceaccountinfo->private_key;
$clientemail = $serviceaccountinfo->client_email;
$jwtpayload = [
'iss' => $clientemail,
'sub' => $clientemail,
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $clock->time(),
'exp' => $clock->time() + HOURSECS,
];
$jwt = JWT::encode($jwtpayload, $privatekey, 'RS256', null, ['kid' => $kid]);

$client = new http_client([
'timeout' => get_config('local_ai_manager', 'requesttimeout'),
]);
$options['query'] = [
'assertion' => $jwt,
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
];

try {
$response = $client->post('https://oauth2.googleapis.com/token', $options);
} catch (ClientExceptionInterface $exception) {
return ['error' => $exception->getMessage()];
}
if ($response->getStatusCode() === 200) {
$content = $response->getBody()->getContents();
if (empty($content)) {
return ['error' => 'Empty response'];
}
$content = json_decode($content, true);
if (empty($content['access_token'])) {
return ['error' => 'Response does not contain "access_token" key'];
}
return [
'access_token' => $content['access_token'],
// We set the expiry time of the access token and reduce it by 10 seconds to avoid some errors caused
// by different clocks on different servers, latency etc.
'expires' => $clock->time() + intval($content['expires_in']) - 10,
];
} else {
return ['error' => 'Response status code is not OK 200, but ' . $response->getStatusCode() . ': ' .
$response->getBody()->getContents()];
}
}

/**
* Gets an access token for accessing Vertex AI endpoints.
*
* This will check if the cached access token still has not expired. If cache is empty or the token has expired
* a new access token will be fetched by calling {@see self::retrieve_access_token} and the new token will be stored
* in the cache.
*
* @return string the access token as string, empty if no
*/
public function get_access_token(): string {
$clock = \core\di::get(\core\clock::class);
$authcache = \cache::make('local_ai_manager', 'googleauth');
$cachedauthinfo = $authcache->get($this->instanceid);
if (empty($cachedauthinfo) || json_decode($cachedauthinfo)->expires < $clock->time()) {
$authinfo = $this->retrieve_access_token();
if (!empty($authinfo['error'])) {
throw new \moodle_exception('Error retrieving access token', '', '', '', $authinfo['error']);
}
$cachedauthinfo = json_encode($authinfo);
$authcache->set($this->instanceid, $cachedauthinfo);
$accesstoken = $authinfo['access_token'];
} else {
$accesstoken = json_decode($cachedauthinfo, true)['access_token'];
}
return $accesstoken;
}

/**
* Refreshes the current access token.
*
* Clears the existing access token and retrieves a new one by invoking {@see self::get_access_token}.
*
* @return string the newly fetched access token as a string
*/
public function refresh_access_token(): string {
$this->clear_access_token();
return $this->get_access_token();
}

/**
* Clears the access token from the cache for the current instance.
*/
public function clear_access_token(): void {
$authcache = \cache::make('local_ai_manager', 'googleauth');
$authcache->delete($this->instanceid);
}

}
4 changes: 2 additions & 2 deletions tools/imagen/db/caches.php → db/caches.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
/**
* Cache definitions.
*
* @package aitool_imagen
* @package local_ai_manager
* @copyright 2024 ISB Bayern
* @author Philipp Memmel
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
Expand All @@ -26,7 +26,7 @@
defined('MOODLE_INTERNAL') || die();

$definitions = [
'auth' => [
'googleauth' => [
'mode' => cache_store::MODE_APPLICATION,
'simplekeys' => true,
'simpledata' => true,
Expand Down
3 changes: 3 additions & 0 deletions lang/de/local_ai_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
$string['error_unavailable_selection'] = 'Dieses Tool ist nur verfügbar, wenn Text markiert wurde.';
$string['error_userlocked'] = 'Ihr Nutzer wurde von Ihrem Tenant-Manager gesperrt.';
$string['error_usernotconfirmed'] = 'Für die Nutzung müssen die Nutzungsbedingungen akzeptiert werden.';
$string['error_vertexai_serviceaccountjsonempty'] = 'Sie müssen den Inhalt der JSON-Datei, die Sie beim Anlegen des Service-Accounts in Ihrer Google-Cloud-Console heruntergeladen haben, einfügen.';
$string['error_vertexai_serviceaccountjsoninvalid'] = 'Ungültiges Format. Es fehlt der Eintrag "{$a}".';
$string['exception_curl'] = 'Es ist ein Fehler bei der Verbindung zum externen KI-Tool aufgetreten.';
$string['exception_curl28'] = 'Die API hat zu lange gebraucht, um Ihre Anfrage zu verarbeiten, oder konnte nicht in angemessener Zeit erreicht werden.';
$string['exception_default'] = 'Ein allgemeiner Fehler ist aufgetreten, während versucht wurde, die Anfrage an das KI-Tool zu senden.';
Expand Down Expand Up @@ -152,6 +154,7 @@
$string['role_unlimited'] = 'Unbeschränkte Rolle';
$string['select_tool_for_purpose'] = 'Einsatzzweck "{$a}"';
$string['selecteduserscount'] = '{$a} ausgewählt';
$string['serviceaccountjson'] = 'Inhalt der JSON-Datei des Google-Serviceaccounts';
$string['settingsgeneral'] = 'Allgemein';
$string['small'] = 'klein';
$string['squared'] = 'quadratisch';
Expand Down
3 changes: 3 additions & 0 deletions lang/en/local_ai_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
$string['error_unavailable_selection'] = 'This tool is only available if no text has been selected.';
$string['error_userlocked'] = 'Your user has been locked by your tenant manager.';
$string['error_usernotconfirmed'] = 'You have not accepted the terms of use yet.';
$string['error_vertexai_serviceaccountjsonempty'] = 'You need to paste the content of the JSON file that you downloaded when creating the service account in your Google Cloud Console.';
$string['error_vertexai_serviceaccountjsoninvalid'] = 'Invalid format. The entry "{$a}" is missing.';
$string['exception_curl'] = 'A connection error to the external API endpoint has occurred';
$string['exception_curl28'] = 'The API took too long to process your request or could not be reached in a reasonable time';
$string['exception_default'] = 'A general error occurred while trying to send the request to the AI tool';
Expand Down Expand Up @@ -152,6 +154,7 @@
$string['role_unlimited'] = 'unlimited role';
$string['select_tool_for_purpose'] = 'Purpose {$a}';
$string['selecteduserscount'] = '{$a} selected';
$string['serviceaccountjson'] = 'Content of the JSON file of the Google service account';
$string['small'] = 'small';
$string['squared'] = 'squared';
$string['statistics_for'] = 'Statistic for {$a}';
Expand Down
Loading

0 comments on commit d1269c3

Please sign in to comment.